Files
wehub-resource-sync b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

279 lines
7.9 KiB
JavaScript
Executable File

#!/usr/bin/env node
// review-queue.mjs -- maintainer CLI for the review/* label workflow.
//
// Zero external deps: only node: builtins, shelling out to `gh`.
//
// Usage:
// node .github/scripts/review-queue.mjs [list] List open PRs grouped by review state.
// node .github/scripts/review-queue.mjs request <pr>... Post `/review` on one or more PRs.
// node .github/scripts/review-queue.mjs request --needs-review
// Post `/review` on every PR labeled
// review/needs-review.
// node .github/scripts/review-queue.mjs --help Show this help.
//
// Options:
// --repo <owner/name> Target repo (default: detected via `gh repo view`).
//
// Requires an authenticated `gh` CLI.
import { spawnSync } from "node:child_process";
// --- gh helpers -------------------------------------------------------------
// Run a gh command, returning stdout. On non-zero exit, print stderr and exit 1.
function gh(args) {
const result = spawnSync("gh", args, { encoding: "utf8" });
if (result.error) {
console.error(`Failed to run gh: ${result.error.message}`);
process.exit(1);
}
if (result.status !== 0) {
const stderr = (result.stderr || "").trim();
console.error(stderr || `gh exited with status ${result.status}`);
process.exit(1);
}
return result.stdout;
}
function detectRepo() {
const out = gh(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]);
return out.trim();
}
// --- review state derivation ------------------------------------------------
const STATE_ORDER = ["needs-rereview", "needs-review", "awaiting-author", "approved", "unlabeled"];
// PR number argument: optional leading "#", then digits.
const PR_NUMBER_RE = /^#?\d+$/;
const PR_HASH_RE = /^#/;
// Derive a PR's review state from its review/* label.
function reviewState(pr) {
const labels = new Set((pr.labels || []).map((l) => l.name));
for (const state of STATE_ORDER) {
if (state === "unlabeled") continue;
if (labels.has(`review/${state}`)) return state;
}
return "unlabeled";
}
// Collect the size/* and area/* labels for display.
function tagLabels(pr) {
return (pr.labels || [])
.map((l) => l.name)
.filter((n) => n.startsWith("size/") || n.startsWith("area/"));
}
// Derive a coarse CI status from the statusCheckRollup array.
function ciStatus(pr) {
const rollup = pr.statusCheckRollup || [];
if (rollup.length === 0) return "none";
let pending = false;
let failed = false;
for (const check of rollup) {
// Check runs use `status` + `conclusion`; status contexts use `state`.
const conclusion = (check.conclusion || "").toUpperCase();
const status = (check.status || "").toUpperCase();
const state = (check.state || "").toUpperCase();
if (status && status !== "COMPLETED") {
// IN_PROGRESS, QUEUED, PENDING, WAITING, etc.
pending = true;
continue;
}
if (
["FAILURE", "TIMED_OUT", "CANCELLED", "ERROR", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(
conclusion,
)
) {
failed = true;
continue;
}
if (["FAILURE", "ERROR"].includes(state)) {
failed = true;
continue;
}
if (["PENDING", "EXPECTED"].includes(state)) {
pending = true;
continue;
}
// SUCCESS / NEUTRAL / SKIPPED count as pass.
}
if (failed) return "fail";
if (pending) return "pending";
return "pass";
}
function ageInDays(createdAt) {
const created = new Date(createdAt).getTime();
if (Number.isNaN(created)) return 0;
return Math.floor((Date.now() - created) / (24 * 60 * 60 * 1000));
}
function truncate(str, max) {
const s = str || "";
if (s.length <= max) return s;
return s.slice(0, max - 1) + "\u2026";
}
// --- commands ---------------------------------------------------------------
function fetchOpenPrs(repo) {
const out = gh([
"pr",
"list",
"--repo",
repo,
"--state",
"open",
"--limit",
// gh's effective max; avoids silently omitting PRs from list / --needs-review.
"1000",
"--json",
"number,title,labels,createdAt,updatedAt,author,isDraft,mergeable,reviewDecision,statusCheckRollup",
]);
try {
return JSON.parse(out);
} catch (e) {
console.error(`Could not parse gh output: ${e.message}`);
process.exit(1);
}
}
function cmdList(repo) {
const prs = fetchOpenPrs(repo);
// Bucket PRs by derived state.
const groups = new Map(STATE_ORDER.map((s) => [s, []]));
for (const pr of prs) {
groups.get(reviewState(pr)).push(pr);
}
console.log(`Open PRs in ${repo}: ${prs.length}`);
console.log("");
for (const state of STATE_ORDER) {
const bucket = groups.get(state);
console.log(`== ${state} (${bucket.length}) ==`);
if (bucket.length === 0) {
console.log(" (none)");
console.log("");
continue;
}
// Sort oldest-first within a group so the most stale floats to the top.
bucket.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
for (const pr of bucket) {
const num = `#${pr.number}`.padEnd(6);
const title = truncate(pr.title, 60).padEnd(60);
const author = `(${pr.author?.login || "unknown"})`.padEnd(20);
const tags = tagLabels(pr).join(",");
const tagCol = `[${tags}]`.padEnd(24);
const age = `${ageInDays(pr.createdAt)}d`.padEnd(5);
const ci = `CI:${ciStatus(pr)}`.padEnd(11);
const mergeable = (pr.mergeable || "UNKNOWN").toLowerCase();
console.log(` ${num} ${title} ${author} ${tagCol} ${age} ${ci} ${mergeable}`);
}
console.log("");
}
}
function postReview(repo, number) {
gh(["pr", "comment", String(number), "--repo", repo, "--body", "/review"]);
console.log(`Requested review on #${number}`);
}
function cmdRequest(repo, args) {
if (args.includes("--needs-review")) {
const prs = fetchOpenPrs(repo);
const targets = prs.filter((pr) =>
(pr.labels || []).some((l) => l.name === "review/needs-review"),
);
if (targets.length === 0) {
console.log("No PRs labeled review/needs-review.");
return;
}
console.log(
`Requesting review on ${targets.length} PR(s): ${targets.map((p) => `#${p.number}`).join(", ")}`,
);
for (const pr of targets) {
postReview(repo, pr.number);
}
return;
}
const numbers = args.filter((a) => PR_NUMBER_RE.test(a)).map((a) => a.replace(PR_HASH_RE, ""));
if (numbers.length === 0) {
console.error("request: provide one or more PR numbers, or --needs-review");
process.exit(1);
}
for (const number of numbers) {
postReview(repo, number);
}
}
function printHelp() {
console.log(
[
"review-queue.mjs -- maintainer CLI for the review/* label workflow",
"",
"Usage:",
" review-queue.mjs [list] List open PRs grouped by review state",
" review-queue.mjs request <pr>... Post /review on one or more PRs",
" review-queue.mjs request --needs-review Post /review on every review/needs-review PR",
" review-queue.mjs --help Show this help",
"",
"Options:",
" --repo <owner/name> Target repo (default: detected via gh repo view)",
"",
"Requires an authenticated gh CLI.",
].join("\n"),
);
}
// --- arg parsing ------------------------------------------------------------
function main() {
const argv = process.argv.slice(2);
if (argv.includes("--help") || argv.includes("-h")) {
printHelp();
return;
}
// Pull out --repo <value> if present; the rest are positional.
let repoOverride;
const rest = [];
for (let i = 0; i < argv.length; i++) {
if (argv[i] === "--repo") {
const value = argv[i + 1];
if (!value || value.startsWith("-")) {
console.error("--repo requires a value, e.g. --repo owner/name");
process.exit(1);
}
repoOverride = value;
i++;
continue;
}
rest.push(argv[i]);
}
const repo = repoOverride || detectRepo();
const command = rest[0] || "list";
switch (command) {
case "list":
cmdList(repo);
break;
case "request":
cmdRequest(repo, rest.slice(1));
break;
default:
console.error(`Unknown command: ${command}`);
printHelp();
process.exit(1);
}
}
main();