/** * GitHub adapter for the tracker port. * * Every operation goes through the already-authenticated `gh` CLI, so the * daemon never handles a token itself: credentials stay in the user's keychain * or `gh` config and are never passed as argv, written to a file, or logged. * * The environment this adapter hands `gh` comes from `credentialedEnv()` — the * one named construction site of credential material in the daemon — so "what in * this package reaches the operator's GitHub credential" is answered by that * function's call sites rather than by auditing every spawn in the tree. It is a * legibility property, not a boundary: sessions run as the daemon's own user and * can reach the same credential. What keeps a run's mutations accountable is * that the daemon performs them, and records them. * * ponytail: shelling out to `gh` is the deliberate simplification. The ceiling * is per-call cost (one process spawn plus one TLS handshake per operation, * ~200-400ms) and failure classification by matching human-readable stderr * instead of reading a status code. Upgrade path when either bites: replace the * body of `gh()` with `fetch("https://api.github.com/...")` using a token from * `gh auth token`; the eleven Tracker methods above it stay untouched. */ import { credentialedEnv } from "../gitops.ts"; import { parsePrDiff } from "../diff-flags.ts"; import type { IssueComment, IssueState, IssueSnapshot, MergedPrInfo, OpenCloser, PrDiff, PrState, PrVerification, ProjectConfig, ReadyIssue, Tracker, WorkflowRun, } from "../types.ts"; /** * Pull requests that would close an issue, with the one field that decides it. * * `gh issue view --json closedByPullRequestsReferences` returns `id`, * `number`, `repository` and `url` and no state, so a guard built on it holds an * issue forever on a reference that is merged or closed-unmerged — which is * exactly what a reopened issue looks like. GraphQL is the only spelling that * yields `state`, so it is the spelling used. * * `includeClosedPrs:false` is deliberately not passed: it is not the filter it * sounds like. Measured on gh 2.86.0 (2026-08-06), `veltro#260` returned a * MERGED reference with the argument both true and false. The state is filtered * in this consumer instead. * * ponytail: one page of ten. Admission stops at the first OPEN closer; salvage * recovery searches that page for its exact branch and repository identity. */ const CLOSERS_QUERY = `query($owner:String!,$repo:String!,$n:Int!){ repository(owner:$owner,name:$repo){ issue(number:$n){ closedByPullRequestsReferences(first:10){ nodes{ number state isDraft url headRefName repository{ nameWithOwner } } } } } }`; const PARENT_QUERY = `query($owner:String!,$repo:String!,$n:Int!){ repository(owner:$owner,name:$repo){ issue(number:$n){ parent{ number } } } }`; /** The `gh api graphql` envelope for {@link CLOSERS_QUERY}. Every level is * nullable: a deleted or wrong-numbered issue answers `null`, not an error. */ interface ClosersResponse { data?: { repository?: { issue?: { closedByPullRequestsReferences?: { nodes?: | ({ state: string; isDraft: boolean; url: string; headRefName?: string; repository?: { nameWithOwner?: string } | null } | null)[] | null; } | null; } | null; } | null; } | null; } interface GhCheck { __typename?: string; name?: string; context?: string; status?: string; conclusion?: string; state?: string; /** When the run started (`startedAt` on a GraphQL CheckRun, `started_at` * over REST); a StatusContext stamps `createdAt` instead. Undefined when * the payload spelled none — unordered entries resolve pessimistically * (#1066), never invented. */ startedAt?: string; createdAt?: string; detailsUrl?: string; } interface GhPrVerification { state?: string; isDraft?: boolean; headRefOid?: string; statusCheckRollup?: GhCheck[] | null; } /** Carries the captured stderr so callers can classify a failure without * re-running the command or parsing the message text of a plain Error. */ export class GhError extends Error { readonly argv: string[]; readonly code: number; readonly stderr: string; /** * Whatever the command printed before it failed. * * Carried because a nonzero exit is not always a failure to *read*: `gh pr * checks` exits 1 when a check failed and 8 when one is pending, having * already written the complete JSON payload asked for. Discarding stdout here * made both CI failure classes unreachable on exactly the pull requests they * exist to classify — a red PR answered "no checks" (#132 follow-up). */ readonly stdout: string; constructor(argv: string[], code: number, stderr: string, stdout = "") { const detail = stderr.trim() || "(no stderr)"; super(`\`gh ${argv.join(" ")}\` exited ${code}: ${detail}`); this.name = "GhError"; this.argv = argv; this.code = code; this.stderr = stderr; this.stdout = stdout; } } /** * The one classified failure {@link Tracker.prState} throws (#779): the named * pull request definitively does not exist. It is thrown only for an * individual REST 404 that a same-repository pulls-list read has corroborated * (see {@link repoPullReadUnavailable}) — a deleted or never-created PR number * in a repository the credential can still read. A repository hidden from the * credential (private, lost token scope, SSO enforcement) also answers 404, * so without that corroboration a 404 stays "could not tell". * * Distinct from a plain {@link GhError} because a corroborated 404 is an * answer, not an outage: retrying can never conjure the PR, so the settle * sweep releases the stranded row — over its preserved branch — instead of * re-asking a nonexistent PR forever. Every other failure (a revoked token, a * flaky network, a server 5xx, an opaque 404) still resolves to `undefined` * from `prState`, and the port's "could not tell, ask again for free" callers * never see it. */ export class GhPrMissingError extends GhError { constructor(cause: GhError) { super(cause.argv, cause.code, cause.stderr, cause.stdout); this.name = "GhPrMissingError"; } } /** * The single execution path for this adapter. `stdin` is written to the child * and closed, which is how bodies containing newlines, backticks or a leading * `-` reach `gh` without ever being interpolated into argv. */ export async function gh(argv: string[], stdin?: string): Promise { const proc = Bun.spawn(["gh", ...argv], { // Always a closed stream: commands that do not read stdin see immediate // EOF instead of an open pipe nobody ends. stdin: new Blob([stdin ?? ""]), stdout: "pipe", stderr: "pipe", // Explicit rather than inherited, so that a future change to this process's // own environment could never silently break the tracker, and so that the // grep for `credentialedEnv` finds this call. env: credentialedEnv(), }); const [stdout, stderr, code] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]); const signal = proc.signalCode; if (code !== 0 || signal) { throw new GhError(argv, code, signal ? `${stderr}\nterminated by ${signal}` : stderr, stdout); } return stdout; } /** * True when a label edit failed only because the requested end state already * holds. Adding a label is idempotent server-side — `gh api -X POST * .../labels` answers 200 for an already-present label — but removing one that * is not present is a 404, and a concurrent daemon restart can easily race into * both. * * The quoted forms are gh's own: `gh issue edit --remove-label x` on a label * the *repository* does not define exits 1 with `'x' not found` (2.97.0), and * `gh api -X DELETE .../labels/x` on an absent label fails with the API's * `Label does not exist` / `HTTP 404`. For a removal both are the end state * already holding — a label nobody defined cannot be on an issue — while for * an add they are a genuine failure. Removing a label the repo defines but the * issue does not carry is already a silent success, so this path is reached * only by the undefined-label case, most often a state label an operator * declined to create at setup. */ function isLabelNoop(err: unknown, op: "add" | "remove"): boolean { if (!(err instanceof GhError)) return false; const stderr = err.stderr; return op === "add" ? /already (?:has|had|exists|applied|added)|label .* already/i.test(stderr) : /label does not exist|not labeled|does not have (?:that|the|this) label|label .* not found|not found on (?:this )?issue|'[^']+' not found|HTTP 404|Not Found/i.test( stderr, ); } /** Every OPEN closer in a `gh api graphql` reply. */ export function openClosers(raw: string): OpenCloser[] { const nodes = (JSON.parse(raw) as ClosersResponse).data?.repository?.issue?.closedByPullRequestsReferences ?.nodes ?? []; const closers: OpenCloser[] = []; for (const node of nodes) { if (node === null || node.state !== "OPEN") continue; closers.push({ url: node.url, headRefName: node.headRefName ?? "", repo: node.repository?.nameWithOwner ?? "", }); } return closers; } /** * The first OPEN closer, for admission's any-open guard. * * A draft counts: it is pushed work, and dispatching another worker duplicates * it. Missing branch or repository identity still guards admission; recovery * reads {@link openClosers} and refuses to adopt such a node. */ export function firstOpenCloser(raw: string): OpenCloser | undefined { return openClosers(raw)[0]; } /** * A pull request URL this adapter is willing to hand to `gh`. * * Load-bearing, not defensive: `gh pr view` also accepts a bare number or a * *branch name*, and resolves those against whatever repository the current * directory belongs to. So a `prUrl` that is not a URL would not fail — it would * be answered, confidently, about some other repository's pull request. A * settle sweep that acted on that answer would mark the wrong run merged. */ const PR_URL = /^https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/\d+\/?$/; /** * Split a pull request URL into the owner, repo and number the REST API path * spelling needs. The same shape {@link PR_URL} guards, with the parts * captured; undefined for anything that guard would reject, so callers can use * the two interchangeably. */ export function prUrlParts( url: string, ): { owner: string; repo: string; number: number } | undefined { const match = /^https?:\/\/[^\s/]+\/([^\s/]+)\/([^\s/]+)\/pull\/(\d+)\/?$/.exec(url); if (match === null || match[1] === undefined || match[2] === undefined || match[3] === undefined) { return undefined; } return { owner: match[1], repo: match[2], number: Number.parseInt(match[3], 10) }; } /** Parse the merge identity emitted by `gh pr view --json mergeCommit,baseRefName`. */ export function mergedPrInfoFrom(raw: string): MergedPrInfo | undefined { let parsed: unknown; try { parsed = JSON.parse(raw) as unknown; } catch { return undefined; } if ( parsed === null || typeof parsed !== "object" || !("mergeCommit" in parsed) || !("baseRefName" in parsed) ) { return undefined; } const commit = parsed.mergeCommit; const mergeSha = commit !== null && typeof commit === "object" && "oid" in commit ? commit.oid : undefined; const baseRef = parsed.baseRefName; return typeof mergeSha === "string" && /^[0-9a-f]{40,64}$/i.test(mergeSha) && typeof baseRef === "string" && baseRef.length > 0 ? { mergeSha, baseRef } : undefined; } /** Parse the commit identity emitted by GitHub's branch REST endpoint. */ export function branchHeadFrom(raw: string): string | undefined { let parsed: unknown; try { parsed = JSON.parse(raw) as unknown; } catch { return undefined; } if ( parsed === null || typeof parsed !== "object" || !("commit" in parsed) || parsed.commit === null || typeof parsed.commit !== "object" || !("sha" in parsed.commit) ) { return undefined; } const sha = parsed.commit.sha; return typeof sha === "string" && /^[0-9a-f]{40,64}$/i.test(sha) ? sha : undefined; } /** Parse an Actions workflow-run list, skipping malformed entries without * turning a valid empty list into an unreadable response. */ export function workflowRunsFrom(raw: string): WorkflowRun[] | undefined { let parsed: unknown; try { parsed = JSON.parse(raw) as unknown; } catch { return undefined; } if (parsed === null || typeof parsed !== "object" || !("workflow_runs" in parsed)) { return undefined; } const entries = parsed.workflow_runs; if (!Array.isArray(entries)) return undefined; const runs: WorkflowRun[] = []; for (const entry of entries) { if ( entry === null || typeof entry !== "object" || !("id" in entry) || !("workflow_id" in entry) || !("name" in entry) || !("status" in entry) || !("html_url" in entry) || !("created_at" in entry) ) { continue; } const id = entry.id; const workflowId = entry.workflow_id; const name = entry.name; const status = entry.status; const conclusion = "conclusion" in entry ? entry.conclusion : undefined; const url = entry.html_url; const createdAt = entry.created_at; if ( typeof id !== "number" || !Number.isSafeInteger(id) || id <= 0 || typeof workflowId !== "number" || !Number.isSafeInteger(workflowId) || workflowId <= 0 || typeof name !== "string" || name.length === 0 || typeof status !== "string" || status.length === 0 || typeof url !== "string" || url.length === 0 || typeof createdAt !== "string" || !Number.isFinite(Date.parse(createdAt)) ) { continue; } runs.push({ id, workflowId, name, status, ...(typeof conclusion === "string" && conclusion.length > 0 ? { conclusion } : {}), url, createdAt, }); } return runs; } /** * How much of a pull request's diff the settlement audit will hold in memory. * * The daemon runs every worker in its own process, so an unbounded read of a * PR that regenerated a 90 MB lockfile would be paid for out of the same * resident set the live workers are using. Truncation only ever loses trailing * files, which costs findings and never invents them, and the parsed diff * carries `truncated` so the report never presents a short read as a clean one. */ const MAX_DIFF_BYTES = 4 * 1024 * 1024; type CheckVerdict = PrVerification["status"]; function checkName(check: GhCheck): string { return check.name ?? check.context ?? "(unnamed check)"; } function checkVerdict(check: GhCheck): CheckVerdict { if (check.__typename === "StatusContext" || check.state !== undefined) { if (check.state === "SUCCESS") return "green"; if (check.state === "PENDING" || check.state === "EXPECTED") return "pending"; return "failed"; } if (check.status !== "COMPLETED") return "pending"; if ( check.conclusion === "SUCCESS" || check.conclusion === "SKIPPED" || check.conclusion === "NEUTRAL" ) { return "green"; } return "failed"; } /** One check normalized onto the verdict vocabulary, carrying whatever run * timestamp its payload spelled: undefined means unordered, which * {@link resolveNewestPerName} treats pessimistically. */ interface NamedVerdict { readonly name: string; readonly verdict: CheckVerdict; readonly state: string; readonly startedAt: string | undefined; } /** Epoch ms of an ISO stamp, or undefined when absent or unreadable — both * leave the entry unordered rather than inventing an age for it. */ function startedAtMs(startedAt: string | undefined): number | undefined { if (startedAt === undefined) return undefined; const ms = Date.parse(startedAt); return Number.isFinite(ms) ? ms : undefined; } /** Worse beats better when a name's deciding entries disagree. */ const VERDICT_RANK: Readonly> = { failed: 0, pending: 1, green: 2 }; /** * Decide every check name by its newest run, never by payload order (#1066). * * One head can carry several check-runs of one name — a re-run creates a new * run record without retiring the old one — and folding them first-match lets * a stale `skipped` mask a newer `failure`: exactly how veltro#717 nearly * merged green over its only upgrade-path check, whose failing run started * five minutes behind the skipped row of the same name. Entries are grouped * by name and each group is decided by its newest tier, GitHub's own checks-UI * rule: * * - timed entries at the maximum `started_at` decide; older runs of the name * cannot mask them, so a re-run that went green supersedes its red * predecessor; * - ties at one instant resolve pessimistically — a failure anywhere in the * deciding tier fails the name; * - an entry without a readable timestamp cannot be proven older than * anything, so it belongs to every deciding tier (pessimistic again). * * The representative kept per name is the deciding entry itself, so the * folds' refusal lines render the decided verdict's own spelling. */ function resolveNewestPerName(entries: readonly NamedVerdict[]): NamedVerdict[] { const groups = new Map(); for (const entry of entries) { const stamped = { entry, startedMs: startedAtMs(entry.startedAt) }; const group = groups.get(entry.name); if (group === undefined) groups.set(entry.name, [stamped]); else group.push(stamped); } const resolved: NamedVerdict[] = []; for (const group of groups.values()) { let newest = -Infinity; for (const { startedMs } of group) { if (startedMs !== undefined && startedMs > newest) newest = startedMs; } let decided: NamedVerdict | undefined; for (const { entry, startedMs } of group) { if (startedMs !== undefined && startedMs !== newest) continue; if (decided === undefined || VERDICT_RANK[entry.verdict] < VERDICT_RANK[decided.verdict]) { decided = entry; } } if (decided !== undefined) resolved.push(decided); } return resolved; } /** * Verify one `gh pr view --json state,isDraft,headRefOid,statusCheckRollup` * payload, optionally against a caller-observed head. A missing rollup is * pending rather than green because GitHub may not have created the checks yet. * * By default this answers the merge gate's question — "may this merge" — and a * non-open PR is a refusal (`expected OPEN`), so a mutating verb never acts on * a merged or closed PR. `opts.read` (the `conductor_pr_status` surface) asks * a different question: *what is this PR's state*, and a merged or closed PR * is reported as a fact rather than a failure — the merge gate paths never * pass `read`, so the `expected OPEN` refusal stays exactly where it gates. * * Same-name entries are decided by recency, not payload order (#1066): only * the newest run of a name answers for it, ties and unreadable stamps resolve * pessimistically, and the green count names de-duplicated checks. */ export function prVerificationFrom( raw: string, expectedHead?: string, opts?: { read?: boolean }, ): PrVerification { const pr = JSON.parse(raw) as GhPrVerification; if (typeof pr.headRefOid !== "string" || pr.headRefOid === "") { throw new Error("PR head is unavailable"); } const headSha = pr.headRefOid; if (pr.state !== "OPEN") { if (opts?.read === true) { const stateName = pr.state?.toLowerCase() ?? "unknown"; return { status: "failed", reason: `PR is ${stateName}; checks are unavailable for a non-open PR`, headSha, }; } return { status: "failed", reason: `PR is ${pr.state ?? "unknown"}, expected OPEN`, headSha }; } if (pr.isDraft !== false) { return { status: "failed", reason: "PR is draft or draft state is unknown", headSha }; } if (expectedHead !== undefined && headSha.toLowerCase() !== expectedHead.toLowerCase()) { return { status: "failed", reason: `PR head changed: expected ${expectedHead}, found ${headSha}`, headSha, }; } const checks = pr.statusCheckRollup ?? []; if (checks.length === 0) { return { status: "pending", reason: "GitHub has not reported any checks yet", headSha }; } // Same-name runs decide by recency, not payload order (#1066): the rollup // can carry an older run of a name beside a newer one, and first-match // folding would let the older verdict answer for the name. const resolved = resolveNewestPerName( checks.map((check): NamedVerdict => ({ name: checkName(check), verdict: checkVerdict(check), state: check.conclusion ?? check.state ?? "unknown", startedAt: check.startedAt ?? check.createdAt, })), ); const failed = resolved.filter((check) => check.verdict === "failed"); if (failed.length > 0) { return { status: "failed", reason: `Checks failed: ${failed.map((check) => `${check.name} (${check.state})`).join(", ")}`, headSha, }; } const pending = resolved.filter((check) => check.verdict === "pending"); if (pending.length > 0) { return { status: "pending", reason: `Checks pending: ${pending.map((check) => check.name).join(", ")}`, headSha, }; } return { status: "green", reason: `${resolved.length} checks succeeded or were skipped`, headSha }; } /** * Whether one raw `gh pr view --json ...statusCheckRollup` payload collected * zero rollup contexts (#999). * * {@link prVerificationFrom} maps zero collected checks to `pending` — "GitHub * has not reported any checks yet" — which is the right refusal when it is * true, and a silent stall when it is not. It is not always true: a head whose * PR was opened by `github-actions[bot]` carries a second, empty * `action_required` check suite beside the completed one, and the rollup read * can resolve that empty suite and collect nothing while 18 check-runs * (one of them failed) sit on the same SHA. So the caller uses this to treat * an empty rollup as *unresolvable* and re-prove from the REST plane, which * flattens every page of both `check_runs` and `statuses` for the exact head * and sees all of them. * * A missing rollup counts as zero collected, exactly as * {@link prVerificationFrom}'s `?? []` does. A body this predicate cannot read * — unparseable, not a JSON object (a bare list is not a `pr view` payload), * or a rollup that is not a list — is not its problem: it answers false so the * existing verdict stands unchanged. */ export function rollupCollectedNoChecks(raw: string): boolean { let parsed: unknown; try { parsed = JSON.parse(raw) as unknown; } catch { return false; } if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return false; const rollup = (parsed as GhPrVerification).statusCheckRollup; if (rollup === undefined || rollup === null) return true; return Array.isArray(rollup) && rollup.length === 0; } function failedCheck(raw: string): GhCheck | undefined { const checks = (JSON.parse(raw) as GhPrVerification).statusCheckRollup ?? []; return checks.find((check) => checkVerdict(check) === "failed"); } /** REST projection of a pull request read by the verifyPr fallback (#653). */ interface RestPrProjection { draft?: unknown; head_sha?: unknown; } /** One REST check, normalized onto the rollup verdict vocabulary. */ interface RestCheckConclusion { name: string; /** The terminal `conclusion`/`status` (run) or `state` (commit status), as * GitHub spelled it — surfaced in refusals exactly like the GraphQL one. */ state: string; verdict: CheckVerdict; /** The run's `started_at` (or a commit status's `updated_at`), undefined * when the page spelled none — unordered, never invented (#1066). */ startedAt: string | undefined; } /** One REST check plane, parsed strictly. */ type RestCheckEvidence = | { readonly ok: true; readonly checks: RestCheckConclusion[] } | { readonly ok: false }; /** The live head SHA from a REST pull-request projection, or undefined. */ function restPrHeadFrom(raw: string): string | undefined { let parsed: unknown; try { parsed = JSON.parse(raw) as unknown; } catch { return undefined; } if (parsed === null || typeof parsed !== "object") return undefined; const sha = (parsed as RestPrProjection).head_sha; return typeof sha === "string" && /^[0-9a-f]{40,64}$/i.test(sha) ? sha : undefined; } /** Whether a REST pull-request projection is a draft. Absent or malformed is * "draft state unknown", the same fail-closed answer {@link prVerificationFrom} * gives for a missing rollup — it must never be read as a clean non-draft. */ function restPrDraftFrom(raw: string): boolean | undefined { let parsed: unknown; try { parsed = JSON.parse(raw) as unknown; } catch { return undefined; } if (parsed === null || typeof parsed !== "object") return undefined; const draft = (parsed as RestPrProjection).draft; return typeof draft === "boolean" ? draft : undefined; } /** One REST check-run member onto the rollup vocabulary, or undefined when the * member's identity or lifecycle fields cannot be read. */ function restRunConclusionFrom(row: { readonly [key: string]: unknown }): RestCheckConclusion | undefined { const name = row["name"]; const status = row["status"]; const conclusion = row["conclusion"]; const startedAt = row["started_at"]; if (typeof name !== "string" || name === "") return undefined; if (typeof status !== "string") return undefined; if (conclusion !== undefined && conclusion !== null && typeof conclusion !== "string") return undefined; // statusCheckRollup semantics: an unfinished run is pending, // `success`/`skipped`/`neutral` are green — GitHub's rollup counts a // completed `neutral` check as successful — anything else is failed. const verdict: CheckVerdict = status !== "completed" ? "pending" : conclusion === "success" || conclusion === "skipped" || conclusion === "neutral" ? "green" : "failed"; return { name, state: typeof conclusion === "string" ? conclusion : status, verdict, startedAt: typeof startedAt === "string" ? startedAt : undefined, }; } /** One REST commit-status member onto the rollup vocabulary, or undefined when * the identity or state cannot be read. */ function restStatusConclusionFrom(row: { readonly [key: string]: unknown }): RestCheckConclusion | undefined { const context = row["context"]; const state = row["state"]; const updatedAt = typeof row["updated_at"] === "string" ? row["updated_at"] : undefined; const createdAt = typeof row["created_at"] === "string" ? row["created_at"] : undefined; if (typeof context !== "string" || context === "") return undefined; if (typeof state !== "string") return undefined; // StatusContext semantics: `success` is green, `pending` pending, anything // else (`failure`/`error`) is failed. const verdict: CheckVerdict = state === "success" ? "green" : state === "pending" ? "pending" : "failed"; return { name: context, state, verdict, startedAt: updatedAt ?? createdAt }; } /** * Parse one raw REST page body of a check plane (`check_runs` or `statuses`) * strictly. Any malformed view answers `{ ok: false }`, never a partial list: * unparseable JSON, a top-level body that is not an object, a plane that is * missing or not an array, or any member whose identity cannot be read. A * garbage entry beside a green one must not let the plane read as green. */ function restPlaneFrom( raw: string, plane: "check_runs" | "statuses", member: (row: { readonly [key: string]: unknown }) => RestCheckConclusion | undefined, ): RestCheckEvidence { let parsed: unknown; try { parsed = JSON.parse(raw) as unknown; } catch { return { ok: false }; } if (parsed === null || typeof parsed !== "object") return { ok: false }; const list = (parsed as { readonly [key: string]: unknown })[plane]; if (!Array.isArray(list)) return { ok: false }; const checks: RestCheckConclusion[] = []; for (const entry of list) { if (entry === null || typeof entry !== "object") return { ok: false }; const conclusion = member(entry as { readonly [key: string]: unknown }); if (conclusion === undefined) return { ok: false }; checks.push(conclusion); } return { ok: true, checks }; } /** * The projection-only verdict of a REST pull-request read (#653): state, draft * and exact head, decided strictly from the one raw projection. These facts do * not need the check endpoints, so a caller proves them as soon as the * projection arrives — a stale head or a non-open PR answered here stays that * refusal even when the check planes are independently degraded, and is never * masked as unresolvable. A projection that cannot prove the head throws, so * the caller maps it to `head-unresolvable` exactly as it does for the GraphQL * parser's failures. */ function restProjectionVerdict( prRaw: string, expectedHead?: string, opts?: { read?: boolean }, ): { readonly ok: true; readonly headSha: string } | { readonly ok: false; readonly verdict: PrVerification } { const headSha = restPrHeadFrom(prRaw); if (headSha === undefined) throw new Error("PR head is unavailable"); const state = prStateFromRest(prRaw); if (state !== "open") { const stateName = state ?? "unknown"; if (opts?.read === true) { return { ok: false, verdict: { status: "failed", reason: `PR is ${stateName}; checks are unavailable for a non-open PR`, headSha, }, }; } return { ok: false, verdict: { status: "failed", reason: `PR is ${stateName}, expected OPEN`, headSha }, }; } if (restPrDraftFrom(prRaw) !== false) { return { ok: false, verdict: { status: "failed", reason: "PR is draft or draft state is unknown", headSha }, }; } if (expectedHead !== undefined && headSha.toLowerCase() !== expectedHead.toLowerCase()) { return { ok: false, verdict: { status: "failed", reason: `PR head changed: expected ${expectedHead}, found ${headSha}`, headSha, }, }; } return { ok: true, headSha }; } /** * REST fallback for {@link prVerificationFrom} (#653): prove the same * execution-time contract from the REST PR projection plus the check-runs and * commit-status endpoints, when the primary GraphQL read cannot answer. * * The head is the REST projection's own `head.sha` — the same read that * supplied state and draft — and the check evidence is pinned to that head via * the commit endpoints, never to the caller's observation. Every page of both * planes is flattened before the verdict is calculated: GitHub defaults each * endpoint to 30 per page, and a non-green context past page one is exactly as * decisive as one on page one. Missing check evidence is pending, never green. * Throws on a payload that cannot prove the head or either check plane, so the * caller maps it to `head-unresolvable` exactly as it does for the GraphQL * parser. * * Same-name runs are decided by recency, not page order (#1066): the newest * attempt of a name answers for it, so a stale `skipped` beside a newer * `failure` fails the verdict whatever order the pages arrive in. */ export function restVerificationFrom( prRaw: string, checkRunPages: string[], statusPages: string[], expectedHead?: string, opts?: { read?: boolean }, ): PrVerification { const projection = restProjectionVerdict(prRaw, expectedHead, opts); if (!projection.ok) return projection.verdict; const headSha = projection.headSha; const checks: RestCheckConclusion[] = []; for (const page of checkRunPages) { const evidence = restPlaneFrom(page, "check_runs", restRunConclusionFrom); if (!evidence.ok) throw new Error("check-run evidence is unresolvable"); checks.push(...evidence.checks); } for (const page of statusPages) { const evidence = restPlaneFrom(page, "statuses", restStatusConclusionFrom); if (!evidence.ok) throw new Error("commit-status evidence is unresolvable"); checks.push(...evidence.checks); } if (checks.length === 0) { return { status: "pending", reason: "GitHub has not reported any checks yet", headSha }; } // Same-name runs decide by recency, not page order (#1066): the flattened // planes carry every attempt of a name, and first-match folding would let // the oldest attempt answer for it. const resolved = resolveNewestPerName(checks); const failed = resolved.filter((c) => c.verdict === "failed"); if (failed.length > 0) { return { status: "failed", reason: `Checks failed: ${failed.map((c) => `${c.name} (${c.state})`).join(", ")}`, headSha, }; } const pending = resolved.filter((c) => c.verdict === "pending"); if (pending.length > 0) { return { status: "pending", reason: `Checks pending: ${pending.map((c) => c.name).join(", ")}`, headSha, }; } return { status: "green", reason: `${resolved.length} checks succeeded or were skipped`, headSha }; } /** * The REST half of {@link Tracker.verifyPr} (#653): re-prove the execution-time * exact-head and check verdict when the primary GraphQL read could not answer. * State, draft and exact head are decided from the PR projection the moment it * arrives, before any check-plane fetch — a refusal the projection already * proves (a stale head, a non-open or draft PR) is returned as that refusal, * never downgraded to unresolvable by an independently degraded check * endpoint. Returns `undefined` only when the live head or the complete check * evidence cannot be proven from REST, so a degraded verification port never * weakens the merge gate — it only ever turns a would-be refusal into the same * refusal reported as unresolvable. */ async function verifyPrRest( runGh: typeof gh, cache: RestListCache, url: string, expectedHead?: string, opts?: { read?: boolean }, onNotModified?: () => void, ): Promise { const parts = prUrlParts(url); if (parts === undefined) return undefined; const base = `repos/${parts.owner}/${parts.repo}`; let prRaw: string; let headSha: string; try { prRaw = await runGh([ "api", `${base}/pulls/${parts.number}`, "--jq", "{state, draft, head_sha: .head.sha, merged_at}", ]); // The projection alone proves state, draft and exact head, so those // verdicts are returned before any check evidence is fetched: a stale // head proven here is reported as such even when the check endpoints are // independently degraded — the check planes must not be able to mask an // already-proven stale head as unresolvable. const projection = restProjectionVerdict(prRaw, expectedHead, opts); if (!projection.ok) return projection.verdict; headSha = projection.headSha; } catch { return undefined; } let checkRunPages: string[]; let statusPages: string[]; try { // Every page of both planes is fetched through the tracked REST funnel // (breakers, rate-limit ledger, conditional revalidation) and flattened // before the verdict runs: each endpoint defaults to 30 per page, so a PR // with more checks than that would otherwise let a non-green context hide // past page one. The evidence is pinned to the head proven by the PR // projection — never to the caller's observation, which could be stale. checkRunPages = await conditionalListPages( runGh, cache, `${base}/commits/${headSha}/check-runs?per_page=100`, /* paginate */ true, onNotModified, ); statusPages = await conditionalListPages( runGh, cache, `${base}/commits/${headSha}/status?per_page=100`, /* paginate */ true, onNotModified, ); } catch { return undefined; } try { return restVerificationFrom(prRaw, checkRunPages, statusPages, expectedHead, opts); } catch { return undefined; } } function runLogArgs(detailsUrl: string): string[] | undefined { const match = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/.exec( detailsUrl, ); if (match?.[1] === undefined || match[2] === undefined) return undefined; return [ "run", "view", match[2], "--repo", match[1], ...(match[3] === undefined ? [] : ["--job", match[3]]), "--log-failed", ]; } function conciseLog(raw: string): string | undefined { const lines = raw .replaceAll(/\u001b\[[0-9;]*m/g, "") .split("\n") .map((line) => line.trimEnd()) .filter((line) => line.trim() !== "") .slice(-8) .map((line) => line.slice(0, 240)); return lines.length === 0 ? undefined : lines.join("\n"); } /** * GitHub's PR state spelling mapped onto {@link PrState}. * * Split from the call so the mapping is pinned without a network round trip, * and unrecognised text answers undefined rather than being coerced: this * function's caller settles a run row on the answer, and every wrong answer * here rewrites history for work that has no other record. `MERGED`, `CLOSED` * and `OPEN` are the only three `gh pr view --json state` emits (verified on * gh 2.97.0); anything else means the CLI changed under us, which is a reason * to leave the row alone and let a human look. */ export function prStateFrom(raw: string): PrState | undefined { switch (raw.trim()) { case "MERGED": return "merged"; case "CLOSED": return "closed"; case "OPEN": return "open"; default: return undefined; } } /** GitHub's issue state spelling, kept fail-closed for cleanup decisions. */ export function issueStateFrom(raw: string): IssueState | undefined { switch (raw.trim()) { case "OPEN": return "open"; case "CLOSED": return "closed"; default: return undefined; } } /** Direct issue admission facts parsed fail-closed from a REST projection. */ export function issueSnapshotFrom(raw: string): IssueSnapshot | undefined { let parsed: unknown; try { parsed = JSON.parse(raw) as unknown; } catch { return undefined; } if (parsed === null || typeof parsed !== "object") return undefined; const row = parsed as { readonly [key: string]: unknown }; const rawState = row["state"]; const labels = row["labels"]; if (typeof rawState !== "string" || !Array.isArray(labels) || !labels.every((label) => typeof label === "string")) { return undefined; } const state = issueStateFrom(rawState.toUpperCase()); return state === undefined ? undefined : { state, labels }; } /** * GitHub's REST pull-request shape (`{state, merged_at}`) mapped onto * {@link PrState}. `merged_at` is what distinguishes a merged PR from a * closed-unmerged one under REST — `state` alone answers "closed" for both, and * a settle sweep that read only state would rewrite a merged row as * closed-unmerged. Anything unrecognised is "could not tell", the same * fail-closed answer {@link prStateFrom} gives. */ export function prStateFromRest(raw: string): PrState | undefined { let parsed: unknown; try { parsed = JSON.parse(raw) as unknown; } catch { return undefined; } if (parsed === null || typeof parsed !== "object") return undefined; const row = parsed as { readonly [key: string]: unknown }; if (row["merged_at"] != null) return "merged"; if (row["state"] === "closed") return "closed"; if (row["state"] === "open") return "open"; return undefined; } /** * Parent number from a raw GraphQL response, or undefined when the issue has * no parent. Throws when the issue or claimed parent is malformed — admission * must not turn an unknown relationship into permission to run siblings. */ export function parentNumberFrom(raw: string): number | undefined { const parsed = JSON.parse(raw) as { data?: { repository?: { issue?: { parent?: { number?: unknown } | null } | null } | null }; }; const issue = parsed.data?.repository?.issue; if (issue == null) throw new Error("unexpected missing issue in parent response"); if (issue.parent == null) return undefined; const n = issue.parent.number; if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) { throw new Error(`unexpected parent number ${JSON.stringify(n)}`); } return n; } /** * Parse `gh pr checks --json name,state,link`. * * Tolerant on purpose: a payload shape this build does not recognise yields an * empty list, which the classifier reads as "could not tell" — the same posture * every other reader in this adapter takes. */ export function checkConclusionsFrom(raw: string): { name: string; state: string; link?: string }[] { let parsed: unknown; try { parsed = JSON.parse(raw) as unknown; } catch { return []; } if (!Array.isArray(parsed)) return []; const checks: { name: string; state: string; link?: string }[] = []; for (const entry of parsed) { if (entry === null || typeof entry !== "object") continue; const row = entry as { readonly [key: string]: unknown }; const name = row["name"]; const state = row["state"]; if (typeof name !== "string" || typeof state !== "string") continue; const link = row["link"]; checks.push({ name, state, ...(typeof link === "string" && link.length > 0 ? { link } : {}), }); } return checks; } /** GitHub's mergeability spelling, fail-open to `unknown` on anything else. */ export function mergeableFrom(raw: string): "conflicting" | "clean" | "unknown" { switch (raw.trim().replaceAll('"', "")) { case "CONFLICTING": return "conflicting"; case "MERGEABLE": return "clean"; default: // `UNKNOWN` is GitHub still computing the merge, and it is common on a PR // pushed seconds ago. Reading it as clean would let a conflict recovery // fire on a PR nobody has assessed yet. return "unknown"; } } /** * The REST mergeability spelling onto the same fail-open triple. `--jq * .mergeable` prints the bare JSON token, so `"true"` and `"false"` are the * only clean answers; `null` (printed verbatim by `--jq`) is GitHub still * computing the merge, and everything else means the API changed under us. */ export function mergeableFromRest(raw: string): "conflicting" | "clean" | "unknown" { switch (raw.trim()) { case "true": return "clean"; case "false": return "conflicting"; default: // `null` is GitHub still computing the merge on a PR pushed seconds ago. // Reading it as clean would let a conflict recovery fire on a PR nobody // has assessed yet — the same reasoning as `UNKNOWN` above. return "unknown"; } } /** * Distinct workflow-run ids behind the non-success checks of a PR. * * Extracted from each check's `link` because that is the only run identifier * `gh pr checks` reports. Deduplicated: one workflow run usually backs several * checks, and re-running it once is the whole point. */ export function failedRunIds( checks: readonly { name: string; state: string; link?: string }[], ): string[] { const ids: string[] = []; for (const check of checks) { const state = check.state.trim().toLowerCase(); if (state === "success" || state === "neutral") continue; const match = /\/actions\/runs\/(\d+)\//.exec(check.link ?? ""); const id = match?.[1]; if (id !== undefined && !ids.includes(id)) ids.push(id); } return ids; } /** * Parse `[{number, state}]` from `gh issue list` or the sub-issues API. * * The REST issues endpoint mixes pull requests into the list, and the reconcile * must never remove labels off a PR — so rows carrying a `pull_request` key are * dropped before the caller sees them. This replaces the `--jq * select(.pull_request == null)` projection listLabeled used to send: `-i` * output (needed for conditional revalidation) cannot carry a jq projection, * so the filter moved in-process where it also covers every future caller. * `childrenOf` rows never carry the key, so the guard is a no-op there. */ export function labeledIssuesFrom(raw: string): { number: number; state: IssueState }[] { let parsed: unknown; try { parsed = JSON.parse(raw) as unknown; } catch { return []; } if (!Array.isArray(parsed)) return []; const issues: { number: number; state: IssueState }[] = []; for (const entry of parsed) { if (entry === null || typeof entry !== "object") continue; const row = entry as { readonly [key: string]: unknown }; if (row["pull_request"] != null) continue; const number = row["number"]; const rawState = row["state"]; if (typeof number !== "number" || !Number.isInteger(number) || typeof rawState !== "string") continue; // The REST API answers lowercase, the CLI uppercase. Both are the same fact. const state = issueStateFrom(rawState.toUpperCase()); if (state === undefined) continue; issues.push({ number, state }); } return issues; } /** * Parse the REST `/repos/{owner}/{repo}/issues` array onto {@link ReadyIssue}. * * The endpoint returns pull requests as well as issues — every PR is an issue * with a `pull_request` key — so that key is the filter that keeps a routed * repo's PR backlog out of the ready queue (#188). The REST field spelling is * snake_case (`html_url`, `updated_at`); nulls are tolerated the same way the * mapping this replaces tolerated them, because the API answers null for any * of these fields. */ export function readyIssuesFromRest(raw: string): ReadyIssue[] { let parsed: unknown; try { parsed = JSON.parse(raw) as unknown; } catch { return []; } if (!Array.isArray(parsed)) return []; const issues: ReadyIssue[] = []; for (const entry of parsed) { if (entry === null || typeof entry !== "object") continue; const row = entry as { readonly [key: string]: unknown }; // The endpoint mixes in pull requests; the queue must never claim one. if (row["pull_request"] != null) continue; const number = row["number"]; const title = row["title"]; const body = row["body"]; const labels = row["labels"]; const url = row["html_url"]; const updatedAt = row["updated_at"]; if (typeof number !== "number" || !Number.isInteger(number)) continue; if (typeof url !== "string" || typeof updatedAt !== "string") continue; const names: string[] = []; if (Array.isArray(labels)) { for (const label of labels) { if (label === null || typeof label !== "object") continue; const name = (label as { readonly name?: unknown }).name; if (typeof name === "string") names.push(name); } } issues.push({ number, title: typeof title === "string" ? title : "", body: typeof body === "string" ? body : "", labels: names, url, updatedAt, }); } return issues; } /** * Parse the REST `/repos/{owner}/{repo}/issues/{n}/comments` body — already * projected by jq to `[{author, body}]` — onto {@link IssueComment}. * * The REST endpoint answers chronological order (oldest first), so the array * order is the order the brief renders. Nulls and unrecognizable payloads are * tolerated the same way {@link readyIssuesFromRest} tolerates them: an empty * answer means "no comments", and *only* a thrown read means "could not tell" — * the caller distinguishes the two, because eliding them is the #517 failure. */ export function commentsFrom(raw: string): IssueComment[] { let parsed: unknown; try { parsed = JSON.parse(raw) as unknown; } catch { return []; } if (!Array.isArray(parsed)) return []; const comments: IssueComment[] = []; for (const entry of parsed) { if (entry === null || typeof entry !== "object") continue; const row = entry as { readonly [key: string]: unknown }; const author = row["author"]; const body = row["body"]; if (typeof author !== "string" || typeof body !== "string") continue; comments.push({ author, body }); } return comments; } /** One cached page per request URL: the ETag GitHub answered, the raw JSON * body it covered, and whether that page advertised a rel="next" successor. * Module-level default so every makeTracker() in the process (board probe, * tick, preview) shares it; keyed by exact URL, so repos, labels and page * numbers never collide. */ export type RestListCache = Map; const sharedRestListCache: RestListCache = new Map(); /** * Split a `gh api -i` answer into status, etag, rel="next" presence, body. * * `gh api -i` prints the status line and response headers, a blank line, then * the body. A page's ETag is its identity for conditional revalidation, and * the `Link` header is the only place the adapter can learn a successor page * exists without parsing the body. Throws on a payload that is not an HTTP * response at all — the caller must not guess what an unrecognisable answer * means. */ export function splitApiInclude(raw: string): { status: number; etag?: string; hasNext: boolean; body: string; } { const blank = /\r?\n\r?\n/.exec(raw); const headerBlock = blank === null ? raw : raw.slice(0, blank.index); const body = blank === null ? "" : raw.slice(blank.index + blank[0].length); const [statusLine = ""] = headerBlock.split(/\r?\n/); const statusMatch = /^HTTP\/[\d.]+ (\d{3})/.exec(statusLine); if (statusMatch === null) { throw new Error("unrecognised gh api -i response"); } let etag: string | undefined; let hasNext = false; for (const line of headerBlock.split(/\r?\n/)) { const colon = line.indexOf(":"); if (colon <= 0) continue; const name = line.slice(0, colon).trim().toLowerCase(); const value = line.slice(colon + 1).trim(); if (name === "etag") etag = value; else if (name === "link" && /rel="next"/.test(value)) hasNext = true; } return { status: Number(statusMatch[1]), etag, hasNext, body }; } /** * GET a REST list path page by page, revalidating each known page with * If-None-Match. A 304 reuses that page's cached body and costs no primary * rate-limit budget; a changed page answers 200 and replaces its cache entry. * Every known page is requested on every pass, so a change on page N is * detected even when earlier pages are unchanged. Follows rel="next" with no * page cap when `paginate`; entries past the final page are pruned. Returns * one raw JSON-array body per page. */ async function conditionalListPages( runGh: typeof gh, cache: RestListCache, path: string, paginate: boolean, onNotModified?: () => void, ): Promise { const bodies: string[] = []; let n = 1; for (;;) { const url = n === 1 ? path : `${path}&page=${n}`; const cached = cache.get(url); const argv = ["api", "-i", ...(cached ? ["-H", `If-None-Match: ${cached.etag}`] : []), url]; let raw: string; try { raw = await runGh(argv); } catch (err) { if (!(err instanceof GhError) || !/^HTTP\/[\d.]+ 304\b/.test(err.stdout)) throw err; // A 304 is gh's documented answer for an unchanged conditional request: // exit 1, with the status line still on stdout. `cached` must exist — // we only send If-None-Match when we hold an etag — so rethrow if not, // fail-loud, never serve a page we did not cache. if (cached === undefined) throw err; bodies.push(cached.body); onNotModified?.(); if (!paginate || !cached.hasNext) break; n += 1; continue; } const r = splitApiInclude(raw); if (r.status === 304) { // A future `gh` that exits 0 on a 304 would land here instead of the // catch above; both arms answer identically. The cache entry keeps its // stored etag — the 304's echo comes back strong-form, while the stored // weak form keeps matching. if (cached === undefined) { throw new Error("a 304 answered a page never sent If-None-Match for"); } bodies.push(cached.body); onNotModified?.(); if (!paginate || !cached.hasNext) break; n += 1; continue; } if (r.etag !== undefined) cache.set(url, { etag: r.etag, body: r.body, hasNext: r.hasNext }); else cache.delete(url); bodies.push(r.body); if (!paginate || !r.hasNext) break; n += 1; } // The list shrank: without this a later pass would revalidate pages that no // longer exist. `n` is the last page fetched; prune every successor key. for (let k = n + 1; cache.has(`${path}&page=${k}`); k += 1) { cache.delete(`${path}&page=${k}`); } return bodies; } /** The two GitHub API budgets this adapter spends: GraphQL for the * closers/parent/rollup reads, core for everything else. `reset` is the REST * spelling — epoch seconds. */ export interface RateLimitStatus { graphql: { remaining: number; limit: number; reset: number }; core: { remaining: number; limit: number; reset: number }; } function rateLimitBucket( raw: unknown, ): { remaining: number; limit: number; reset: number } | undefined { if (raw === null || typeof raw !== "object") return undefined; const row = raw as { readonly [key: string]: unknown }; const remaining = row["remaining"]; const limit = row["limit"]; const reset = row["reset"]; if (typeof remaining !== "number" || typeof limit !== "number" || typeof reset !== "number") { return undefined; } return { remaining, limit, reset }; } /** * Read the GitHub API rate-limit budget for `status` (the tracker layer's * counterpart to the decision probe: the layers differ, so the two stay * independent). * * Any failure — a revoked token, a flaky network, a payload shape this build * does not recognise — answers undefined: a broken `gh` must cost one status * row, never the whole report. */ export async function fetchRateLimit( runGh: typeof gh = gh, ): Promise { try { const parsed = JSON.parse( await runGh(["api", "rate_limit", "--jq", "{graphql: .resources.graphql, core: .resources.core}"]), ) as unknown; if (parsed === null || typeof parsed !== "object") return undefined; const row = parsed as { readonly [key: string]: unknown }; const graphql = rateLimitBucket(row["graphql"]); const core = rateLimitBucket(row["core"]); if (graphql === undefined || core === undefined) return undefined; return { graphql, core }; } catch { return undefined; } } /** * True when a `gh` failure is GitHub refusing us at the rate-limit layer. * * Three distinct bodies arrive through the CLI and all mean "stop sending": * the primary budget exhausted (`API rate limit exceeded`, or the 403 the REST * layer reports as `HTTP 403 ... rate limit`), the secondary budget * (`secondary rate limit`), and an abuse-control trip (`abuse detection`). * gh's stderr carries none of the header values (`Retry-After`, `reset`) that * would drive a precise backoff, so the cooldown is the tracker's fixed window * below rather than a header-driven one. */ export function isRateLimitRefusal(err: unknown): boolean { return ( err instanceof GhError && /API rate limit exceeded|secondary rate limit|abuse detection|HTTP 403.*rate limit/i.test( err.stderr, ) ); } /** * True when a `gh` stderr text carries a transient GitHub server-side 5xx — * the API answering "try later" rather than judging the request. gh's stderr * carries the status in two spellings: the REST `HTTP 500: Internal Server * Error` style, and the GraphQL `No server is currently available to service * your request. (HTTP 503)` recorded in #642. Both mean "stop asking for a * while", and both open the tracker's bounded GraphQL breaker so admission * does not multiply one provider outage into O(queue size) spawns per pass. * * Deliberately narrower than "anything nonzero": a 4xx answers the request * (bad credentials, not found, an exhausted-but-named budget), and a GraphQL * product error carries no HTTP status at all — neither is a provider outage, * and neither may masquerade as the breaker event that holds every candidate. * The status must be a complete three-digit 5xx token (`HTTP 503)`, `HTTP 500:`): * a malformed suffix like `HTTP 5030` is an invalid response, not a provider * outage, so it stays a plain failure instead of opening the breaker. */ export function isTransientServer5xx(stderr: string): boolean { return /HTTP 5\d\d\b/i.test(stderr); } export function isTransientServerError(err: unknown): boolean { return err instanceof GhError && isTransientServer5xx(err.stderr); } /** * True when a `gh` failure is the REST layer answering 404 for the requested * resource. The raw status is deliberately not the definitive verdict: GitHub * returns the same 404 for a PR number absent from a readable repository and * for a whole repository hidden from the credential, so the caller must * corroborate against a same-repository pull-read (see * {@link repoPullReadUnavailable}) before a 404 means "the PR is gone". What a * 404 does always mean is that it is not a 5xx ("ask later") and not a 401/403 * credential refusal. gh's stderr renders the status as `HTTP 404: Not Found`. */ export function isMissingPr404(err: unknown): err is GhError { return err instanceof GhError && /HTTP 404\b/i.test(err.stderr); } /** * True when a `gh` argv rides the GraphQL API surface rather than core REST. * * `gh api graphql` is the raw surface. The named subcommands ride GraphQL * mutations in the gh version this fleet pins (2.86): `issue edit` * (`addLabelsToLabelable` / `removeLabelsFromLabelable` / `updateIssue`), * `issue comment` (`addComment`), `issue close` (`closeIssue`), `pr create` * (`createPullRequest`), `pr edit` (`updatePullRequest`), `pr merge` * (`mergePullRequest`) and `pr update-branch` (`updatePullRequestBranch`). * gh's transports move between versions, which is why this list is pinned and * tested rather than guessed: a subcommand that stops riding GraphQL only * stops being gated — it never stops working. * * Everything else the tracker and the verb actions shell — `gh api * repos/...`, `gh pr diff`, `gh release create` — is core REST and must keep * running while the GraphQL breaker is open: #642 measured REST healthy * through the whole outage, and the ready-queue read and settle sweeps run on * it. */ export function isGraphqlSurface(argv: readonly string[]): boolean { if (argv[0] === "api") return argv[1] === "graphql"; if (argv[0] === "issue") { return argv[1] === "edit" || argv[1] === "comment" || argv[1] === "close"; } if (argv[0] === "pr") { return argv[1] === "create" || argv[1] === "edit" || argv[1] === "merge" || argv[1] === "update-branch"; } return false; } /** * The process-local transient-server-error breaker for the GraphQL surface * (#642). One instance is shared by the tracker and the verb actions of a * project, so a 503 observed by either side gates both: admission's * `parentOf` / `openCloserFor` checks AND the orchestrator mutation commands * that ride GraphQL in gh 2.86 (`gh issue edit`, `gh pr merge`, * `gh pr update-branch`, ...) fast-fail instead of each spawning `gh` into * the same unavailable surface. * * Deliberately separate from the rate-limit breaker: a 5xx is a provider * outage, not budget exhaustion, and must not fire `onRefusal` or touch the * `gh_refusals` ledger. The cooldown is the same fixed window, because gh's * stderr carries no `Retry-After` for either class. */ export class GraphqlBreaker { private refusedUntil = 0; constructor(private readonly cooldownMs: number = RATE_LIMIT_COOLDOWN_MS) {} /** Record a transient 5xx: every GraphQL-surface call fast-fails until the * bounded cooldown elapses. */ open(at = Date.now()): void { this.refusedUntil = at + this.cooldownMs; } /** Whether a GraphQL-surface call must fast-fail right now. */ refused(at = Date.now()): boolean { return at < this.refusedUntil; } /** Wall-clock ms after which a real call may be attempted again; 0 = closed. */ get until(): number { return this.refusedUntil; } } /** The fast-fail wording both the tracker's throw and the verb actions' * failed result carry, so a refusal reads the same from either side. */ export function graphqlBreakerRefusal(untilMs: number): string { return `circuit breaker open (a prior GitHub server error holds the GraphQL cooldown until ${new Date(untilMs).toISOString()})`; } /** * A rate-limit refusal rethrown by {@link makeTracker}. This is the one * failure class the tracker names instead of swallowing: the circuit breaker * throws it without spawning `gh`, and it is what a caller can distinguish * from an ordinary transport fault. */ export class GhRateLimitError extends GhError { /** Wall-clock millis after which a call may be attempted again. */ readonly retryAtMs: number; constructor(argv: string[], code: number, stderr: string, retryAtMs: number, stdout = "") { super(argv, code, stderr, stdout); this.name = "GhRateLimitError"; this.retryAtMs = retryAtMs; this.message = `${this.message} — GitHub rate limit; retry at ${new Date(retryAtMs).toISOString()}`; } } /** How long a rate-limit refusal or a transient server 5xx holds the tracker's * circuit breaker open. One window bounds both breakers: the all-call gate a * rate-limit refusal opens and the GraphQL-only gate a 5xx opens. */ export const RATE_LIMIT_COOLDOWN_MS = 60_000; /** * The control that makes an individual PR read's 404 definitive (#781 review * round 1): whether the repository named by `parts` answers a Pull Requests * list read to the current credential. * * GitHub returns the same 404 bytes in two opposite situations — a PR number * that does not exist in a repository this credential can read, and a whole * repository hidden from the credential (private, lost token scope, SSO * enforcement) — so a bare 404 on the individual read says nothing. Asking * the same-repository pulls surface splits them: a 200, even `[]`, proves * pull-read access and makes the individual 404 a genuinely missing PR; a * control that is itself unavailable leaves the question open and the caller * must keep its row retryable. Returns true when pull-read access cannot be * proven. */ async function repoPullReadUnavailable( runGh: typeof gh, parts: { owner: string; repo: string }, ): Promise { // `--jq length` bounds the payload to one integer while still exercising the // exact pull-read surface; `gh api` returns the first page only by default. try { await runGh(["api", `repos/${parts.owner}/${parts.repo}/pulls`, "--jq", "length"]); return false; } catch { return true; } } /** Instrumentation hooks the daemon binds the tracker to (#198). */ export interface TrackerHooks { /** Fired immediately before each `gh` spawn, so the daemon can count its own * API spend. Not fired for a breaker fast-fail, which spawns nothing. */ onCall?: () => void; /** Fired once per observed rate-limit refusal, with the wall-clock moment. * A transient server 5xx opens the GraphQL breaker but is not a rate-limit * refusal and does not fire this hook (#642): the `gh_refusals` ledger and * the rate-limit decisions that read it stay about rate limits. */ onRefusal?: (at: number) => void; /** Fired once per list page GitHub answered 304 for — a spawn that cost no * primary-rate-limit budget. Lets the daemon's call counter tell billed * reads from free revalidations (#203 on top of #198's counting). */ onNotModified?: () => void; } interface TrackerOpts { /** Override {@link RATE_LIMIT_COOLDOWN_MS}, the shared window for both * breakers (tests use a 0ms window). */ rateLimitCooldownMs?: number; /** The project's shared transient-server-error breaker (#642). Absent, the * tracker owns a fresh one; the daemon passes the same instance to the * verb actions so a 503 observed by either side gates both. */ graphqlBreaker?: GraphqlBreaker; /** Conditional-request cache for REST list reads; defaults to the shared * module-level cache. Tests pass a fresh Map. */ listCache?: RestListCache; } export function makeTracker( p: ProjectConfig, injectedRunGh: typeof gh = gh, hooks: TrackerHooks = {}, opts: TrackerOpts = {}, ): Tracker { const repo = p.tracker.repo; const refuseForMs = opts.rateLimitCooldownMs ?? RATE_LIMIT_COOLDOWN_MS; const cache = opts.listCache ?? sharedRestListCache; // The single funnel around the injected `gh`, with two bounded breakers: // // - A rate-limit refusal is account-wide — every budget the token can spend // (REST and GraphQL) is exhausted or throttled — so it opens a breaker // that fast-fails EVERY call. // - A transient server 5xx is surface-specific. #642 measured a GraphQL 503 // that admission then re-asked once per candidate (`parentOf` / // `openCloserFor`), multiplying one outage into O(queue size) spawns, // TLS handshakes, failures and latency per pass while REST stayed // healthy. It opens a breaker that fast-fails GraphQL calls only — every // GraphQL-surface call, tracker reads AND the mutation commands the // orchestrator verbs shell (`gh issue edit`, `gh pr merge`, ...), because // both ride the same broken surface — so the ready-queue read and the // settle sweeps keep running and the pass still degrades into holds // instead of dying. // // The GraphQL breaker is shared with the verb actions through the daemon // (#642 comment 4): a 503 observed while serving `conductor_pr_update_branch` // must gate the next admission pass too, and vice versa — one provider // outage must not be re-asked by every candidate AND every mutation. // // While a breaker is open every gated call fails fast WITHOUT spawning gh — // a burst under a persistent outage degrades the tick into holds // (`open-pr-lookup-error` / `parent-lookup-error` fail closed per candidate // and retry next tick) instead of an error storm or a stalled loop. No // in-wrapper sleeps. A 5xx is not a rate-limit refusal: it never fires // `onRefusal`, so the `gh_refusals` ledger and the rate-limit decisions // that read it are untouched by a provider outage. const graphqlBreaker = opts.graphqlBreaker ?? new GraphqlBreaker(refuseForMs); let refusedUntil = 0; const runGh = async (argv: string[], stdin?: string): Promise => { const now = Date.now(); const graphql = isGraphqlSurface(argv); if (now < refusedUntil) { throw new GhRateLimitError( argv, 0, "circuit breaker open (a prior GitHub rate-limit refusal holds the cooldown)", refusedUntil, ); } if (graphql && graphqlBreaker.refused(now)) { throw new GhError(argv, 0, graphqlBreakerRefusal(graphqlBreaker.until)); } hooks.onCall?.(); try { return await injectedRunGh(argv, stdin); } catch (err) { if (isRateLimitRefusal(err)) { const at = Date.now(); refusedUntil = at + refuseForMs; hooks.onRefusal?.(at); const cause = err instanceof GhError ? err : undefined; throw new GhRateLimitError( argv, cause?.code ?? 1, cause?.stderr ?? String(err), refusedUntil, cause?.stdout ?? "", ); } // A transient 5xx on the GraphQL surface proves THAT surface unavailable // for this pass: the next candidate's parent/closer checks AND the next // GraphQL-backed mutation must fail fast instead of re-asking it. The // original error is rethrown unchanged, so the failing candidate holds // on the true provider message. if (graphql && isTransientServerError(err)) { graphqlBreaker.open(); } throw err; } }; const readOpenClosers = async (issue: number): Promise => { // GraphQL wants the halves of `owner/repo` separately. Config validates // that spelling, so an empty half means a hand-edited config: `gh` then // errors and the caller holds the candidate rather than guessing. const [owner = "", name = ""] = repo.split("/"); const raw = await runGh([ "api", "graphql", "-f", `query=${CLOSERS_QUERY}`, "-F", `owner=${owner}`, "-F", `repo=${name}`, // -F, not -f: the query declares $n as Int! and a string would be a // type error rather than a coerced number. "-F", `n=${issue}`, ]); return openClosers(raw); }; const readIssueSnapshot = async (issue: number): Promise => { try { return issueSnapshotFrom( await runGh([ "api", `repos/${repo}/issues/${issue}`, "--jq", "{state: .state, labels: [.labels[].name]}", ]), ); } catch { return undefined; } }; // Named rather than returned inline, so `rerunFailedChecks` can reuse // `checkConclusions` instead of re-implementing the same `gh` call. const tracker: Tracker = { async listReady(): Promise { // REST, not `issue list --json`: the ready-queue read is a hot-path poll // on every dispatch pass, and GraphQL budget is the scarce one (#188). // Paginated rather than one page of 100 — now that an idle revalidation // costs an unconditional-200's worth of nothing, truncating a deep queue // to save calls saves nothing that matters; the cap was the call count, // and the conditional path changed what a call costs. const pages = await conditionalListPages( runGh, cache, `repos/${repo}/issues?state=open&labels=${encodeURIComponent(p.queueLabel)}&per_page=100`, /* paginate */ true, hooks.onNotModified, ); // The REST endpoint always answers a JSON array — an empty queue is `[]`, // not empty text — but an empty body still parses to no issues here, and // the empty queue is the normal steady state, not an error. return pages.flatMap(readyIssuesFromRest); }, async listOpenIssues(): Promise { const pages = await conditionalListPages( runGh, cache, `repos/${repo}/issues?state=open&per_page=100`, /* paginate */ true, hooks.onNotModified, ); return pages.flatMap(readyIssuesFromRest); }, async listComments(issue: number): Promise { // REST, like `issueSnapshot` — deliberately not `gh issue view // --comments`, whose renderer goes through the interactive pager and // prints nothing on a non-TTY while still exiting 0 (the #517 failure). // `gh api` output is never paged. One page: the brief truncates a huge // thread with an explicit marker rather than paginating. return commentsFrom( await runGh([ "api", `repos/${repo}/issues/${issue}/comments`, "--jq", "[.[] | {author: .user.login, body: .body}]", ]), ); }, async getIssue(issue: number): Promise { try { // The single-issue REST answer is that one object alone; it is wrapped // so the shared parser stays the single mapping of a GitHub row onto // ReadyIssue. A missing issue or an unreadable API answers undefined, // never a guessed or partial row. return readyIssuesFromRest(`[${await runGh(["api", `repos/${repo}/issues/${issue}`])}]`)[0]; } catch { return undefined; } }, async addLabel(issue: number, label: string): Promise { try { // REST POST is natively idempotent: re-adding a label the issue already // carries answers 200 with the label, so the noop classification below // is reachable only on a genuinely odd failure — kept for parity. await runGh([ "api", "-X", "POST", `repos/${repo}/issues/${issue}/labels`, "-f", `labels[]=${label}`, ]); } catch (err) { if (!isLabelNoop(err, "add")) throw err; } }, async removeLabel(issue: number, label: string): Promise { try { await runGh([ "api", "-X", "DELETE", `repos/${repo}/issues/${issue}/labels/${encodeURIComponent(label)}`, ]); } catch (err) { if (!isLabelNoop(err, "remove")) throw err; } }, async comment(issue: number, body: string): Promise { // `--body-file -` reads stdin, so the body is never shell- or argv- // mangled and has no length limit worth worrying about. await runGh(["issue", "comment", String(issue), "--repo", repo, "--body-file", "-"], body); }, async close(issue: number): Promise { await runGh(["issue", "close", String(issue), "--repo", repo]); }, async linkParent(child: number, parent: number): Promise { // Native sub-issue linkage rather than a body mention: it is what the // repo's own epic rollups read, so a human sees the split without us // maintaining a second index of it. await runGh(["issue", "edit", String(parent), "--repo", repo, "--add-sub-issue", String(child)]); }, async parentOf(issue: number): Promise { // gh 2.86 cannot expose `parent` through `issue view --json`; its raw // GraphQL command can, and is already the adapter's path for PR closers. const [owner = "", name = ""] = repo.split("/"); return parentNumberFrom( await runGh([ "api", "graphql", "-f", `query=${PARENT_QUERY}`, "-F", `owner=${owner}`, "-F", `repo=${name}`, "-F", `n=${issue}`, ]), ); }, async openCloserFor(issue: number): Promise { return (await readOpenClosers(issue))[0]; }, openClosersFor: readOpenClosers, async issueState(issue: number): Promise { try { // REST answers lowercase `open`/`closed`; the shared parser expects the // CLI's uppercase spelling, so the answer is normalised before it. return issueStateFrom( (await runGh(["api", `repos/${repo}/issues/${issue}`, "--jq", ".state"])).trim().toUpperCase(), ); } catch { return undefined; } }, issueSnapshot: readIssueSnapshot, async issueBody(issue: number): Promise { try { // Same single-issue REST read as `issueSnapshot`, projected onto the // body alone for the dependency-graph cycle pass (#421). return await runGh([ "api", `repos/${repo}/issues/${issue}`, "--jq", ".body // \"\"", ]); } catch { return undefined; } }, async prState(url: string): Promise { // No `--repo`: the full URL names the repository, and the daemon runs // from its own state directory rather than a checkout — so the REST path // halves come out of the URL itself, never out of the current directory. if (!PR_URL.test(url)) return undefined; const parts = prUrlParts(url); if (parts === undefined) return undefined; try { return prStateFromRest( await runGh([ "api", `repos/${parts.owner}/${parts.repo}/pulls/${parts.number}`, "--jq", "{state, merged_at}", ]), ); } catch (err) { // One classified throw, and only one: an individual REST 404 that the // same-repository pulls read corroborates. 404 alone is ambiguous — // GitHub hides a whole repository the credential cannot read as the // same 404, so a lost token scope or SSO change would otherwise // terminalize every real run (#781 review round 1). Only a PR number // that is genuinely gone while the repo still answers pull-read access // is definitively missing. Everything else — a hidden/private repo, a // revoked token, a flaky network, a server 5xx — stays undefined per // the port's fail-closed contract, and the row retries for free. if (isMissingPr404(err) && !(await repoPullReadUnavailable(runGh, parts))) { throw new GhPrMissingError(err); } return undefined; } }, async prHead(url: string): Promise { if (!PR_URL.test(url)) return undefined; const parts = prUrlParts(url); if (parts === undefined) return undefined; try { // The REST pull-request projection carries the head at `.head.sha` — // there is no top-level `head_sha` (verified live on #812). The jq // renames it, the same idiom `verifyPrRest` uses, so the shared // `restPrHeadFrom` parser sees the shape it is tested against. return restPrHeadFrom( await runGh([ "api", `repos/${parts.owner}/${parts.repo}/pulls/${parts.number}`, "--jq", "{head_sha: .head.sha}", ]), ); } catch { return undefined; } }, async mergedPrInfo(url: string): Promise { if (!PR_URL.test(url)) return undefined; try { return mergedPrInfoFrom( await runGh(["pr", "view", url, "--json", "mergeCommit,baseRefName"]), ); } catch { return undefined; } }, async branchHead(repo: string, branch: string): Promise { if (!/^[^/\s]+\/[^/\s]+$/.test(repo) || branch.length === 0) return undefined; try { return branchHeadFrom( await runGh(["api", `repos/${repo}/branches/${encodeURIComponent(branch)}`]), ); } catch { return undefined; } }, async workflowRunsAt( repo: string, sha: string, opts?: { event?: string; branch?: string }, ): Promise { if (!/^[^/\s]+\/[^/\s]+$/.test(repo) || !/^[0-9a-f]{40,64}$/i.test(sha)) { return undefined; } const query = [`head_sha=${encodeURIComponent(sha)}`]; if (opts?.event !== undefined) query.push(`event=${encodeURIComponent(opts.event)}`); if (opts?.branch !== undefined) query.push(`branch=${encodeURIComponent(opts.branch)}`); query.push("per_page=50"); try { return workflowRunsFrom( await runGh(["api", `repos/${repo}/actions/runs?${query.join("&")}`]), ); } catch { return undefined; } }, async runFailedAttemptLogs( repo: string, runUrl: string, maxAttempts: number, ): Promise { // The attempt-blind Tracker surface cannot reach a failed job that a // rerun-to-green left behind; the attempt-scoped jobs register still // names it. Routed through this adapter's guarded `runGh`, so call/ // refusal accounting and the rate-limit breaker apply to the historical // reconciliation's reads exactly as they do to a live tick's (#638). const match = /^https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/actions\/runs\/(\d+)(?:\/job\/\d+)?/.exec( runUrl, ); const runId = match?.[2]; if (runId === undefined || !/^[^/\s]+\/[^/\s]+$/.test(repo)) return []; let latest: number; try { const raw = await runGh([ "api", `repos/${repo}/actions/runs/${runId}`, "--jq", ".run_attempt", ]); latest = Number.parseInt(raw.trim(), 10); } catch { // The run register could not be read: a no-mutation refusal the next // pass asks again, not a determinately-clean answer. return undefined; } if (!Number.isInteger(latest) || latest <= 0) return undefined; // Refuse rather than classify a prefix as the complete attempt history: // infra evidence in attempts 1..N-1 must not waive a row whose later // attempt carries the real compile/test failure (review #654). if (latest > maxAttempts) return undefined; const chunks: string[] = []; for (let attempt = 1; attempt <= latest; attempt++) { // One chunk per failed job, not one aggregate per attempt: a setup // job's codeload 429 in the same attempt as a sibling matrix job's // compile error must surface as two chunks so the caller's all-infra // guard sees the mixed failure (review #654). Each chunk is the // failed steps of that one job at that attempt — the exact surface // the forward classifier's `checkLog` reads (`--log-failed`), so a // repaired row and a fresh row share one definition of "the log // proves infra". Full and untruncated: a truncated last-400-lines // view can discard contrary failure evidence. let failedJobIds: number[]; try { const raw = await runGh([ "api", `repos/${repo}/actions/runs/${runId}/attempts/${attempt}/jobs`, "--jq", '[.jobs[] | select(.conclusion == "action_required" or .conclusion == "failure" or .conclusion == "startup_failure" or .conclusion == "timed_out") | .id]', ]); failedJobIds = JSON.parse(raw) as number[]; } catch { // The attempt's job register could not be read: undecided, never a // clean answer assembled from whatever earlier attempts yielded. return undefined; } for (const jobId of failedJobIds) { try { const raw = await runGh([ "run", "view", runId, "--repo", repo, "--job", String(jobId), "--attempt", String(attempt), "--log-failed", ]); const cleaned = raw.replaceAll(/\u001b\[[0-9;]*m/g, ""); // Push even an empty chunk: a failed job whose failed steps carry // no signature is determinately not infrastructure, and omitting // it would let a sibling 429 chunk waive the row on partial // evidence. chunks.push(cleaned); } catch { // Any per-attempt log read failure is a no-mutation refusal: the // earlier chunks are not the complete evidence set, and returning // them would let a later compile/test failure go unseen (review // #654). return undefined; } } } return chunks; }, async previousWorkflowRun( repo: string, workflowId: number, branch: string, before: string, ): Promise { if ( !/^[^/\s]+\/[^/\s]+$/.test(repo) || !Number.isSafeInteger(workflowId) || workflowId <= 0 || branch.length === 0 || !Number.isFinite(Date.parse(before)) ) { return undefined; } try { const runs = workflowRunsFrom( await runGh([ "api", `repos/${repo}/actions/workflows/${workflowId}/runs?branch=${encodeURIComponent(branch)}&created=${encodeURIComponent(`<${before}`)}&per_page=2`, ]), ); if (runs === undefined) return undefined; const beforeMs = Date.parse(before); return ( runs .filter((run) => Date.parse(run.createdAt) < beforeMs) .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0] ?? null ); } catch { return undefined; } }, async verifyPr( url: string, expectedHead?: string, opts?: { read?: boolean }, ): Promise { if (!PR_URL.test(url)) return undefined; try { const raw = await runGh([ "pr", "view", url, "--json", "state,isDraft,headRefOid,statusCheckRollup", ]); const verification = prVerificationFrom(raw, expectedHead, opts); if (verification.status === "pending" && rollupCollectedNoChecks(raw)) { // An empty rollup is not proof that the head has no checks (#999): // a PR opened by `github-actions[bot]` carries a second, empty // `action_required` suite beside the completed one, the rollup read // resolves that suite, collects nothing, and this verdict reads // "GitHub has not reported any checks yet" over a head with 18 // check-runs and a failure among them. A commit that carries an // empty suite beside a completed one is not a commit with no // checks, so zero collected contexts is unresolvable rather than // decisive: re-prove from REST, which flattens every page of both // `check_runs` and `statuses` for the exact head. REST that cannot // answer leaves this pending exactly as it stands — fail-closed, // never green by omission. const rest = await verifyPrRest(runGh, cache, url, expectedHead, opts, hooks.onNotModified); return rest ?? verification; } if (verification.status === "green") { // A rollup context is one NAME, not one RUN (#1066): veltro#717 // read eighteen green contexts while the same SHA carried a newer // `failure` run of a name whose rollup row still said `skipped`. // Recency ordering helps only when both rows arrive together; a // rollup that omits the newer run entirely cannot be ordered back // to correctness, so green is re-proven from the flattened REST // planes — the same evidence the empty-rollup path above trusts. // REST that cannot answer leaves this verdict standing, exactly as // a degraded plane leaves the #999 pending standing. const rest = await verifyPrRest(runGh, cache, url, expectedHead, opts, hooks.onNotModified); return rest ?? verification; } if (verification.status !== "failed") return verification; const detailsUrl = failedCheck(raw)?.detailsUrl; const args = detailsUrl === undefined ? undefined : runLogArgs(detailsUrl); if (args === undefined) return verification; try { const digest = conciseLog(await runGh(args)); return digest === undefined ? verification : { ...verification, reason: `${verification.reason}\n${digest}` }; } catch { return verification; } } catch { // The primary read is GraphQL (`gh pr view ... statusCheckRollup`). // When that plane is degraded (#653), the verification port is not // single-transport: re-prove the exact head and the complete check // verdict from REST, or report the head unresolvable. A REST read that // cannot prove green never weakens the contract — it only replaces one // refusal with the same refusal reported as unresolvable. return verifyPrRest(runGh, cache, url, expectedHead, opts, hooks.onNotModified); } }, async prBody(url: string): Promise { // Same guarded REST read as `issueBody`, projected onto the PR: the // settlement audit's claimed-proof check is advisory, so an unreadable // body costs one unaudited flag family and never a run's state. if (!PR_URL.test(url)) return undefined; const parts = prUrlParts(url); if (parts === undefined) return undefined; try { return await runGh([ "api", `repos/${parts.owner}/${parts.repo}/pulls/${parts.number}`, "--jq", ".body // \"\"", ]); } catch { return undefined; } }, async prDiff(url: string): Promise { if (!PR_URL.test(url)) return undefined; try { // `--color never` rather than relying on the default: `auto` is decided // from the child's stdout, and this adapter pipes it, but a diff with // ANSI escapes in it would silently stop matching every pattern the // audit looks for — a failure that looks exactly like a clean PR. const raw = await runGh(["pr", "diff", url, "--color", "never"]); return raw.length > MAX_DIFF_BYTES ? parsePrDiff(raw.slice(0, MAX_DIFF_BYTES), true) : parsePrDiff(raw); } catch { // Advisory: an unreadable diff costs one unaudited settlement, never a // run's state. Undefined is "could not tell", not "clean". return undefined; } }, async checkConclusions(prUrl: string): Promise<{ name: string; state: string; link?: string }[]> { if (!PR_URL.test(prUrl)) return []; try { return checkConclusionsFrom(await runGh(["pr", "checks", prUrl, "--json", "name,state,link"])); } catch (err) { // A nonzero exit is the *documented* answer for a PR with a failing or // pending check — `gh pr checks` exits 1 and 8 respectively, having // already printed the whole payload. Reading only the throw is what made // `ci-infra` and `ci-deterministic` unreachable on live data: every red // PR answered "no checks", so it classified `unknown` and escalated. // // So the payload wins when there is one, and only a genuinely unreadable // answer falls through to "could not tell". const stdout = err instanceof GhError ? err.stdout : ""; return checkConclusionsFrom(stdout); } }, async checkLog(detailsUrl: string): Promise { const args = runLogArgs(detailsUrl); if (args === undefined) return undefined; try { const raw = await runGh(args); const lines = raw.replaceAll(/\u001b\[[0-9;]*m/g, "").split("\n"); return lines.slice(-400).join("\n"); } catch { // Best-effort, never throws: a revoked token, a deleted run or a flaky // network must not error the classifier out of gathering facts. The // caller treats `undefined` as "could not read the log" and classifies // conservatively (#177). return undefined; } }, async mergeable(prUrl: string): Promise<"conflicting" | "clean" | "unknown"> { if (!PR_URL.test(prUrl)) return "unknown"; const parts = prUrlParts(prUrl); if (parts === undefined) return "unknown"; try { return mergeableFromRest( await runGh([ "api", `repos/${parts.owner}/${parts.repo}/pulls/${parts.number}`, "--jq", ".mergeable", ]), ); } catch { return "unknown"; } }, async rerunFailedChecks(prUrl: string): Promise { const runIds = failedRunIds(await tracker.checkConclusions(prUrl)); for (const id of runIds) { try { await runGh(["run", "rerun", id, "--failed"]); } catch { // Per run id: a workflow run too old to re-run, or one already // re-running, must not stop the others. The sweep re-verifies the PR // against its head on a later tick either way. } } }, async listLabeled(label: string, limit = 50): Promise<{ number: number; state: IssueState }[]> { try { // One page, deliberately: `limit` is the caller's work bound // (RECONCILE_LIMIT), not a queue-depth cap, and the reconcile only // ever acts on the page it reads. The conditional cache still makes a // steady-state pass cost nothing even though the response is big. const pages = await conditionalListPages( runGh, cache, `repos/${repo}/issues?state=all&labels=${encodeURIComponent(label)}&per_page=${limit}`, /* paginate */ false, hooks.onNotModified, ); // paginate: false means exactly one body, always a JSON array. return labeledIssuesFrom(pages[0] ?? ""); } catch { // A reconcile that cannot list must remove no labels: an empty answer is // read as "no evidence", never as "nothing carries this label". return []; } }, async childrenOf(issue: number): Promise<{ number: number; state: IssueState }[]> { return labeledIssuesFrom( await runGh(["api", `repos/${repo}/issues/${issue}/sub_issues`, "--jq", "[.[] | {number, state}]"]), ); }, }; return tracker; }