Skip to content

Commit 75daa84

Browse files
andriypolanskiandriy-polanskicursoragent
authored
fix(miner): page contribution-profile label fetches past 100 (#8010) (#8040)
Follow GitHub Link rel=next the same way ci-poller pages check-runs so extractContributionProfile sees the full label set, not only page one. Co-authored-by: Andriy Polanski <andriy.polanski@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 37369b9 commit 75daa84

2 files changed

Lines changed: 271 additions & 10 deletions

File tree

packages/loopover-miner/lib/contribution-profile-extract.ts

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,12 @@ function githubHeaders(githubToken: string | undefined): Record<string, string>
9999
* falling back to its fail-open contract: returns null on a non-retryable/exhausted HTTP, transport, or parse
100100
* failure. `timeoutMs` gives each attempt its own fresh `AbortSignal.timeout` (preserving the per-request bound),
101101
* and `sleepFn` is the injectable no-real-timers seam every other `fetchWithRetry` call site exposes. */
102-
async function getJson(
102+
async function getJsonResponse(
103103
url: string,
104104
headers: Record<string, string>,
105105
fetchImpl: typeof fetch,
106106
sleepFn: ((ms: number) => Promise<unknown>) | undefined,
107-
): Promise<unknown> {
107+
): Promise<{ payload: unknown; response: Response } | null> {
108108
let response: Response;
109109
try {
110110
// Cast: the JS always passes `sleepFn` (possibly undefined); EOPT rejects an explicit undefined optional.
@@ -118,7 +118,57 @@ async function getJson(
118118
return null;
119119
}
120120
if (!response.ok) return null;
121-
return response.json().catch(() => null);
121+
const payload = await response.json().catch(() => null);
122+
return { payload, response };
123+
}
124+
125+
async function getJson(
126+
url: string,
127+
headers: Record<string, string>,
128+
fetchImpl: typeof fetch,
129+
sleepFn: ((ms: number) => Promise<unknown>) | undefined,
130+
): Promise<unknown> {
131+
const result = await getJsonResponse(url, headers, fetchImpl, sleepFn);
132+
return result?.payload ?? null;
133+
}
134+
135+
/** Same Link-header check as `ci-poller.ts`'s check-run pagination (#8010). */
136+
function hasNextLink(response: Response): boolean {
137+
const link =
138+
typeof response.headers?.get === "function" ? response.headers.get("link") : null;
139+
return /<[^>]+>;\s*rel="next"/.test(link ?? "");
140+
}
141+
142+
/** Cap runaway pagination the way opportunity-fanout caps `maxPages` — 50×100 covers pathological repos
143+
* without inventing a different paging scheme than ci-poller's `page=` loop. */
144+
const MAX_LABEL_PAGES = 50;
145+
146+
/** Fetch every label on the repo, following GitHub `Link: rel="next"` the same way `ci-poller.ts` pages
147+
* check-runs (#8010). Fail-open: a failed/malformed page returns whatever was collected so far. */
148+
async function fetchRepoLabels(
149+
base: string,
150+
target: { owner: string; repo: string },
151+
headers: Record<string, string>,
152+
fetchImpl: typeof fetch,
153+
sleepFn: ((ms: number) => Promise<unknown>) | undefined,
154+
): Promise<GithubLabel[]> {
155+
const labels: GithubLabel[] = [];
156+
for (let page = 1; page <= MAX_LABEL_PAGES; page += 1) {
157+
const result = await getJsonResponse(
158+
`${base}/repos/${target.owner}/${target.repo}/labels?per_page=100&page=${page}`,
159+
headers,
160+
fetchImpl,
161+
sleepFn,
162+
);
163+
if (result === null) return labels;
164+
if (!Array.isArray(result.payload)) return labels;
165+
const pageLabels = result.payload as GithubLabel[];
166+
labels.push(...pageLabels);
167+
if (!hasNextLink(result.response)) return labels;
168+
if (pageLabels.length === 0) return labels;
169+
}
170+
/* v8 ignore next -- defensive page cap; a real repo never has 5000+ labels. */
171+
return labels;
122172
}
123173

124174
/**
@@ -256,13 +306,7 @@ export async function extractContributionProfile(
256306
);
257307

258308
const sleepFn = options.sleepFn;
259-
const labelsPayload = await getJson(
260-
`${base}/repos/${target.owner}/${target.repo}/labels?per_page=100`,
261-
headers,
262-
fetchImpl,
263-
sleepFn,
264-
);
265-
const labels = Array.isArray(labelsPayload) ? (labelsPayload as GithubLabel[]) : [];
309+
const labels = await fetchRepoLabels(base, target, headers, fetchImpl, sleepFn);
266310
const contributing = await fetchContributing(
267311
base,
268312
target,

test/unit/contribution-profile-extract.test.ts

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ function stubFetch(
1818
contributingGithubDir?: string | null;
1919
} = {},
2020
) {
21+
const emptyHeaders = { get: (_name: string) => null };
2122
return asFetch(
2223
vi.fn(async (url: string) => {
2324
const u = String(url);
@@ -26,11 +27,13 @@ function stubFetch(
2627
return {
2728
ok: false,
2829
status: opts.labels,
30+
headers: emptyHeaders,
2931
json: async () => ({}),
3032
} as unknown as Response;
3133
return {
3234
ok: true,
3335
status: 200,
36+
headers: emptyHeaders,
3437
json: async () => opts.labels ?? [],
3538
} as unknown as Response;
3639
}
@@ -39,11 +42,13 @@ function stubFetch(
3942
return {
4043
ok: false,
4144
status: 404,
45+
headers: emptyHeaders,
4246
json: async () => ({}),
4347
} as unknown as Response;
4448
return {
4549
ok: true,
4650
status: 200,
51+
headers: emptyHeaders,
4752
json: async () => ({
4853
encoding: "base64",
4954
content: Buffer.from(String(opts.contributing)).toString("base64"),
@@ -55,11 +60,13 @@ function stubFetch(
5560
return {
5661
ok: false,
5762
status: 404,
63+
headers: emptyHeaders,
5864
json: async () => ({}),
5965
} as unknown as Response;
6066
return {
6167
ok: true,
6268
status: 200,
69+
headers: emptyHeaders,
6370
json: async () => ({
6471
encoding: "base64",
6572
content: Buffer.from(String(opts.contributingGithubDir)).toString(
@@ -71,6 +78,7 @@ function stubFetch(
7178
return {
7279
ok: false,
7380
status: 404,
81+
headers: emptyHeaders,
7482
json: async () => ({}),
7583
} as unknown as Response;
7684
}),
@@ -246,9 +254,215 @@ describe("extractContributionProfile (#6796)", () => {
246254
expect(profile.completeness).toBe("absent");
247255
});
248256

257+
it("pages through Link rel=next and collects labels past the first 100 (#8010)", async () => {
258+
const emptyHeaders = { get: (_name: string) => null };
259+
let labelsCalls = 0;
260+
const page1 = Array.from({ length: 100 }, (_, i) => ({
261+
name: `bulk-${i}`,
262+
description: null as string | null,
263+
}));
264+
const fetchImpl = vi.fn(async (url: string) => {
265+
const u = String(url);
266+
if (u.includes("/labels")) {
267+
labelsCalls += 1;
268+
if (labelsCalls === 1) {
269+
expect(u).toContain("page=1");
270+
return {
271+
ok: true,
272+
status: 200,
273+
headers: {
274+
get: (name: string) =>
275+
name.toLowerCase() === "link"
276+
? '<https://api.github.com/repos/acme/widgets/labels?page=2>; rel="next"'
277+
: null,
278+
},
279+
json: async () => page1,
280+
} as unknown as Response;
281+
}
282+
expect(u).toContain("page=2");
283+
return {
284+
ok: true,
285+
status: 200,
286+
headers: emptyHeaders,
287+
json: async () => [
288+
{ name: "good first issue", description: "Starter work" },
289+
{ name: "bulk-extra", description: null },
290+
],
291+
} as unknown as Response;
292+
}
293+
return {
294+
ok: false,
295+
status: 404,
296+
headers: emptyHeaders,
297+
json: async () => ({}),
298+
} as unknown as Response;
299+
});
300+
301+
const profile = await extractContributionProfile("acme/widgets", {
302+
fetchImpl: asFetch(fetchImpl),
303+
generatedAt: AT,
304+
});
305+
306+
expect(labelsCalls).toBe(2);
307+
expect(profile.eligibilityLabels.confidence).toBe("explicit");
308+
expect(profile.eligibilityLabels.value).toEqual([
309+
{ field: "name", contains: "good first issue" },
310+
]);
311+
expect(profile.eligibilityLabels.provenance).toEqual([
312+
{ source: "labels", detail: "good first issue" },
313+
]);
314+
});
315+
316+
it("keeps page-1 labels when a later page fails (fail-open pagination, #8010)", async () => {
317+
const emptyHeaders = { get: (_name: string) => null };
318+
let labelsCalls = 0;
319+
const fetchImpl = vi.fn(async (url: string) => {
320+
const u = String(url);
321+
if (u.includes("/labels")) {
322+
labelsCalls += 1;
323+
if (labelsCalls === 1) {
324+
return {
325+
ok: true,
326+
status: 200,
327+
headers: {
328+
get: (name: string) =>
329+
name.toLowerCase() === "link"
330+
? '<https://api.github.com/repos/acme/widgets/labels?page=2>; rel="next"'
331+
: null,
332+
},
333+
json: async () => [{ name: "help wanted", description: null }],
334+
} as unknown as Response;
335+
}
336+
return {
337+
ok: false,
338+
status: 500,
339+
headers: emptyHeaders,
340+
json: async () => ({}),
341+
} as unknown as Response;
342+
}
343+
return {
344+
ok: false,
345+
status: 404,
346+
headers: emptyHeaders,
347+
json: async () => ({}),
348+
} as unknown as Response;
349+
});
350+
351+
const profile = await extractContributionProfile("acme/widgets", {
352+
fetchImpl: asFetch(fetchImpl),
353+
generatedAt: AT,
354+
sleepFn: async () => {},
355+
});
356+
357+
expect(labelsCalls).toBeGreaterThanOrEqual(2);
358+
expect(profile.eligibilityLabels.confidence).toBe("explicit");
359+
expect(profile.eligibilityLabels.value).toEqual([
360+
{ field: "name", contains: "help wanted" },
361+
]);
362+
});
363+
364+
it("stops pagination when a next page returns an empty array (#8010)", async () => {
365+
const emptyHeaders = { get: (_name: string) => null };
366+
let labelsCalls = 0;
367+
const fetchImpl = vi.fn(async (url: string) => {
368+
const u = String(url);
369+
if (u.includes("/labels")) {
370+
labelsCalls += 1;
371+
if (labelsCalls === 1) {
372+
return {
373+
ok: true,
374+
status: 200,
375+
headers: {
376+
get: (name: string) =>
377+
name.toLowerCase() === "link"
378+
? '<https://api.github.com/repos/acme/widgets/labels?page=2>; rel="next"'
379+
: null,
380+
},
381+
json: async () => [{ name: "help wanted", description: null }],
382+
} as unknown as Response;
383+
}
384+
// Empty page still advertises a next link — the empty-page guard must stop the loop.
385+
return {
386+
ok: true,
387+
status: 200,
388+
headers: {
389+
get: (name: string) =>
390+
name.toLowerCase() === "link"
391+
? '<https://api.github.com/repos/acme/widgets/labels?page=3>; rel="next"'
392+
: null,
393+
},
394+
json: async () => [],
395+
} as unknown as Response;
396+
}
397+
return {
398+
ok: false,
399+
status: 404,
400+
headers: emptyHeaders,
401+
json: async () => ({}),
402+
} as unknown as Response;
403+
});
404+
405+
const profile = await extractContributionProfile("acme/widgets", {
406+
fetchImpl: asFetch(fetchImpl),
407+
generatedAt: AT,
408+
});
409+
410+
expect(labelsCalls).toBe(2);
411+
expect(profile.eligibilityLabels.value).toEqual([
412+
{ field: "name", contains: "help wanted" },
413+
]);
414+
});
415+
416+
it("stops pagination when a later page returns a non-array payload (#8010)", async () => {
417+
const emptyHeaders = { get: (_name: string) => null };
418+
let labelsCalls = 0;
419+
const fetchImpl = vi.fn(async (url: string) => {
420+
const u = String(url);
421+
if (u.includes("/labels")) {
422+
labelsCalls += 1;
423+
if (labelsCalls === 1) {
424+
return {
425+
ok: true,
426+
status: 200,
427+
headers: {
428+
get: (name: string) =>
429+
name.toLowerCase() === "link"
430+
? '<https://api.github.com/repos/acme/widgets/labels?page=2>; rel="next"'
431+
: null,
432+
},
433+
json: async () => [{ name: "help wanted", description: null }],
434+
} as unknown as Response;
435+
}
436+
return {
437+
ok: true,
438+
status: 200,
439+
headers: emptyHeaders,
440+
json: async () => ({ message: "weird" }),
441+
} as unknown as Response;
442+
}
443+
return {
444+
ok: false,
445+
status: 404,
446+
headers: emptyHeaders,
447+
json: async () => ({}),
448+
} as unknown as Response;
449+
});
450+
451+
const profile = await extractContributionProfile("acme/widgets", {
452+
fetchImpl: asFetch(fetchImpl),
453+
generatedAt: AT,
454+
});
455+
456+
expect(labelsCalls).toBe(2);
457+
expect(profile.eligibilityLabels.value).toEqual([
458+
{ field: "name", contains: "help wanted" },
459+
]);
460+
});
461+
249462
it("retries a transient 5xx on the labels fetch and yields the same profile as an immediate success (#7090)", async () => {
250463
// A single 5xx blip on the first attempt must NOT degrade the label signal — the retry rides it out and the
251464
// resulting profile is identical to one where the labels fetch succeeded immediately.
465+
const emptyHeaders = { get: (_name: string) => null };
252466
const sleeps: number[] = [];
253467
let labelsCalls = 0;
254468
const fetchImpl = vi.fn(async (url: string) => {
@@ -259,11 +473,13 @@ describe("extractContributionProfile (#6796)", () => {
259473
return {
260474
ok: false,
261475
status: 500,
476+
headers: emptyHeaders,
262477
json: async () => ({}),
263478
} as unknown as Response;
264479
return {
265480
ok: true,
266481
status: 200,
482+
headers: emptyHeaders,
267483
json: async () => [
268484
{ name: "help wanted", description: "Extra attention is needed" },
269485
],
@@ -272,6 +488,7 @@ describe("extractContributionProfile (#6796)", () => {
272488
return {
273489
ok: false,
274490
status: 404,
491+
headers: emptyHeaders,
275492
json: async () => ({}),
276493
} as unknown as Response;
277494
});

0 commit comments

Comments
 (0)