Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/github/backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1286,7 +1286,12 @@ async function fetchPagedSegment<T>(
supplementDescription?: string;
} = {},
): Promise<{ status: RepoSyncSegmentRecord["status"]; segment: RepoSyncSegmentRecord }> {
const previous = mode === "resume" ? await getRepoSyncSegment(env, repo.fullName, segmentName) : null;
// Load the prior segment for EVERY mode, not just resume (#1942): a scheduled light/full crawl can then send the
// stored ETag/If-Modified-Since as a conditional request, so an unchanged single-page list returns a 0-body 304
// instead of a full re-list — the largest avoidable GitHub cost on the backfill cadence. Resume PAGINATION stays
// gated on `canResumePreviousScan` (mode === "resume") below, and the open-scan segments that must reconcile
// GitHub-side closes still force `allowEtag: false`, so this only enables the 304 fast-path where it is safe.
const previous = await getRepoSyncSegment(env, repo.fullName, segmentName);
const requiresCurrentOpenScan = Boolean(options.reconcileOnComplete);
const canResumePreviousScan =
mode === "resume" &&
Expand Down
35 changes: 35 additions & 0 deletions test/unit/backfill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1283,6 +1283,41 @@ describe("GitHub backfill", () => {
);
});

it("validates unchanged single-page segments on the scheduled light cadence, not just resume (#1942)", async () => {
const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" });
await seedRegisteredRepo(env);
const labelHeaders: Array<{ ifNoneMatch: string | null; ifModifiedSince: string | null }> = [];
let labelFetches = 0;
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === "https://api.github.com/graphql") return githubTotalsResponse({ openIssues: 0, openPullRequests: 0, mergedPullRequests: 0, closedPullRequests: 0, labels: 1 });
if (url.includes("/labels?")) {
const headers = new Headers(init?.headers);
labelHeaders.push({ ifNoneMatch: headers.get("if-none-match"), ifModifiedSince: headers.get("if-modified-since") });
labelFetches += 1;
if (labelFetches === 1) {
return Response.json([{ name: "bug", color: "cc0000", description: "Bug" }], {
headers: { etag: '"labels-v1"', "last-modified": "Tue, 26 May 2026 00:00:00 GMT" },
});
}
return new Response(null, { status: 304, headers: { etag: '"labels-v1"', "last-modified": "Tue, 26 May 2026 00:00:00 GMT" } });
}
return new Response("not found", { status: 404 });
});

const first = await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "labels", mode: "light", force: true });
const second = await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "labels", mode: "light", force: true });

expect(first).toMatchObject({ status: "complete", fetchedCount: 1, expectedCount: 1 });
// Before the fix, a light crawl loaded no prior segment, so the second pass sent no validators and re-listed in
// full ("complete"). The scheduled cadence now sends If-None-Match, and a 304 short-circuits to not_modified.
expect(second).toMatchObject({ status: "not_modified", fetchedCount: 1, expectedCount: 1 });
expect(labelHeaders).toEqual([
{ ifNoneMatch: null, ifModifiedSince: null },
{ ifNoneMatch: '"labels-v1"', ifModifiedSince: "Tue, 26 May 2026 00:00:00 GMT" },
]);
});

it("preserves stored validators when an unauthenticated fallback returns not modified without validators", async () => {
const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" });
await seedRegisteredRepo(env);
Expand Down
Loading