Skip to content

Commit 3ee9e4e

Browse files
authored
fix(github): paginate GraphQL PR-detail fallback past 100 nodes (#7493)
fetchPullRequestDetailsFromGraphQl's files/reviews connections requested only first: 100 with no pageInfo, so a PR with more than 100 changed files or more than 100 reviews silently truncated when the REST detail fetch fell back to GraphQL -- with no way to even detect it. Add pageInfo { hasNextPage endCursor } to both connections and walk each independently with cursor pagination, bounded by a new PR_DETAIL_GRAPHQL_MAX_PAGES constant mirroring the REST path's PR_DETAIL_MAX_PAGES. A later-page failure keeps the items already fetched instead of discarding them, matching githubPaginatedList's partial-result semantics; a page-1 failure still surfaces the same way to callers as before. Closes #7453
1 parent 90b01f6 commit 3ee9e4e

2 files changed

Lines changed: 196 additions & 36 deletions

File tree

src/github/backfill.ts

Lines changed: 91 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -257,11 +257,14 @@ type GitHubOpenPullRequestsResponse = {
257257
errors?: Array<{ message?: string }>;
258258
};
259259

260+
type GitHubGraphQlPageInfo = { hasNextPage?: boolean | null; endCursor?: string | null } | null;
261+
260262
type GitHubPullRequestDetailsResponse = {
261263
data?: {
262264
repository?: {
263265
pullRequest?: {
264266
files?: {
267+
pageInfo?: GitHubGraphQlPageInfo;
265268
nodes?: Array<{
266269
path?: string | null;
267270
additions?: number | null;
@@ -270,6 +273,7 @@ type GitHubPullRequestDetailsResponse = {
270273
} | null> | null;
271274
} | null;
272275
reviews?: {
276+
pageInfo?: GitHubGraphQlPageInfo;
273277
nodes?: Array<{
274278
databaseId?: number | null;
275279
author?: { login?: string | null } | null;
@@ -4137,6 +4141,12 @@ export async function fetchLinkedIssueFacts(
41374141
};
41384142
}
41394143

4144+
// GraphQL's `files`/`reviews` connections default to first: 100 with no follow-up, so a PR with more than
4145+
// 100 changed files or more than 100 reviews silently truncates here too -- mirror PR_DETAIL_MAX_PAGES's
4146+
// REST bound with the same 10-page-of-100 (1,000 item) ceiling, walked independently per connection via
4147+
// `pageInfo { hasNextPage endCursor }` instead of a Link header.
4148+
const PR_DETAIL_GRAPHQL_MAX_PAGES = 10;
4149+
41404150
async function fetchPullRequestDetailsFromGraphQl(
41414151
env: Env,
41424152
repoFullName: string,
@@ -4146,48 +4156,93 @@ async function fetchPullRequestDetailsFromGraphQl(
41464156
): Promise<{ files: GitHubFilePayload[]; reviews: GitHubReviewPayload[] }> {
41474157
/* v8 ignore start -- GitHub detail GraphQL sparse-node fallbacks are exercised through PR detail hydration tests. */
41484158
const { owner, name } = repoParts(repoFullName);
4149-
const query = `query LoopOverPullRequestDetails {
4150-
repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) {
4151-
pullRequest(number: ${pullNumber}) {
4152-
files(first: 100) {
4159+
const files: GitHubFilePayload[] = [];
4160+
const reviews: GitHubReviewPayload[] = [];
4161+
let filesCursor: string | undefined;
4162+
let reviewsCursor: string | undefined;
4163+
let filesHasNextPage = true;
4164+
let reviewsHasNextPage = true;
4165+
4166+
for (let page = 1; page <= PR_DETAIL_GRAPHQL_MAX_PAGES && (filesHasNextPage || reviewsHasNextPage); page += 1) {
4167+
// Only request a connection that still has more pages -- once one connection finishes, the other keeps
4168+
// paginating alone rather than re-fetching data already fully collected.
4169+
const filesSelection = filesHasNextPage
4170+
? `files(first: 100${filesCursor ? `, after: ${JSON.stringify(filesCursor)}` : ""}) {
4171+
pageInfo { hasNextPage endCursor }
41534172
nodes { path additions deletions changeType }
4154-
}
4155-
reviews(first: 100) {
4173+
}`
4174+
: "";
4175+
const reviewsSelection = reviewsHasNextPage
4176+
? `reviews(first: 100${reviewsCursor ? `, after: ${JSON.stringify(reviewsCursor)}` : ""}) {
4177+
pageInfo { hasNextPage endCursor }
41564178
nodes { databaseId author { login } state authorAssociation submittedAt }
4157-
}
4179+
}`
4180+
: "";
4181+
const query = `query LoopOverPullRequestDetails {
4182+
repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) {
4183+
pullRequest(number: ${pullNumber}) {
4184+
${filesSelection}
4185+
${reviewsSelection}
41584186
}
41594187
}
41604188
rateLimit { remaining resetAt }
41614189
}`;
4162-
const response = await githubGraphQl<GitHubPullRequestDetailsResponse>(env, query, token, admissionKey);
4163-
const pullRequest = response.data?.repository?.pullRequest;
4164-
if (!pullRequest) throw new GitHubApiError(`GitHub GraphQL failed for ${repoFullName} pull request #${pullNumber}: pull request not found`, 404, null, null, null, "");
4165-
const files: GitHubFilePayload[] = (pullRequest.files?.nodes ?? []).flatMap((file) => {
4166-
if (!file?.path) return [];
4167-
const additions = Number(file.additions ?? 0);
4168-
const deletions = Number(file.deletions ?? 0);
4169-
return [
4170-
{
4171-
filename: file.path,
4172-
status: String(file.changeType ?? "modified").toLowerCase(),
4173-
additions,
4174-
deletions,
4175-
changes: additions + deletions,
4176-
},
4177-
];
4178-
});
4179-
const reviews: GitHubReviewPayload[] = (pullRequest.reviews?.nodes ?? []).flatMap((review) => {
4180-
if (!review?.databaseId) return [];
4181-
return [
4182-
{
4183-
id: review.databaseId,
4184-
...(review.author?.login ? { user: { login: review.author.login } } : {}),
4185-
...(review.state ? { state: review.state } : {}),
4186-
...(review.authorAssociation ? { author_association: review.authorAssociation } : {}),
4187-
...(review.submittedAt === undefined ? {} : { submitted_at: review.submittedAt }),
4188-
},
4189-
];
4190-
});
4190+
// A page-1 failure surfaces the same way the un-paginated call always did (the caller's `.catch(() =>
4191+
// undefined)` falls through to the REST+GraphQL-failed warning). A LATER page failing must not discard
4192+
// the pages already collected -- same "keep a successful partial result" semantics as githubPaginatedList.
4193+
let response: GitHubPullRequestDetailsResponse | undefined;
4194+
try {
4195+
response = await githubGraphQl<GitHubPullRequestDetailsResponse>(env, query, token, admissionKey);
4196+
} catch (error) {
4197+
if (page === 1) throw error;
4198+
break;
4199+
}
4200+
const pullRequest = response.data?.repository?.pullRequest;
4201+
if (!pullRequest) {
4202+
if (page === 1) throw new GitHubApiError(`GitHub GraphQL failed for ${repoFullName} pull request #${pullNumber}: pull request not found`, 404, null, null, null, "");
4203+
break;
4204+
}
4205+
if (filesHasNextPage) {
4206+
files.push(
4207+
...(pullRequest.files?.nodes ?? []).flatMap((file) => {
4208+
if (!file?.path) return [];
4209+
const additions = Number(file.additions ?? 0);
4210+
const deletions = Number(file.deletions ?? 0);
4211+
return [
4212+
{
4213+
filename: file.path,
4214+
status: String(file.changeType ?? "modified").toLowerCase(),
4215+
additions,
4216+
deletions,
4217+
changes: additions + deletions,
4218+
},
4219+
];
4220+
}),
4221+
);
4222+
const pageInfo = pullRequest.files?.pageInfo;
4223+
if (pageInfo?.hasNextPage && pageInfo.endCursor) filesCursor = pageInfo.endCursor;
4224+
else filesHasNextPage = false;
4225+
}
4226+
if (reviewsHasNextPage) {
4227+
reviews.push(
4228+
...(pullRequest.reviews?.nodes ?? []).flatMap((review) => {
4229+
if (!review?.databaseId) return [];
4230+
return [
4231+
{
4232+
id: review.databaseId,
4233+
...(review.author?.login ? { user: { login: review.author.login } } : {}),
4234+
...(review.state ? { state: review.state } : {}),
4235+
...(review.authorAssociation ? { author_association: review.authorAssociation } : {}),
4236+
...(review.submittedAt === undefined ? {} : { submitted_at: review.submittedAt }),
4237+
},
4238+
];
4239+
}),
4240+
);
4241+
const pageInfo = pullRequest.reviews?.pageInfo;
4242+
if (pageInfo?.hasNextPage && pageInfo.endCursor) reviewsCursor = pageInfo.endCursor;
4243+
else reviewsHasNextPage = false;
4244+
}
4245+
}
41914246
return { files, reviews };
41924247
/* v8 ignore stop */
41934248
}

test/unit/backfill.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3663,6 +3663,111 @@ describe("GitHub backfill", () => {
36633663
);
36643664
});
36653665

3666+
it("paginates the GraphQL PR-detail fallback past 100 files instead of truncating at the first page (#7453)", async () => {
3667+
const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" });
3668+
await seedRegisteredRepo(env);
3669+
await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", {
3670+
number: 30,
3671+
title: "PR with more than 100 changed files",
3672+
state: "open",
3673+
user: { login: "oktofeesh1" },
3674+
head: { sha: "hugesha" },
3675+
labels: [],
3676+
body: "",
3677+
});
3678+
const firstPageFiles = Array.from({ length: 100 }, (_, index) => ({ path: `src/file-${index}.ts`, additions: 1, deletions: 0, changeType: "MODIFIED" }));
3679+
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
3680+
const url = input.toString();
3681+
if (url === "https://api.github.com/graphql") {
3682+
const query = JSON.parse(String(init?.body ?? "{}")).query as string;
3683+
if (query.includes("LoopOverPullRequestDetails")) {
3684+
// A second-page request carries the `after` cursor the first page's pageInfo.endCursor handed back.
3685+
// The un-paginated fallback never sends this, so this branch only fires against the fixed code.
3686+
if (query.includes('after: "files-page-2"')) {
3687+
return Response.json({
3688+
data: {
3689+
repository: {
3690+
pullRequest: {
3691+
files: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [{ path: "src/file-100.ts", additions: 1, deletions: 0, changeType: "MODIFIED" }] },
3692+
},
3693+
},
3694+
rateLimit: { remaining: 4998, resetAt: "2026-05-25T16:00:00Z" },
3695+
},
3696+
});
3697+
}
3698+
return Response.json({
3699+
data: {
3700+
repository: {
3701+
pullRequest: {
3702+
files: { pageInfo: { hasNextPage: true, endCursor: "files-page-2" }, nodes: firstPageFiles },
3703+
reviews: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [{ databaseId: 1, author: { login: "rev1" }, state: "APPROVED", authorAssociation: "MEMBER", submittedAt: "2026-05-25T00:00:00Z" }] },
3704+
},
3705+
},
3706+
rateLimit: { remaining: 4999, resetAt: "2026-05-25T16:00:00Z" },
3707+
},
3708+
});
3709+
}
3710+
}
3711+
if (url.includes("/pulls/30/files") || url.includes("/pulls/30/reviews")) return new Response("", { status: 404 });
3712+
if (url.includes("/commits/hugesha/check-runs")) return Response.json({});
3713+
return Response.json([]);
3714+
});
3715+
3716+
const result = await backfillOpenPullRequestDetails(env, { repoFullName: "JSONbored/gittensory", mode: "full", cursor: 0 });
3717+
3718+
expect(result).toMatchObject({ status: "complete", processed: 1, warnings: [] });
3719+
// 101 files total (100 on page 1 + 1 on page 2) must all be persisted -- truncating at page 1 would leave 100.
3720+
expect(await listPullRequestFiles(env, "JSONbored/gittensory", 30)).toHaveLength(101);
3721+
expect(await listPullRequestReviews(env, "JSONbored/gittensory", 30)).toEqual([expect.objectContaining({ id: "JSONbored/gittensory#30#1", reviewerLogin: "rev1" })]);
3722+
});
3723+
3724+
it("does not send a second GraphQL PR-detail page when the fallback's files/reviews already fit on page one", async () => {
3725+
const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" });
3726+
await seedRegisteredRepo(env);
3727+
await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", {
3728+
number: 31,
3729+
title: "Single-page PR",
3730+
state: "open",
3731+
user: { login: "oktofeesh1" },
3732+
head: { sha: "singlesha" },
3733+
labels: [],
3734+
body: "",
3735+
});
3736+
let graphQlCalls = 0;
3737+
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
3738+
const url = input.toString();
3739+
if (url === "https://api.github.com/graphql") {
3740+
const query = JSON.parse(String(init?.body ?? "{}")).query as string;
3741+
if (query.includes("LoopOverPullRequestDetails")) {
3742+
graphQlCalls += 1;
3743+
return Response.json({
3744+
data: {
3745+
repository: {
3746+
pullRequest: {
3747+
files: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [{ path: "src/only.ts", additions: 2, deletions: 1, changeType: "MODIFIED" }] },
3748+
reviews: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [{ databaseId: 60, author: { login: "solo-reviewer" }, state: "APPROVED", authorAssociation: "MEMBER", submittedAt: "2026-05-25T00:00:00Z" }] },
3749+
},
3750+
},
3751+
rateLimit: { remaining: 4999, resetAt: "2026-05-25T16:00:00Z" },
3752+
},
3753+
});
3754+
}
3755+
}
3756+
if (url.includes("/pulls/31/files") || url.includes("/pulls/31/reviews")) return new Response("", { status: 404 });
3757+
if (url.includes("/commits/singlesha/check-runs")) return Response.json({});
3758+
return Response.json([]);
3759+
});
3760+
3761+
const result = await backfillOpenPullRequestDetails(env, { repoFullName: "JSONbored/gittensory", mode: "full", cursor: 0 });
3762+
3763+
expect(result).toMatchObject({ status: "complete", processed: 1, warnings: [] });
3764+
expect(await listPullRequestFiles(env, "JSONbored/gittensory", 31)).toEqual([expect.objectContaining({ path: "src/only.ts", changes: 3 })]);
3765+
expect(await listPullRequestReviews(env, "JSONbored/gittensory", 31)).toEqual([expect.objectContaining({ id: "JSONbored/gittensory#31#60", reviewerLogin: "solo-reviewer" })]);
3766+
// fetchPullRequestFiles and fetchPullRequestReviews each independently fall back to GraphQL (one call
3767+
// apiece), but neither should send a page-2 request when pageInfo.hasNextPage is false on page one.
3768+
expect(graphQlCalls).toBe(2);
3769+
});
3770+
36663771
it("refreshes one pull request's files before gate evaluation and drops stale cached paths", async () => {
36673772
const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" });
36683773
await seedRegisteredRepo(env);

0 commit comments

Comments
 (0)