@@ -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