From 0f3823c084dcac58972db5446f1fbde8a9b60303 Mon Sep 17 00:00:00 2001 From: Dean Rie Date: Thu, 22 Oct 2026 09:17:06 +0300 Subject: [PATCH] fix(pstack): watch-pr --status-only reports a head with no checks instead of retrying for minutes On a PR in a repository with no CI or check apps, resolveChecks throws a retryable ChecksUnavailable when both gh pr checks and the GraphQL rollup are empty, and --status-only went through pollUntilTerminal with the full --max-query-errors budget. The result was RETRY lines at 60/120/240 s and no status at all, which stalls babysit's check mode. - readSnapshot gains noChecks: "report" | "retry". With "report" an empty check set becomes a new ci-none state (source "none", no checks, merge assessment still read); the default keeps today's retry, because a freshly pushed head has no checks yet either and polling must not call it ready. - runSimple passes "report" for --status-only and clamps maxQueryErrors to 1 for that mode, so a status read ends on the first query error instead of sleeping through the backoff schedule. - --pretty renders ci-none as "no checks reported"; --status-only help says it reads GitHub once. Tests: readSnapshot report/retry, CLI --status-only on a no-checks head (JSON and --pretty), first-error exit, and polling still emitting RETRY. bun test 74/74, tsc clean, harness tree regenerated. Fixes #750. --- .../poteto-mode/scripts/watch-pr/cli.test.ts | 75 +++++++++++++++++++ .../poteto-mode/scripts/watch-pr/cli.ts | 6 +- .../scripts/watch-pr/policy.test.ts | 48 ++++++++++++ .../poteto-mode/scripts/watch-pr/policy.ts | 37 ++++++++- .../poteto-mode/scripts/watch-pr/render.ts | 2 + .../poteto-mode/scripts/watch-pr/types.ts | 12 ++- .../poteto-mode/scripts/watch-pr/cli.test.ts | 75 +++++++++++++++++++ .../poteto-mode/scripts/watch-pr/cli.ts | 6 +- .../scripts/watch-pr/policy.test.ts | 48 ++++++++++++ .../poteto-mode/scripts/watch-pr/policy.ts | 37 ++++++++- .../poteto-mode/scripts/watch-pr/render.ts | 2 + .../poteto-mode/scripts/watch-pr/types.ts | 12 ++- 12 files changed, 352 insertions(+), 8 deletions(-) diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/cli.test.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/cli.test.ts index 0656b413e..73977530e 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/cli.test.ts +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/cli.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { type CliRuntime, main, parseArgs } from "./cli.ts"; import { fakeReader, passingCheck } from "./fakes.test-helper.ts"; +import { WatcherQueryError } from "./github.ts"; import { renderJson, renderPretty } from "./render.ts"; import type { GitHubReader, WatcherVerdict } from "./types.ts"; import { parsePrNumber } from "./types.ts"; @@ -103,6 +104,80 @@ describe("parseArgs", () => { }); }); +describe("heads without checks", () => { + const noChecks = () => + fakeReader({ + fastPath: { + kind: "unusable", + exitCode: 1, + stderr: "no checks reported on the 'feature' branch", + }, + rollupPages: [{ checks: [], endCursor: null }], + }); + + it("--status-only reports the head once instead of retrying", async () => { + const harness = testRuntime(noChecks()); + expect(await main(["--pr", "1", "--status-only"], harness.runtime)).toBe(0); + expect(harness.stdout).toHaveLength(1); + const verdict = JSON.parse(harness.stdout[0] ?? "") as WatcherVerdict; + expect(verdict.kind).toBe("STATUS"); + if (verdict.kind !== "STATUS") throw new Error("expected STATUS"); + const row = verdict.rows[0]; + expect(row.kind).toBe("open"); + if (row.kind !== "open") throw new Error("expected open row"); + expect(row.ci.kind).toBe("ci-none"); + }); + + it("--status-only --pretty names the missing checks", async () => { + const harness = testRuntime(noChecks()); + expect( + await main(["--pr", "1", "--status-only", "--pretty"], harness.runtime) + ).toBe(0); + expect(harness.stdout.join("")).toContain("no checks reported"); + }); + + it("--status-only ends on the first query error instead of sleeping", async () => { + const reader = noChecks(); + const failing: GitHubReader = { + ...reader, + async pullRequest() { + throw new WatcherQueryError({ + kind: "json-parse", + retryable: true, + detail: "simulated outage", + }); + }, + }; + const harness = testRuntime(failing); + const code = await main(["--pr", "1", "--status-only"], harness.runtime); + expect(code).not.toBe(0); + expect(harness.stdout).toHaveLength(1); + const verdict = JSON.parse(harness.stdout[0] ?? "") as WatcherVerdict; + expect(verdict.kind).not.toBe("RETRY"); + }); + + it("polling still treats a head without checks as a retryable error", async () => { + const harness = testRuntime(noChecks()); + const slept: number[] = []; + const runtime: CliRuntime = { + ...harness.runtime, + clock: { + ...harness.runtime.clock, + async sleep(seconds) { + slept.push(seconds); + if (slept.length >= 2) throw new Error("stop test"); + }, + }, + }; + await expect( + main(["--pr", "1", "--max-query-errors", "5"], runtime) + ).rejects.toThrow("stop test"); + expect(slept.length).toBe(2); + const first = JSON.parse(harness.stdout[0] ?? "") as WatcherVerdict; + expect(first.kind).toBe("RETRY"); + }); +}); + describe("rendering", () => { const context = { owner: "owner", diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/cli.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/cli.ts index ec8a2f27c..ea9f0895c 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/cli.ts +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/cli.ts @@ -126,7 +126,11 @@ export function parseArgs( positiveInteger, 5 ) - .option("--status-only", "print one status table and exit 0", false) + .option( + "--status-only", + "read GitHub once, print one status table and exit 0; a head with no checks is reported, not retried", + false + ) .option("--allow-draft", "do not treat a draft as a merge gate", false) .option("--pretty", "render human text instead of JSON", false); program.parse(argv, { from: "user" }); diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.test.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.test.ts index 6dae16da7..d14d0d11a 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.test.ts +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.test.ts @@ -91,6 +91,54 @@ describe("readiness truth table", () => { }); describe("snapshot query planning", () => { + it("reports a head without checks as ci-none when asked to", async () => { + const reader = fakeReader({ + fastPath: { + kind: "unusable", + exitCode: 1, + stderr: "no checks reported on the 'feature' branch", + }, + rollupPages: [{ checks: [], endCursor: null }], + }); + const snapshot = await readSnapshot({ + reader, + context: context(7), + pendingHistory: "include", + allowDraft: false, + noChecks: "report", + }); + expect(snapshot.kind).toBe("open"); + if (snapshot.kind !== "open") throw new Error("expected open snapshot"); + expect(snapshot.ci.kind).toBe("ci-none"); + expect(snapshot.ci.all).toEqual([]); + expect(snapshot.reviewAutomationRunning).toBe(false); + if (snapshot.ci.kind !== "ci-none") throw new Error("expected ci-none"); + expect(snapshot.ci.github.kind).toBe("allowed"); + expect(reader.calls).toContain("commitRollups"); + }); + + it("keeps a head without checks as a retryable query error by default", async () => { + const reader = fakeReader({ + fastPath: { kind: "checks", checks: [] }, + rollupPages: [{ checks: [], endCursor: null }], + }); + let caught: unknown = null; + try { + await readSnapshot({ + reader, + context: context(8), + pendingHistory: "include", + allowDraft: false, + }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(WatcherQueryError); + if (!(caught instanceof WatcherQueryError)) throw new Error("expected query error"); + expect(caught.failure.kind).toBe("checks-unavailable"); + expect(caught.failure.retryable).toBe(true); + }); + it("does not query commit rollups while queued checks are pending", async () => { const reader = fakeReader({ fastPath: { kind: "checks", checks: [pendingCheck()] }, diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.ts index cabadb9bf..5dfdb5d33 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.ts +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.ts @@ -1,4 +1,5 @@ import { + ChecksUnavailable, WatcherQueryError, resolveChecks, resolveReviewThreads, @@ -61,6 +62,8 @@ export async function readSnapshot(args: { readonly context: T.PrContext; readonly pendingHistory: "include" | "omit"; readonly allowDraft: boolean; + /** `report`: a head with no checks becomes `ci-none`. `retry` (default): it stays a retryable query error, because a just-pushed head looks the same. */ + readonly noChecks?: "report" | "retry"; }): Promise { const facts = await args.reader.pullRequest(args.context); if (facts.state === "MERGED" || facts.mergedAt !== null) @@ -68,7 +71,30 @@ export async function readSnapshot(args: { if (facts.state === "CLOSED") return { kind: "closed", context: args.context, facts }; const threads = await resolveReviewThreads(args.reader, args.context); - const checks = await resolveChecks(args.reader, args.context); + let checks: T.CheckRead; + try { + checks = await resolveChecks(args.reader, args.context); + } catch (error) { + if (!(error instanceof ChecksUnavailable) || args.noChecks !== "report") + throw error; + const merge = await mergeAssessment(args.reader, facts); + return { + kind: "open", + context: args.context, + facts, + threads, + ci: { + kind: "ci-none", + source: "none", + all: [], + failed: [], + pending: [], + hadPreviousPassingCi: merge.hadPreviousPassingCi, + github: merge.github, + }, + reviewAutomationRunning: false, + }; + } const failed = nonEmpty( checks.checks.filter( (check): check is T.FailedCheck => check.kind === "failed" @@ -422,6 +448,12 @@ export async function runSimple(args: { readonly options: T.PollingOptions; }): Promise { const stamp = verdictFactory(args.dependencies.clock, args.mode); + // `--status-only` reads GitHub once: a head without checks is reported as + // such instead of being retried, and a query error ends the run on the + // first failure instead of sleeping through the backoff schedule. + const options: T.PollingOptions = args.statusOnly + ? { ...args.options, maxQueryErrors: 1 } + : args.options; const step = async (): Promise> => { const rows: T.PrSnapshot[] = []; for (const context of args.contexts) @@ -431,6 +463,7 @@ export async function runSimple(args: { context, pendingHistory: "include", allowDraft: args.options.allowDraft, + noChecks: args.statusOnly ? "report" : "retry", }) ); const complete = nonEmpty(rows); @@ -512,7 +545,7 @@ export async function runSimple(args: { }; return pollUntilTerminal({ dependencies: args.dependencies, - options: args.options, + options, stamp, step, }); diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/render.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/render.ts index 0e9358393..a0da9ac18 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/render.ts +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/render.ts @@ -13,6 +13,8 @@ function ciCell(row: T.PrSnapshot): string { return `❌ ${row.ci.failed.length} failed${row.ci.pending.length ? `, ${row.ci.pending.length} pending` : ""}${was}`; case "ci-github-rejected": return `❌ GitHub reports failing checks${was}`; + case "ci-none": + return `no checks reported${was}`; default: { const exhaustive: never = row.ci; return exhaustive; diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/types.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/types.ts index 6c94e0a68..c024f4b1c 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/types.ts +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/types.ts @@ -141,7 +141,17 @@ export type CiClean = CiBase & { readonly pending: readonly []; readonly github: GitHubMergeAllowed; }; -export type CiState = CiFailing | CiGithubRejected | CiPending | CiClean; +/** GitHub reported no checks at all for the head. Only `--status-only` produces this; polling keeps retrying, since a freshly pushed head has no checks yet either. */ +export interface CiNone { + readonly kind: "ci-none"; + readonly source: "none"; + readonly all: readonly []; + readonly failed: readonly []; + readonly pending: readonly []; + readonly hadPreviousPassingCi: boolean; + readonly github: GitHubMergeAssessment; +} +export type CiState = CiFailing | CiGithubRejected | CiPending | CiClean | CiNone; export type PrSnapshot = | { readonly kind: "merged" | "closed"; diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/cli.test.ts b/pstack/skills/poteto-mode/scripts/watch-pr/cli.test.ts index 0656b413e..73977530e 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/cli.test.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/cli.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { type CliRuntime, main, parseArgs } from "./cli.ts"; import { fakeReader, passingCheck } from "./fakes.test-helper.ts"; +import { WatcherQueryError } from "./github.ts"; import { renderJson, renderPretty } from "./render.ts"; import type { GitHubReader, WatcherVerdict } from "./types.ts"; import { parsePrNumber } from "./types.ts"; @@ -103,6 +104,80 @@ describe("parseArgs", () => { }); }); +describe("heads without checks", () => { + const noChecks = () => + fakeReader({ + fastPath: { + kind: "unusable", + exitCode: 1, + stderr: "no checks reported on the 'feature' branch", + }, + rollupPages: [{ checks: [], endCursor: null }], + }); + + it("--status-only reports the head once instead of retrying", async () => { + const harness = testRuntime(noChecks()); + expect(await main(["--pr", "1", "--status-only"], harness.runtime)).toBe(0); + expect(harness.stdout).toHaveLength(1); + const verdict = JSON.parse(harness.stdout[0] ?? "") as WatcherVerdict; + expect(verdict.kind).toBe("STATUS"); + if (verdict.kind !== "STATUS") throw new Error("expected STATUS"); + const row = verdict.rows[0]; + expect(row.kind).toBe("open"); + if (row.kind !== "open") throw new Error("expected open row"); + expect(row.ci.kind).toBe("ci-none"); + }); + + it("--status-only --pretty names the missing checks", async () => { + const harness = testRuntime(noChecks()); + expect( + await main(["--pr", "1", "--status-only", "--pretty"], harness.runtime) + ).toBe(0); + expect(harness.stdout.join("")).toContain("no checks reported"); + }); + + it("--status-only ends on the first query error instead of sleeping", async () => { + const reader = noChecks(); + const failing: GitHubReader = { + ...reader, + async pullRequest() { + throw new WatcherQueryError({ + kind: "json-parse", + retryable: true, + detail: "simulated outage", + }); + }, + }; + const harness = testRuntime(failing); + const code = await main(["--pr", "1", "--status-only"], harness.runtime); + expect(code).not.toBe(0); + expect(harness.stdout).toHaveLength(1); + const verdict = JSON.parse(harness.stdout[0] ?? "") as WatcherVerdict; + expect(verdict.kind).not.toBe("RETRY"); + }); + + it("polling still treats a head without checks as a retryable error", async () => { + const harness = testRuntime(noChecks()); + const slept: number[] = []; + const runtime: CliRuntime = { + ...harness.runtime, + clock: { + ...harness.runtime.clock, + async sleep(seconds) { + slept.push(seconds); + if (slept.length >= 2) throw new Error("stop test"); + }, + }, + }; + await expect( + main(["--pr", "1", "--max-query-errors", "5"], runtime) + ).rejects.toThrow("stop test"); + expect(slept.length).toBe(2); + const first = JSON.parse(harness.stdout[0] ?? "") as WatcherVerdict; + expect(first.kind).toBe("RETRY"); + }); +}); + describe("rendering", () => { const context = { owner: "owner", diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/cli.ts b/pstack/skills/poteto-mode/scripts/watch-pr/cli.ts index ec8a2f27c..ea9f0895c 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/cli.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/cli.ts @@ -126,7 +126,11 @@ export function parseArgs( positiveInteger, 5 ) - .option("--status-only", "print one status table and exit 0", false) + .option( + "--status-only", + "read GitHub once, print one status table and exit 0; a head with no checks is reported, not retried", + false + ) .option("--allow-draft", "do not treat a draft as a merge gate", false) .option("--pretty", "render human text instead of JSON", false); program.parse(argv, { from: "user" }); diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts b/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts index 6dae16da7..d14d0d11a 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts @@ -91,6 +91,54 @@ describe("readiness truth table", () => { }); describe("snapshot query planning", () => { + it("reports a head without checks as ci-none when asked to", async () => { + const reader = fakeReader({ + fastPath: { + kind: "unusable", + exitCode: 1, + stderr: "no checks reported on the 'feature' branch", + }, + rollupPages: [{ checks: [], endCursor: null }], + }); + const snapshot = await readSnapshot({ + reader, + context: context(7), + pendingHistory: "include", + allowDraft: false, + noChecks: "report", + }); + expect(snapshot.kind).toBe("open"); + if (snapshot.kind !== "open") throw new Error("expected open snapshot"); + expect(snapshot.ci.kind).toBe("ci-none"); + expect(snapshot.ci.all).toEqual([]); + expect(snapshot.reviewAutomationRunning).toBe(false); + if (snapshot.ci.kind !== "ci-none") throw new Error("expected ci-none"); + expect(snapshot.ci.github.kind).toBe("allowed"); + expect(reader.calls).toContain("commitRollups"); + }); + + it("keeps a head without checks as a retryable query error by default", async () => { + const reader = fakeReader({ + fastPath: { kind: "checks", checks: [] }, + rollupPages: [{ checks: [], endCursor: null }], + }); + let caught: unknown = null; + try { + await readSnapshot({ + reader, + context: context(8), + pendingHistory: "include", + allowDraft: false, + }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(WatcherQueryError); + if (!(caught instanceof WatcherQueryError)) throw new Error("expected query error"); + expect(caught.failure.kind).toBe("checks-unavailable"); + expect(caught.failure.retryable).toBe(true); + }); + it("does not query commit rollups while queued checks are pending", async () => { const reader = fakeReader({ fastPath: { kind: "checks", checks: [pendingCheck()] }, diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts b/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts index cabadb9bf..5dfdb5d33 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts @@ -1,4 +1,5 @@ import { + ChecksUnavailable, WatcherQueryError, resolveChecks, resolveReviewThreads, @@ -61,6 +62,8 @@ export async function readSnapshot(args: { readonly context: T.PrContext; readonly pendingHistory: "include" | "omit"; readonly allowDraft: boolean; + /** `report`: a head with no checks becomes `ci-none`. `retry` (default): it stays a retryable query error, because a just-pushed head looks the same. */ + readonly noChecks?: "report" | "retry"; }): Promise { const facts = await args.reader.pullRequest(args.context); if (facts.state === "MERGED" || facts.mergedAt !== null) @@ -68,7 +71,30 @@ export async function readSnapshot(args: { if (facts.state === "CLOSED") return { kind: "closed", context: args.context, facts }; const threads = await resolveReviewThreads(args.reader, args.context); - const checks = await resolveChecks(args.reader, args.context); + let checks: T.CheckRead; + try { + checks = await resolveChecks(args.reader, args.context); + } catch (error) { + if (!(error instanceof ChecksUnavailable) || args.noChecks !== "report") + throw error; + const merge = await mergeAssessment(args.reader, facts); + return { + kind: "open", + context: args.context, + facts, + threads, + ci: { + kind: "ci-none", + source: "none", + all: [], + failed: [], + pending: [], + hadPreviousPassingCi: merge.hadPreviousPassingCi, + github: merge.github, + }, + reviewAutomationRunning: false, + }; + } const failed = nonEmpty( checks.checks.filter( (check): check is T.FailedCheck => check.kind === "failed" @@ -422,6 +448,12 @@ export async function runSimple(args: { readonly options: T.PollingOptions; }): Promise { const stamp = verdictFactory(args.dependencies.clock, args.mode); + // `--status-only` reads GitHub once: a head without checks is reported as + // such instead of being retried, and a query error ends the run on the + // first failure instead of sleeping through the backoff schedule. + const options: T.PollingOptions = args.statusOnly + ? { ...args.options, maxQueryErrors: 1 } + : args.options; const step = async (): Promise> => { const rows: T.PrSnapshot[] = []; for (const context of args.contexts) @@ -431,6 +463,7 @@ export async function runSimple(args: { context, pendingHistory: "include", allowDraft: args.options.allowDraft, + noChecks: args.statusOnly ? "report" : "retry", }) ); const complete = nonEmpty(rows); @@ -512,7 +545,7 @@ export async function runSimple(args: { }; return pollUntilTerminal({ dependencies: args.dependencies, - options: args.options, + options, stamp, step, }); diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/render.ts b/pstack/skills/poteto-mode/scripts/watch-pr/render.ts index 0e9358393..a0da9ac18 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/render.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/render.ts @@ -13,6 +13,8 @@ function ciCell(row: T.PrSnapshot): string { return `❌ ${row.ci.failed.length} failed${row.ci.pending.length ? `, ${row.ci.pending.length} pending` : ""}${was}`; case "ci-github-rejected": return `❌ GitHub reports failing checks${was}`; + case "ci-none": + return `no checks reported${was}`; default: { const exhaustive: never = row.ci; return exhaustive; diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/types.ts b/pstack/skills/poteto-mode/scripts/watch-pr/types.ts index 6c94e0a68..c024f4b1c 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/types.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/types.ts @@ -141,7 +141,17 @@ export type CiClean = CiBase & { readonly pending: readonly []; readonly github: GitHubMergeAllowed; }; -export type CiState = CiFailing | CiGithubRejected | CiPending | CiClean; +/** GitHub reported no checks at all for the head. Only `--status-only` produces this; polling keeps retrying, since a freshly pushed head has no checks yet either. */ +export interface CiNone { + readonly kind: "ci-none"; + readonly source: "none"; + readonly all: readonly []; + readonly failed: readonly []; + readonly pending: readonly []; + readonly hadPreviousPassingCi: boolean; + readonly github: GitHubMergeAssessment; +} +export type CiState = CiFailing | CiGithubRejected | CiPending | CiClean | CiNone; export type PrSnapshot = | { readonly kind: "merged" | "closed";