diff --git a/scripts/ci/pr-readiness.d.mts b/scripts/ci/pr-readiness.d.mts index 5ae731dc08..c42684eb1c 100644 --- a/scripts/ci/pr-readiness.d.mts +++ b/scripts/ci/pr-readiness.d.mts @@ -11,6 +11,14 @@ export interface ReadinessResult { readonly description: string; } export interface ReadinessGitHub { + graphql( + query: string, + variables: { + readonly owner: string; + readonly name: string; + readonly after: string | null; + }, + ): Promise; readonly rest: { readonly pulls: { get(input: { diff --git a/scripts/ci/pr-readiness.mjs b/scripts/ci/pr-readiness.mjs index f78f709dd5..c1e8ba8b2c 100644 --- a/scripts/ci/pr-readiness.mjs +++ b/scripts/ci/pr-readiness.mjs @@ -2,6 +2,57 @@ import { readFile } from "node:fs/promises"; const result = (state, description) => ({ state, description }); +const OPEN_HEADS_QUERY = `query ReadinessOpenHeads($owner: String!, $name: String!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequests(first: 100, after: $after, states: OPEN, orderBy: {field: CREATED_AT, direction: ASC}) { + nodes { number headRefOid } + pageInfo { hasNextPage endCursor } + } + } +}`; + +/** An unindexed commit needs complete live head coverage, not PR bodies. */ +async function openHeadsMatching(github, repository, sha) { + const matches = []; + const numbers = new Set(); + const cursors = new Set(); + let after = null; + do { + const response = await github.graphql(OPEN_HEADS_QUERY, { + owner: repository.owner, + name: repository.repo, + after, + }); + const page = response?.repository?.pullRequests; + if ( + !Array.isArray(page?.nodes) || + typeof page.pageInfo?.hasNextPage !== "boolean" + ) + throw new Error("GitHub returned incomplete open-PR head metadata."); + for (const node of page.nodes) { + if ( + !Number.isSafeInteger(node?.number) || + node.number <= 0 || + typeof node.headRefOid !== "string" || + !/^(?:[a-f\d]{40}|[a-f\d]{64})$/u.test(node.headRefOid) || + numbers.has(node.number) + ) + throw new Error( + "GitHub returned invalid or repeated open-PR head metadata.", + ); + numbers.add(node.number); + if (node.headRefOid === sha) + matches.push({ number: node.number, state: "open", head: { sha } }); + } + if (!page.pageInfo.hasNextPage) return matches; + const next = page.pageInfo.endCursor; + if (typeof next !== "string" || next.length === 0 || cursors.has(next)) + throw new Error("GitHub open-PR head pagination did not advance."); + cursors.add(next); + after = next; + } while (true); +} + function readinessLines(markdown) { const lines = markdown.split(/\r?\n/u); const visible = []; @@ -125,18 +176,17 @@ export async function runReadinessCheck({ github, context, core }) { ); let verdict = evaluateReadiness(pull, template); if (verdict.state === "success") { - const associated = await github.paginate( + let associated = await github.paginate( github.rest.repos.listPullRequestsAssociatedWithCommit, { ...repository, commit_sha: pull.head.sha, per_page: 100 }, ); - if ( - !associated.some( - (other) => - other.number === number && - other.state === "open" && - other.head.sha === pull.head.sha, - ) - ) + const ownsHead = (other) => + other.number === number && + other.state === "open" && + other.head.sha === pull.head.sha; + if (!associated.some(ownsHead)) + associated = await openHeadsMatching(github, repository, pull.head.sha); + if (!associated.some(ownsHead)) verdict = result( "pending", "GitHub has not confirmed this PR's head association; rerun PR readiness.", diff --git a/tests/boundary/filesystem/prReadiness.test.ts b/tests/boundary/filesystem/prReadiness.test.ts index f2b54353b0..ead81379de 100644 --- a/tests/boundary/filesystem/prReadiness.test.ts +++ b/tests/boundary/filesystem/prReadiness.test.ts @@ -35,6 +35,18 @@ const makePull = ( ...changes, }); +const headPage = ( + nodes: readonly { number: number; headRefOid: string }[], + next: string | null = null, +) => ({ + repository: { + pullRequests: { + nodes, + pageInfo: { hasNextPage: next !== null, endCursor: next }, + }, + }, +}); + function fixture(changes: Partial = {}) { const pull = makePull(changes); const get = vi.fn(async () => ({ data: pull })); @@ -46,7 +58,13 @@ function fixture(changes: Partial = {}) { ) => ({}), ); const paginate = vi.fn(async () => [pull]); + const graphql = vi.fn( + async ( + ..._arguments: Parameters + ): Promise => headPage([]), + ); const github = { + graphql, rest: { pulls: { get }, repos: { @@ -63,7 +81,16 @@ function fixture(changes: Partial = {}) { runId: 123, }; const core = { info: vi.fn(), setFailed: vi.fn() }; - return { github, context, core, get, createCommitStatus, paginate, pull }; + return { + github, + context, + core, + get, + createCommitStatus, + paginate, + graphql, + pull, + }; } describe("initial author declaration", () => { @@ -192,6 +219,7 @@ describe("GitHub status boundary", () => { expect((await runReadinessCheck(input))?.state).toBe("pending"); expect(input.core.setFailed).not.toHaveBeenCalled(); expect(input.paginate).not.toHaveBeenCalled(); + expect(input.graphql).not.toHaveBeenCalled(); }); it("keeps the initial declaration valid after later pushes", async () => { @@ -202,6 +230,7 @@ describe("GitHub status boundary", () => { expect.objectContaining({ sha, state: "success" }), ); expect(input.github.rest.pulls).toEqual({ get: input.get }); + expect(input.graphql).not.toHaveBeenCalled(); } }); @@ -244,6 +273,7 @@ describe("GitHub status boundary", () => { input.paginate.mockResolvedValue([]); expect((await runReadinessCheck(input))?.state).toBe("pending"); expect(input.core.setFailed).not.toHaveBeenCalled(); + expect(input.graphql).toHaveBeenCalledOnce(); }); it.each([{ state: "closed" as const }, { base: { ref: "other-branch" } }])( @@ -272,6 +302,139 @@ describe("GitHub status boundary", () => { }); }); +describe("Live PR-head identity", () => { + it("confirms an unindexed commit through complete paged live PR heads", async () => { + const input = fixture(); + input.paginate.mockResolvedValue([]); + input.graphql + .mockResolvedValueOnce( + headPage([{ number: 1, headRefOid: "b".repeat(40) }], "next-page"), + ) + .mockResolvedValueOnce( + headPage([ + { number: input.pull.number, headRefOid: input.pull.head.sha }, + ]), + ); + expect((await runReadinessCheck(input))?.state).toBe("success"); + expect(input.graphql).toHaveBeenNthCalledWith(1, expect.any(String), { + owner: "morluto", + name: "rea", + after: null, + }); + expect(input.graphql).toHaveBeenNthCalledWith(2, expect.any(String), { + owner: "morluto", + name: "rea", + after: "next-page", + }); + expect(input.createCommitStatus).toHaveBeenLastCalledWith( + expect.objectContaining({ sha: input.pull.head.sha, state: "success" }), + ); + }); + + it("rejects a shared head discovered on a later live page", async () => { + const input = fixture(); + input.paginate.mockResolvedValue([]); + input.graphql + .mockResolvedValueOnce( + headPage( + [{ number: input.pull.number, headRefOid: input.pull.head.sha }], + "next", + ), + ) + .mockResolvedValueOnce( + headPage([{ number: 43, headRefOid: input.pull.head.sha }]), + ); + expect((await runReadinessCheck(input))?.state).toBe("failure"); + expect(input.core.setFailed).toHaveBeenCalledWith( + expect.stringContaining("distinct head commits"), + ); + }); + + it("does not publish stale success when a fallback scan spans a head update", async () => { + const input = fixture(); + input.paginate.mockResolvedValue([]); + input.graphql.mockResolvedValue( + headPage([ + { number: input.pull.number, headRefOid: input.pull.head.sha }, + ]), + ); + input.get + .mockResolvedValueOnce({ data: input.pull }) + .mockResolvedValueOnce({ + data: makePull({ head: { sha: "b".repeat(40) } }), + }); + expect((await runReadinessCheck(input))?.state).toBe("pending"); + expect(input.createCommitStatus).toHaveBeenLastCalledWith( + expect.objectContaining({ sha: "b".repeat(40), state: "pending" }), + ); + }); +}); + +describe("Live PR-head coverage failures", () => { + it("leaves readiness pending when live head metadata is unavailable", async () => { + const input = fixture(); + input.paginate.mockResolvedValue([]); + input.graphql.mockRejectedValue(new Error("live heads unavailable")); + await expect(runReadinessCheck(input)).rejects.toThrow( + "live heads unavailable", + ); + expect(input.createCommitStatus).toHaveBeenCalledOnce(); + expect(input.createCommitStatus).toHaveBeenCalledWith( + expect.objectContaining({ state: "pending" }), + ); + }); + + it.each([ + null, + { repository: null }, + { + repository: { + pullRequests: { nodes: null, pageInfo: { hasNextPage: false } }, + }, + }, + headPage([{ number: 42, headRefOid: "unknown" }]), + headPage([{ number: 43, headRefOid: "A".repeat(40) }]), + ])( + "cannot turn incomplete live metadata into success (%#)", + async (response) => { + const input = fixture(); + input.paginate.mockResolvedValue([]); + input.graphql.mockResolvedValue(response); + await expect(runReadinessCheck(input)).rejects.toThrow( + /open-PR head metadata/u, + ); + expect(input.createCommitStatus).toHaveBeenCalledOnce(); + }, + ); + + it("rejects non-progressing live pagination without another API request", async () => { + const input = fixture(); + input.paginate.mockResolvedValue([]); + input.graphql + .mockRejectedValue(new Error("unexpected extra page")) + .mockResolvedValueOnce(headPage([], "repeat")) + .mockResolvedValueOnce(headPage([], "repeat")); + await expect(runReadinessCheck(input)).rejects.toThrow( + "pagination did not advance", + ); + expect(input.graphql).toHaveBeenCalledTimes(2); + expect(input.createCommitStatus).toHaveBeenCalledOnce(); + }); + + it("rejects overlapping live pages instead of claiming complete coverage", async () => { + const input = fixture(); + input.paginate.mockResolvedValue([]); + const node = { number: input.pull.number, headRefOid: input.pull.head.sha }; + input.graphql + .mockResolvedValueOnce(headPage([node], "next")) + .mockResolvedValueOnce(headPage([node])); + await expect(runReadinessCheck(input)).rejects.toThrow( + "repeated open-PR head metadata", + ); + expect(input.createCommitStatus).toHaveBeenCalledOnce(); + }); +}); + const readWorkflow = async () => z .object({