Skip to content

Commit 48e1c9e

Browse files
committed
fix(review): paginate getLatestDeploymentStatus's deployments and statuses reads
getLatestDeploymentStatus fetched only page 1 of a head SHA/ref's Deployments list (per_page=10) and page 1 of each deployment's statuses -- never following GitHub's Link: rel="next", unlike this file's own findPreviewUrlFromPrComments and getPreviewBuildState. A ref with more than 10 deployments (repeated CI re-runs, multiple environments, a long-lived branch) could carry the deployment with the real environment_url outside page 1, so the function under-reported a missing preview exactly as if none existed -- the same false-negative class the file's own header warns about, and the class #7469 already fixed for the comment/check-run reads. Reuse the existing findAcrossPages helper for both reads, the way the sibling functions do: walk deployment pages, and per deployment walk its status pages, returning the first usable environment_url. findAcrossPages now awaits its probe so the outer deployments scan can fetch each deployment's statuses (a sync probe is unaffected). The sawFailure/sawPending bookkeeping and DeploymentLookup return contract are preserved. Closes #7805
1 parent 9d95c96 commit 48e1c9e

2 files changed

Lines changed: 234 additions & 33 deletions

File tree

src/review/visual/preview-url.ts

Lines changed: 49 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -100,14 +100,16 @@ async function findAcrossPages<TItem, TResult>(
100100
firstPageUrl: string,
101101
init: GithubJsonInit,
102102
selectItems: (payload: unknown) => TItem[],
103-
probe: (items: TItem[]) => TResult | null,
103+
// The probe may be async (e.g. it fetches a nested list per item, #7805); a synchronous probe still works
104+
// unchanged, since `await` on a non-promise is a no-op.
105+
probe: (items: TItem[]) => TResult | null | Promise<TResult | null>,
104106
): Promise<TResult | null> {
105107
for (let page = 1; page <= PREVIEW_LIST_MAX_PAGES; page += 1) {
106108
// Callers pass a `per_page=100` first-page URL; append the 1-based page cursor for page 2+ only (page 1 is
107109
// GitHub's default, so leaving it bare keeps that request byte-identical to the pre-pagination read).
108110
const url = page === 1 ? firstPageUrl : `${firstPageUrl}&page=${page}`;
109111
const { payload, link } = await githubJsonWithLink<unknown>(url, init);
110-
const found = probe(selectItems(payload));
112+
const found = await probe(selectItems(payload));
111113
if (found !== null) return found;
112114
if (!hasNextPage(link)) return null;
113115
}
@@ -138,44 +140,59 @@ export async function getLatestDeploymentStatus(params: {
138140
? `ref=${encodeURIComponent(params.ref)}`
139141
: "";
140142
if (!selector) return { url: null, failed: false };
141-
let deployments: Array<{ id?: number }>;
142-
try {
143-
deployments = await githubJson<Array<{ id?: number }>>(`${base}/deployments?${selector}&per_page=10`, {
144-
token: params.token,
145-
apiVersion: params.apiVersion,
146-
rateLimitAdmissionKey: params.rateLimitAdmissionKey,
143+
const opts = { token: params.token, apiVersion: params.apiVersion, rateLimitAdmissionKey: params.rateLimitAdmissionKey };
144+
145+
// sawFailure/sawPending accumulate across every deployment (and every page of them), so the final
146+
// failed-vs-still-coming verdict reflects all deployments, not just the first page (#7805).
147+
let sawFailure = false;
148+
let sawPending = false;
149+
150+
// Scan one deployment's statuses across ALL pages (#7805): return its environment_url when a usable status is
151+
// found, else null after recording whether its latest status looked failed/pending. Mirrors getPreviewBuildState.
152+
const findDeploymentUrl = (id: number): Promise<string | null> =>
153+
findAcrossPages<{ state?: string; environment_url?: string }, string>(
154+
`${base}/deployments/${id}/statuses?per_page=100`,
155+
opts,
156+
(payload) => (Array.isArray(payload) ? (payload as Array<{ state?: string; environment_url?: string }>) : []),
157+
(statuses) => {
158+
for (const status of statuses) {
159+
const ok = status.state === "success" || status.state === "in_progress";
160+
if (ok && status.environment_url) return status.environment_url;
161+
}
162+
// Only the first page's first entry is GitHub's "latest" status; later pages are older, so the
163+
// failed/pending bookkeeping keys off statuses[0] exactly as the pre-pagination single-page read did.
164+
const latest = statuses[0]?.state;
165+
if (latest === "failure" || latest === "error") sawFailure = true;
166+
else if (latest === "in_progress" || latest === "queued" || latest === "pending") sawPending = true;
167+
return null;
168+
},
169+
).catch((error) => {
170+
console.log(JSON.stringify({ event: "deployment_status_error", deployment: id, message: String(error).slice(0, 200) }));
171+
return null;
147172
});
173+
174+
let url: string | null;
175+
try {
176+
// Walk every page of deployments, and on each page scan each deployment's statuses; return the first usable
177+
// environment_url found, letting findAcrossPages stop as soon as a page yields one.
178+
url = await findAcrossPages<{ id?: number }, string>(
179+
`${base}/deployments?${selector}&per_page=100`,
180+
opts,
181+
(payload) => (Array.isArray(payload) ? (payload as Array<{ id?: number }>) : []),
182+
async (deployments) => {
183+
const ids = deployments.map((d) => d.id).filter((id): id is number => id != null);
184+
const statusUrls = await Promise.all(ids.map((id) => findDeploymentUrl(id)));
185+
return statusUrls.find((found): found is string => found !== null) ?? null;
186+
},
187+
);
148188
} catch (error) {
149189
// 404 → the ref genuinely has no deployments. Any other failure (403 missing scope, rate limit, 5xx) is
150190
// NOT "no preview"; report `error` so the caller keeps polling rather than showing a false terminal state.
151191
if (error instanceof PreviewGitHubError && error.status === 404) return { url: null, failed: false };
152192
console.log(JSON.stringify({ event: "deployment_lookup_error", repo: `${params.repo.owner}/${params.repo.repo}`, selector, message: String(error).slice(0, 200) }));
153193
return { url: null, failed: false, error: true };
154194
}
155-
const ids = deployments.map((d) => d.id).filter((id): id is number => id != null);
156-
const statusLists = await Promise.all(
157-
ids.map((id) =>
158-
githubJson<Array<{ state?: string; environment_url?: string }>>(`${base}/deployments/${id}/statuses?per_page=10`, {
159-
token: params.token,
160-
apiVersion: params.apiVersion,
161-
rateLimitAdmissionKey: params.rateLimitAdmissionKey,
162-
}).catch((error) => {
163-
console.log(JSON.stringify({ event: "deployment_status_error", deployment: id, message: String(error).slice(0, 200) }));
164-
return [] as Array<{ state?: string; environment_url?: string }>;
165-
}),
166-
),
167-
);
168-
let sawFailure = false;
169-
let sawPending = false;
170-
for (const statuses of statusLists) {
171-
for (const status of statuses) {
172-
const ok = status.state === "success" || status.state === "in_progress";
173-
if (ok && status.environment_url) return { url: status.environment_url, failed: false };
174-
}
175-
const latest = statuses[0]?.state;
176-
if (latest === "failure" || latest === "error") sawFailure = true;
177-
else if (latest === "in_progress" || latest === "queued" || latest === "pending") sawPending = true;
178-
}
195+
if (url !== null) return { url, failed: false };
179196
return { url: null, failed: sawFailure && !sawPending };
180197
}
181198

test/unit/preview-url.test.ts

Lines changed: 185 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { afterEach, describe, expect, it, vi } from "vitest";
22
import { clearGitHubResponseCacheForTest, githubRateLimitAdmissionKeyForInstallation, latestGitHubRestRateLimitObservation } from "../../src/github/client";
3-
import { extractPreviewUrl, findPreviewUrlFromPrComments, getPreviewBuildState } from "../../src/review/visual/preview-url";
3+
import { extractPreviewUrl, findPreviewUrlFromPrComments, getLatestDeploymentStatus, getPreviewBuildState } from "../../src/review/visual/preview-url";
44

55
/** GitHub's `Link` header for a page that advertises a next page (the exact shape findAcrossPages walks). */
66
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"';
@@ -166,6 +166,190 @@ describe("preview-url pagination (#7450)", () => {
166166
await expect(getPreviewBuildState({ token: "t", repo: REPO, sha: "fail" })).resolves.toBe("absent");
167167
expect(failLater).toHaveBeenCalledTimes(2);
168168
});
169+
170+
it("getLatestDeploymentStatus finds a deployment whose environment_url sits on page 2 of the deployments list (#7805)", async () => {
171+
// Page 1: 100 deployments none of which carry a usable status; page 2: the deployment with the real URL.
172+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
173+
const url = String(input);
174+
if (url.includes("/deployments?")) {
175+
if (isPage2(input)) return Response.json([{ id: 777 }]);
176+
return Response.json(
177+
Array.from({ length: 100 }, (_v, i) => ({ id: i + 1 })),
178+
{ headers: { link: NEXT_LINK } },
179+
);
180+
}
181+
// Deployment 777's statuses carry the preview URL; every page-1 deployment's statuses are empty.
182+
if (url.includes("/deployments/777/statuses")) {
183+
return Response.json([{ state: "success", environment_url: "https://p2.pages.dev/" }]);
184+
}
185+
return Response.json([]);
186+
});
187+
vi.stubGlobal("fetch", fetchMock);
188+
await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "abc" })).resolves.toEqual({
189+
url: "https://p2.pages.dev/",
190+
failed: false,
191+
});
192+
// Page 1 + page 2 of deployments were both fetched (the bug was that page 2 was never read).
193+
expect(fetchMock.mock.calls.some((c) => isPage2(c[0]))).toBe(true);
194+
});
195+
196+
it("getLatestDeploymentStatus finds a usable status on page 2 of a deployment's statuses (#7805)", async () => {
197+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
198+
const url = String(input);
199+
if (url.includes("/deployments?")) return Response.json([{ id: 42 }]);
200+
if (url.includes("/deployments/42/statuses")) {
201+
if (isPage2(input)) return Response.json([{ state: "success", environment_url: "https://s2.workers.dev/" }]);
202+
// Page 1: 100 older statuses, none usable, advertising a next page.
203+
return Response.json(
204+
Array.from({ length: 100 }, () => ({ state: "queued" })),
205+
{ headers: { link: NEXT_LINK } },
206+
);
207+
}
208+
return Response.json([]);
209+
});
210+
vi.stubGlobal("fetch", fetchMock);
211+
await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "abc" })).resolves.toEqual({
212+
url: "https://s2.workers.dev/",
213+
failed: false,
214+
});
215+
expect(fetchMock.mock.calls.some((c) => String(c[0]).includes("/statuses") && isPage2(c[0]))).toBe(true);
216+
});
217+
218+
it("getLatestDeploymentStatus reports failed when a deployment's latest status is a failure and none are pending (#7805)", async () => {
219+
// No usable environment_url anywhere, and the single deployment's latest (statuses[0]) is a hard failure:
220+
// sawFailure is set, sawPending stays false, so the terminal verdict is failed:true.
221+
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
222+
const url = String(input);
223+
if (url.includes("/deployments?")) return Response.json([{ id: 5 }]);
224+
if (url.includes("/deployments/5/statuses")) return Response.json([{ state: "failure" }, { state: "success" }]);
225+
return Response.json([]);
226+
});
227+
await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "abc" })).resolves.toEqual({
228+
url: null,
229+
failed: true,
230+
});
231+
});
232+
233+
it("getLatestDeploymentStatus is NOT failed when another deployment is still pending, even if one failed (#7805)", async () => {
234+
// Two deployments: one failed, one still in_progress. sawPending being set suppresses the failed verdict, so
235+
// the caller keeps polling rather than declaring a false terminal failure.
236+
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
237+
const url = String(input);
238+
if (url.includes("/deployments?")) return Response.json([{ id: 1 }, { id: 2 }]);
239+
if (url.includes("/deployments/1/statuses")) return Response.json([{ state: "error" }]);
240+
if (url.includes("/deployments/2/statuses")) return Response.json([{ state: "in_progress" }]);
241+
return Response.json([]);
242+
});
243+
await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "abc" })).resolves.toEqual({
244+
url: null,
245+
failed: false,
246+
});
247+
});
248+
249+
it("getLatestDeploymentStatus swallows a per-deployment statuses-fetch error and treats that deployment as URL-less (#7805)", async () => {
250+
// A statuses read throwing must not abort the whole lookup: findDeploymentUrl's .catch degrades that one
251+
// deployment to null, so the overall result is a clean "no URL yet, not failed".
252+
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
253+
const url = String(input);
254+
if (url.includes("/deployments?")) return Response.json([{ id: 9 }]);
255+
if (url.includes("/deployments/9/statuses")) throw new Error("statuses network down");
256+
return Response.json([]);
257+
});
258+
await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "abc" })).resolves.toEqual({
259+
url: null,
260+
failed: false,
261+
});
262+
});
263+
264+
it("getLatestDeploymentStatus skips a deployment whose id is absent, leaving no statuses to inspect (#7805)", async () => {
265+
// The id filter drops an id-less deployment entry, so there is nothing to fetch statuses for -> no URL, not
266+
// failed. (A statuses fetch for a real id is never issued here.)
267+
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
268+
const url = String(input);
269+
if (url.includes("/deployments?")) return Response.json([{ notAnId: true }]);
270+
return Response.json([]);
271+
});
272+
await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "abc" })).resolves.toEqual({
273+
url: null,
274+
failed: false,
275+
});
276+
});
277+
278+
it("getLatestDeploymentStatus treats a non-array deployments payload as an empty page (#7805)", async () => {
279+
// Array.isArray(payload) ? ... : [] -- the deployments list read returning a non-array object degrades to an
280+
// empty page rather than throwing.
281+
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
282+
const url = String(input);
283+
if (url.includes("/deployments?")) return Response.json({ message: "unexpected non-array deployments shape" });
284+
return Response.json([]);
285+
});
286+
await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "abc" })).resolves.toEqual({
287+
url: null,
288+
failed: false,
289+
});
290+
});
291+
292+
it("getLatestDeploymentStatus treats a non-array statuses payload for a deployment as empty (#7805)", async () => {
293+
// The inner Array.isArray(payload) ? ... : [] guard: a deployment's /statuses returning a non-array object is
294+
// treated as "no statuses", so that deployment contributes no URL and no failed/pending signal.
295+
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
296+
const url = String(input);
297+
if (url.includes("/deployments?")) return Response.json([{ id: 3 }]);
298+
if (url.includes("/deployments/3/statuses")) return Response.json({ message: "unexpected non-array statuses shape" });
299+
return Response.json([]);
300+
});
301+
await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "abc" })).resolves.toEqual({
302+
url: null,
303+
failed: false,
304+
});
305+
});
306+
307+
it("getLatestDeploymentStatus builds a ref-scoped deployments query when given a ref instead of a sha (#7805)", async () => {
308+
// The selector ternary's ref arm: with params.ref (and no sha) the deployments list is queried by ref=..., and
309+
// a usable status still resolves to its environment_url.
310+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
311+
const url = String(input);
312+
if (url.includes("/deployments?")) return Response.json([{ id: 8 }]);
313+
if (url.includes("/deployments/8/statuses")) return Response.json([{ state: "success", environment_url: "https://ref.pages.dev/" }]);
314+
return Response.json([]);
315+
});
316+
vi.stubGlobal("fetch", fetchMock);
317+
await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, ref: "feature-branch" })).resolves.toEqual({
318+
url: "https://ref.pages.dev/",
319+
failed: false,
320+
});
321+
// The deployments query was scoped by ref=..., not sha=...
322+
expect(fetchMock.mock.calls.some((c) => String(c[0]).includes("/deployments?ref=feature-branch"))).toBe(true);
323+
});
324+
325+
it("getLatestDeploymentStatus returns error:true when the deployments read fails with a non-404 status (#7805)", async () => {
326+
// A 403/5xx (not a 404 "genuinely no deployments") must surface error:true so the caller keeps polling rather
327+
// than showing a false terminal "no preview" state.
328+
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
329+
const url = String(input);
330+
if (url.includes("/deployments?")) return new Response("forbidden", { status: 403 });
331+
return Response.json([]);
332+
});
333+
await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "abc" })).resolves.toEqual({
334+
url: null,
335+
failed: false,
336+
error: true,
337+
});
338+
});
339+
340+
it("getLatestDeploymentStatus returns no-preview (not error) when the deployments read 404s (#7805)", async () => {
341+
// A 404 means the ref genuinely has no deployments -> a clean {url:null, failed:false}, distinct from the
342+
// non-404 error path above.
343+
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
344+
const url = String(input);
345+
if (url.includes("/deployments?")) return new Response("not found", { status: 404 });
346+
return Response.json([]);
347+
});
348+
await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "abc" })).resolves.toEqual({
349+
url: null,
350+
failed: false,
351+
});
352+
});
169353
});
170354

171355
describe("extractPreviewUrl", () => {

0 commit comments

Comments
 (0)