chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
// Attach sandboxed-plugin tarballs to the GitHub releases that
|
||||
// `changesets/action` creates during a release run.
|
||||
//
|
||||
// The decentralized plugin registry (RFC 0001) stores only a *link* to the
|
||||
// plugin bytes, not the bytes themselves. By bundling each published
|
||||
// sandboxed plugin and uploading the tarball as a release asset, every
|
||||
// version automatically gets a stable public URL that an `emdash-plugin
|
||||
// publish --url ...` step (or a human) can point a registry record at.
|
||||
//
|
||||
// Input: the `publishedPackages` output from changesets/action, passed via
|
||||
// the PUBLISHED_PACKAGES env var as a JSON array of `{ name, version }`.
|
||||
// Only packages under `packages/plugins/*` that expose a `./sandbox` export
|
||||
// and are not private are processed; everything else (native plugins, test
|
||||
// fixtures) is skipped.
|
||||
//
|
||||
// Set DRY_RUN=1 to bundle and resolve tarballs without calling `gh`.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const PLUGINS_DIR = "packages/plugins";
|
||||
const BUNDLER = "packages/plugin-cli/dist/index.mjs";
|
||||
|
||||
const SLASH_RE = /\//g;
|
||||
const LEADING_AT_RE = /^@/;
|
||||
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
const dryRun = process.env.DRY_RUN === "1";
|
||||
|
||||
if (!dryRun && !repo) {
|
||||
console.error("GITHUB_REPOSITORY is not set; cannot target `gh release upload`.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// `?? "[]"` only catches undefined/null. An unset Actions output arrives as
|
||||
// an empty string, which would make JSON.parse throw, so coalesce that too.
|
||||
const raw = process.env.PUBLISHED_PACKAGES?.trim() || "[]";
|
||||
let published;
|
||||
try {
|
||||
published = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
console.error(`Could not parse PUBLISHED_PACKAGES as JSON: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!Array.isArray(published) || published.length === 0) {
|
||||
console.log("No published packages to process.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// name -> version for the packages that were just published.
|
||||
const publishedVersions = new Map(published.map((p) => [p.name, p.version]));
|
||||
|
||||
/** Slugify a manifest id the same way the bundler names its tarball. */
|
||||
const slugify = (id) => id.replace(SLASH_RE, "-").replace(LEADING_AT_RE, "");
|
||||
|
||||
const failures = [];
|
||||
let attached = 0;
|
||||
|
||||
for (const entry of readdirSync(PLUGINS_DIR, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const dir = join(PLUGINS_DIR, entry.name);
|
||||
const pkgPath = join(dir, "package.json");
|
||||
if (!existsSync(pkgPath)) continue;
|
||||
|
||||
// We read every plugin dir, so a single malformed package.json must not
|
||||
// abort the whole step. Treat an unreadable manifest as a skip.
|
||||
let pkg;
|
||||
try {
|
||||
pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||
} catch {
|
||||
console.warn(`Skipping ${entry.name}: could not read/parse package.json`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only published, public, sandboxed plugins.
|
||||
if (!publishedVersions.has(pkg.name)) continue;
|
||||
if (pkg.private === true) continue;
|
||||
if (!pkg.exports?.["./sandbox"]) {
|
||||
console.log(`Skipping ${pkg.name}: no ./sandbox export (not a sandboxed plugin).`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const version = publishedVersions.get(pkg.name);
|
||||
const tag = `${pkg.name}@${version}`;
|
||||
console.log(`\n=== ${pkg.name}@${version} ===`);
|
||||
|
||||
try {
|
||||
// Bundle. This rebuilds from source and writes dist/<slug>-<version>.tar.gz
|
||||
// plus dist/manifest.json, so we can read the exact id/version back out
|
||||
// rather than guessing the filename (stale tarballs may linger in dist/).
|
||||
execFileSync("node", [BUNDLER, "bundle", "--dir", dir], { stdio: "inherit" });
|
||||
|
||||
const manifest = JSON.parse(readFileSync(join(dir, "dist", "manifest.json"), "utf-8"));
|
||||
const tarball = join(dir, "dist", `${slugify(manifest.id)}-${manifest.version}.tar.gz`);
|
||||
if (!existsSync(tarball)) {
|
||||
throw new Error(`Expected tarball not found: ${tarball}`);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`[dry-run] would upload ${tarball} to release ${tag}`);
|
||||
} else {
|
||||
// --clobber so re-runs replace the asset instead of failing.
|
||||
execFileSync("gh", ["release", "upload", tag, tarball, "--clobber", "--repo", repo], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
console.log(`Attached ${tarball} to ${tag}`);
|
||||
}
|
||||
attached++;
|
||||
} catch (error) {
|
||||
console.error(`Failed to attach tarball for ${pkg.name}: ${error.message}`);
|
||||
failures.push(pkg.name);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nAttached ${attached} plugin tarball(s).`);
|
||||
if (failures.length > 0) {
|
||||
console.error(`Failed: ${failures.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync } from "node:fs";
|
||||
import { glob } from "node:fs/promises";
|
||||
|
||||
const offenders = [];
|
||||
const seen = [];
|
||||
|
||||
for await (const file of glob("**/package.json", {
|
||||
exclude: (path) =>
|
||||
path.includes("node_modules") || path.includes("/dist/") || path.includes("/.git/"),
|
||||
})) {
|
||||
let pkg;
|
||||
try {
|
||||
pkg = JSON.parse(readFileSync(file, "utf8"));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (pkg.private || !pkg.name || !pkg.version) continue;
|
||||
seen.push(`${pkg.name}@${pkg.version}`);
|
||||
const major = Number.parseInt(pkg.version.split(".")[0], 10);
|
||||
if (Number.isFinite(major) && major >= 1) {
|
||||
offenders.push(`${pkg.name}@${pkg.version} (${file})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (offenders.length > 0) {
|
||||
console.error("::error::Non-0.x versions detected. Releases must stay in 0.x while in pre-1.0:");
|
||||
for (const o of offenders) console.error(` ${o}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Checked ${seen.length} non-private packages, all are 0.x.`);
|
||||
@@ -0,0 +1,28 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const mode = process.argv[2];
|
||||
|
||||
const run = (args) => execFileSync("pnpm", args, { stdio: "inherit" });
|
||||
|
||||
// changesets/action runs `git checkout changeset-release/main` + `git reset
|
||||
// --hard` immediately before this, which can leave the deps state out of
|
||||
// sync with pnpm-workspace.yaml. The next gated pnpm call
|
||||
// (verifyDepsBeforeRun: error) would then abort the release. `pnpm install`
|
||||
// is not gated, so reconcile here, after the action's git work and before
|
||||
// any `pnpm changeset` call. Do not hoist this into an earlier workflow
|
||||
// step: the action's git reset runs after workflow steps and undoes it.
|
||||
// Must be --no-frozen-lockfile, not --prefer-frozen-lockfile: prefer-frozen
|
||||
// can take the lockfile fast path and skip rewriting the stale deps-state
|
||||
// hash, which is the exact condition that trips the gate.
|
||||
run(["install", "--no-frozen-lockfile"]);
|
||||
|
||||
if (mode === "version") {
|
||||
run(["changeset", "version"]);
|
||||
run(["install", "--no-frozen-lockfile"]);
|
||||
} else if (mode === "publish") {
|
||||
run(["changeset", "publish"]);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unknown release mode: ${JSON.stringify(mode)} (expected "version" or "publish")`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
// Resolves the model alias from a /bonk, /review, or @ask-bonk comment body
|
||||
// and emits the model id and OPENCODE_CONFIG_CONTENT for the ask-bonk action.
|
||||
//
|
||||
// Inputs (env):
|
||||
// BODY — the raw comment / review body
|
||||
// GITHUB_OUTPUT — path to the workflow step output file
|
||||
//
|
||||
// Outputs (to $GITHUB_OUTPUT):
|
||||
// alias — resolved alias (default if none requested)
|
||||
// model — full model id passed to ask-bonk's `model:` input
|
||||
// opencode_config — JSON string for OPENCODE_CONFIG_CONTENT env var
|
||||
//
|
||||
// The first word after a trigger ("/bonk", "/review", or "@ask-bonk") selects
|
||||
// an alias from .github/bonk-models.json. An absent or unknown word falls
|
||||
// back to the registry's `default`. Only the selected model is registered in
|
||||
// the opencode provider config; the rest of the registry stays unused at
|
||||
// runtime to keep the env var small.
|
||||
|
||||
import { appendFileSync, readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const registryPath = resolve(here, "..", "bonk-models.json");
|
||||
const registry = JSON.parse(readFileSync(registryPath, "utf8"));
|
||||
|
||||
const body = process.env.BODY ?? "";
|
||||
|
||||
// Match a trigger preceded by start-of-string or whitespace, then capture the
|
||||
// next bare word. The leading-boundary guard avoids matching "/bonk" as a
|
||||
// substring of unrelated text (e.g. a URL fragment).
|
||||
const triggerRe = /(?:^|\s)(?:\/bonk|\/review|@ask-bonk)\s+([a-zA-Z][a-zA-Z0-9_-]*)/;
|
||||
const match = body.match(triggerRe);
|
||||
const requested = match ? match[1].toLowerCase() : null;
|
||||
|
||||
const fallback = registry.default;
|
||||
const alias = requested && registry.models[requested] ? requested : fallback;
|
||||
const entry = registry.models[alias];
|
||||
if (!entry) {
|
||||
console.error(`bonk-models.json default "${fallback}" missing from models map`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const model = `cloudflare-ai-gateway/${entry.id}`;
|
||||
|
||||
// OPENCODE_CONFIG_CONTENT bundles two unrelated overrides:
|
||||
//
|
||||
// 1. Register the selected model with the pinned opencode version. Without
|
||||
// this, opencode raises ProviderModelNotFoundError on any model whose
|
||||
// release_date is after its bundled models.dev snapshot.
|
||||
//
|
||||
// 2. Resolve the two opencode permission defaults that ask interactively
|
||||
// (`external_directory` and `doom_loop`) so a CI run with no TTY can
|
||||
// never deadlock waiting for approval. external_directory is
|
||||
// deny-by-default with /tmp/** and ~/** allowed (scratch files,
|
||||
// home-dir caches); doom_loop is deny so a stuck loop aborts instead
|
||||
// of prompting. Repro: PR #769 timed out at 30 min waiting for an
|
||||
// `external_directory` prompt on `git show ... > /tmp/foo`.
|
||||
const opencodeConfig = {
|
||||
permission: {
|
||||
external_directory: {
|
||||
"*": "deny",
|
||||
"/tmp/**": "allow",
|
||||
"~/**": "allow",
|
||||
},
|
||||
doom_loop: "deny",
|
||||
},
|
||||
provider: {
|
||||
"cloudflare-ai-gateway": {
|
||||
models: {
|
||||
[entry.id]: entry.registration,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const out = process.env.GITHUB_OUTPUT;
|
||||
if (!out) {
|
||||
console.error("GITHUB_OUTPUT is not set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const eof = "OPENCODE_CONFIG_EOF";
|
||||
appendFileSync(
|
||||
out,
|
||||
[
|
||||
`alias=${alias}`,
|
||||
`requested=${requested ?? ""}`,
|
||||
`model=${model}`,
|
||||
`opencode_config<<${eof}`,
|
||||
JSON.stringify(opencodeConfig),
|
||||
eof,
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
console.log(`Resolved alias "${alias}" -> ${model}`);
|
||||
Executable
+278
@@ -0,0 +1,278 @@
|
||||
#!/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();
|
||||
Reference in New Issue
Block a user