diff --git a/README.md b/README.md index 5aa9eede..3c60f2be 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,7 @@ Neither command touches branches or PRs. `quest wait` blocks until a GitHub PR has a review, comment, review-thread reply, reaction to the PR or one of its reviews, push, merge, close, removal -from the merge queue, or disabled auto-merge: +from the merge queue, disabled auto-merge, or required CI result: ```sh quest wait 101 @@ -242,8 +242,24 @@ timeline, excluding the one that created the starting head. A merge through the queue prints `removed_from_merge_queue` about a second before `merged`, so a poll can catch the removal alone; check `quest status` before treating it as a failed merge. Actors are printed without deciding -whose review counts. Check runs and edits to existing comments or reviews are -outside this command's scope. +whose review counts. Edits to existing comments or reviews are outside this +command's scope. + +CI results are read for the current head. The first required check to fail +prints `checks:failed` right away. `checks:passed` prints once nothing on the +head is still going (no pending check and no unfinished GitHub Actions run, which +may still create jobs), every check and workflow the base branch names is +present, and every required check passed; it names the last check to finish, +when the head went idle. Each prints once per head, with the check's name +(spaces as `_`) in the actor field and its details URL. Results count as +`quest status` counts them, so neutral and skipped results pass. Each job of a +workflow a ruleset requires is required. +Requiredness comes from the base branch's protection and rulesets, readable +with read access; optional checks never wake the wait, unless the base branch +requires none, in which case every check is required. A result counts when it +was reached at or after the starting point, so a wait started after CI finished +still reports it; a push starts over for the new head, and the cursor records +what was reported, so resuming never replays it. Review-thread replies and reactions never reach the PR timeline, so they are read separately. A reply prints as `replied`; a thread's first comment is part @@ -259,8 +275,9 @@ visible after a newer reaction is still reported. Each poll makes one conditional timeline request and one conditional review-comment request every five minutes once it has read the existing pages, -following a new page when the current one fills, plus one GraphQL query for -reactions. It honors GitHub's rate-limit headers and fails loudly on invalid responses. +following a new page when the current one fills, plus one GraphQL query each +for reactions and checks. It reads the base branch's required checks once per +call. It honors GitHub's rate-limit headers and fails loudly on invalid responses. There is no deadline unless `--timeout ` is supplied. Activity exits 0, timeout exits 3, invalid arguments exit 2, and other failures exit 1; interrupt with Ctrl-C to stop waiting. A timeout prints a resume point as its only line: diff --git a/assets/skills/complete.md b/assets/skills/complete.md index caa06c20..6b6d49e3 100644 --- a/assets/skills/complete.md +++ b/assets/skills/complete.md @@ -24,7 +24,7 @@ Consider ordering, queuing any merges if they would result in conflicts. Run each `/quest-ready`, `/quest-merge`, or `/quest-iterate` in its own sub-agent with its own isolated worktree. Each sub-agent blocks on its own waits and reports back only when done or blocked. -For GitHub review or merge activity, it uses `quest wait` and resumes with the +For GitHub CI, review, or merge activity, it uses one `quest wait` and resumes with the returned cursor as described by `quest skill merge`, rather than polling GitHub. Limit the number of active agents to the CPU core count. Other sessions share this machine: hold new agents while the load average exceeds the core count. diff --git a/assets/skills/merge.md b/assets/skills/merge.md index d1ef66d8..e811342e 100644 --- a/assets/skills/merge.md +++ b/assets/skills/merge.md @@ -21,16 +21,17 @@ This includes deciding if we should `/quest-plan` for suggested follow-ups. A background agent that cannot prompt lists its decisions and suggested follow-ups in its report instead. -When waiting for a required review or for an enabled auto-merge to finish, run +When waiting for CI, a required review, or an enabled auto-merge to finish, run `quest wait --timeout 3300` as one blocking command instead of polling -GitHub in a loop. Inspect the events and decide whether they satisfy the wait; +GitHub in a loop; it also reports the head's required checks as `checks:failed` +or `checks:passed`, so no separate check watcher is needed. +Inspect the events and decide whether they satisfy the wait; the command does not decide whose review counts. If activity is irrelevant (including bot notices), call it again with `--since` set to the last field of its last line. Exit 3 means the deadline passed with no activity; re-run with `--since` set to the printed cursor, so nothing during the first wait is missed. Other failures must be reported rather than treated as activity. -Check CI with its own blocking check watcher, bounded under an hour the same -way; check runs are not PR timeline activity. Do not wait for optional reviews. +Do not wait for optional reviews. Run each wait so its exit wakes you, such as a background task that notifies on exit, and end your turn until then. Never poll its output or rerun it under a foreground time limit; each wake rereads your whole context. The hour bound diff --git a/quest/m0/helpers/README.md b/quest/m0/helpers/README.md index 622351d7..9bf1e552 100644 --- a/quest/m0/helpers/README.md +++ b/quest/m0/helpers/README.md @@ -48,7 +48,6 @@ commands from Sep-Oct. Session counts in each child come from that window. ## Required -- [CI checks in wait](/quest/m0/helpers/wait-checks.md) - `quest wait` wakes on required check results for the head - [PR state commands](/quest/m0/helpers/pr-states.md) - `quest iterate`, `ready`, `merge`, and `release` label the one owner of a PR - [Headless agents](/quest/m0/helpers/headless-agents.md) - `/quest-spawn api` launches, resumes, and stops billed agents through one command auto mode allows diff --git a/quest/m0/helpers/wait-checks.md b/quest/m0/helpers/wait-checks.md deleted file mode 100644 index 2271ae05..00000000 --- a/quest/m0/helpers/wait-checks.md +++ /dev/null @@ -1,31 +0,0 @@ -# [M] Wake quest wait on required CI checks - -## Goal - -`quest wait` also reports the head's required CI results, so one blocking -wait covers reviews, merges, and CI. The `merge` skill drops its "own -blocking check watcher", which led 119 of 396 sessions to poll -`gh pr checks` or write their own `wait-ci.sh`. - -## Plan - -- Decided 2026-10-19 (user): add events to every wait rather than a separate - `--checks` mode. `checks:failed` fires at the head's first failing required - check; `checks:passed` fires once every required check on the head passes. - Each event names the check and links its details. -- Decided (user): only required checks wake the wait. Read requiredness from - GraphQL `isRequired(pullRequestNumber:)` on check runs and status contexts, - which needs only read access and covers branch protection and rulesets. A - repository with no required checks treats every check as required. - Neutral and skipped results pass. Non-required failures never wake; - `quest status` lists them. -- Report a check result reached after `--since`, so a wait started after CI - finished still reports it. A new push starts over for the new head. The - resume cursor records what was reported, so resuming never replays it. -- Read checks in the existing poll; keep its interval, rate-limit handling, - and exit codes. Drop "Check runs ... are not activity" from `--help`. -- Update the `merge` and `complete` skill prose: one `quest wait` covers - CI, so delete the separate check watcher. -- Test a required failure, an optional failure that does not wake, all - required passing, no required checks, a push mid-wait, and resuming from - a cursor after a reported result. diff --git a/src/cli/main.ts b/src/cli/main.ts index 25db658e..1a857274 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -253,14 +253,19 @@ export function main(argv: string[], out: Output): number | Promise { .summary("Wait for pull request activity on GitHub") .description( "Wait for a review, comment, review-thread reply, reaction to the PR or a review, push, merge, close, " + - "merge-queue removal, or disabled auto-merge. Polls every 5 minutes; rate limits delay requests. No timeout by default.\n\n" + + "merge-queue removal, disabled auto-merge, or required CI result. Polls every 5 minutes; rate limits delay requests. No timeout by default.\n\n" + "Prints one plain line per event: timestamp (or commit SHA), type, actor, URL; the last line adds a resume cursor. " + "Reactions print as reacted:, such as reacted:thumbs_up. " + + "The head's first failing required check prints as checks:failed, and every required check passing as checks:passed, " + + "each once per head, with the check's name as the actor and its details URL. " + + "Results count as quest status counts them, so neutral and skipped results pass. " + + "checks:passed waits until no check is pending, no GitHub Actions run is unfinished, and every check and workflow the base branch names is present, then names the last check to finish. " + + "Optional checks never wake the wait, unless the base branch requires none, which requires them all. " + "Pass the final field of the last line to --since to resume without replaying events. " + "On timeout, the only line is a resume point for --since (a cursor, or the given --since or head SHA if setup did not finish), so a retry with it still reports activity from the first wait.\n\n" + "Uses GH_TOKEN, GITHUB_TOKEN, or gh auth token. Replies and reactions by that account are ignored. " + "Exits 0 for activity, 3 on timeout, 2 for invalid arguments, and 1 for other failures. " + - "Check runs and edits are not activity.", + "Edits are not activity.", ) .argument("", "GitHub PR URL, or a PR number in origin") .option("--since ", "ISO timestamp, pushed-tip SHA, or resume cursor (default: current head push time)") diff --git a/src/cli/status.ts b/src/cli/status.ts index dd428aef..e9045f61 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -178,20 +178,9 @@ function summarize( pending: [], } as Status["checks"]; for (const context of contexts) { - const run = context.__typename === "CheckRun"; - const check: Check = { - name: string(run ? context.name : context.context), - required: context.isRequired === true, - result: - run && context.status !== "COMPLETED" - ? lower(context.status) - : lower(run ? context.conclusion : context.state), - url: optional(run ? context.detailsUrl : context.targetUrl), - }; - if (run ? context.status !== "COMPLETED" : ["pending", "expected"].includes(check.result)) - checks.pending.push(check); - else if (run ? PASSING.includes(check.result) : check.result === "success") checks.passed++; - else checks.failing.push(check); + const { check, outcome } = classifyCheck(context); + if (outcome === "passed") checks.passed++; + else checks[outcome].push(check); } const reactions: Reaction[] = []; @@ -267,6 +256,29 @@ function summarize( }; } +/** + * A check run or commit status from a `statusCheckRollup` context, and whether it passed, failed, or + * is still pending. `quest wait` shares this, so both commands agree on what passes. + */ +export function classifyCheck(context: ObjectValue): { check: Check; outcome: "passed" | "failing" | "pending" } { + const run = context.__typename === "CheckRun"; + const check: Check = { + name: string(run ? context.name : context.context), + required: context.isRequired === true, + result: + run && context.status !== "COMPLETED" + ? lower(context.status) + : lower(run ? context.conclusion : context.state), + url: optional(run ? context.detailsUrl : context.targetUrl), + }; + if (run ? context.status !== "COMPLETED" : ["pending", "expected"].includes(check.result)) + return { check, outcome: "pending" }; + return { + check, + outcome: (run ? PASSING.includes(check.result) : check.result === "success") ? "passed" : "failing", + }; +} + /** Stable plain lines: a record type, then space-separated fields, with any free text last. */ export function statusLines(status: Status): string[] { const dash = (value: string | number | null) => (value === null ? "-" : String(value)); diff --git a/src/cli/wait.ts b/src/cli/wait.ts index 3f867d69..5ee98532 100644 --- a/src/cli/wait.ts +++ b/src/cli/wait.ts @@ -11,6 +11,7 @@ import { type PullRequest, string, } from "./github"; +import { classifyCheck } from "./status"; const POLL = 5 * 60_000; const SHA = /^[a-f\d]{7,40}$/i; @@ -30,6 +31,28 @@ const REACTIONS = `query($owner: String!, $repo: String!, $number: Int!, $after: } } fragment reaction on Reaction { id content createdAt user { login } }`; +/** The head's check runs and commit statuses, with whether the base branch requires each. */ +const CHECKS = `query($owner: String!, $repo: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + baseRefName + headRefOid + commits(last: 1) { nodes { commit { oid statusCheckRollup { + contexts(first: 100, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { + __typename + ... on CheckRun { + name status conclusion completedAt detailsUrl isRequired(pullRequestNumber: $number) + checkSuite { status workflowRun { file { path } } } + } + ... on StatusContext { context state createdAt targetUrl isRequired(pullRequestNumber: $number) } + } + } + } } } } + } + } +}`; export interface WaitOptions { since?: string; @@ -69,6 +92,28 @@ interface Cursor { commits: string[]; /** Force pushes at or before the starting point, at their repository-activity time. */ pushes: { sha: string; time: number }[]; + /** Check results reached at or after `since` are reported once per head, as `:`. */ + checks: { since: number; reported: string[] }; +} + +/** One check run or commit status on the head, with its result once it has one. */ +interface Check { + name: string; + required: boolean; + url: string | null; + /** The workflow file a GitHub Actions job runs in. */ + workflow?: string; + /** A GitHub Actions job whose workflow run has not finished, so it may still create jobs. */ + running: boolean; + result?: { outcome: "passed" | "failed"; point: string; time: number }; +} + +/** What the base branch requires, read from branch protection and rulesets. */ +interface Requirements { + /** Required check names. */ + names: Set; + /** Workflow files that must pass as a whole, so each of their jobs is required. */ + workflows: string[]; } /** Wait until activity, returning plain output lines. Network and time are injected. */ @@ -188,6 +233,7 @@ async function watch( pushes: forced .filter((row) => !after(row)) .map((row) => ({ sha: string(row.after), time: timestamp(string(row.timestamp)) })), + checks: { since, reported: [] }, }; const plain = pushed.filter((row) => string(row.activity_type) !== "force_push" && after(row)); // A push racing setup is reported once, as a push, not again as a new commit. @@ -241,13 +287,15 @@ async function watch( } }; }; + const graphql = (query: string, after: string | null) => + github.graphql(query, { owner: pr.owner, repo: pr.repo, number: pr.number, after }); /** REST has no endpoint for reactions on reviews, and GraphQL also names the waiter. */ const reactions = async (): Promise<{ viewer: string; events: Activity[] }> => { const events: Activity[] = []; let viewer: string; let after: string | null = null; do { - const data = await github.graphql(REACTIONS, { owner: pr.owner, repo: pr.repo, number: pr.number, after }); + const data = await graphql(REACTIONS, after); viewer = string(object(data.viewer).login); const pull = object(object(data.repository).pullRequest); const reviews = object(pull.reviews); @@ -260,6 +308,62 @@ async function watch( } while (after !== null); return { viewer, events }; }; + /** GraphQL says which checks the base branch requires, through branch protection or rulesets. */ + const checks = async (): Promise<{ head: string; base: string; checks: Check[] } | undefined> => { + const result: Check[] = []; + let head: string | undefined; + let after: string | null = null; + let pull: ObjectValue; + do { + pull = object(object((await graphql(CHECKS, after)).repository).pullRequest); + const commit = object(array(object(pull.commits).nodes)[0]?.commit); + // A push racing these pages is read whole on the next poll. + if (commit.oid !== pull.headRefOid || (head !== undefined && commit.oid !== head)) return undefined; + head = string(commit.oid); + if (!fullSha(head)) throw new Error("invalid GitHub head SHA"); + if (commit.statusCheckRollup === null) break; + const contexts = object(object(commit.statusCheckRollup).contexts); + result.push(...array(contexts.nodes).map(check)); + const page = object(contexts.pageInfo); + after = page.hasNextPage === true ? string(page.endCursor) : null; + } while (after !== null); + return { head: head!, base: string(pull.baseRefName), checks: result }; + }; + /** + * What the base branch requires, read once per wait. Only a requirement says whether a missing + * check has yet to start, or whether the repository requires none. + */ + const requirements = new Map(); + const required = async (branch: string): Promise => { + const known = requirements.get(branch); + if (known) return known; + const ref = encodeURIComponent(branch); + const protection = object( + object(await (await github.request(`${base}/branches/${ref}`)).json()).protection ?? {}, + ); + const legacy = object(protection.required_status_checks ?? {}); + // Classic protection may list only `contexts`, without the newer `checks`. + const contexts = + legacy.checks === undefined + ? (Array.isArray(legacy.contexts) ? legacy.contexts : []).map(string) + : array(legacy.checks).map((row) => string(row.context)); + const rules = await github.all(`${base}/rules/branches/${ref}?per_page=100`); + const found: Requirements = { + names: new Set([ + ...(legacy.enforcement_level === "off" ? [] : contexts), + ...rules + .filter((rule) => rule.type === "required_status_checks") + .flatMap((rule) => array(object(rule.parameters).required_status_checks)) + .map((row) => string(row.context)), + ]), + workflows: rules + .filter((rule) => rule.type === "workflows") + .flatMap((rule) => array(object(rule.parameters).workflows)) + .map((row) => string(row.path)), + }; + requirements.set(branch, found); + return found; + }; const timeline = pager(`${base}/issues/${pr.number}/timeline?per_page=100`); // `since` excludes its own instant and filters by update time, which is never before @@ -312,8 +416,13 @@ async function watch( if (event.actor !== viewer && fresh("replies", event)) found.replies.push(event); } found.reactions.push(...events.filter((event) => event.actor !== viewer && fresh("reactions", event))); + // Check results are state rather than a log, so each poll reads them afresh. + const head = await checks(); + const reached = head + ? checkActivities(head.head, head.checks, await required(head.base), cursor.checks, prUrl) + : []; - if (SOURCES.some((source) => found[source].length)) { + if (reached.length || SOURCES.some((source) => found[source].length)) { const marks = Object.fromEntries( SOURCES.map((source) => { const mark = cursor.marks[source]; @@ -330,8 +439,16 @@ async function watch( marks, commits: [...seenCommits], pushes: cursor.pushes.filter((push) => push.time + TIMELINE_LAG >= marks.timeline.time), + checks: { + since: cursor.checks.since, + // A new head starts over, so only the current head's reports are kept. + reported: [ + ...cursor.checks.reported.filter((id) => !head || id.startsWith(`${head.head}:`)), + ...reached.map((event) => event.id), + ], + }, }); - const all = SOURCES.flatMap((source) => found[source]).sort((a, b) => a.time - b.time); + const all = [...SOURCES.flatMap((source) => found[source]), ...reached].sort((a, b) => a.time - b.time); const unique = [...new Map(all.map((event) => [event.id, event])).values()]; return unique.map( (event, index) => @@ -362,6 +479,74 @@ function timelineActivities(row: ObjectValue, prUrl: string): Activity[] { return [timedActivity(row, type, type === "reviewed" ? "submitted_at" : "created_at", prUrl)]; } +/** A check run or commit status, classified as `quest status` does, with when it finished. */ +function check(row: ObjectValue): Check { + const run = row.__typename === "CheckRun"; + const { check: classified, outcome } = classifyCheck(row); + const suite = run && row.checkSuite ? object(row.checkSuite) : undefined; + const file = suite?.workflowRun ? object(suite.workflowRun).file : null; + const found: Check = { + name: classified.name, + required: classified.required, + url: classified.url, + ...(file ? { workflow: string(object(file).path) } : {}), + running: suite?.workflowRun ? suite.status !== "COMPLETED" : false, + }; + if (outcome === "pending") return found; + const point = string(run ? row.completedAt : row.createdAt); + found.result = { outcome: outcome === "passed" ? "passed" : "failed", point, time: timestamp(point) }; + return found; +} + +/** + * The head's first required failure, and every required check passing once the head is idle, each + * reported once per head when reached at or after the starting point. A base branch that requires + * no checks requires them all. + */ +function checkActivities( + head: string, + all: Check[], + needs: Requirements, + state: Cursor["checks"], + prUrl: string, +): Activity[] { + const inWorkflow = (entry: Check) => entry.workflow !== undefined && needs.workflows.includes(entry.workflow); + const required = + needs.names.size || needs.workflows.length ? all.filter((entry) => entry.required || inWorkflow(entry)) : all; + const reached = (outcome: "passed" | "failed", entry: Check | undefined): Activity[] => { + const id = `${head}:${outcome}`; + if (!entry?.result || entry.result.time < state.since || state.reported.includes(id)) return []; + return [ + { + point: entry.result.point, + time: entry.result.time, + type: `checks:${outcome}`, + actor: entry.name.replace(/\s/g, "_"), + url: entry.url ?? `${prUrl}/checks`, + id, + }, + ]; + }; + const settled = required.flatMap((entry) => (entry.result ? [{ ...entry, result: entry.result }] : [])); + settled.sort((a, b) => a.result.time - b.result.time); + const failed = settled.find((entry) => entry.result.outcome === "failed" && entry.result.time >= state.since); + // GitHub lists a check only once it starts, so a pass waits until nothing on the head is still + // going: no pending check, no workflow run that may yet create a job, and every required name + // and workflow present. It is dated by the last check to finish, when the head went idle. + const idle = + all.every((entry) => entry.result && !entry.running) && + [...needs.names].every((name) => all.some((entry) => entry.name === name)) && + needs.workflows.every((path) => all.some((entry) => entry.workflow === path)); + const passed = + idle && settled.length === required.length && settled.every((entry) => entry.result.outcome === "passed") + ? all.reduce( + (last, entry) => (!last || entry.result!.time >= last.result!.time ? entry : last), + undefined, + ) + : undefined; + return [...reached("failed", failed), ...reached("passed", passed)]; +} + function timedActivity(row: ObjectValue, type: string, timeField: string, fallback: string): Activity { const point = string(row[timeField]); const id = row.id; @@ -407,6 +592,7 @@ function decodeCursor(value: string, pr: string): Cursor { try { const parsed = object(JSON.parse(Buffer.from(value.slice(3), "base64url").toString("utf8"))); const marks = object(parsed.marks); + const checks = object(parsed.checks); if ( parsed.pr !== pr || !SOURCES.every((source) => { @@ -421,7 +607,10 @@ function decodeCursor(value: string, pr: string): Cursor { !Array.isArray(parsed.commits) || !parsed.commits.every(fullSha) || !Array.isArray(parsed.pushes) || - !parsed.pushes.every((push: ObjectValue | null) => fullSha(push?.sha) && Number.isFinite(push?.time)) + !parsed.pushes.every((push: ObjectValue | null) => fullSha(push?.sha) && Number.isFinite(push?.time)) || + !Number.isFinite(checks.since) || + !Array.isArray(checks.reported) || + !checks.reported.every((id) => typeof id === "string") ) throw new Error(); return parsed as unknown as Cursor; diff --git a/test/wait.test.ts b/test/wait.test.ts index 84bd8cce..9c9da9b4 100644 --- a/test/wait.test.ts +++ b/test/wait.test.ts @@ -11,6 +11,8 @@ const POINT = "2026-10-07T12:00:00Z"; const pr = { owner: "example", repo: "project", number: 1 }; const base = "/repos/example/project"; const timeline = `${base}/issues/1/timeline?per_page=100`; +/** The GitHub Actions app, which reports workflow check runs. */ +const ACTIONS = 15368; function response(body: unknown, headers?: Record, status = 200): Response { return new Response(status === 304 ? null : JSON.stringify(body), { status, headers }); @@ -70,11 +72,96 @@ function replied(id: number, time = POINT, login = "reviewer", inReplyTo: number }; } -/** Review comments and reactions come from their own queues, empty once drained. */ -function setup(responses: Response[], baseline = [A], side: { comments?: Response[]; graphql?: Response[] } = {}) { +interface CheckFixture { + name: string; + required?: boolean; + /** A check run's conclusion, or a commit status's state; omitted while it runs. */ + result?: string; + time?: string; + status?: boolean; + /** The workflow file a job runs in, and whether its run is still going, so it may yet create jobs. */ + workflow?: string; + running?: boolean; +} + +/** The head's checks; `null` means the head has no checks at all. */ +function checks( + contexts: CheckFixture[] | null = null, + head = A, + page: { hasNextPage: boolean; endCursor: string | null } = { hasNextPage: false, endCursor: null }, + listed = head, +) { + const node = (entry: CheckFixture) => + entry.status + ? { + __typename: "StatusContext", + context: entry.name, + state: entry.result ?? "PENDING", + createdAt: entry.time ?? POINT, + targetUrl: null, + isRequired: entry.required ?? false, + } + : { + __typename: "CheckRun", + name: entry.name, + status: entry.result ? "COMPLETED" : "IN_PROGRESS", + conclusion: entry.result ?? null, + completedAt: entry.result ? (entry.time ?? POINT) : null, + detailsUrl: `https://github.com/example/project/actions/runs/1/job/${entry.name.length}`, + isRequired: entry.required ?? false, + checkSuite: { + status: entry.result && !entry.running ? "COMPLETED" : "IN_PROGRESS", + workflowRun: { file: { path: entry.workflow ?? ".github/workflows/check.yml" } }, + }, + }; + return response({ + data: { + repository: { + pullRequest: { + baseRefName: "main", + headRefOid: head, + commits: { + nodes: [ + { + commit: { + oid: listed, + statusCheckRollup: contexts && { + contexts: { pageInfo: page, nodes: contexts.map(node) }, + }, + }, + }, + ], + }, + }, + }, + }, + }); +} + +/** + * Review comments, reactions, and checks come from their own queues, empty once drained. + * The base branch requires `protected` through branch protection and `ruled` through rulesets, + * reported by GitHub Actions. `rules` adds further rules on a second page. + */ +function setup( + responses: Response[], + baseline = [A], + side: { + comments?: Response[]; + graphql?: Response[]; + checks?: Response[]; + protected?: string[]; + /** Classic protection that lists only `contexts`, without `checks`. */ + contextsOnly?: boolean; + ruled?: string[]; + rules?: unknown[]; + } = {}, +) { let now = Date.parse("2026-10-07T12:10:00Z"); const calls: { path: string; headers: Headers }[] = []; const sideCalls: { path: string; headers: Headers; body?: unknown }[] = []; + const checkCalls: { path: string; headers: Headers; body?: unknown }[] = []; + const ruleCalls: { path: string; headers: Headers }[] = []; const sleeps: number[] = []; const warnings: string[] = []; const deps: GitHubDependencies = { @@ -93,9 +180,43 @@ function setup(responses: Response[], baseline = [A], side: { comments?: Respons return side.comments?.shift() ?? response([]); } if (parsed.pathname === "/graphql") { - sideCalls.push({ ...call, body: JSON.parse(String(init?.body)) }); + const body = JSON.parse(String(init?.body)) as { query: string }; + if (body.query.includes("statusCheckRollup")) { + checkCalls.push({ ...call, body }); + return side.checks?.shift() ?? checks(); + } + sideCalls.push({ ...call, body }); return side.graphql?.shift() ?? reactions(); } + if (parsed.pathname === `${base}/branches/main`) { + ruleCalls.push(call); + const contexts = side.protected ?? []; + const checks = contexts.map((context) => ({ context, app_id: null })); + const level = contexts.length ? "everyone" : "off"; + return response({ + protection: { + enabled: contexts.length > 0, + required_status_checks: side.contextsOnly + ? { enforcement_level: level, contexts } + : { enforcement_level: level, checks }, + }, + }); + } + if (parsed.pathname === `${base}/rules/branches/main`) { + ruleCalls.push(call); + if (parsed.searchParams.get("page") === "2") return response(side.rules); + const checks = (side.ruled ?? []).map((context) => ({ context, integration_id: ACTIONS })); + const next = `; rel="next"`; + return response( + [ + { type: "deletion" }, + ...(checks.length + ? [{ type: "required_status_checks", parameters: { required_status_checks: checks } }] + : []), + ], + side.rules ? { link: next } : undefined, + ); + } calls.push(call); if (parsed.pathname.endsWith("/commits")) return response(baseline.map((sha) => ({ sha }))); const next = responses.shift(); @@ -103,7 +224,7 @@ function setup(responses: Response[], baseline = [A], side: { comments?: Respons return next; }, }; - return { deps, calls, sideCalls, sleeps, warnings, responses }; + return { deps, calls, sideCalls, checkCalls, ruleCalls, sleeps, warnings, responses }; } function graphql(fixture: ReturnType) { @@ -618,3 +739,323 @@ test("GraphQL errors fail loudly instead of hiding reactions", async () => { }); await expect(waitForActivity(pr, {}, fixture.deps)).rejects.toThrow("GitHub GraphQL error"); }); + +const LATER = "2026-10-07T12:05:00Z"; +const LATEST = "2026-10-07T12:08:00Z"; + +test("a required failure wakes the wait, naming the check and linking its details", async () => { + const fixture = setup([pull(), response([pushed()]), response([committed(A)])], [A], { + checks: [ + checks([ + { name: "lint", result: "FAILURE", time: LATER }, + { name: "build and test", required: true, result: "TIMED_OUT", time: LATEST }, + { name: "check", required: true }, + ]), + ], + protected: ["build and test", "check"], + }); + const lines = await waitForActivity(pr, {}, fixture.deps); + expect(lines).toHaveLength(1); + expect(lines[0]).toMatch( + /^2026-10-07T12:08:00Z checks:failed build_and_test https:\/\/github.com\/example\/project\/actions\/runs\/1\/job\/14 q1\./, + ); + expect(fixture.checkCalls[0].body).toMatchObject({ variables: { owner: "example", repo: "project", number: 1 } }); +}); + +test("an optional failure never wakes the wait, and checks:passed waits for every check to finish", async () => { + const fixture = setup( + [pull(), response([pushed()]), response([committed(A)]), response([committed(A)]), response([committed(A)])], + [A], + { + checks: [ + checks([ + { name: "lint", result: "FAILURE", time: LATER }, + { name: "check", required: true, result: "SUCCESS", time: LATER }, + { name: "test", required: true }, + ]), + checks([ + { name: "lint", result: "FAILURE", time: LATER }, + { name: "check", required: true, result: "SUCCESS", time: LATER }, + { name: "test", required: true, result: "SKIPPED", time: LATEST }, + { name: "slow" }, + ]), + checks([ + { name: "lint", result: "FAILURE", time: LATER }, + { name: "check", required: true, result: "SUCCESS", time: LATER }, + { name: "test", required: true, result: "SKIPPED", time: LATEST }, + { name: "slow", result: "SUCCESS", time: LATER }, + ]), + ], + ruled: ["check", "test"], + }, + ); + const lines = await waitForActivity(pr, {}, fixture.deps); + expect(fixture.sleeps).toEqual([300_000, 300_000]); + expect(lines).toHaveLength(1); + expect(lines[0]).toMatch( + /^2026-10-07T12:08:00Z checks:passed test https:\/\/github.com\/example\/project\/actions\/runs\/1\/job\/4 q1\./, + ); + // The base branch's requirements are read once per wait. + expect(fixture.ruleCalls.map((call) => call.path)).toEqual([ + `${base}/branches/main`, + `${base}/rules/branches/main?per_page=100`, + ]); +}); + +test("classic protection that lists only contexts still names required checks", async () => { + const fixture = setup([pull(), response([pushed()]), response([committed(A)]), response([committed(A)])], [A], { + checks: [ + checks([{ name: "check", required: true, result: "SUCCESS", time: LATER }]), + checks([ + { name: "check", required: true, result: "SUCCESS", time: LATER }, + { name: "gate", required: true, result: "SUCCESS", time: LATEST }, + ]), + ], + protected: ["check", "gate"], + contextsOnly: true, + }); + const lines = await waitForActivity(pr, {}, fixture.deps); + expect(fixture.sleeps).toEqual([300_000]); + expect(lines).toHaveLength(1); + expect(lines[0]).toMatch(/^2026-10-07T12:08:00Z checks:passed gate /); +}); + +test("optional checks never stand in for required checks that have not started", async () => { + const fixture = setup( + [pull(), response([pushed()]), response([committed(A)]), response([committed(A)]), response([committed(A)])], + [A], + { + checks: [ + checks([{ name: "lint", result: "FAILURE", time: LATER }]), + checks([ + { name: "lint", result: "FAILURE", time: LATER }, + { name: "check", required: true, result: "SUCCESS", time: LATER }, + ]), + checks([ + { name: "lint", result: "FAILURE", time: LATER }, + { name: "check", required: true, result: "SUCCESS", time: LATER }, + { name: "gate", required: true, result: "SUCCESS", time: LATEST }, + ]), + ], + protected: ["check"], + ruled: ["gate"], + }, + ); + const lines = await waitForActivity(pr, {}, fixture.deps); + expect(fixture.sleeps).toEqual([300_000, 300_000]); + expect(lines).toHaveLength(1); + expect(lines[0]).toMatch(/^2026-10-07T12:08:00Z checks:passed gate /); +}); + +test("a required check named on a later page of rules holds back checks:passed", async () => { + const fixture = setup([pull(), response([pushed()]), response([committed(A)]), response([committed(A)])], [A], { + checks: [ + checks([{ name: "check", required: true, result: "SUCCESS", time: LATER }]), + checks([ + { name: "check", required: true, result: "SUCCESS", time: LATER }, + { name: "deploy", required: true, result: "SUCCESS", time: LATEST }, + ]), + ], + ruled: ["check"], + rules: [ + { + type: "required_status_checks", + parameters: { required_status_checks: [{ context: "deploy", integration_id: 42 }] }, + }, + ], + }); + const lines = await waitForActivity(pr, {}, fixture.deps); + expect(fixture.sleeps).toEqual([300_000]); + expect(lines).toHaveLength(1); + expect(lines[0]).toMatch(/^2026-10-07T12:08:00Z checks:passed deploy /); + expect(fixture.ruleCalls.map((call) => call.path)).toEqual([ + `${base}/branches/main`, + `${base}/rules/branches/main?per_page=100`, + `${base}/rules/branches/main?per_page=100&page=2`, + ]); +}); + +const CI = ".github/workflows/ci.yml"; +const workflowRule = { type: "workflows", parameters: { workflows: [{ path: CI, repository_id: 1 }] } }; + +test("a required workflow requires its jobs, keeps optional checks optional, and holds back checks:passed until its run finishes", async () => { + const fixture = setup( + [pull(), response([pushed()]), response([committed(A)]), response([committed(A)]), response([committed(A)])], + [A], + { + checks: [ + checks([{ name: "lint", result: "FAILURE", time: LATER }]), + // The run's next job has not been created yet. + checks([ + { name: "lint", result: "FAILURE", time: LATER }, + { name: "build", result: "SUCCESS", time: LATER, workflow: CI, running: true }, + ]), + checks([ + { name: "lint", result: "FAILURE", time: LATER }, + { name: "build", result: "SUCCESS", time: LATER, workflow: CI }, + { name: "gate", result: "SUCCESS", time: LATEST, workflow: CI }, + ]), + ], + rules: [workflowRule], + }, + ); + const lines = await waitForActivity(pr, {}, fixture.deps); + expect(fixture.sleeps).toEqual([300_000, 300_000]); + expect(lines).toHaveLength(1); + expect(lines[0]).toMatch(/^2026-10-07T12:08:00Z checks:passed gate /); +}); + +test("a failing job in a required workflow wakes the wait", async () => { + const fixture = setup([pull(), response([pushed()]), response([committed(A)])], [A], { + checks: [ + checks([ + { name: "lint", result: "SUCCESS", time: LATER }, + { name: "build", result: "FAILURE", time: LATEST, workflow: CI, running: true }, + ]), + ], + rules: [workflowRule], + }); + const lines = await waitForActivity(pr, {}, fixture.deps); + expect(lines).toHaveLength(1); + expect(lines[0]).toMatch(/^2026-10-07T12:08:00Z checks:failed build /); +}); + +test("checks:passed is dated when the head goes idle, after required checks finished before the start", async () => { + const fixture = setup([pull(), response([pushed()]), response([committed(A)]), response([committed(A)])], [A], { + checks: [ + checks([{ name: "check", required: true, result: "SUCCESS", time: POINT }, { name: "slow" }]), + checks([ + { name: "check", required: true, result: "SUCCESS", time: POINT }, + { name: "slow", result: "SUCCESS", time: LATEST }, + ]), + ], + protected: ["check"], + }); + const lines = await waitForActivity(pr, { since: LATER }, fixture.deps); + expect(fixture.sleeps).toEqual([300_000]); + expect(lines).toHaveLength(1); + expect(lines[0]).toMatch(/^2026-10-07T12:08:00Z checks:passed slow /); +}); + +test("with no required checks, an unfinished workflow run holds back checks:passed", async () => { + const fixture = setup([pull(), response([pushed()]), response([committed(A)]), response([committed(A)])], [A], { + checks: [ + checks([{ name: "build", result: "SUCCESS", time: LATER, running: true }]), + checks([ + { name: "build", result: "SUCCESS", time: LATER }, + { name: "gate", result: "SUCCESS", time: LATEST }, + ]), + ], + }); + const lines = await waitForActivity(pr, {}, fixture.deps); + expect(fixture.sleeps).toEqual([300_000]); + expect(lines).toHaveLength(1); + expect(lines[0]).toMatch(/^2026-10-07T12:08:00Z checks:passed gate /); +}); + +test("a repository with no required checks requires every check, commit statuses included", async () => { + const fixture = setup( + [pull(), response([pushed()]), response([committed(A)]), response([committed(A)]), response([committed(A)])], + [A], + { + checks: [ + checks([]), + checks([ + { name: "check", result: "NEUTRAL", time: LATER }, + { name: "ci/legacy", status: true }, + ]), + checks([ + { name: "check", result: "NEUTRAL", time: LATER }, + { name: "ci/legacy", status: true, result: "ERROR", time: LATEST }, + ]), + ], + }, + ); + const lines = await waitForActivity(pr, {}, fixture.deps); + expect(fixture.sleeps).toEqual([300_000, 300_000]); + expect(lines).toHaveLength(1); + expect(lines[0]).toMatch( + /^2026-10-07T12:08:00Z checks:failed ci\/legacy https:\/\/github.com\/example\/project\/pull\/1\/checks q1\./, + ); +}); + +test("results reached before an explicit starting point are not reported", async () => { + const fixture = setup([pull(), response([pushed()]), response([committed(A)])], [A], { + checks: [checks([{ name: "check", required: true, result: "FAILURE", time: LATER }])], + protected: ["check"], + }); + const timeout = await waitForActivity(pr, { since: LATEST, timeout: 10 }, fixture.deps).catch( + (error: unknown) => error, + ); + expect(timeout).toBeInstanceOf(WaitTimeout); +}); + +test("a resumed wait never replays a reported result, but a push starts over for the new head", async () => { + const failing = (head: string, time = LATER) => + checks([{ name: "check", required: true, result: "FAILURE", time }], head); + const protect = { protected: ["check"] }; + const first = setup([pull(), response([pushed()]), response([committed(A)])], [A], { + checks: [failing(A)], + ...protect, + }); + const reported = await waitForActivity(pr, {}, first.deps); + expect(reported[0]).toContain("checks:failed check"); + // The same failure is not reported again; the push is. + const second = setup([response([committed(A)]), response([committed(A), committed(B)])], [A], { + checks: [failing(A), failing(A)], + ...protect, + }); + const pushedLines = await waitForActivity(pr, { since: cursor(reported) }, second.deps); + expect(second.sleeps).toEqual([300_000]); + expect(pushedLines).toHaveLength(1); + expect(pushedLines[0]).toMatch(new RegExp(`^${B} committed `)); + // The new head's failure is reported even though the old head's was. + const third = setup([response([committed(A), committed(B)])], [A], { checks: [failing(B, LATEST)], ...protect }); + const again = await waitForActivity(pr, { since: cursor(pushedLines) }, third.deps); + expect(again).toHaveLength(1); + expect(again[0]).toMatch(/^2026-10-07T12:08:00Z checks:failed check /); + // A later pass on that head is still reported once. + const fourth = setup([response([committed(A), committed(B)])], [A], { + checks: [checks([{ name: "check", required: true, result: "SUCCESS", time: "2026-10-07T12:09:00Z" }], B)], + ...protect, + }); + const passed = await waitForActivity(pr, { since: cursor(again) }, fourth.deps); + expect(passed).toHaveLength(1); + expect(passed[0]).toContain("checks:passed check"); +}); + +test("a timed-out wait's cursor still reports a result reached during the first wait", async () => { + const first = setup([pull(), response([pushed()]), response([committed(A)])], [A], { + checks: [checks([{ name: "check", required: true }])], + protected: ["check"], + }); + const timeout = await waitForActivity(pr, { timeout: 10 }, first.deps).catch((error: unknown) => error); + const second = setup([response([committed(A)])], [A], { + checks: [checks([{ name: "check", required: true, result: "SUCCESS", time: LATER }])], + protected: ["check"], + }); + const lines = await waitForActivity(pr, { since: (timeout as WaitTimeout).cursor }, second.deps); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("checks:passed check"); +}); + +test("checks are read from every page, and a head racing the read waits for the next poll", async () => { + const page = { hasNextPage: true, endCursor: "next" }; + const fixture = setup([pull(), response([pushed()]), response([committed(A)]), response([committed(A)])], [A], { + checks: [ + checks([{ name: "check", required: true, result: "FAILURE", time: LATER }], A, undefined, B), + checks([{ name: "check", required: true, result: "SUCCESS", time: LATER }], A, page), + checks([{ name: "test", required: true, result: "FAILURE", time: LATEST }]), + ], + protected: ["check", "test"], + }); + const lines = await waitForActivity(pr, {}, fixture.deps); + expect(fixture.sleeps).toEqual([300_000]); + expect(lines).toHaveLength(1); + expect(lines[0]).toMatch(/^2026-10-07T12:08:00Z checks:failed test /); + expect(fixture.checkCalls.map((call) => (call.body as { variables: { after: unknown } }).variables.after)).toEqual([ + null, + null, + "next", + ]); +});