// GitHub review fetch for the review-ready poller (SPEC §10). // // Two transports, selected by `NANO_PR_GITHUB_TRANSPORT` (auto | gh | token): // • gh — shell out to the host `gh` CLI. It uses the user's own GitHub login, so the // poller reaches every repository the user can reach — including private repos // that no PAT is (or can be) issued for. This is the default on a workstation. // • token — HTTP `fetch` to api.github.com with `GITHUB_TOKEN`. Used in headless/CI where // no interactive `gh` login exists. // • auto — prefer `gh` when the binary is present; otherwise fall back to `token`. // // The poller is app-side host glue (main.ts), so host-specific subprocess I/O is allowed here. // Cross-runtime: runs under Node (`node:child_process`). import { createHash } from "node:crypto"; // Type-only import (erased at runtime, so no runtime cycle with mergeProtocol.ts, which imports // `fetchRepoFile` from here): `classifyMergeability` reads a repo's declared required checks to gate // a merge independently of GitHub branch protection. import type { MergeProtocol } from "./mergeProtocol.ts"; /** A GitHub pull-request review, narrowed to the fields the poller needs. */ export interface GhReview { id: number; state: string; submitted_at?: string; /** The commit SHA the review was submitted against (GitHub's `commit_id`). Used to detect a * review that predates the PR's current HEAD — a STALE review whose advisories are about code the * head has since moved past (issue #799). Absent on data GitHub did not carry a `commit_id` for. */ commit_id?: string | null; } export type GithubTransport = "gh" | "token" | "auto"; /** Resolve the configured transport, defaulting to `auto`. */ export function githubTransport(): GithubTransport { const t = (process.env.NANO_PR_GITHUB_TRANSPORT ?? "auto").trim().toLowerCase(); return t === "gh" || t === "token" ? t : "auto"; } /** Run the host `gh` CLI with the given args (no shell — args are passed as a vector, so a * `repo`/`number` from the datastore cannot inject a command). Resolves stdout, rejects on a * non-zero exit with stderr as the message. */ async function runGh(args: string[]): Promise { const { execFile } = await import("node:child_process"); return await new Promise((resolve, reject) => { execFile( "gh", args, { maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => { if (err) reject(new Error(String(stderr || "").trim() || err.message)); else resolve(String(stdout)); }, ); }); } let ghAvailable: Promise | undefined; /** Whether the host `gh` CLI is present (memoized — probed at most once per process). */ function isGhAvailable(): Promise { if (!ghAvailable) { ghAvailable = runGh(["--version"]).then(() => true, () => false); } return ghAvailable; } /** Fetch the reviews for one PR via the configured transport. Throws on transport failure so * the caller can log-and-continue; returns `null` when no transport is usable (idle). Pages the * FULL (oldest→newest) reviews list — the poller picks the newest fresh review by id, so reading * only the first `per_page=100` page would, on a >100-review convergence loop, surface the OLDEST * 100 and miss the genuinely newest review (repeatedly nudging while a current-head review sits on a * later page, or classifying an old review as stale). This mirrors {@link fetchLatestCopilotReview}'s * paging so both readers agree on which review is newest. */ export async function fetchPrReviews( repo: string, number: number | string, token: string, ): Promise { const mode = githubTransport(); const useGh = mode === "gh" || (mode === "auto" && (await isGhAvailable())); const path = `repos/${repo}/pulls/${number}/reviews?per_page=100`; if (useGh) { // `--paginate --slurp` walks EVERY page of the (oldest→newest) reviews array, so a >100-review // convergence loop still surfaces the genuinely newest review rather than the oldest 100. Plain // `--paginate` concatenates one JSON array PER PAGE (multiple documents) which `JSON.parse` // cannot read; `--slurp` wraps the pages in an outer array we flatten one level (mirrors // {@link githubReleasesCommand}/{@link parseReleases}). const out = await runGh([ "api", "--paginate", "--slurp", path, "-H", "Accept: application/vnd.github+json", ]); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape return (JSON.parse(out) as GhReview[][]).flat(); } if (!token) return null; // token mode with no token → poller idles // Page the token transport the same way; 20×100 reviews is far past any real convergence loop, and // a genuinely deeper history we can't reach is unverifiable → fail CLOSED (throw) rather than // return a partial list the poller would treat as complete (selecting an older review, re-nudging). const reviews: GhReview[] = []; const MAX_PAGES = 20; for (let page = 1; page <= MAX_PAGES; page++) { const r = await fetch(`https://api.github.com/${path}&page=${page}`, { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" }, }); if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim()); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape const batch = (await r.json()) as GhReview[]; reviews.push(...batch); // A short final page means we've read every review — the list is complete. if (batch.length < 100) return reviews; // A full page on the last allowed page is only truncated if GitHub says there's more; trust the // `Link` header's `rel="next"` (mirrors {@link fetchPrFiles}) so an exact multiple of 100 isn't a // false positive, and throw when the cap genuinely truncates rather than under-reading history. if (page === MAX_PAGES && /<[^>]*>;\s*rel="next"/.test(r.headers.get("link") ?? "")) { throw new Error( `github pr reviews truncated: ${repo}#${number} exceeds ${MAX_PAGES * 100}-review paging cap`, ); } } return reviews; } // ── Review-comment convergence gate (don't converge with unaddressed comments) ────────────── // // A PR must not be declared converged while Copilot still has unaddressed review comments. Two // kinds must be gated: // • unresolved review THREADS — deterministic (GraphQL `isResolved`). // • SUPPRESSED / low-confidence advisories — Copilot folds these into the review BODY under a // "Suppressed comments (N)" block; they are NOT threads, cannot be resolved, and are re-listed // every round. To make "acknowledged" trackable, the review-round agent must post a RESOLVED // review thread carrying a `nano-ack:` marker for each advisory it applies or declines. The gate // then treats an advisory as addressed iff a resolved thread carries a matching ack marker. // // The ack key must be LINE-STABLE. Keying it on `path:line` (issue #787) livelocks a DECLINED // advisory: Copilot re-emits a declined advisory every round, but any unrelated edit in the PR // shifts its line, so Copilot re-anchors it to a new line. A prior-round `nano-ack: path:OLD` no // longer matches the re-emitted `path:NEW`, the gate sees a freshly "unacknowledged" advisory, and // escalates to a human every round. The fix keys acknowledgement on a line-independent identity — // `#` of the advisory's PROSE — so a drifted line still matches. The resolved // ack thread is itself the durable store: its marker text survives across rounds regardless of the // line, so a decline stays acknowledged without the agent re-acking each round. The new marker is // `nano-ack: :: `. This line-stable prose key is the SOLE ack // identity. A bare `nano-ack: :` form is NOT honoured: keyed only on `path:line`, it is // blind to the advisory's prose, so a resolved legacy ack for advisory A at a line would silently // acknowledge a genuinely NEW advisory B re-emitted at that same line — a false-OPEN this gate exists // to prevent. (Issue #787 introduces the ack mechanism itself in this change, so there is no pre-#787 // legacy-ack corpus to protect by honouring the prose-blind form.) /** One PR review thread, narrowed to what the convergence gate needs. */ export interface ReviewThread { isResolved: boolean; path: string | null; bodies: string[]; } /** A suppressed / low-confidence Copilot advisory parsed out of a review body. Its `key` is the * line-stable identity (survives a line drift); `label` is the human-facing `path:line` shown in * block reasons. */ export interface SuppressedAdvisory { path: string; line: number; /** The advisory prose (first non-empty line after the header), used for the stable fingerprint. */ text: string; /** Line-stable identity: `#` of the normalized prose. Survives line drift. */ key: string; /** Human-facing `path:line` label for block-reason messages. */ label: string; } /** Any `nano-ack:` marker — captures the rest of the marker's line (path + optional `:: text`). */ const ACK_MARKER = /nano-ack:\s*([^\n\r]+)/gi; /** The ONLY honoured ack form: line-stable ` :: `. The delimiter is ` :: ` with * REQUIRED surrounding whitespace (matching the canonical marker the agent authors), so a bare `::` * inside a valid GitHub path (e.g. `src/a::b.ts`) is NOT mistaken for the separator — the path group * parses non-greedily up to the first *whitespace-delimited* ` :: `, so a path containing spaces * (e.g. `docs/my file.md`) is still honoured. A bare `:` marker is intentionally not * parsed: keyed only on `path:line`, it is blind to the advisory prose and would false-OPEN a new * advisory re-emitted at a previously-acked line. */ const NEW_ACK = /^(.+?)\s+::\s+(.+)$/s; /** Normalize advisory prose to a line-/format-independent form before fingerprinting: strip a * leading markdown bullet, NFC-normalize, lowercase, and collapse runs of WHITESPACE to a single * space. Punctuation is PRESERVED, NOT collapsed: the prompt requires the agent to copy the * advisory's first line VERBATIM, so whitespace/case tolerance is all that is needed to absorb * trivial markdown/whitespace reflow. Collapsing every non-word run into a space (as an earlier * revision did) instead ALIASES genuinely-distinct advisories whose prose differs only by * punctuation-vs-space — e.g. `Use foo() here` vs `Use foo here`, or `foo/bar` vs `foo bar` — so a * resolved ack for advisory A would silently acknowledge a DIFFERENT advisory B that normalizes to * the same key: a false-OPEN this gate exists to prevent. Preserving punctuation errs toward a * stricter match, which is fail-CLOSED: a benign punctuation mismatch merely re-escalates to a * human, and never converges an unacknowledged advisory. * * NFC — canonical composition — is used deliberately in preference to NFKC. NFKC additionally folds * COMPATIBILITY variants (full-width `!` → ASCII `!`, ligatures, super/subscripts, …), which would * ALIAS genuinely-distinct advisories such as `Use foo!` and `Use foo!` to one key — the very * false-OPEN this fingerprint exists to prevent, and a contradiction with "punctuation is * preserved". NFC only unifies sequences that are canonically equivalent (visually and semantically * identical, e.g. a precomposed `é` vs `e`+combining-acute), so verbatim copies still match while * distinct compatibility forms stay distinct (fail-CLOSED). Unicode letters/digits are preserved * rather than stripped, so non-ASCII-only prose still yields a non-empty, distinct key. * * The leading-bullet strip keeps the ADVISORY side (Copilot renders suppressed prose as `* …`, which * `parseSuppressedAdvisories` also strips for display) and the ACK side SYMMETRIC: the prompt tells * the agent to copy the advisory's first line verbatim, so an ack marker legitimately carries the * `* ` bullet — without stripping it here the ack key would differ from the advisory key and the * gate would never converge (fail-CLOSED livelock). Applying it in this shared canonicaliser is the * SINGLE source of truth for both sides. * * The bullet marker REQUIRES trailing whitespace (`[-*]\s+`): a genuine markdown bullet is always * `- ` / `* ` followed by a space, so `-foo` / `*foo` (leading punctuation, no separator) is NOT a * bullet and its leading char is PRESERVED. A greedy `\s*` there would strip the `-`/`*` off such * prose too, collapsing distinct first lines like `-foo` and `foo` to one key — a false-ACK * (false-OPEN) where acking one silently satisfies the other. */ function normalizeAdvisoryText(text: string): string { return text .normalize("NFC") .replace(/^\s*[-*]\s+/u, "") .toLowerCase() .replace(/\s+/gu, " ") .trim(); } /** COLLISION-RESISTANT fingerprint of a string → 32-hex-char (128-bit) digest, the leading half of * a SHA-256 hash. Deterministic and dependency-free (Node's built-in `node:crypto`, no npm dep). * * The gate treats an advisory whose key `#` matches a resolved ack as addressed, * so a *collision* would let a NEWER, unacknowledged advisory on the same path pass without its own * ack — a false-OPEN that violates the gate's no-false-open guarantee. The former 32-bit FNV-1a * digest was cheap to collide (birthday bound ~2^16); a 128-bit SHA-256 slice makes an accidental * collision (~2^-64 for realistic advisory counts) infeasible. The digest is INTERNAL to the key — * it never appears in a human-authored `nano-ack:` marker (those carry the verbatim prose, which is * re-fingerprinted at read time), so widening it neither lengthens any marker nor breaks a * previously-issued one: both the advisory side and the ack side recompute with this same function. */ function fingerprint(s: string): string { return createHash("sha256").update(s, "utf8").digest("hex").slice(0, 32); } /** The line-stable acknowledgement key for an advisory: `#`. * Exported so the review-round agent's contract and tests share one canonical implementation. */ export function advisoryStableKey(path: string, text: string): string { return `${path.trim()}#${fingerprint(normalizeAdvisoryText(text))}`; } /** The line-stable fingerprint of a convergence escalation QUESTION (issue #806): the SAME canonical * `normalizeAdvisoryText` + `fingerprint` digest advisory acks key on, applied to the escalation's * question text. Reuses the ONE normaliser/fingerprint pair (no second implementation) so a durable * wait-answer adjudication keyed by `(prKey, questionFingerprint)` is byte/semantic-stable the exact * disciplined way an advisory ack is — only a semantically-identical, already-answered question is * suppressed; a materially different question keys differently and still escalates. */ export function questionFingerprint(text: string): string { return fingerprint(normalizeAdvisoryText(text)); } /** Parse Copilot's suppressed / low-confidence advisories out of a review body. Copilot renders them * under a `Suppressed comments (N)` block, each as a bold `**path:line**` header * followed by the advisory prose. Returns de-duplicated advisories (empty when there is no block). */ export function parseSuppressedAdvisories(reviewBody: string | null | undefined): SuppressedAdvisory[] { const body = reviewBody ?? ""; const idx = body.search(/Suppressed comments\s*\(/i); if (idx < 0) return []; // Scan only from the "Suppressed comments" marker onward so a `**path:line**` elsewhere in the // overview prose can never be mistaken for an advisory. const region = body.slice(idx); const lines = region.split(/\r?\n/); const headerRe = /\*\*([^*]+?):(\d+)\*\*/; const out: SuppressedAdvisory[] = []; const seen = new Set(); for (let i = 0; i < lines.length; i++) { const h = headerRe.exec(lines[i]); if (!h) continue; const path = h[1].trim(); const line = Number(h[2]); const label = `${path}:${line}`; // The advisory prose is the first non-empty line after the header (up to the next header). A // single bullet is the common shape; strip a leading markdown bullet marker for the display // `text`. (Keying is bullet-insensitive regardless: `normalizeAdvisoryText` strips a leading // bullet too, so the ack side — which copies the bulleted first line verbatim — keys the same.) let text = ""; for (let j = i + 1; j < lines.length; j++) { if (headerRe.test(lines[j])) break; const t = lines[j].replace(/^\s*[-*]\s+/, "").trim(); if (t) { text = t; break; } } const key = advisoryStableKey(path, text); if (seen.has(key)) continue; seen.add(key); out.push({ path, line, text, key, label }); } return out; } /** The line-stable advisory keys carried by a SINGLE comment body's canonical `nano-ack: :: * ` markers. This is the SOLE recognizer of an acknowledgement, shared by `isAckThread` and * `parseAckedAdvisories` so "is this an ack?" has ONE canonical implementation (derivation over * duplication — no drift between the two consumers). The bare `nano-ack: :` form yields * NOTHING here: `NEW_ACK` requires the ` :: ` prose (a bare `path:line` is prose-blind and * would false-OPEN a new advisory re-emitted at a previously-acked line). */ function canonicalAckKeys(body: string): string[] { const keys: string[] = []; ACK_MARKER.lastIndex = 0; let m: RegExpExecArray | null; // biome-ignore lint/suspicious/noAssignInExpressions: canonical regex-exec accumulation loop while ((m = ACK_MARKER.exec(body)) !== null) { const nw = NEW_ACK.exec(m[1].trim()); if (nw) keys.push(advisoryStableKey(nw[1], nw[2])); } return keys; } /** True when a review thread is a DEDICATED `nano-ack:` acknowledgement thread — one whose ROOT * comment (`bodies[0]`, the thread-opening comment) carries a valid canonical `nano-ack: :: * ` marker — rather than a substantive code-review thread. This is a CLASSIFICATION only: the * converge gate never DROPS an unresolved thread on the strength of this predicate. An unresolved ack * thread still BLOCKS convergence (it is a genuinely-open GitHub thread); the classification only * routes that block onto the recoverable ack-only path — a partially-completed acknowledgement the * bounded #796 auto-ack retry can finish (post-and-resolve) — instead of escalating a human. A * substantive unresolved thread escalates to a human. * * Because the gate BLOCKS either way, this predicate is FAIL-CLOSED even under a false positive: * 1. Only the canonical prose-keyed form counts (via `canonicalAckKeys`); the retired bare * `nano-ack: :` form does NOT — matching `parseAckedAdvisories`. * 2. Only the ROOT comment is inspected — a reviewer's substantive finding is ALWAYS its thread's * root and (canonical-form) never carries this marker, so a substantive thread that merely * quotes or replies `nano-ack:` in a later comment is not mis-classified. * 3. Even if a root DID quote the canonical marker mid-prose and were mis-labelled an ack, the * thread is NOT excluded — it still blocks (as ack-only), and the bounded auto-ack retry cannot * ack a non-advisory, so it escalates to a human on exhaustion. Marker presence never finalizes * the gate with an open thread (the fail-OPEN this design forecloses). */ export function isAckThread(thread: ReviewThread): boolean { const root = thread.bodies[0]; return root !== undefined && canonicalAckKeys(root).length > 0; } /** Extract the acknowledged advisory keys from a set of review threads (only RESOLVED threads * count — an open ack thread is not yet an acknowledgement). Returns line-stable keys (`#`) * parsed from the `nano-ack: :: ` form ONLY (via the shared `canonicalAckKeys`). A bare * `nano-ack: :` marker is intentionally NOT honoured: its `path:line` key is blind to the * advisory prose and would false-OPEN a genuinely new advisory re-emitted at a previously-acked line. * The gate treats an advisory as acked iff its stable key appears here. */ export function parseAckedAdvisories(threads: ReviewThread[]): string[] { const acked = new Set(); for (const t of threads) { if (!t.isResolved) continue; for (const body of t.bodies) for (const k of canonicalAckKeys(body)) acked.add(k); } return [...acked]; } /** Pick the newest Copilot review body from a reviews list (GitHub returns them oldest→newest). * `truncated = true` means we could NOT read every page — the genuinely-latest review may be unread, * so the result is UNVERIFIABLE and we fail CLOSED (`null`) rather than return a stale page's body; * a fail-OPEN on the advisory dimension (reading an old review and missing a newer suppressed * advisory) is the exact class this gate exists to prevent. A verified-complete read with no Copilot * review returns `""` (a verified "no advisories"). Pure; unit-tested. */ export function pickLatestCopilotReviewBody( reviews: { user?: { login?: string }; body?: string }[], truncated: boolean, ): string | null { const picked = pickLatestCopilotReview(reviews, truncated); return picked === null ? null : picked.body; } /** Pick the newest Copilot review — body AND the commit SHA it was submitted against — from a * reviews list (GitHub returns them oldest→newest). Semantics mirror {@link pickLatestCopilotReviewBody} * exactly: `truncated = true` fails CLOSED (`null`, unverifiable); a verified-complete read with no * Copilot review returns `{ body: "", commitId: null }` (a verified "no advisories"). The `commitId` * lets a caller detect a review that predates the PR's current HEAD — a STALE review whose advisories * are about code the head has since moved past (issue #799). Pure; unit-tested. */ export function pickLatestCopilotReview( reviews: { user?: { login?: string }; body?: string; commit_id?: string | null }[], truncated: boolean, ): { body: string; commitId: string | null } | null { if (truncated) return null; const copilot = reviews.filter((rv) => isCopilot(rv.user?.login)); const latest = copilot[copilot.length - 1]; return { body: latest?.body ?? "", commitId: latest?.commit_id ?? null }; } /** Fetch the latest Copilot review body for a PR (the newest review authored by the automated * Copilot reviewer). Returns `null` ONLY when no transport is usable (unverifiable → the worker * fails closed); returns `""` when transport is usable but the PR has no Copilot review yet (a * verified "no suppressed advisories"). Throws on a genuine transport failure. This split keeps * `null` from conflating "unverifiable" with "empty" and fail-OPENing the advisory dimension. * Thin wrapper over {@link fetchLatestCopilotReview} (the single fetch implementation). */ export async function fetchLatestCopilotReviewBody( repo: string, number: number | string, token: string, ): Promise { const picked = await fetchLatestCopilotReview(repo, number, token); return picked === null ? null : picked.body; } /** Fetch the latest Copilot review — body AND the commit SHA it was submitted against — for a PR. * Same null/`""`-vs-unverifiable semantics as {@link fetchLatestCopilotReviewBody} (which delegates * here): `null` ONLY when no transport is usable (unverifiable → fail closed); a verified read with * no Copilot review yet returns `{ body: "", commitId: null }`. The `commitId` lets the convergence * gate detect a review that predates the PR's current HEAD — a STALE review whose advisories are * about code the head has since moved past (issue #799) — and re-solicit a fresh review rather than * block/escalate against the obsolete body. Throws on a genuine transport failure. */ export async function fetchLatestCopilotReview( repo: string, number: number | string, token: string, ): Promise<{ body: string; commitId: string | null } | null> { const mode = githubTransport(); const useGh = mode === "gh" || (mode === "auto" && (await isGhAvailable())); const basePath = `repos/${repo}/pulls/${number}/reviews?per_page=100`; interface Review { user?: { login?: string }; body?: string; commit_id?: string | null; } if (useGh) { // `--paginate --slurp` walks EVERY page of the (oldest→newest) reviews array, so a >100-review // convergence loop still surfaces the genuinely newest Copilot review rather than the oldest // 100 — reading only the first page here would fail-OPEN the advisory dimension. Plain // `--paginate` concatenates one JSON array PER PAGE (multiple documents) which `JSON.parse` // cannot read; `--slurp` wraps the pages in an outer array we flatten one level. const out = await runGh([ "api", "--paginate", "--slurp", basePath, "-H", "Accept: application/vnd.github+json", ]); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape const reviews = (JSON.parse(out) as Review[][]).flat(); return pickLatestCopilotReview(reviews, false); } if (!token) return null; // Page the token transport the same way; 20×100 reviews is far past any real convergence loop, and // a genuinely deeper history we can't reach is unverifiable → fail closed. const reviews: Review[] = []; const MAX_PAGES = 20; for (let page = 1; page <= MAX_PAGES; page++) { const r = await fetch(`https://api.github.com/${basePath}&page=${page}`, { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" }, }); if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim()); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape const batch = (await r.json()) as Review[]; reviews.push(...batch); // A short page means we've read every review — the list is complete. if (batch.length < 100) return pickLatestCopilotReview(reviews, false); // A full page on the last allowed page is only truncated if GitHub says there's more; trust the // `Link` header's `rel="next"` so an exact multiple of 100 isn't a false positive. if (page === MAX_PAGES && /<[^>]*>;\s*rel="next"/.test(r.headers.get("link") ?? "")) { return pickLatestCopilotReview(reviews, true); } } return pickLatestCopilotReview(reviews, false); } /** Raw GraphQL response shape for the review-threads query. */ export interface ReviewThreadsResponse { data?: { repository?: { pullRequest?: { reviewThreads?: { pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }; nodes?: { isResolved?: boolean; path?: string | null; comments?: { nodes?: { body?: string }[] } }[]; }; }; }; }; } /** One page of a review-threads GraphQL response, plus the cursor to advance to the next page. */ export interface ReviewThreadsPage { threads: ReviewThread[]; hasNextPage: boolean; endCursor: string | null; } /** Map ONE page of a review-threads GraphQL response, FAILING CLOSED (returns `null`) on an * UNVERIFIABLE read: a missing `reviewThreads` block (GraphQL errors, permission issues, a malformed * payload) OR a page whose completeness signal (`pageInfo.hasNextPage`) is not a readable boolean. * A readable page yields its mapped nodes plus `hasNextPage`/`endCursor` so the CALLER can page to * completeness (`fetchReviewThreads` follows `endCursor` up to a bounded cap). A `first:100` page * cannot see thread 101+, so a truncated read must be paged, not silently mapped to "no more * threads" and converged (a fail-OPEN, the exact class this gate exists to prevent). Exceeding the * caller's page cap while GitHub still reports more is the caller's fail-closed decision, not this * mapper's. Pure; unit-tested. */ export function parseReviewThreadsPage(payload: ReviewThreadsResponse): ReviewThreadsPage | null { const block = payload.data?.repository?.pullRequest?.reviewThreads; if (!block || typeof block.pageInfo?.hasNextPage !== "boolean") return null; const nodes = block.nodes ?? []; return { threads: nodes.map((t) => ({ isResolved: !!t.isResolved, path: t.path ?? null, bodies: (t.comments?.nodes ?? []).map((c) => c.body ?? ""), })), hasNextPage: block.pageInfo.hasNextPage, endCursor: block.pageInfo.endCursor ?? null, }; } /** 20×100 review threads is far past any real convergence loop; a genuinely deeper set we can't * page to is unverifiable → fail closed. */ const MAX_THREAD_PAGES = 20; /** Fetch ALL of a PR's review threads (resolution state + path + comment bodies) via GraphQL, paging * to completeness up to `MAX_THREAD_PAGES`. `null` when no transport is usable, a page is * unreadable, or the set is still truncated past the page cap (fail closed); throws on a genuine * transport failure. */ export async function fetchReviewThreads( repo: string, number: number | string, token: string, ): Promise { const [owner, name] = repo.split("/"); const query = "query($o:String!,$r:String!,$n:Int!,$after:String){repository(owner:$o,name:$r){pullRequest(number:$n){" + "reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor}nodes{isResolved path comments(first:100){nodes{body}}}}}}}"; const mode = githubTransport(); const useGh = mode === "gh" || (mode === "auto" && (await isGhAvailable())); if (!useGh && !token) return null; const fetchPage = async (after: string | null): Promise => { if (useGh) { const args = ["api", "graphql", "-f", `query=${query}`, "-F", `o=${owner}`, "-F", `r=${name}`, "-F", `n=${number}`]; if (after !== null) args.push("-F", `after=${after}`); const out = await runGh(args); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape return JSON.parse(out) as ReviewThreadsResponse; } const variables: Record = { o: owner, r: name, n: Number(number) }; if (after !== null) variables.after = after; const r = await fetch("https://api.github.com/graphql", { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ query, variables }), }); if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim()); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape return (await r.json()) as ReviewThreadsResponse; }; const all: ReviewThread[] = []; let after: string | null = null; for (let page = 1; page <= MAX_THREAD_PAGES; page++) { const parsed = parseReviewThreadsPage(await fetchPage(after)); // An unreadable page is unverifiable — fail closed rather than converging on a partial read. if (parsed === null) return null; all.push(...parsed.threads); // A confirmed last page is the only complete read. if (!parsed.hasNextPage) return all; // More pages exist but no cursor to advance — unverifiable, fail closed. if (parsed.endCursor === null) return null; after = parsed.endCursor; } // Exceeded the page cap and GitHub still reports more — unverifiable, fail closed. return null; } // ── Copilot re-request (review-wait liveness) ─────────────────────────────── // A PR parked in `waiting_review` blocks on a *fresh* Copilot review. Copilot won't // spontaneously re-review a round with no new commit, and routinely dismisses a re-request, so // the poller must actively solicit the next round's review. Reliable re-request is the REST // reviewers endpoint with the exact `[bot]` login below — the bare `Copilot` login and the // GraphQL `requestReviews` mutation both silently no-op (GraphQL resolves Users only). /** The exact reviewer login GitHub's REST reviewers endpoint accepts for the automated Copilot * reviewer. NOT the bare `Copilot` display login (which no-ops) and NOT the `copilot-swe-agent` * coding bot. */ export const COPILOT_REVIEWER = "copilot-pull-request-reviewer[bot]"; /** The `requested_reviewers` GET surfaces the pending Copilot reviewer under its *display* login * `Copilot`, whereas the POST requires the `[bot]` login above — so a pending check must match * either spelling. */ function isCopilot(login: string | undefined): boolean { return login === "Copilot" || login === COPILOT_REVIEWER; } /** Whether Copilot is currently a *pending* (requested-but-not-yet-submitted) reviewer on the PR. * The poller uses this to avoid re-requesting a review that is already in flight. `null` when no * transport is usable (poller idles); throws on a genuine transport failure. */ export async function hasPendingCopilotReviewer( repo: string, number: number | string, token: string, ): Promise { const path = `repos/${repo}/pulls/${number}/requested_reviewers`; let users: { login?: string }[]; if (await useGh()) { const out = await runGh(["api", path, "-H", "Accept: application/vnd.github+json"]); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape users = (JSON.parse(out) as { users?: { login?: string }[] }).users ?? []; } else { if (!token) return null; const r = await fetch(`https://api.github.com/${path}`, { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" }, }); if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim()); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape users = ((await r.json()) as { users?: { login?: string }[] }).users ?? []; } return users.some((u) => isCopilot(u.login)); } /** Request a fresh Copilot review on the PR (REST reviewers endpoint, exact `[bot]` login), so * the process's `readiness-ready` review wait-gate catch can eventually fire. Returns `"requested"` on success, * `"unavailable"` when Copilot is not an assignable reviewer on that repo (HTTP 422 — e.g. * Copilot review not enabled there), or `null` when no transport is usable. Never throws for the * 422 "not assignable" case; only a genuine transport failure propagates. */ export async function requestCopilotReview( repo: string, number: number | string, token: string, ): Promise<"requested" | "unavailable" | null> { const path = `repos/${repo}/pulls/${number}/requested_reviewers`; if (await useGh()) { try { await runGh(["api", path, "-X", "POST", "-f", `reviewers[]=${COPILOT_REVIEWER}`]); return "requested"; } catch (err) { const msg = err instanceof Error ? err.message : String(err); // gh surfaces the 422 as its HTTP status and/or the "Unprocessable"/"not be requested" // body; treat any of those as "Copilot isn't assignable here" rather than a hard failure. if (/\b422\b|unprocessable|cannot be requested|not.*(assignable|be requested)/i.test(msg)) { return "unavailable"; } throw err; } } if (!token) return null; const r = await fetch(`https://api.github.com/${path}`, { method: "POST", headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "content-type": "application/json", }, body: JSON.stringify({ reviewers: [COPILOT_REVIEWER] }), }); if (r.ok) return "requested"; if (r.status === 422) return "unavailable"; throw new Error(`github ${r.status} ${r.statusText}: ${(await r.text()).slice(0, 300)}`.trim()); } // ── Merge stage (SPEC §11) ────────────────────────────────────────────────── // The same two-transport model (gh | token) backs the merge stage: read a PR's merge state to // decide when it is landable, and perform the merge (directly or via the repo's merge queue). /** Whether to use the `gh` CLI for this pass, honouring `NANO_PR_GITHUB_TRANSPORT`. */ async function useGh(): Promise { const mode = githubTransport(); return mode === "gh" || (mode === "auto" && (await isGhAvailable())); } /** PR metadata we read once at submit: the title (to label the row) and the body (to scan for a * `Depends-on:` line). `null` when no transport is usable. */ export interface PrMeta { title: string | null; body: string; /** The PR's head branch name (e.g. `feat/issue-12`). Drives the c8ctl harness's isolated * workspace checkout (`io.nanobpm.agentTask.repository.ref`) so the review agent lands on the * PR branch instead of the worker's launch directory. `null` when GitHub doesn't return it. */ headRef: string | null; /** The PR's base branch name (e.g. `main`). Emitted in the repository envelope so the c8ctl * harness fetches the base tip alongside the single-branch head clone, keeping `git diff * origin/...HEAD` (the review 3-dot diff) computable. `null` when GitHub doesn't return it. */ baseRef: string | null; } export async function fetchPrMeta( repo: string, number: number | string, token: string, ): Promise { if (await useGh()) { const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "title,body,headRefName,baseRefName"]); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape const j = JSON.parse(out) as { title?: string; body?: string; headRefName?: string | null; baseRefName?: string | null }; return { title: j.title ?? null, body: j.body ?? "", headRef: j.headRefName ?? null, baseRef: j.baseRefName ?? null }; } if (!token) return null; const r = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" }, }); if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim()); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape const j = (await r.json()) as { title?: string; body?: string; head?: { ref?: string | null }; base?: { ref?: string | null } }; return { title: j.title ?? null, body: j.body ?? "", headRef: j.head?.ref ?? null, baseRef: j.base?.ref ?? null }; } /** Fetch an issue's title via the configured transport, mirroring `fetchPrMeta` (both `gh` and * token transports). Best-effort and tolerant of failure: returns `null` when no transport is * usable OR when the fetch fails/returns no title, so a caller can label a row with the real issue * title on success and fall back to the `owner/repo#N` key otherwise — a title fetch must never * block an epic/feature start. Unlike the merge-stage reads it does NOT throw on a transport * failure; the identity it feeds is cosmetic, not a correctness gate. */ export async function fetchIssueTitle( repo: string, number: number | string, token: string, ): Promise { try { if (await useGh()) { const out = await runGh(["issue", "view", String(number), "--repo", repo, "--json", "title"]); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape const j = JSON.parse(out) as { title?: string }; return j.title ?? null; } if (!token) return null; // token mode with no token → no identity to fetch (caller falls back to the key) const r = await fetch(`https://api.github.com/repos/${repo}/issues/${number}`, { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" }, }); if (!r.ok) return null; // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape const j = (await r.json()) as { title?: string }; return j.title ?? null; } catch { return null; } } /** Coalesce best-effort title candidates to a non-blank identity for the title-led grids (issue * #248). A candidate that is null/undefined OR blank/whitespace-only is treated as missing — * external data (`fetchIssueTitle`/`fetchPrMeta`) can legitimately return `""`, which `??` would * wrongly persist as a blank identity cell. Returns the first non-blank candidate, else the last * one (the caller's key fallback, which is always non-blank). Mirrors the 036 backfill's * `trim(title) = ''` test so write-time and backfill agree. */ export function coalesceTitle(...candidates: (string | null | undefined)[]): string { for (const c of candidates) { if (c != null && c.trim() !== "") return c; } return candidates[candidates.length - 1] ?? ""; } /** A PR's merge state, narrowed to what the merge poller needs to classify landability. * `mergeStateStatus` uses GitHub's vocabulary (CLEAN | BLOCKED | BEHIND | DIRTY | UNSTABLE | * DRAFT | HAS_HOOKS | UNKNOWN). `failingChecks` is `-1` when the transport can't enumerate * checks (token mode) so the classifier stays conservative. `failingCheckNames` lists those * failing gates (empty in token mode) so the CI-fix agent knows what to make green. */ export interface PrState { merged: boolean; /** GitHub's high-level PR lifecycle state, normalised to `"open" | "closed" | "merged"`. A PR * closed *without* merging reports `"closed"` (GitHub also reports a merged PR as `"closed"` on * the REST list, but `merged` disambiguates it). Lets a caller gate on PR liveness — see * `classifyPrLiveness` — so neither loop escalates against a non-open PR (#342). */ state: "open" | "closed" | "merged"; mergeStateStatus: string; failingChecks: number; failingCheckNames: string[]; /** Total head check runs of any state (pending/failed/passed). `0` = no run exists at all (the * frugal-CI stuck state the fresh-head-run remedy targets); `-1` when the transport can't * enumerate checks (token mode). */ totalChecks: number; /** Names of every head check present in any state (pending/failed/passed). Empty in token mode * (the REST fallback can't enumerate checks). Lets the fresh-head-run remedy judge whether the * repo's *required* checks (per its merge protocol) are actually present on the head — an * unrelated always-on check (e.g. Mergify's "Merge Queue") must not read as "the required run * already happened". */ presentCheckNames: string[]; /** Names of every head check still in flight (queued/in progress, not yet concluded and not a hard * failure), derived over the newest run per check (`pendingCheckNames`). Empty in token mode (the * REST fallback can't enumerate checks). Lets `classifyMergeability` hold a merge when a * declared-required check has not yet concluded, without re-deriving conclusions by hand. */ pendingCheckNames: string[]; /** The newest concluded conclusion per head check (name → uppercase conclusion, e.g. `SUCCESS` / * `FAILURE` / `NEUTRAL` / `SKIPPED`), derived over `latestRunPerCheck` so a `CANCELLED` run * superseded by a newer green run on the same head reports the green result (#348). A still-pending * run maps to `""` (it has no conclusion yet — use `pendingCheckNames`). Empty in token mode. Lets * `classifyMergeability` honour a required check's `acceptedConclusions` precisely. */ checkConclusions: Record; /** Whether the PR is a draft (a fresh head run is produced by marking it ready, not reopen). */ isDraft: boolean; /** Current head commit. Used to scope one-shot merge-protocol nudges to a landing attempt. */ headRefOid: string | null; /** GROUND-TRUTH native-merge-queue membership, populated only when `fetchPrState` is called with * `{ withMergeQueue: true }` (the block-4 queued-PR reconciliation) — otherwise `null`. It lets the * poller OBSERVE a queue eviction instead of inferring "still queued" from `mergeStateStatus` * alone (which cannot see a CI-on-`merge_group` eviction — the head reverts to BLOCKED/UNSTABLE/ * CLEAN, never DIRTY). Tri-state: * • `true` — the PR is currently enrolled in the repo's native GitHub merge queue. * • `false` — the base branch HAS a native merge queue but the PR is NO LONGER in it (a genuine * eviction: CI failed on the speculative `merge_group` commit, the base moved, or a manual * dequeue). `queuedVerdict` turns this into `evicted` → `arm-merge` re-drives the mergeable gate. * • `null` — indeterminate: not probed, no usable transport, a transport error, OR the base * branch has no native merge queue at all (a Mergify/plain-merge repo — see #556). A * perpetually-null entry on such a repo must NOT read as an eviction, so the classifier stays * conservative and leaves the `landedWaitTimeout` human backstop to cover a never-lands wedge. */ mergeQueueEntry: boolean | null; } /** Map GitHub's REST `mergeable_state` (lower-case) onto the GraphQL `mergeStateStatus` * vocabulary the classifier speaks, so both transports feed one code path. */ function normalizeMergeState(s: string): string { return (s || "unknown").toUpperCase(); } interface RollupEntry { status?: string; conclusion?: string; state?: string; name?: string; context?: string; workflowName?: string; /** CheckRun timestamps (GraphQL `statusCheckRollup`). A superseded run and the newer run that * replaced it carry the same check name but different times, so they order the runs of one check. * StatusContext carries `createdAt` instead. All are ISO-8601 or absent. */ startedAt?: string; completedAt?: string; createdAt?: string; } /** The canonical identity of a check across its reruns: its name (CheckRun) or context * (StatusContext), falling back to its `workflowName` and finally the sentinel `"check"` when * neither is present. GitHub CI concurrency can leave several runs of the SAME check on one head * commit — a superseded run plus the newer run that replaced it — so this is what we group by. */ function checkKey(c: RollupEntry): string { return c.name || c.context || c.workflowName || "check"; } /** A run's ordering timestamp (newest wins): its completion, else its start, else its creation. * `0` when none is present (the shape carries no time) so a timed run always outranks an untimed * one. */ function runOrder(c: RollupEntry): number { const t = c.completedAt || c.startedAt || c.createdAt; if (!t) return 0; const ms = Date.parse(t); return Number.isNaN(ms) ? 0 : ms; } /** True when a run's conclusion is `CANCELLED` — the state GitHub CI concurrency stamps on a run it * supersedes with a newer run on the identical head SHA (a stale/transient cancellation, not a code * defect). */ function isCancelled(c: RollupEntry): boolean { return (c.conclusion || c.state || "").toUpperCase() === "CANCELLED"; } /** Collapse a head's rollup to the **newest run per check**. GitHub's CI-concurrency cancellation * (issue #348) leaves BOTH a superseded run (stamped `CANCELLED`) and the newer run that replaced * it on the *same head SHA*, under the same check name. Counting the stale `CANCELLED` as a failure * escalates a self-healing PR whose head is actually green. The rollup is already scoped to the head * commit, so grouping by check name and keeping the newest run per name yields one ground-truth * conclusion per `(headSha, checkName)`. Ties (equal/absent timestamps) prefer a non-`CANCELLED` * run, so a superseded cancellation never shadows the real result even when GitHub omits times. */ export function latestRunPerCheck(rollup: RollupEntry[]): RollupEntry[] { const newest = new Map(); for (const c of rollup) { const key = checkKey(c); const prev = newest.get(key); if (prev === undefined) { newest.set(key, c); continue; } const dt = runOrder(c) - runOrder(prev); if (dt > 0) { newest.set(key, c); } else if (dt === 0 && isCancelled(prev) && !isCancelled(c)) { // Same/unknown time: a CANCELLED run is the superseded one — the real result wins. newest.set(key, c); } } return [...newest.values()]; } /** Names of the checks whose result is a hard failure (as opposed to pending/success). Covers * both the CheckRun shape (`conclusion` + `name`/`workflowName`) and the legacy StatusContext * shape (`state` + `context`). The names are what the CI-fix agent is handed so it knows which * gates to make green; `failingChecks` (the count) is derived from this list. Derivation is over the * **newest run per check** (`latestRunPerCheck`) so a `CANCELLED` run superseded by a newer green * run on the identical head SHA is not counted as a failing gate (issue #348). */ export function failingCheckNames(rollup: RollupEntry[]): string[] { const bad = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "ERROR"]); const names: string[] = []; for (const c of latestRunPerCheck(rollup)) { const v = (c.conclusion || c.state || "").toUpperCase(); if (bad.has(v)) names.push(checkKey(c)); } return names; } /** Names of checks that are still in flight — queued or in progress, i.e. NOT yet complete and not a * hard failure. Covers the CheckRun shape (`status` QUEUED/IN_PROGRESS/PENDING/WAITING/… anything but * COMPLETED) and the legacy StatusContext shape (`state` PENDING/EXPECTED). Derived over the newest * run per check (`latestRunPerCheck`) like {@link failingCheckNames}, so a superseded run doesn't * linger as pending. A `checks-green` gate MUST count these so it never reports green while a run has * not yet concluded (a pending run has no failing conclusion, so it would otherwise slip through). */ export function pendingCheckNames(rollup: RollupEntry[]): string[] { const names: string[] = []; for (const c of latestRunPerCheck(rollup)) { const status = (c.status || "").toUpperCase(); if (status !== "") { // CheckRun: anything other than COMPLETED is still running/queued. if (status !== "COMPLETED") names.push(checkKey(c)); } else { // Legacy StatusContext: PENDING/EXPECTED are not-yet-concluded. const state = (c.state || "").toUpperCase(); if (state === "PENDING" || state === "EXPECTED") names.push(checkKey(c)); } } return names; } /** Names of every head check present, regardless of state. Covers both the CheckRun shape * (`name`/`workflowName`) and the legacy StatusContext shape (`context`). Used to test whether a * repo's *required* checks are present on the head — so an unrelated always-on check (e.g. * Mergify's "Merge Queue") doesn't masquerade as the required CI run having already happened. * Deduped to the newest run per check so a superseded rerun doesn't list a check name twice. */ export function allCheckNames(rollup: RollupEntry[]): string[] { const names: string[] = []; for (const c of latestRunPerCheck(rollup)) { const name = c.name || c.context || c.workflowName; if (name) names.push(name); } return names; } /** The ground-truth conclusion of each head check, keyed by check name, derived over the **newest run * per check** (`latestRunPerCheck`) so a `CANCELLED` run superseded by a newer green run on the * identical head SHA reports the green result, not the stale cancellation (issue #348). The value is * the run's `conclusion` (CheckRun) or `state` (legacy StatusContext), upper-cased; a still-in-flight * run that has not concluded maps to `""` (it has no conclusion — `pendingCheckNames` tracks those). * In-flight is normalised to `""` for BOTH shapes: a CheckRun whose `status` is not `COMPLETED`, and a * legacy StatusContext whose `state` is `PENDING`/`EXPECTED`, so a caller never mistakes a pending * status-context's `PENDING`/`EXPECTED` `state` for a terminal conclusion. * Lets `classifyMergeability` intersect a repo's declared `requiredChecks` against actual head * conclusions and honour each check's `acceptedConclusions` without re-deriving per-run state. */ export function checkConclusions(rollup: RollupEntry[]): Record { const out: Record = {}; for (const c of latestRunPerCheck(rollup)) { const status = (c.status || "").toUpperCase(); const state = (c.state || "").toUpperCase(); // A still-in-flight run has no terminal conclusion — normalise both shapes to "" (mirrors // `pendingCheckNames`): CheckRun status != COMPLETED, or legacy StatusContext state PENDING/EXPECTED. const inFlight = status !== "" ? status !== "COMPLETED" : state === "PENDING" || state === "EXPECTED"; out[checkKey(c)] = inFlight ? "" : (c.conclusion || c.state || "").toUpperCase(); } return out; } /** True when `err` is GitHub reporting that a ref which parsed as `owner/repo#N` is not a pull * request — either it's an issue (issues and PRs share GitHub's number space, so an issue number * is indistinguishable from a PR number by shape alone) or the number does not exist. Both * transports surface here: `gh` mode throws the GraphQL message "Could not resolve to a * PullRequest with the number of N", and token mode throws `github 404 …` from * `GET /repos/{repo}/pulls/{N}`. A ref that is not a pull request can never merge, so a caller * gating a merge queue on it (see `isDepMerged`) must treat it as non-blocking instead of wedging * forever. Transient failures (rate-limit, 5xx, network) deliberately return `false` so the caller * keeps waiting/retrying rather than silently clearing a real dependency. */ export function isNotAPullRequestError(err: unknown): boolean { const msg = err instanceof Error ? err.message : String(err); return /could not resolve to a pullrequest/i.test(msg) || /\bgithub 404\b/i.test(msg); } /** GraphQL response for the merge-queue membership probe. Both transports (`gh api graphql` and the * raw GraphQL endpoint) wrap the payload in a top-level `data`. */ interface MergeQueueMembershipResponse { data?: { repository?: { mergeQueue?: { id?: string } | null; pullRequest?: { mergeQueueEntry?: { id?: string } | null } | null; } | null; } | null; } /** Read GROUND-TRUTH native-merge-queue membership for a PR the merge loop enqueued (parked at * `wait-landed`), so an eviction is OBSERVED rather than inferred from `mergeStateStatus` (which * cannot see a CI-on-`merge_group` eviction — the head reverts to BLOCKED/UNSTABLE/CLEAN, never * DIRTY). Returns: * • `true` — the PR is currently enrolled in the base branch's native GitHub merge queue. * • `false` — the base branch HAS a native merge queue but the PR is NO LONGER in it (a genuine * eviction: CI failed on the speculative `merge_group` commit, the base moved, or a manual * dequeue). * • `null` — indeterminate: no usable transport / a transport error, OR the base branch has no * native merge queue at all (a Mergify/plain-merge repo whose "queued" classification came from * an ambiguous signal — #556). We gate on `repository.mergeQueue` existing first so a * perpetually-null `mergeQueueEntry` on such a repo is never mistaken for an eviction; the * `landedWaitTimeout` human backstop covers the genuinely-never-lands case there. * Never throws — any failure degrades to `null` so the poller keeps waiting rather than falsely * evicting a still-legitimately-queuing PR. */ export async function fetchMergeQueueMembership( repo: string, number: number | string, baseBranch: string, token: string, ): Promise { if (!baseBranch) return null; // can't scope `mergeQueue(branch:)` without the base ref → stay conservative const [owner, name] = repo.split("/"); const query = "query($o:String!,$r:String!,$n:Int!,$b:String!){repository(owner:$o,name:$r){" + "mergeQueue(branch:$b){id}pullRequest(number:$n){mergeQueueEntry{id}}}}"; const mode = githubTransport(); const useGhHere = mode === "gh" || (mode === "auto" && (await isGhAvailable())); if (!useGhHere && !token) return null; try { let payload: MergeQueueMembershipResponse; if (useGhHere) { const out = await runGh([ "api", "graphql", "-f", `query=${query}`, "-f", `o=${owner}`, "-f", `r=${name}`, "-F", `n=${number}`, "-f", `b=${baseBranch}`, ]); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape payload = JSON.parse(out) as MergeQueueMembershipResponse; } else { const r = await fetch("https://api.github.com/graphql", { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ query, variables: { o: owner, r: name, n: Number(number), b: baseBranch } }), }); if (!r.ok) return null; // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape payload = (await r.json()) as MergeQueueMembershipResponse; } const repository = payload.data?.repository; if (!repository) return null; // unreadable / GraphQL error → indeterminate // No native merge queue on this base branch → an eviction is unobservable here (Mergify/plain). if (!repository.mergeQueue) return null; // A missing `pullRequest` (partial GraphQL `data` alongside `errors`, or an unreadable PR) is // NOT an eviction — treat it as indeterminate so a transport hiccup can't thrash `arm-merge`. const pr = repository.pullRequest; if (pr == null) return null; // Native queue exists and the PR is readable: enrolled iff it still carries a live queue entry. return pr.mergeQueueEntry != null; } catch { // A transport/parse failure must not falsely evict — stay conservative. return null; } } export async function fetchPrState( repo: string, number: number | string, token: string, opts?: { withMergeQueue?: boolean }, ): Promise { if (await useGh()) { const out = await runGh([ "pr", "view", String(number), "--repo", repo, "--json", "state,mergedAt,mergeStateStatus,statusCheckRollup,isDraft,headRefOid,baseRefName", ]); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape const j = JSON.parse(out) as { state?: string; mergedAt?: string | null; mergeStateStatus?: string; statusCheckRollup?: RollupEntry[]; isDraft?: boolean; headRefOid?: string | null; baseRefName?: string | null; }; const rollup = j.statusCheckRollup ?? []; const names = failingCheckNames(rollup); const merged = j.state === "MERGED" || !!j.mergedAt; // Probe native-queue membership only for the queued-PR reconciliation (block 4) — it is an extra // GraphQL round-trip, so every other caller leaves `mergeQueueEntry` null (unprobed). const mergeQueueEntry = opts?.withMergeQueue ? await fetchMergeQueueMembership(repo, number, j.baseRefName ?? "", token) : null; return { merged, state: merged ? "merged" : (j.state ?? "").toUpperCase() === "CLOSED" ? "closed" : "open", mergeStateStatus: (j.mergeStateStatus || "UNKNOWN").toUpperCase(), failingChecks: names.length, failingCheckNames: names, totalChecks: rollup.length, presentCheckNames: allCheckNames(rollup), pendingCheckNames: pendingCheckNames(rollup), checkConclusions: checkConclusions(rollup), isDraft: !!j.isDraft, headRefOid: j.headRefOid ?? null, mergeQueueEntry, }; } if (!token) return null; const r = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" }, }); if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim()); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape const j = (await r.json()) as { merged?: boolean; merged_at?: string | null; state?: string; mergeable_state?: string; draft?: boolean; head?: { sha?: string | null }; base?: { ref?: string | null }; }; const restMerged = !!j.merged || !!j.merged_at; const mergeQueueEntry = opts?.withMergeQueue ? await fetchMergeQueueMembership(repo, number, j.base?.ref ?? "", token) : null; return { // The single-PR GET returns a `merged` boolean (unlike the list endpoint); we also honour // `merged_at` so this mirrors the gh branch's `state === "MERGED" || mergedAt` rule. merged: restMerged, // REST reports a merged PR as `state:"closed"` too, so `merged` disambiguates: a `closed` PR // here is genuinely closed WITHOUT merging (e.g. superseded) — the #342 abandon case. state: restMerged ? "merged" : (j.state ?? "").toLowerCase() === "closed" ? "closed" : "open", mergeStateStatus: normalizeMergeState(j.mergeable_state ?? "unknown"), failingChecks: -1, // REST here doesn't enumerate checks → classifier treats BLOCKED as "wait" failingCheckNames: [], // …and the CI-fix agent gets no per-check list in token mode totalChecks: -1, // …and the fresh-head-run remedy stays conservative (never reopens blind) presentCheckNames: [], // …can't enumerate checks in token mode → no required-check presence signal pendingCheckNames: [], // …no per-check pending signal either → classifier degrades to today's switch checkConclusions: {}, // …no per-check conclusions → protocol-aware gate falls through in token mode isDraft: !!j.draft, headRefOid: j.head?.sha ?? null, mergeQueueEntry, // native-queue membership (GraphQL), probed only for block-4 reconciliation }; } /** Map a PR's live GitHub state to one **liveness** verdict shared by both durable loops (merge + * convergence), so neither can ever escalate against a non-open PR (#342): * * • `open` — proceed with the normal protocol. * • `merged` — already landed (out-of-band); complete the loop as merged. * • `closed` — closed on GitHub WITHOUT merging (e.g. superseded); the PR can never merge, so * the loop must **abandon** (terminate) it — NOT escalate a merge no human can * complete. This is terminal state, not a human decision. * • `unknown` — a transport hiccup left us without live state (`fetchPrState` returned null); * stay conservative and fall through to the normal path rather than abandoning a * PR we could not read. * * Deriving all three from one source keeps a single canonical liveness gate instead of each loop * re-implementing `pre?.merged`/closed checks against drifting field names. */ export function classifyPrLiveness(pre: PrState | null): "open" | "merged" | "closed" | "unknown" { if (!pre) return "unknown"; if (pre.merged) return "merged"; if (pre.state === "closed") return "closed"; return "open"; } /** The changed file paths of a PR (for the D2 conflict-scan, #58). `gh` returns them directly; * the token transport pages `/pulls/{n}/files` (100/page, capped). Returns `null` when no * transport is usable (idle), an empty array for a PR with no files. */ export async function fetchPrFiles( repo: string, number: number | string, token: string, ): Promise { if (await useGh()) { const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "files"]); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape const j = JSON.parse(out) as { files?: { path?: string }[] }; return (j.files ?? []).map((f) => f.path ?? "").filter((p) => p !== ""); } if (!token) return null; const paths: string[] = []; // Cap the paging so a freak huge PR can't spin the scan; 5×100 files is far past any real slice. const MAX_PAGES = 5; for (let page = 1; page <= MAX_PAGES; page++) { const r = await fetch( `https://api.github.com/repos/${repo}/pulls/${number}/files?per_page=100&page=${page}`, { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" } }, ); if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim()); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape const batch = (await r.json()) as { filename?: string }[]; for (const f of batch) if (f.filename) paths.push(f.filename); // A short final page means we've read every file — the list is complete. if (batch.length < 100) return paths; // A full page on the last allowed page is only truncated if GitHub says there's more. Trust the // `Link` header's `rel="next"` rather than page size, so an exact multiple of 100 (e.g. exactly // 500 files, no next page) is returned as complete instead of throwing a false positive. When // the cap genuinely truncates, throw so the caller can log-and-skip rather than recording // exclusions from an incomplete (under-approximated) file set that could miss real overlaps. if (page === MAX_PAGES && /<[^>]*>;\s*rel="next"/.test(r.headers.get("link") ?? "")) { throw new Error( `github pr files truncated: ${repo}#${number} exceeds ${MAX_PAGES * 100}-file paging cap`, ); } } return paths; } /** The PR head ref/sha for D3's trial-merge gate. `null` when no transport is usable. `headRepo` is * the head branch's OWNING repository as `owner/repo` — the FORK for a cross-repo PR, else the base * repo — so a caller that resolves the head ref (e.g. the no-progress head reader, #786) queries the * repository the head branch actually lives in, not the base repo (where a same-named branch would * resolve to an unrelated SHA). `null` when the head repository cannot be resolved (e.g. a deleted * fork). */ export async function fetchPrHead( repo: string, number: number | string, token: string, ): Promise<{ headRef: string | null; headSha: string | null; baseRef: string | null; headRepo: string | null } | null> { if (await useGh()) { const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "headRefName,headRefOid,baseRefName,headRepository,headRepositoryOwner"]); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape const j = JSON.parse(out) as { headRefName?: string | null; headRefOid?: string | null; baseRefName?: string | null; headRepository?: { name?: string | null } | null; headRepositoryOwner?: { login?: string | null } | null }; const owner = j.headRepositoryOwner?.login; const name = j.headRepository?.name; const headRepo = owner && name ? `${owner}/${name}` : null; return { headRef: j.headRefName ?? null, headSha: j.headRefOid ?? null, baseRef: j.baseRefName ?? null, headRepo }; } if (!token) return null; const r = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" }, }); if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim()); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape const j = (await r.json()) as { head?: { ref?: string | null; sha?: string | null; repo?: { full_name?: string | null } | null }; base?: { ref?: string | null } }; return { headRef: j.head?.ref ?? null, headSha: j.head?.sha ?? null, baseRef: j.base?.ref ?? null, headRepo: j.head?.repo?.full_name ?? null }; } /** The head commit SHA of `branch` on `repo`, read from the git-ref endpoint * (`git/ref/heads/`) — the ref that GitHub updates ATOMICALLY with the push, unlike a PR * object's `head.sha`, which is an asynchronously-denormalized projection that can briefly report a * stale-but-valid SHA after a push. The no-progress guard (#786) reads this in preference to the PR * head so a lagging PR denormalization can never fabricate a no-advance escalation. `null` when the * branch does not exist (a 404) or no transport is usable; throws only on a genuine transport * failure. */ export async function fetchBranchHead( repo: string, branch: string, token: string, ): Promise { // Honor the documented no-transport contract at this public boundary, exactly like the sibling // readers `fetchPrHead`/`fetchPrBase`: with no `gh` CLI and no token there is no usable transport, // which is the idle "unknown" case → `null`, NOT an exception. The internal `branchHeadSha` still // throws in that case for `ensureBaseBranch`'s callers, which treat a missing transport as a hard // failure; this wrapper's `Promise` contract promises `null` instead. if (!(await useGh()) && !token) return null; return branchHeadSha(repo, branch, token); } /** The PR's current base branch ref — the branch this PR would land *into*. `null` when no * transport is usable (idle). Used by the dead-end-base guard (#60) so we never land a PR into a * base that has itself already merged to the default branch. */ export async function fetchPrBase( repo: string, number: number | string, token: string, ): Promise { if (await useGh()) { const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "baseRefName"]); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape const j = JSON.parse(out) as { baseRefName?: string }; return j.baseRefName ?? null; } if (!token) return null; const r = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" }, }); if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim()); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape const j = (await r.json()) as { base?: { ref?: string } }; return j.base?.ref ?? null; } const defaultBranchCache = new Map(); const DEFAULT_BRANCH_TTL_MS = 5 * 60_000; /** The repo's default branch (e.g. `main`), memoized per repo for 5 min. A PR that targets the * default branch can never be a dead-end, so the guard short-circuits on it. `null` when no * transport is usable. */ export async function fetchDefaultBranch(repo: string, token: string): Promise { const hit = defaultBranchCache.get(repo); if (hit && Date.now() - hit.at < DEFAULT_BRANCH_TTL_MS) return hit.name; let name: string | null = null; if (await useGh()) { const out = await runGh(["repo", "view", repo, "--json", "defaultBranchRef"]); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape const j = JSON.parse(out) as { defaultBranchRef?: { name?: string } }; name = j.defaultBranchRef?.name ?? null; } else if (token) { const r = await fetch(`https://api.github.com/repos/${repo}`, { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" }, }); if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim()); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape const j = (await r.json()) as { default_branch?: string }; name = j.default_branch ?? null; } else { return null; // no transport → leave the cache untouched so a later call can resolve it } defaultBranchCache.set(repo, { at: Date.now(), name }); return name; } /** Test-only: drop the memoized default-branch entries so a suite can't leak a warmed cache * (which ignores transport/token state on a hit) into a test that expects a cold lookup. */ export function resetDefaultBranchCache(): void { defaultBranchCache.clear(); } /** Whether a branch has already *landed* — i.e. it is the head of a `MERGED` PR. Returns: * • `landed` — a merged PR exists from this branch → the branch is a dead-end target * • `open` — an open PR exists from it (still alive) * • `unknown` — no PR references it, or no transport (ambiguous → never treated as dead-end) * The guard blocks a merge only on a positive `landed` signal, so a valid stacked merge is never * wrongly held. */ export async function baseBranchLanded( repo: string, branch: string, token: string, ): Promise<"landed" | "open" | "unknown"> { if (await useGh()) { const out = await runGh([ "pr", "list", "--repo", repo, "--head", branch, "--state", "all", "--json", "state", "--limit", "20", ]); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape const arr = JSON.parse(out) as { state?: string }[]; if (arr.some((p) => (p.state ?? "").toUpperCase() === "MERGED")) return "landed"; if (arr.some((p) => (p.state ?? "").toUpperCase() === "OPEN")) return "open"; return "unknown"; } if (!token) return "unknown"; const owner = repo.split("/")[0]; const r = await fetch( `https://api.github.com/repos/${repo}/pulls?state=all&head=${encodeURIComponent(`${owner}:${branch}`)}&per_page=20`, { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" } }, ); if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim()); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape const arr = (await r.json()) as { state?: string; merged_at?: string | null }[]; if (arr.some((p) => p.merged_at || (p.state ?? "").toUpperCase() === "MERGED")) return "landed"; if (arr.some((p) => (p.state ?? "").toLowerCase() === "open")) return "open"; return "unknown"; } /** A settled landability verdict, or `waiting` when GitHub hasn't determined it yet (or is * still running checks / awaiting review). `draft` is a settled *not-landable* verdict: a draft PR * can never be merged (GitHub refuses it outright), regardless of its checks — so it outranks every * other signal and carries its own actionable remedy (mark it ready). The poller only advances the * process on a settled verdict; `waiting` means re-poll later. */ export type Mergeability = "ready" | "waiting" | "conflict" | "blocked" | "draft"; /** Intersect a repo's declared `requiredChecks` against the head's actual per-check conclusions — * an INDEPENDENT backstop that runs BEFORE the `mergeStateStatus` switch, so nwf never merges a red * required check even on a repo that has NOT wired that check as a GitHub-required status check * (issue #392). Returns: * • `"blocked"` — a declared-required check is present, concluded, and its conclusion is NOT in that * check's `acceptedConclusions` (a hard failure like `FAILURE`, or any other unaccepted terminal * conclusion) → route to fix-ci, do not merge. * • `"waiting"` — a declared-required check is still pending, or absent from the head entirely * (not-yet-run counts as pending, NOT as pass): a declared-required check that has not * concluded is never mergeable. * • `"pass"` — every declared-required check is present and its conclusion accepted → fall through to * today's `mergeStateStatus` logic (GitHub branch protection stays the primary gate). * Degrades safely: with no declared `requiredChecks`, or in token mode where checks can't be * enumerated (`failingChecks < 0`, so the per-check lists are empty), it returns `"pass"` and never * newly blocks or waits — repos keep exactly today's behaviour. */ function requiredChecksVerdict(s: PrState, protocol?: MergeProtocol): "blocked" | "waiting" | "pass" { const required = protocol?.requiredChecks ?? []; if (required.length === 0) return "pass"; // Token mode: the transport can't enumerate checks (`failingChecks === -1`), so the per-check lists // are empty and absence is indistinguishable from not-yet-run. Do NOT newly block/wait — fall // through to today's `mergeStateStatus` behaviour. (A real gh-mode head with no checks yet reports // `failingChecks === 0`, so absence there is correctly treated as not-yet-run below.) if (s.failingChecks < 0) return "pass"; const present = new Set(s.presentCheckNames); const pending = new Set(s.pendingCheckNames); let anyBlocked = false; let anyPending = false; for (const rc of required) { // Absent from the head, or still in flight → not-yet-run → wait (never treat absence as pass). if (!present.has(rc.name) || pending.has(rc.name)) { anyPending = true; continue; } const conclusion = (s.checkConclusions[rc.name] ?? "").toUpperCase(); if (conclusion === "") { // Present but no terminal conclusion yet (and not flagged pending) — treat conservatively as // not-yet-concluded rather than as a pass. anyPending = true; continue; } const accepted = rc.acceptedConclusions.map((a) => a.toUpperCase()); if (accepted.includes(conclusion)) continue; // satisfied anyBlocked = true; // present, concluded, NOT accepted → a red required check } // A failing required check outranks a pending one: it needs fix-ci now, not more waiting. if (anyBlocked) return "blocked"; if (anyPending) return "waiting"; return "pass"; } export function classifyMergeability(s: PrState, protocol?: MergeProtocol): Mergeability { // A draft PR is NEVER landable — GitHub refuses the merge outright, whatever its checks say — so // draft outranks every other signal (issue #454). Surface it as a first-class verdict rather than // letting a green draft read as `CLEAN` → `"ready"` → an attempted merge that GitHub blocks with an // opaque "the merge did not land (blocked)" escalation. The remedy is always the same: mark it // ready. The poller self-heals this (mark-ready) when the repo's protocol wants a fresh head run, // else escalates with an actionable message. if (s.isDraft) return "draft"; // Protocol-aware backstop FIRST (issue #392): honour the repo's declared `requiredChecks` // against the actual head rollup, so an `UNSTABLE` PR with a red DECLARED-required // check is no longer blindly `ready`. This never weakens GitHub branch protection (the switch // below still gates) — it only tightens merges on repos that under-specify their required checks. const gate = requiredChecksVerdict(s, protocol); if (gate === "blocked") return "blocked"; if (gate === "waiting") return "waiting"; switch (s.mergeStateStatus) { case "CLEAN": case "HAS_HOOKS": case "UNSTABLE": // only non-required checks failing — still mergeable (no DECLARED-required red) case "BEHIND": // out of date; a queue rebases, a direct merge is still allowed return "ready"; case "DIRTY": return "conflict"; case "BLOCKED": // A required check failed -> a human must act. Pending checks / awaiting review -> wait. // When we can't enumerate checks (failingChecks < 0, token mode) stay conservative: wait. return s.failingChecks > 0 ? "blocked" : "waiting"; default: // UNKNOWN / "" — GitHub is still computing mergeability return "waiting"; } } export type MergeMethod = "squash" | "merge" | "rebase"; export interface MergeOptions { method: MergeMethod; admin: boolean; } export interface MergeResult { outcome: "merged" | "queued" | "blocked" | "retry"; detail: string; } /** GitHub-flagged *retryable* merge races: the base (or head) branch advanced between the * mergeability read and the merge mutation, so GitHub aborted the merge with a "… try the merge * again" message. These are transient — GitHub itself tells us to just retry — so the merge loop * must re-attempt on the settled base, NOT page a human. Matches GitHub's stable message across * both the GraphQL `mergePullRequest` error and its HTTP 405 REST variant. Kept narrow — the exact * " branch was modified" phrase — so a genuine block (conflict, failing required check, * 403 perms, 422 not-mergeable) is never swallowed as transient. */ export function isTransientMergeRace(detail: string): boolean { return /\b(?:base|head) branch was modified\b/i.test(detail); } /** Attempt to land the PR. Returns `merged` (landed now), `queued` (added to the repo's merge * queue — the poller then watches for it to land), `retry` (a transient base/head-moved race — * GitHub says to re-attempt on the settled base, no human needed), or `blocked` (GitHub refused — * a human must resolve it, then reply to retry). `null` when no transport is usable. Never throws * for a refused merge; only a genuine transport failure propagates. */ export async function mergePr( repo: string, number: number | string, token: string, opts: MergeOptions, ): Promise { const methodFlag = `--${opts.method}`; if (await useGh()) { const args = ["pr", "merge", String(number), "--repo", repo, methodFlag]; if (opts.admin) args.push("--admin"); try { const out = await runGh(args); // gh prints "… will be added to the merge queue" when the branch requires one. if (/merge queue/i.test(out)) return { outcome: "queued", detail: out.trim() }; return { outcome: "merged", detail: out.trim() || "merged" }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); // A merge-queue-required branch surfaces as an error on older gh; treat as queued when the // message says so, otherwise it is a genuine block (conflict, failing gate, perms). if (/added to the merge queue|enqueued/i.test(msg)) return { outcome: "queued", detail: msg }; // A base/head-moved race is transient (GitHub says to retry) — re-enter the merge loop // rather than escalate. Checked before the catch-all block so it is never swallowed as blocked. if (isTransientMergeRace(msg)) return { outcome: "retry", detail: msg }; return { outcome: "blocked", detail: msg }; } } if (!token) return null; const r = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}/merge`, { method: "PUT", headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "content-type": "application/json", }, body: JSON.stringify({ merge_method: opts.method }), }); if (r.ok) { // A 2xx from the REST merge endpoint does not guarantee the PR has *landed*: the body's // `merged` flag is authoritative, and a merge-queue-required branch is enrolled (not merged) // in this pass. Trust `merged` when true; otherwise verify the PR's actual state and report // `queued` when it hasn't landed yet, so the merge-loop waits for `merge-landed` rather than // marking it merged prematurely. // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape const body = (await r.json().catch(() => ({}))) as { merged?: boolean }; if (body.merged) return { outcome: "merged", detail: "merged" }; const st = await fetchPrState(repo, number, token).catch(() => null); if (st?.merged) return { outcome: "merged", detail: "merged" }; return { outcome: "queued", detail: "merge accepted; PR not yet landed (awaiting merge queue)" }; } const detail = `github ${r.status} ${r.statusText}: ${(await r.text()).slice(0, 300)}`.trim(); // The REST merge endpoint returns 405 "Base branch was modified. Review and try the merge again." // for the same transient race — classify it as retry, not a human-actionable block. if (isTransientMergeRace(detail)) return { outcome: "retry", detail }; return { outcome: "blocked", detail }; } // ── Merge-protocol execution helpers (issue #43) ──────────────────────────── // Two capabilities the frugal-CI + on-demand-queue landing protocol needs, on top of the plain // `gh pr merge` above: (a) read an arbitrary file from the target repo to discover its published // merge protocol, and (b) produce a fresh head `pull_request` run + enqueue via a comment. /** Read a text file from the *target* repo (default branch) via the configured transport, or * `null` when it doesn't exist / no transport is usable. Used to discover a repo's published * merge-protocol descriptor (see app/mergeProtocol.ts). Never throws on a 404 — a repo without * the file simply has no descriptor. */ export async function fetchRepoFile( repo: string, path: string, token: string, ): Promise { const apiPath = `repos/${repo}/contents/${path}`; if (await useGh()) { try { return await runGh(["api", apiPath, "-H", "Accept: application/vnd.github.raw"]); } catch { return null; // 404 / not found → no descriptor } } if (!token) return null; const r = await fetch(`https://api.github.com/${apiPath}`, { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github.raw" }, }); if (!r.ok) return null; return await r.text(); } /** Produce a fresh head `pull_request` run so branch protection has a run to count. `ready` marks * a draft ready (`gh pr ready`); `reopen` closes then reopens the PR (the `reopened` event fires a * fresh run). gh transport only — headless token mode can't reliably drive these, so it no-ops * (the poller then simply keeps waiting, i.e. today's behaviour). Best-effort: resolves even on * failure so a transient error never wedges the merge-loop. */ export async function ensureFreshHeadRun( repo: string, number: number | string, action: "ready" | "reopen", ): Promise { if (!(await useGh())) return false; const n = String(number); try { if (action === "ready") { await runGh(["pr", "ready", n, "--repo", repo]); } else { await runGh(["pr", "close", n, "--repo", repo]); await runGh(["pr", "reopen", n, "--repo", repo]); } return true; } catch { return false; } } /** Post a comment on the PR (e.g. `@mergifyio queue`) to enqueue it in the repo's merge queue. * gh transport shells out; token mode posts an issue comment via REST. Returns whether the * comment was accepted. */ export async function enqueueViaComment( repo: string, number: number | string, token: string, comment: string, ): Promise { const n = String(number); if (await useGh()) { try { await runGh(["pr", "comment", n, "--repo", repo, "--body", comment]); return true; } catch { return false; } } if (!token) return false; const r = await fetch(`https://api.github.com/repos/${repo}/issues/${n}/comments`, { method: "POST", headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "content-type": "application/json", }, body: JSON.stringify({ body: comment }), }); return r.ok; } // ── Epic base-branch admission (ADR 0003, rule 2) ─────────────────────────── // `ensureBaseBranch` is the create-if-missing primitive that guarantees an epic's integration // branch exists BEFORE any task fans out, with an `epic/*` guard so a typo can't silently spawn a // wrong-rooted branch. It is idempotent — an existing branch is a NO-OP (the ref is never reset, // which would nuke in-flight task PRs stacked on it) — so it is safe to call repeatedly: at // admission (fail fast), from the durable `ensure-base-branch` head task, and again on a re-plan. /** Thrown when a base branch that does NOT match the `epic/*` convention is missing. A * non-`epic/*` base must already exist — a mistyped name is an operator error, not something to * auto-create off the default branch (that would silently produce a wrong-rooted branch). */ export class BaseBranchMustExistError extends Error { readonly branch: string; constructor(branch: string) { super( `base branch "${branch}" does not exist and is not an epic/* branch, so it will not be ` + `auto-created — create it first, or use the epic/* convention for an auto-created ` + `integration branch`, ); this.name = "BaseBranchMustExistError"; this.branch = branch; } } /** Whether `branch` matches the auto-creatable `epic/*` convention (migration 019). */ function isEpicBranch(branch: string): boolean { return branch.startsWith("epic/"); } /** Resolve the head commit SHA of `branch` on `repo`, or `null` when the branch does not exist * (a 404 from the git-ref endpoint). Throws only on a genuine transport failure. */ async function branchHeadSha(repo: string, branch: string, token: string): Promise { // Percent-encode each ref SEGMENT (git permits `#`, `?`, spaces, etc. in a branch name) while // preserving the `/` separators that git uses for hierarchical refs (`feat/x`). Interpolating the // raw name would, in the direct `fetch` URL, let a `#` start a fragment (and `?` a query) — the // path is truncated, the wrong ref (or a 404) is read, and the no-progress guard fails open. gh // api receives the same already-encoded path. const encodedBranch = branch.split("/").map(encodeURIComponent).join("/"); const apiPath = `repos/${repo}/git/ref/heads/${encodedBranch}`; if (await useGh()) { try { const out = await runGh(["api", apiPath]); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape const j = JSON.parse(out) as { object?: { sha?: string } }; return j.object?.sha ?? null; } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (/\b404\b|not found|no such/i.test(msg)) return null; throw err; } } if (!token) throw new Error(`no GitHub transport available to read ${apiPath}`); const r = await fetch(`https://api.github.com/${apiPath}`, { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" }, }); if (r.status === 404) return null; if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim()); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape const j = (await r.json()) as { object?: { sha?: string } }; return j.object?.sha ?? null; } /** Create `refs/heads/` pointing at `sha`. Idempotent: a concurrent create / re-plan * that already made the ref (GitHub `422 Reference already exists`) is treated as a no-op. * Returns `true` when this call actually created the ref, `false` when it lost the race and the * ref already existed (the 422 case) — so the caller can report an honest exists/created outcome. */ async function createBranchRef( repo: string, branch: string, sha: string, token: string, ): Promise { const ref = `refs/heads/${branch}`; if (await useGh()) { try { await runGh(["api", `repos/${repo}/git/refs`, "-X", "POST", "-f", `ref=${ref}`, "-f", `sha=${sha}`]); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (/\b422\b|already exists/i.test(msg)) return false; // idempotent — someone else created it throw err; } return true; } if (!token) throw new Error(`no GitHub transport available to create ${ref}`); const r = await fetch(`https://api.github.com/repos/${repo}/git/refs`, { method: "POST", headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "content-type": "application/json", }, body: JSON.stringify({ ref, sha }), }); if (r.ok) return true; if (r.status === 422) return false; // reference already exists — idempotent throw new Error(`github ${r.status} ${r.statusText}: ${(await r.text()).slice(0, 300)}`.trim()); } /** The outcome of `ensureBaseBranch`: the branch was already present (`exists`, a no-op) or was * just created off the default branch HEAD (`created`). */ export type EnsureBaseBranchResult = "exists" | "created"; /** Guarantee the epic base `branch` exists on `repo` (ADR 0003 rule 2), idempotently: * • already exists → `"exists"` — NO-OP; the ref is never moved/reset. * • missing and matches `epic/*` → create `refs/heads/` off the default branch HEAD, * return `"created"`. * • missing and not `epic/*` → throw `BaseBranchMustExistError` (a non-`epic/*` base must * pre-exist; a typo must fail fast, not silently spawn a wrong-rooted branch). * Safe to call repeatedly (at admission AND as the durable head task, and on a re-plan). */ export async function ensureBaseBranch( repo: string, branch: string, token: string, ): Promise { const existing = await branchHeadSha(repo, branch, token); if (existing !== null) return "exists"; // never reset an existing ref if (!isEpicBranch(branch)) throw new BaseBranchMustExistError(branch); const defaultBranch = await fetchDefaultBranch(repo, token); if (!defaultBranch) { throw new Error(`cannot resolve the default branch of ${repo} to create ${branch}`); } const defaultSha = await branchHeadSha(repo, defaultBranch, token); if (!defaultSha) { throw new Error(`cannot resolve HEAD of default branch ${defaultBranch} on ${repo} to create ${branch}`); } // A concurrent create / re-plan may have raced us to the ref (GitHub 422); in that case it // already exists and we did not create it, so report "exists" rather than misleading "created". const created = await createBranchRef(repo, branch, defaultSha, token); return created ? "created" : "exists"; } // ── Epic promotion PR (issue #299) ────────────────────────────────────────── // Once an epic's slices have all merged into its `epic/*` integration branch, the poller opens a // single `epic/* → ` promotion PR to deliver the epic. These helpers are the GitHub side // of that: discover an already-open promotion PR (idempotency against a crash between create and // the DB write) and, when none exists, create it. /** A pull request discovered for a head branch — the subset the promotion idempotency check reads. */ export interface HeadPr { number: number; url: string; state: string; baseRef: string | null; } /** List the PRs (any state) whose HEAD branch is `headBranch` on `repo`. Used to reconcile the * promotion PR idempotently: an `epic/*` integration branch is only ever the HEAD of its promotion * PR (slices target it as their BASE), so any result is that promotion PR. Returns `null` when no * transport is usable (idle — the caller retries next pass). */ export async function listPrsForHead( repo: string, headBranch: string, token: string, ): Promise { if (await useGh()) { const out = await runGh([ "pr", "list", "--repo", repo, "--head", headBranch, "--state", "all", "--json", "number,url,state,baseRefName", "--limit", "20", ]); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape const arr = JSON.parse(out) as { number?: number; url?: string; state?: string; baseRefName?: string | null }[]; return arr.map((p) => ({ number: Number(p.number), url: p.url ?? "", state: (p.state ?? "").toLowerCase(), baseRef: p.baseRefName ?? null, })); } if (!token) return null; const owner = repo.split("/")[0]; const r = await fetch( `https://api.github.com/repos/${repo}/pulls?state=all&head=${encodeURIComponent(`${owner}:${headBranch}`)}&per_page=20`, { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" } }, ); if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim()); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape const arr = (await r.json()) as { number?: number; html_url?: string; state?: string; base?: { ref?: string | null } }[]; return arr.map((p) => ({ number: Number(p.number), url: p.html_url ?? "", state: (p.state ?? "").toLowerCase(), baseRef: p.base?.ref ?? null, })); } /** The identity of a freshly-created (or reused) PR. */ export interface CreatedPr { number: number; url: string; } /** Open a pull request from `headBranch` into `baseBranch` on `repo`. Returns the new PR's * number + URL, or `null` when no transport is usable (idle — the caller retries next pass). Throws * on a genuine create failure so the caller logs and retries rather than silently losing the PR. */ export async function createPullRequest( repo: string, headBranch: string, baseBranch: string, title: string, body: string, token: string, ): Promise { if (await useGh()) { const out = await runGh([ "pr", "create", "--repo", repo, "--base", baseBranch, "--head", headBranch, "--title", title, "--body", body, ]); // `gh pr create` prints the new PR's URL on stdout; parse its number from the canonical path. const url = out.trim().split(/\s+/).pop() ?? ""; const m = url.match(/\/pull\/(\d+)/); if (!m) throw new Error(`could not parse a PR number from \`gh pr create\` output: ${out.trim()}`); return { number: Number(m[1]), url }; } if (!token) return null; const r = await fetch(`https://api.github.com/repos/${repo}/pulls`, { method: "POST", headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "content-type": "application/json", }, body: JSON.stringify({ title, head: headBranch, base: baseBranch, body }), }); if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}: ${(await r.text()).slice(0, 300)}`.trim()); // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape const j = (await r.json()) as { number?: number; html_url?: string }; return { number: Number(j.number), url: j.html_url ?? "" }; } /** The outcome of `ensurePromotionPr`: the promotion PR's number + URL and whether THIS call * created it (`created: false` ⇒ an existing one was reused, keeping the open idempotent). */ export interface EnsurePromotionPrResult extends CreatedPr { created: boolean; } /** Idempotently guarantee the `headBranch → baseBranch` promotion PR exists on `repo`. First * reconciles against GitHub — an `epic/*` integration branch is only ever the HEAD of its own * promotion PR, so ANY open/merged PR from it IS that promotion PR and is reused (this closes the * window where a crash between GitHub-create and the DB write would otherwise duplicate the PR). * Only when none exists is a new one created. Returns `null` when no transport is usable. */ export async function ensurePromotionPr( repo: string, headBranch: string, baseBranch: string, title: string, body: string, token: string, ): Promise { const existing = await listPrsForHead(repo, headBranch, token); if (existing === null) return null; // no transport → retry next pass // Prefer a PR that already targets the intended base; otherwise reuse any PR from this branch // (the head is unique to the promotion PR, so this can only be it). const reuse = existing.find((p) => p.baseRef === baseBranch) ?? existing[0]; if (reuse && Number.isFinite(reuse.number) && reuse.number > 0) { return { number: reuse.number, url: reuse.url, created: false }; } const created = await createPullRequest(repo, headBranch, baseBranch, title, body, token); if (!created) return null; return { ...created, created: true }; }