Skip to content

Commit 6fb53ba

Browse files
fix(review): align deployment pagination with findDeploymentUrl helper (#7805)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8dd5416 commit 6fb53ba

2 files changed

Lines changed: 94 additions & 35 deletions

File tree

src/review/visual/preview-url.ts

Lines changed: 31 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -139,62 +139,58 @@ export async function getLatestDeploymentStatus(params: {
139139
: "";
140140
if (!selector) return { url: null, failed: false };
141141
const opts = { token: params.token, apiVersion: params.apiVersion, rateLimitAdmissionKey: params.rateLimitAdmissionKey };
142-
type DeploymentStatus = { state?: string; environment_url?: string };
143-
const selectStatuses = (payload: unknown) => (Array.isArray(payload) ? (payload as DeploymentStatus[]) : []);
144-
const probeStatusesForUrl = (statuses: DeploymentStatus[]) => {
145-
for (const status of statuses) {
146-
const ok = status.state === "success" || status.state === "in_progress";
147-
if (ok && status.environment_url) return status.environment_url;
148-
}
149-
return null;
150-
};
151-
const inspectDeploymentStatuses = async (deploymentId: number): Promise<{ url: string | null; latestState?: string }> => {
152-
let latestState: string | undefined;
153-
let capturedLatest = false;
154-
const url = await findAcrossPages<DeploymentStatus, string>(
155-
`${base}/deployments/${deploymentId}/statuses?per_page=10`,
142+
143+
// sawFailure/sawPending accumulate across every deployment (and every page of them), so the final
144+
// failed-vs-still-coming verdict reflects all deployments, not just the first page (#7805).
145+
let sawFailure = false;
146+
let sawPending = false;
147+
148+
// Scan one deployment's statuses across ALL pages (#7805): return its environment_url when a usable status is
149+
// found, else null after recording whether its latest status looked failed/pending.
150+
const findDeploymentUrl = (id: number): Promise<string | null> =>
151+
findAcrossPages<{ state?: string; environment_url?: string }, string>(
152+
`${base}/deployments/${id}/statuses?per_page=10`,
156153
opts,
157-
selectStatuses,
154+
(payload) => (Array.isArray(payload) ? (payload as Array<{ state?: string; environment_url?: string }>) : []),
158155
(statuses) => {
159-
if (!capturedLatest) {
160-
latestState = statuses[0]?.state;
161-
capturedLatest = true;
156+
for (const status of statuses) {
157+
const ok = status.state === "success" || status.state === "in_progress";
158+
if (ok && status.environment_url) return status.environment_url;
162159
}
163-
return probeStatusesForUrl(statuses);
160+
// Only the first page's first entry is GitHub's "latest" status; later pages are older, so the
161+
// failed/pending bookkeeping keys off statuses[0] exactly as the pre-pagination single-page read did.
162+
const latest = statuses[0]?.state;
163+
if (latest === "failure" || latest === "error") sawFailure = true;
164+
else if (latest === "in_progress" || latest === "queued" || latest === "pending") sawPending = true;
165+
return null;
164166
},
165167
).catch((error) => {
166-
console.log(JSON.stringify({ event: "deployment_status_error", deployment: deploymentId, message: String(error).slice(0, 200) }));
168+
console.log(JSON.stringify({ event: "deployment_status_error", deployment: id, message: String(error).slice(0, 200) }));
167169
return null;
168170
});
169-
if (url) return { url };
170-
return latestState !== undefined ? { url: null, latestState } : { url: null };
171-
};
172-
let sawFailure = false;
173-
let sawPending = false;
171+
172+
let url: string | null;
174173
try {
175-
const url = await findAcrossPages<{ id?: number }, string>(
174+
// Walk every page of deployments, and on each page scan each deployment's statuses; return the first usable
175+
// environment_url found, letting findAcrossPages stop as soon as a page yields one.
176+
url = await findAcrossPages<{ id?: number }, string>(
176177
`${base}/deployments?${selector}&per_page=10`,
177178
opts,
178179
(payload) => (Array.isArray(payload) ? (payload as Array<{ id?: number }>) : []),
179180
async (deployments) => {
180-
for (const deployment of deployments) {
181-
if (deployment.id == null) continue;
182-
const { url: foundUrl, latestState } = await inspectDeploymentStatuses(deployment.id);
183-
if (foundUrl) return foundUrl;
184-
if (latestState === "failure" || latestState === "error") sawFailure = true;
185-
else if (latestState === "in_progress" || latestState === "queued" || latestState === "pending") sawPending = true;
186-
}
187-
return null;
181+
const ids = deployments.map((d) => d.id).filter((id): id is number => id != null);
182+
const statusUrls = await Promise.all(ids.map((id) => findDeploymentUrl(id)));
183+
return statusUrls.find((found): found is string => found !== null) ?? null;
188184
},
189185
);
190-
if (url) return { url, failed: false };
191186
} catch (error) {
192187
// 404 → the ref genuinely has no deployments. Any other failure (403 missing scope, rate limit, 5xx) is
193188
// NOT "no preview"; report `error` so the caller keeps polling rather than showing a false terminal state.
194189
if (error instanceof PreviewGitHubError && error.status === 404) return { url: null, failed: false };
195190
console.log(JSON.stringify({ event: "deployment_lookup_error", repo: `${params.repo.owner}/${params.repo.repo}`, selector, message: String(error).slice(0, 200) }));
196191
return { url: null, failed: false, error: true };
197192
}
193+
if (url !== null) return { url, failed: false };
198194
return { url: null, failed: sawFailure && !sawPending };
199195
}
200196

test/unit/preview-url.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,69 @@ describe("preview-url pagination (#7450)", () => {
313313
});
314314
await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "shape" })).resolves.toEqual({ url: null, failed: false });
315315
});
316+
317+
it("getLatestDeploymentStatus keeps failed:false when one deployment failed but another is still pending", async () => {
318+
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
319+
const url = String(input);
320+
if (url.includes("/deployments?")) return Response.json([{ id: 1 }, { id: 2 }]);
321+
if (url.includes("/deployments/1/statuses")) return Response.json([{ state: "error" }]);
322+
if (url.includes("/deployments/2/statuses")) return Response.json([{ state: "in_progress" }]);
323+
return Response.json([]);
324+
});
325+
await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "mixed" })).resolves.toEqual({ url: null, failed: false });
326+
});
327+
328+
it("getLatestDeploymentStatus skips deployments without an id", async () => {
329+
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
330+
const url = String(input);
331+
if (url.includes("/deployments?")) return Response.json([{ notAnId: true }]);
332+
return Response.json([]);
333+
});
334+
await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "no-id" })).resolves.toEqual({ url: null, failed: false });
335+
});
336+
337+
it("getLatestDeploymentStatus builds a ref-scoped deployments query when given a ref instead of a sha", async () => {
338+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
339+
const url = String(input);
340+
if (url.includes("/deployments?")) return Response.json([{ id: 8 }]);
341+
if (url.includes("/deployments/8/statuses")) return Response.json([{ state: "success", environment_url: "https://ref.pages.dev/" }]);
342+
return Response.json([]);
343+
});
344+
vi.stubGlobal("fetch", fetchMock);
345+
await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, ref: "feature-branch" })).resolves.toEqual({
346+
url: "https://ref.pages.dev/",
347+
failed: false,
348+
});
349+
expect(fetchMock.mock.calls.some((c) => String(c[0]).includes("/deployments?ref=feature-branch"))).toBe(true);
350+
});
351+
352+
it("getLatestDeploymentStatus treats a non-array statuses payload for a deployment as empty", async () => {
353+
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
354+
const url = String(input);
355+
if (url.includes("/deployments?")) return Response.json([{ id: 3 }]);
356+
if (url.includes("/deployments/3/statuses")) return Response.json({ message: "unexpected non-array statuses shape" });
357+
return Response.json([]);
358+
});
359+
await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "bad-statuses" })).resolves.toEqual({ url: null, failed: false });
360+
});
361+
362+
it("getLatestDeploymentStatus keeps failed:false when the latest status is queued", async () => {
363+
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
364+
const url = String(input);
365+
if (url.includes("/deployments?")) return Response.json([{ id: 6 }]);
366+
return Response.json([{ state: "queued" }]);
367+
});
368+
await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, ref: "feature/x" })).resolves.toEqual({ url: null, failed: false });
369+
});
370+
371+
it("getLatestDeploymentStatus treats an empty statuses page as absent without a latest state", async () => {
372+
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
373+
const url = String(input);
374+
if (url.includes("/deployments?")) return Response.json([{ id: 8 }]);
375+
return Response.json([]);
376+
});
377+
await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "empty-statuses" })).resolves.toEqual({ url: null, failed: false });
378+
});
316379
});
317380

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

0 commit comments

Comments
 (0)