diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/github.test.ts b/pstack/skills/poteto-mode/scripts/watch-pr/github.test.ts index 87f4048f..fa2ddead 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/github.test.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/github.test.ts @@ -63,6 +63,32 @@ describe("checks fallback chain", () => { expect(reader.calls).toEqual(["checksFastPath", "checkRollupPage:null"]); }); + it("accepts an empty check set confirmed by both readers", async () => { + const reader = fakeReader({ + fastPath: { + kind: "unusable", + exitCode: 1, + stderr: "no checks reported on the feature branch", + }, + rollupPages: [{ checks: [], endCursor: null }], + }); + const read = await resolveChecks(reader, context); + expect(read.source).toBe("graphql-rollup"); + expect(read.checks).toEqual([]); + expect(reader.calls).toEqual(["checksFastPath", "checkRollupPage:null"]); + }); + + it("accepts valid empty fast-path JSON confirmed by the rollup", async () => { + const reader = fakeReader({ + fastPath: { kind: "checks", checks: [] }, + rollupPages: [{ checks: [], endCursor: null }], + }); + const read = await resolveChecks(reader, context); + expect(read.source).toBe("gh-pr-checks"); + expect(read.checks).toEqual([]); + expect(reader.calls).toEqual(["checksFastPath", "checkRollupPage:null"]); + }); + it("fails closed when both paths are empty", async () => { const reader = fakeReader({ fastPath: { @@ -76,6 +102,20 @@ describe("checks fallback chain", () => { ); expect(reader.calls).toEqual(["checksFastPath", "checkRollupPage:null"]); }); + + it("fails closed when exit 1 has an unrelated error", async () => { + const reader = fakeReader({ + fastPath: { + kind: "unusable", + exitCode: 1, + stderr: "resource not accessible by integration", + }, + rollupPages: [{ checks: [], endCursor: null }], + }); + await expect(resolveChecks(reader, context)).rejects.toBeInstanceOf( + ChecksUnavailable + ); + }); }); describe("rollup node mapping", () => { diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/github.ts b/pstack/skills/poteto-mode/scripts/watch-pr/github.ts index 79bc06fb..57dda8d8 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/github.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/github.ts @@ -617,6 +617,15 @@ export async function resolveChecks( } while (after !== null); const fallback = nonEmpty(checks); if (fallback !== null) return { source: "graphql-rollup", checks: fallback }; + const fastPathConfirmsEmpty = + fast.kind === "checks" || + (fast.exitCode === 1 && + /^no checks reported on the .+ branch\s*$/im.test(fast.stderr)); + if (fastPathConfirmsEmpty) + return { + source: fast.kind === "checks" ? "gh-pr-checks" : "graphql-rollup", + checks: [], + }; const suffix = fast.kind === "unusable" ? `fast path exit=${fast.exitCode}; GraphQL rollup was empty${firstLine(fast.stderr) ? `; ${firstLine(fast.stderr)}` : ""}` 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 0620dded..23e421a1 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts @@ -88,6 +88,42 @@ describe("readiness truth table", () => { blocker: { kind: "failing-checks" }, }); }); + + it("classifies a PR with no configured checks as CI-clean", async () => { + const reader = fakeReader({ + fastPath: { kind: "checks", checks: [] }, + rollupPages: [{ checks: [], endCursor: null }], + }); + const snapshot = await readSnapshot({ + reader, + context: context(2), + pendingHistory: "include", + allowDraft: false, + }); + expect(snapshot.kind).toBe("open"); + if (snapshot.kind !== "open") throw new Error("expected open snapshot"); + expect(snapshot.ci.kind).toBe("ci-clean"); + expect(snapshot.ci.all).toEqual([]); + }); + + it("waits when a new head temporarily has no checks after prior CI passed", async () => { + const reader = fakeReader({ + fastPath: { kind: "checks", checks: [] }, + rollupPages: [{ checks: [], endCursor: null }], + commitRollups: [ + { oid: "previous", state: "SUCCESS" }, + { oid: "head", state: null }, + ], + }); + await expect(readSnapshot({ + reader, + context: context(3), + pendingHistory: "include", + allowDraft: false, + })).rejects.toThrow( + "PR head has no checks yet after a previously checked commit" + ); + }); }); describe("snapshot query planning", () => { diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts b/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts index 4665be37..3b214c40 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts @@ -1,4 +1,8 @@ -import { WatcherQueryError, resolveChecks } from "./github.ts"; +import { + ChecksUnavailable, + WatcherQueryError, + resolveChecks, +} from "./github.ts"; import type * as T from "./types.ts"; import { nonEmpty } from "./types.ts"; export function assessGitHubMerge(args: { @@ -108,6 +112,10 @@ export async function readSnapshot(args: { pending: pending ?? [], github: merge.github, }; + else if (checks.checks.length === 0 && merge.hadPreviousPassingCi) + throw new ChecksUnavailable( + "PR head has no checks yet after a previously checked commit" + ); else if (pending !== null) ci = { ...base, kind: "ci-pending", failed: [], pending }; else diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/types.ts b/pstack/skills/poteto-mode/scripts/watch-pr/types.ts index b67144e0..192dfe1d 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/types.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/types.ts @@ -88,7 +88,7 @@ export type FailedCheck = Extract; export type PendingCheck = Extract; export interface CheckRead { readonly source: "gh-pr-checks" | "graphql-rollup"; - readonly checks: NonEmpty; + readonly checks: readonly Check[]; } export interface CommitRollup { readonly oid: string; @@ -115,7 +115,7 @@ export type GitHubMergeAllowed = export type GitHubMergeAssessment = GitHubMergeAllowed | GitHubMergeRefusal; interface CiBase { readonly source: CheckRead["source"]; - readonly all: NonEmpty; + readonly all: readonly Check[]; readonly hadPreviousPassingCi: boolean; } export type CiFailing = CiBase & {