From 727a2fe0930c894644e9b10bddd2d67b70ac5c73 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Fri, 24 Jul 2026 19:52:56 +0800 Subject: [PATCH] fix(github): stop the open-issues GraphQL supplement when endCursor is missing supplementOpenIssuesFromGraphQl broke pagination on hasNextPage alone, so a GitHub response claiming another page with a null/absent endCursor serialized into a malformed 'after: undefined' query. That request fails, the function throws, and supplementUnderCountIfNeeded's catch discards the entire supplement attempt including every issue already fetched on prior pages. Mirrors the guard its sibling supplementOpenPullRequestsFromGraphQl already has (added two days later as a close copy and never backported), so the issues path degrades gracefully and keeps what it fetched. Adds a regression test that fails without the guard. Closes #8312 --- src/github/backfill.ts | 2 +- test/unit/backfill.test.ts | 43 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/github/backfill.ts b/src/github/backfill.ts index e52465c595..6a041e950c 100644 --- a/src/github/backfill.ts +++ b/src/github/backfill.ts @@ -1828,7 +1828,7 @@ async function supplementOpenIssuesFromGraphQl(env: Env, repo: RepositoryRecord, existingNumbers.add(issue.number); supplemented += 1; } - if (!issues?.pageInfo?.hasNextPage) break; + if (!issues?.pageInfo?.hasNextPage || !issues.pageInfo.endCursor) break; after = `, after: ${JSON.stringify(issues.pageInfo.endCursor)}`; } return supplemented; diff --git a/test/unit/backfill.test.ts b/test/unit/backfill.test.ts index 2c4d23d5d0..d9d198ba30 100644 --- a/test/unit/backfill.test.ts +++ b/test/unit/backfill.test.ts @@ -4266,6 +4266,49 @@ describe("GitHub backfill", () => { expect(await listPullRequests(env, "JSONbored/gittensory")).toEqual(expect.arrayContaining([expect.objectContaining({ number: 301, title: "Pull request #301", labels: ["bug"] })])); }); + it("stops the open-issues supplement when GitHub reports hasNextPage with no endCursor, keeping the pages already fetched (#8312)", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedRegisteredRepo(env); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url === "https://api.github.com/graphql") { + const query = JSON.parse(String(init?.body ?? "{}")).query as string; + if (query.includes("LoopOverRepoTotals")) { + return githubTotalsResponse({ openIssues: 2, openPullRequests: 0, mergedPullRequests: 0, closedPullRequests: 0, labels: 0 }); + } + // With the endCursor guard this follow-up page is never requested. Without it, the null cursor is + // serialized into a malformed `after:` argument; GitHub rejects the query, supplementOpenIssuesFromGraphQl + // throws, and supplementUnderCountIfNeeded's catch discards the WHOLE supplement -- losing issue 201 below. + if (query.includes("LoopOverOpenIssuesSupplement") && query.includes("after:")) { + return new Response("malformed cursor", { status: 502 }); + } + if (query.includes("LoopOverOpenIssuesSupplement")) { + return Response.json({ + data: { + repository: { + issues: { + // The anomaly: more pages claimed, but no cursor to fetch them with. + pageInfo: { hasNextPage: true, endCursor: null }, + nodes: [{ number: 201, labels: { nodes: [{ name: "bug" }] } }], + }, + }, + }, + }); + } + } + if (url.includes("/issues?")) return Response.json([]); + return Response.json([]); + }); + + const issuesResult = await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "open_issues", mode: "full", force: true }); + + // Graceful partial stop: the page already fetched is kept rather than thrown away. + expect(issuesResult).toMatchObject({ status: "partial", fetchedCount: 1, expectedCount: 2 }); + expect(await listIssues(env, "JSONbored/gittensory")).toEqual( + expect.arrayContaining([expect.objectContaining({ number: 201, labels: ["bug"] })]), + ); + }); + it("keeps unauthenticated open-data undercounts partial when GraphQL supplements are unavailable", async () => { const env = createTestEnv(); await seedRegisteredRepo(env);