Skip to content

Commit 7b8beb3

Browse files
committed
fix(review): paginate preview-url.ts's PR-comment and check-run GitHub reads
findPreviewUrlFromPrComments and getPreviewBuildState each read only one per_page=100 page, so on a PR with >100 comments or a commit with >100 check-runs the Cloudflare Workers Builds bot's preview comment/check-run could land on page 2+ and be silently missed (null/absent as if it did not exist). Both now walk the Link: rel="next" header, bounded to PREVIEW_LIST_MAX_PAGES=10, mirroring the existing githubPaginatedList (backfill.ts) and workflow-run listing (app.ts) precedents. The fail-safe contract is preserved: each function still degrades to null/absent on any failure and a mid-pagination failure falls back to earlier pages, never throwing. Adds regression tests for the page-2 case, early-exit, the page bound against a pathological always-next mock, and mid-page failure. Closes #7450
1 parent 25decd9 commit 7b8beb3

2 files changed

Lines changed: 199 additions & 26 deletions

File tree

src/review/visual/preview-url.ts

Lines changed: 77 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,12 @@ class PreviewGitHubError extends Error {
3838
}
3939
}
4040

41-
/** Minimal fetch→JSON helper (mirrors reviewbot's core/github.ts githubJson). Throws PreviewGitHubError on a
42-
* non-2xx so callers can distinguish a 404 ("no deployments") from a transient outage. */
43-
async function githubJson<T>(
44-
url: string,
45-
init: { token?: string | undefined; apiVersion?: string | undefined; rateLimitAdmissionKey?: GitHubRateLimitAdmissionKey | undefined } = {},
46-
): Promise<T> {
41+
type GithubJsonInit = { token?: string | undefined; apiVersion?: string | undefined; rateLimitAdmissionKey?: GitHubRateLimitAdmissionKey | undefined };
42+
43+
/** Minimal fetch→JSON helper that also surfaces the response's `Link` header for pagination (mirrors
44+
* reviewbot's core/github.ts githubJson). Throws PreviewGitHubError on a non-2xx so callers can distinguish
45+
* a 404 ("no deployments") from a transient outage. */
46+
async function githubJsonWithLink<T>(url: string, init: GithubJsonInit = {}): Promise<{ payload: T; link: string | null }> {
4747
const headers = new Headers();
4848
headers.set("accept", "application/vnd.github+json");
4949
headers.set("user-agent", PRODUCT_USER_AGENT);
@@ -68,7 +68,50 @@ async function githubJson<T>(
6868
const message = typeof (payload as { message?: string })?.message === "string" ? (payload as { message: string }).message : `GitHub ${response.status}`;
6969
throw new PreviewGitHubError(response.status, message);
7070
}
71-
return payload as T;
71+
return { payload: payload as T, link: response.headers.get("link") };
72+
}
73+
74+
async function githubJson<T>(url: string, init: GithubJsonInit = {}): Promise<T> {
75+
return (await githubJsonWithLink<T>(url, init)).payload;
76+
}
77+
78+
// GitHub caps list endpoints at 100 items/page, so a single `per_page=100` read silently truncates: a PR with
79+
// >100 discussion comments, or a commit with >100 check-runs, would push the Cloudflare Workers Builds bot's
80+
// comment / check-run onto page 2+ and this discovery would then return null/"absent" as if it genuinely
81+
// didn't exist (a truncated page-1 response is indistinguishable from an empty one). Walk the `Link: rel="next"`
82+
// header instead, bounded so a pathological PR/commit (or a mock that always advertises a next page) can't turn
83+
// one read into an unbounded fetch loop -- mirrors src/github/backfill.ts's githubPaginatedList/PR_DETAIL_MAX_PAGES
84+
// and src/github/app.ts's workflow-run listing (MAX_WORKFLOW_RUN_LIST_PAGES), both bounded to 10.
85+
const PREVIEW_LIST_MAX_PAGES = 10;
86+
87+
function hasNextPage(link: string | null): boolean {
88+
return Boolean(link?.split(",").some((part) => /rel="next"/.test(part)));
89+
}
90+
91+
/**
92+
* Walk a GitHub list endpoint's `Link: rel="next"` pages, probing each page's items as it arrives and
93+
* returning the first non-null probe result. Bounded to PREVIEW_LIST_MAX_PAGES (see the note above) so a
94+
* pathological resource can never spin. A page fetch/parse failure propagates to the caller, whose own
95+
* try/catch degrades it to null/"absent" -- earlier pages were already probed, so a mid-pagination failure
96+
* falls back to what they yielded (nothing usable) rather than dropping a successful first page, mirroring
97+
* githubPaginatedList's own "a later-page failure keeps the pages already fetched" contract.
98+
*/
99+
async function findAcrossPages<TItem, TResult>(
100+
firstPageUrl: string,
101+
init: GithubJsonInit,
102+
selectItems: (payload: unknown) => TItem[],
103+
probe: (items: TItem[]) => TResult | null,
104+
): Promise<TResult | null> {
105+
for (let page = 1; page <= PREVIEW_LIST_MAX_PAGES; page += 1) {
106+
// Callers pass a `per_page=100` first-page URL; append the 1-based page cursor for page 2+ only (page 1 is
107+
// GitHub's default, so leaving it bare keeps that request byte-identical to the pre-pagination read).
108+
const url = page === 1 ? firstPageUrl : `${firstPageUrl}&page=${page}`;
109+
const { payload, link } = await githubJsonWithLink<unknown>(url, init);
110+
const found = probe(selectItems(payload));
111+
if (found !== null) return found;
112+
if (!hasNextPage(link)) return null;
113+
}
114+
return null;
72115
}
73116

74117
export type DeploymentLookup = { url: string | null; failed: boolean; error?: boolean };
@@ -215,22 +258,26 @@ export async function findPreviewUrlFromPrComments(params: {
215258
rateLimitAdmissionKey?: GitHubRateLimitAdmissionKey | undefined;
216259
}): Promise<string | null> {
217260
const base = `https://api.github.com/repos/${params.repo.owner}/${params.repo.repo}`;
261+
const opts = { token: params.token, apiVersion: params.apiVersion, rateLimitAdmissionKey: params.rateLimitAdmissionKey };
218262
try {
219-
const comments = await githubJson<Array<{ user?: { login?: string }; body?: string }>>(
263+
return await findAcrossPages<{ user?: { login?: string }; body?: string }, string>(
220264
`${base}/issues/${params.prNumber}/comments?per_page=100`,
221-
{ token: params.token, apiVersion: params.apiVersion, rateLimitAdmissionKey: params.rateLimitAdmissionKey },
222-
).catch(() => null);
223-
if (!Array.isArray(comments)) return null;
224-
// Newest first (the bot edits one comment in place).
225-
for (const c of [...comments].reverse()) {
226-
if ((c.user?.login ?? "").toLowerCase() !== "cloudflare-workers-and-pages[bot]") continue;
227-
const url = extractPreviewUrl(c.body);
228-
if (url) return url;
229-
}
265+
opts,
266+
(payload) => (Array.isArray(payload) ? (payload as Array<{ user?: { login?: string }; body?: string }>) : []),
267+
(comments) => {
268+
// Newest first (the bot edits one comment in place).
269+
for (const c of [...comments].reverse()) {
270+
if ((c.user?.login ?? "").toLowerCase() !== "cloudflare-workers-and-pages[bot]") continue;
271+
const url = extractPreviewUrl(c.body);
272+
if (url) return url;
273+
}
274+
return null;
275+
},
276+
);
230277
} catch (error) {
231278
console.log(JSON.stringify({ event: "preview_from_comments_error", repo: `${params.repo.owner}/${params.repo.repo}`, message: String(error).slice(0, 200) }));
279+
return null;
232280
}
233-
return null;
234281
}
235282

236283
/**
@@ -247,15 +294,20 @@ export async function getPreviewBuildState(params: {
247294
rateLimitAdmissionKey?: GitHubRateLimitAdmissionKey | undefined;
248295
}): Promise<"building" | "succeeded" | "failed" | "absent"> {
249296
const base = `https://api.github.com/repos/${params.repo.owner}/${params.repo.repo}`;
297+
const opts = { token: params.token, apiVersion: params.apiVersion, rateLimitAdmissionKey: params.rateLimitAdmissionKey };
250298
try {
251-
const checks = await githubJson<{ check_runs?: Array<{ name?: string; status?: string; conclusion?: string }> }>(
299+
const state = await findAcrossPages<{ name?: string; status?: string; conclusion?: string }, "building" | "succeeded" | "failed">(
252300
`${base}/commits/${encodeURIComponent(params.sha)}/check-runs?per_page=100`,
253-
{ token: params.token, apiVersion: params.apiVersion, rateLimitAdmissionKey: params.rateLimitAdmissionKey },
254-
).catch(() => null);
255-
const build = (checks?.check_runs ?? []).find((r) => /workers builds|cloudflare/i.test(r.name ?? ""));
256-
if (!build) return "absent";
257-
if (build.status !== "completed") return "building"; // queued / in_progress → the preview is coming
258-
return build.conclusion === "success" ? "succeeded" : "failed";
301+
opts,
302+
(payload) => (payload as { check_runs?: Array<{ name?: string; status?: string; conclusion?: string }> })?.check_runs ?? [],
303+
(runs) => {
304+
const build = runs.find((r) => /workers builds|cloudflare/i.test(r.name ?? ""));
305+
if (!build) return null; // not on this page — keep walking until found, Link exhausts, or the page bound
306+
if (build.status !== "completed") return "building"; // queued / in_progress → the preview is coming
307+
return build.conclusion === "success" ? "succeeded" : "failed";
308+
},
309+
);
310+
return state ?? "absent";
259311
} catch {
260312
return "absent";
261313
}

test/unit/preview-url.test.ts

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { afterEach, describe, expect, it, vi } from "vitest";
22
import { clearGitHubResponseCacheForTest, githubRateLimitAdmissionKeyForInstallation, latestGitHubRestRateLimitObservation } from "../../src/github/client";
3-
import { extractPreviewUrl, getPreviewBuildState } from "../../src/review/visual/preview-url";
3+
import { extractPreviewUrl, findPreviewUrlFromPrComments, getPreviewBuildState } from "../../src/review/visual/preview-url";
4+
5+
/** GitHub's `Link` header for a page that advertises a next page (the exact shape findAcrossPages walks). */
6+
const NEXT_LINK = '<https://api.github.com/resource?per_page=100&page=99>; rel="next", <https://api.github.com/resource?per_page=100&page=99>; rel="last"';
7+
const REPO = { owner: "o", repo: "r" };
8+
const isPage2 = (input: RequestInfo | URL) => /[?&]page=2\b/.test(String(input));
49

510
afterEach(() => {
611
clearGitHubResponseCacheForTest();
@@ -47,6 +52,122 @@ describe("preview-url GitHub reads", () => {
4752
});
4853
});
4954

55+
describe("preview-url pagination (#7450)", () => {
56+
it("findPreviewUrlFromPrComments follows Link: rel=next and finds the bot comment on page 2", async () => {
57+
const page1 = Array.from({ length: 100 }, (_v, i) => ({ user: { login: `user${i}` }, body: "just chatter" }));
58+
const page2 = [{ user: { login: "cloudflare-workers-and-pages[bot]" }, body: "Preview ready: https://pr-9.app.workers.dev/route" }];
59+
const fetchMock = vi.fn(async (input: RequestInfo | URL) =>
60+
isPage2(input) ? Response.json(page2) : Response.json(page1, { headers: { link: NEXT_LINK } }),
61+
);
62+
vi.stubGlobal("fetch", fetchMock);
63+
64+
await expect(findPreviewUrlFromPrComments({ token: "t", repo: REPO, prNumber: 9 })).resolves.toBe("https://pr-9.app.workers.dev");
65+
expect(fetchMock).toHaveBeenCalledTimes(2);
66+
expect(String(fetchMock.mock.calls[0]![0])).toContain("/issues/9/comments?per_page=100");
67+
expect(String(fetchMock.mock.calls[0]![0])).not.toContain("&page="); // page 1 stays the bare pre-pagination read
68+
expect(String(fetchMock.mock.calls[1]![0])).toContain("&page=2");
69+
});
70+
71+
it("findPreviewUrlFromPrComments stops as soon as the bot comment is found, without fetching further pages", async () => {
72+
const fetchMock = vi.fn(async () =>
73+
Response.json([{ user: { login: "cloudflare-workers-and-pages[bot]" }, body: "https://pr-1.app.workers.dev" }], { headers: { link: NEXT_LINK } }),
74+
);
75+
vi.stubGlobal("fetch", fetchMock);
76+
await expect(findPreviewUrlFromPrComments({ token: "t", repo: REPO, prNumber: 1 })).resolves.toBe("https://pr-1.app.workers.dev");
77+
expect(fetchMock).toHaveBeenCalledTimes(1); // early exit despite the advertised next page
78+
});
79+
80+
it("findPreviewUrlFromPrComments returns null when no bot comment exists and there is no next page", async () => {
81+
vi.stubGlobal("fetch", async () => Response.json([{ user: { login: "someone" }, body: "hi" }]));
82+
await expect(findPreviewUrlFromPrComments({ token: "t", repo: REPO, prNumber: 2 })).resolves.toBeNull();
83+
});
84+
85+
it("findPreviewUrlFromPrComments treats a non-array comments payload as empty", async () => {
86+
vi.stubGlobal("fetch", async () => Response.json({ message: "unexpected shape" }));
87+
await expect(findPreviewUrlFromPrComments({ token: "t", repo: REPO, prNumber: 5 })).resolves.toBeNull();
88+
});
89+
90+
it("findPreviewUrlFromPrComments degrades to null when a later-page fetch fails, never throwing", async () => {
91+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
92+
if (isPage2(input)) throw new Error("network down");
93+
return Response.json([{ user: { login: "x" }, body: "hi" }], { headers: { link: NEXT_LINK } });
94+
});
95+
vi.stubGlobal("fetch", fetchMock);
96+
await expect(findPreviewUrlFromPrComments({ token: "t", repo: REPO, prNumber: 3 })).resolves.toBeNull();
97+
expect(fetchMock).toHaveBeenCalledTimes(2);
98+
});
99+
100+
it("findPreviewUrlFromPrComments is bounded: a pathological always-Link:next response can't loop unboundedly", async () => {
101+
const fetchMock = vi.fn(async () => Response.json([{ user: { login: "x" }, body: "hi" }], { headers: { link: NEXT_LINK } }));
102+
vi.stubGlobal("fetch", fetchMock);
103+
await expect(findPreviewUrlFromPrComments({ token: "t", repo: REPO, prNumber: 4 })).resolves.toBeNull();
104+
expect(fetchMock).toHaveBeenCalledTimes(10); // PREVIEW_LIST_MAX_PAGES
105+
});
106+
107+
it("findPreviewUrlFromPrComments skips a user-less comment and a bot comment with no preview link, then returns the real one", async () => {
108+
// Order matters: the scan reverses each page (newest first), so the url-bearing bot comment (index 0) is
109+
// examined LAST -- the user-less comment and the link-less bot comment are examined first.
110+
vi.stubGlobal("fetch", async () =>
111+
Response.json([
112+
{ user: { login: "cloudflare-workers-and-pages[bot]" }, body: "Preview: https://pr-7.app.workers.dev" },
113+
{ user: { login: "cloudflare-workers-and-pages[bot]" }, body: "build started, no link yet" }, // bot, no URL -> if(url) is false
114+
{ body: "a comment with no user object at all" }, // user absent -> `c.user?.login ?? ""` is ""
115+
]),
116+
);
117+
await expect(findPreviewUrlFromPrComments({ token: "t", repo: REPO, prNumber: 7 })).resolves.toBe("https://pr-7.app.workers.dev");
118+
});
119+
120+
it("getPreviewBuildState ignores a nameless check-run and still classifies the Workers Builds one", async () => {
121+
vi.stubGlobal("fetch", async () =>
122+
Response.json({
123+
check_runs: [
124+
{ status: "completed", conclusion: "success" }, // no name -> `r.name ?? ""` -> regex miss
125+
{ name: "Cloudflare Workers Builds", status: "completed", conclusion: "success" },
126+
],
127+
}),
128+
);
129+
await expect(getPreviewBuildState({ token: "t", repo: REPO, sha: "nameless" })).resolves.toBe("succeeded");
130+
});
131+
132+
it("getPreviewBuildState follows Link: rel=next and finds the Workers Builds check on page 2", async () => {
133+
const page1 = { check_runs: Array.from({ length: 100 }, () => ({ name: "unit tests", status: "completed", conclusion: "success" })) };
134+
const page2 = { check_runs: [{ name: "Cloudflare Workers Builds", status: "in_progress" }] };
135+
const fetchMock = vi.fn(async (input: RequestInfo | URL) =>
136+
isPage2(input) ? Response.json(page2) : Response.json(page1, { headers: { link: NEXT_LINK } }),
137+
);
138+
vi.stubGlobal("fetch", fetchMock);
139+
await expect(getPreviewBuildState({ token: "t", repo: REPO, sha: "abc" })).resolves.toBe("building");
140+
expect(fetchMock).toHaveBeenCalledTimes(2);
141+
});
142+
143+
it("getPreviewBuildState classifies a completed Workers Builds check as succeeded or failed", async () => {
144+
vi.stubGlobal("fetch", async () => Response.json({ check_runs: [{ name: "cloudflare pages", status: "completed", conclusion: "success" }] }));
145+
await expect(getPreviewBuildState({ token: "t", repo: REPO, sha: "s1" })).resolves.toBe("succeeded");
146+
vi.stubGlobal("fetch", async () => Response.json({ check_runs: [{ name: "cloudflare pages", status: "completed", conclusion: "failure" }] }));
147+
await expect(getPreviewBuildState({ token: "t", repo: REPO, sha: "s2" })).resolves.toBe("failed");
148+
});
149+
150+
it("getPreviewBuildState treats a payload without a check_runs array as absent", async () => {
151+
vi.stubGlobal("fetch", async () => Response.json({}));
152+
await expect(getPreviewBuildState({ token: "t", repo: REPO, sha: "s3" })).resolves.toBe("absent");
153+
});
154+
155+
it("getPreviewBuildState is bounded and degrades to absent on a later-page failure", async () => {
156+
const spin = vi.fn(async () => Response.json({ check_runs: [] }, { headers: { link: NEXT_LINK } }));
157+
vi.stubGlobal("fetch", spin);
158+
await expect(getPreviewBuildState({ token: "t", repo: REPO, sha: "spin" })).resolves.toBe("absent");
159+
expect(spin).toHaveBeenCalledTimes(10); // PREVIEW_LIST_MAX_PAGES
160+
161+
const failLater = vi.fn(async (input: RequestInfo | URL) => {
162+
if (isPage2(input)) throw new Error("boom");
163+
return Response.json({ check_runs: [] }, { headers: { link: NEXT_LINK } });
164+
});
165+
vi.stubGlobal("fetch", failLater);
166+
await expect(getPreviewBuildState({ token: "t", repo: REPO, sha: "fail" })).resolves.toBe("absent");
167+
expect(failLater).toHaveBeenCalledTimes(2);
168+
});
169+
});
170+
50171
describe("extractPreviewUrl", () => {
51172
it.each([
52173
["null", null],

0 commit comments

Comments
 (0)