mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(ci): validate unindexed readiness heads with live PR metadata (#5316)
This commit is contained in:
@@ -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<unknown>;
|
||||
readonly rest: {
|
||||
readonly pulls: {
|
||||
get(input: {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<ReadinessPullRequest> = {}) {
|
||||
const pull = makePull(changes);
|
||||
const get = vi.fn(async () => ({ data: pull }));
|
||||
@@ -46,7 +58,13 @@ function fixture(changes: Partial<ReadinessPullRequest> = {}) {
|
||||
) => ({}),
|
||||
);
|
||||
const paginate = vi.fn(async () => [pull]);
|
||||
const graphql = vi.fn(
|
||||
async (
|
||||
..._arguments: Parameters<ReadinessGitHub["graphql"]>
|
||||
): Promise<unknown> => headPage([]),
|
||||
);
|
||||
const github = {
|
||||
graphql,
|
||||
rest: {
|
||||
pulls: { get },
|
||||
repos: {
|
||||
@@ -63,7 +81,16 @@ function fixture(changes: Partial<ReadinessPullRequest> = {}) {
|
||||
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({
|
||||
|
||||
Reference in New Issue
Block a user