github-issue-tower-defence-management 1.122.0 → 1.122.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +4 -1
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +14 -7
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
- package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +297 -137
- package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +175 -7
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +22 -8
- package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +294 -0
- package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +400 -165
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +3 -2
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
- package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts +10 -0
- package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
|
@@ -5,6 +5,7 @@ import { RestIssueRepository } from './RestIssueRepository';
|
|
|
5
5
|
import {
|
|
6
6
|
GraphqlProjectItemRepository,
|
|
7
7
|
ProjectItem,
|
|
8
|
+
ProjectItemLight,
|
|
8
9
|
} from './GraphqlProjectItemRepository';
|
|
9
10
|
import { LocalStorageCacheRepository } from '../LocalStorageCacheRepository';
|
|
10
11
|
import { LocalStorageRepository } from '../LocalStorageRepository';
|
|
@@ -70,6 +71,17 @@ const buildProjectItem = (url: string, title: string): ProjectItem => ({
|
|
|
70
71
|
customFields: [],
|
|
71
72
|
});
|
|
72
73
|
|
|
74
|
+
const buildLightItem = (
|
|
75
|
+
id: string,
|
|
76
|
+
url: string,
|
|
77
|
+
updatedAt: string,
|
|
78
|
+
): ProjectItemLight => ({
|
|
79
|
+
id,
|
|
80
|
+
updatedAt,
|
|
81
|
+
url,
|
|
82
|
+
number: 1,
|
|
83
|
+
});
|
|
84
|
+
|
|
73
85
|
describe('ApiV3CheerioRestIssueRepository', () => {
|
|
74
86
|
describe('convertProjectItemToIssue', () => {
|
|
75
87
|
const testCases: {
|
|
@@ -254,7 +266,7 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
254
266
|
});
|
|
255
267
|
|
|
256
268
|
describe('getAllIssues incremental fetch', () => {
|
|
257
|
-
it('
|
|
269
|
+
it('light-scans the lastFetchedAt UTC day with no previous-day overlap, detail-fetches changed items by id, and upserts by url', async () => {
|
|
258
270
|
const {
|
|
259
271
|
repository,
|
|
260
272
|
graphqlProjectItemRepository,
|
|
@@ -263,9 +275,9 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
263
275
|
dateRepository,
|
|
264
276
|
} = createApiV3CheerioRestIssueRepository();
|
|
265
277
|
const cachedProject = buildTestProject('cached-project');
|
|
266
|
-
dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:
|
|
278
|
+
dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:45:00Z'));
|
|
267
279
|
localStorageCacheRepository.getSingle.mockResolvedValue({
|
|
268
|
-
lastFetchedAt: '2026-07-07T00:
|
|
280
|
+
lastFetchedAt: '2026-07-07T00:30:00.000Z',
|
|
269
281
|
lastFullFetchAt: '2026-07-07T00:00:00.000Z',
|
|
270
282
|
project: cachedProject,
|
|
271
283
|
issues: [
|
|
@@ -275,7 +287,19 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
275
287
|
),
|
|
276
288
|
],
|
|
277
289
|
});
|
|
278
|
-
graphqlProjectItemRepository.
|
|
290
|
+
graphqlProjectItemRepository.fetchProjectItemsLight.mockResolvedValue([
|
|
291
|
+
buildLightItem(
|
|
292
|
+
'item-fresh',
|
|
293
|
+
'https://github.com/o/r/issues/1',
|
|
294
|
+
'2026-07-07T00:40:00.000Z',
|
|
295
|
+
),
|
|
296
|
+
buildLightItem(
|
|
297
|
+
'item-new',
|
|
298
|
+
'https://github.com/o/r/issues/2',
|
|
299
|
+
'2026-07-07T00:44:00.000Z',
|
|
300
|
+
),
|
|
301
|
+
]);
|
|
302
|
+
graphqlProjectItemRepository.fetchProjectItemsByIds.mockResolvedValue([
|
|
279
303
|
buildProjectItem('https://github.com/o/r/issues/1', 'fresh title'),
|
|
280
304
|
buildProjectItem('https://github.com/o/r/issues/2', 'new issue'),
|
|
281
305
|
]);
|
|
@@ -286,9 +310,16 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
286
310
|
expect(result.cacheUsed).toBe(true);
|
|
287
311
|
expect(result.project).toBe(cachedProject);
|
|
288
312
|
expect(projectRepository.getProject).not.toHaveBeenCalled();
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
expect(
|
|
313
|
+
const lightCall =
|
|
314
|
+
graphqlProjectItemRepository.fetchProjectItemsLight.mock.calls[0];
|
|
315
|
+
expect(lightCall[0]).toBe('cached-project');
|
|
316
|
+
expect(lightCall[1]).toBe('updated:>=2026-07-07');
|
|
317
|
+
expect(
|
|
318
|
+
graphqlProjectItemRepository.fetchProjectItems,
|
|
319
|
+
).not.toHaveBeenCalled();
|
|
320
|
+
expect(
|
|
321
|
+
graphqlProjectItemRepository.fetchProjectItemsByIds,
|
|
322
|
+
).toHaveBeenCalledWith(['item-fresh', 'item-new']);
|
|
292
323
|
const titlesByUrl = new Map(
|
|
293
324
|
result.issues.map((issue) => [issue.url, issue.title]),
|
|
294
325
|
);
|
|
@@ -299,6 +330,143 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
299
330
|
'new issue',
|
|
300
331
|
);
|
|
301
332
|
expect(result.issues).toHaveLength(2);
|
|
333
|
+
const cacheWrite = localStorageCacheRepository.setSingle.mock.calls[0][1];
|
|
334
|
+
expect(cacheWrite).toEqual(
|
|
335
|
+
expect.objectContaining({
|
|
336
|
+
lastFetchedAt: '2026-07-07T00:45:00.000Z',
|
|
337
|
+
lastFullFetchAt: '2026-07-07T00:00:00.000Z',
|
|
338
|
+
}),
|
|
339
|
+
);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('includes items within the clock-skew buffer before lastFetchedAt and excludes items older than the buffer', async () => {
|
|
343
|
+
const {
|
|
344
|
+
repository,
|
|
345
|
+
graphqlProjectItemRepository,
|
|
346
|
+
localStorageCacheRepository,
|
|
347
|
+
dateRepository,
|
|
348
|
+
} = createApiV3CheerioRestIssueRepository();
|
|
349
|
+
dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:45:00Z'));
|
|
350
|
+
localStorageCacheRepository.getSingle.mockResolvedValue({
|
|
351
|
+
lastFetchedAt: '2026-07-07T00:30:00.000Z',
|
|
352
|
+
lastFullFetchAt: '2026-07-07T00:00:00.000Z',
|
|
353
|
+
project: buildTestProject('cached-project'),
|
|
354
|
+
issues: [],
|
|
355
|
+
});
|
|
356
|
+
graphqlProjectItemRepository.fetchProjectItemsLight.mockResolvedValue([
|
|
357
|
+
buildLightItem(
|
|
358
|
+
'wellBefore',
|
|
359
|
+
'https://github.com/o/r/issues/1',
|
|
360
|
+
'2026-07-07T00:20:00.000Z',
|
|
361
|
+
),
|
|
362
|
+
buildLightItem(
|
|
363
|
+
'withinBuffer',
|
|
364
|
+
'https://github.com/o/r/issues/2',
|
|
365
|
+
'2026-07-07T00:27:00.000Z',
|
|
366
|
+
),
|
|
367
|
+
buildLightItem(
|
|
368
|
+
'atLastFetched',
|
|
369
|
+
'https://github.com/o/r/issues/3',
|
|
370
|
+
'2026-07-07T00:30:00.000Z',
|
|
371
|
+
),
|
|
372
|
+
buildLightItem(
|
|
373
|
+
'after',
|
|
374
|
+
'https://github.com/o/r/issues/4',
|
|
375
|
+
'2026-07-07T00:40:00.000Z',
|
|
376
|
+
),
|
|
377
|
+
]);
|
|
378
|
+
graphqlProjectItemRepository.fetchProjectItemsByIds.mockResolvedValue([]);
|
|
379
|
+
localStorageCacheRepository.setSingle.mockResolvedValue();
|
|
380
|
+
|
|
381
|
+
await repository.getAllIssues('cached-project');
|
|
382
|
+
|
|
383
|
+
expect(
|
|
384
|
+
graphqlProjectItemRepository.fetchProjectItemsByIds,
|
|
385
|
+
).toHaveBeenCalledWith(['withinBuffer', 'atLastFetched', 'after']);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('applies the skew buffer across a UTC-midnight boundary, scanning the previous UTC day rather than today', async () => {
|
|
389
|
+
const {
|
|
390
|
+
repository,
|
|
391
|
+
graphqlProjectItemRepository,
|
|
392
|
+
localStorageCacheRepository,
|
|
393
|
+
dateRepository,
|
|
394
|
+
} = createApiV3CheerioRestIssueRepository();
|
|
395
|
+
dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:30:00Z'));
|
|
396
|
+
localStorageCacheRepository.getSingle.mockResolvedValue({
|
|
397
|
+
lastFetchedAt: '2026-07-07T00:02:00.000Z',
|
|
398
|
+
lastFullFetchAt: '2026-07-07T00:00:00.000Z',
|
|
399
|
+
project: buildTestProject('cached-project'),
|
|
400
|
+
issues: [],
|
|
401
|
+
});
|
|
402
|
+
graphqlProjectItemRepository.fetchProjectItemsLight.mockResolvedValue([
|
|
403
|
+
buildLightItem(
|
|
404
|
+
'previousDay',
|
|
405
|
+
'https://github.com/o/r/issues/1',
|
|
406
|
+
'2026-07-06T23:58:00.000Z',
|
|
407
|
+
),
|
|
408
|
+
buildLightItem(
|
|
409
|
+
'beforeBuffer',
|
|
410
|
+
'https://github.com/o/r/issues/2',
|
|
411
|
+
'2026-07-06T23:55:00.000Z',
|
|
412
|
+
),
|
|
413
|
+
]);
|
|
414
|
+
graphqlProjectItemRepository.fetchProjectItemsByIds.mockResolvedValue([]);
|
|
415
|
+
localStorageCacheRepository.setSingle.mockResolvedValue();
|
|
416
|
+
|
|
417
|
+
await repository.getAllIssues('cached-project');
|
|
418
|
+
|
|
419
|
+
const lightCall =
|
|
420
|
+
graphqlProjectItemRepository.fetchProjectItemsLight.mock.calls[0];
|
|
421
|
+
expect(lightCall[1]).toBe('updated:>=2026-07-06');
|
|
422
|
+
expect(
|
|
423
|
+
graphqlProjectItemRepository.fetchProjectItemsByIds,
|
|
424
|
+
).toHaveBeenCalledWith(['previousDay']);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('skips the detail fetch entirely when no light item changed since lastFetchedAt', async () => {
|
|
428
|
+
const {
|
|
429
|
+
repository,
|
|
430
|
+
graphqlProjectItemRepository,
|
|
431
|
+
localStorageCacheRepository,
|
|
432
|
+
dateRepository,
|
|
433
|
+
} = createApiV3CheerioRestIssueRepository();
|
|
434
|
+
dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:45:00Z'));
|
|
435
|
+
localStorageCacheRepository.getSingle.mockResolvedValue({
|
|
436
|
+
lastFetchedAt: '2026-07-07T00:30:00.000Z',
|
|
437
|
+
lastFullFetchAt: '2026-07-07T00:00:00.000Z',
|
|
438
|
+
project: buildTestProject('cached-project'),
|
|
439
|
+
issues: [
|
|
440
|
+
buildCachedIssueRecord(
|
|
441
|
+
'https://github.com/o/r/issues/1',
|
|
442
|
+
'unchanged title',
|
|
443
|
+
),
|
|
444
|
+
],
|
|
445
|
+
});
|
|
446
|
+
graphqlProjectItemRepository.fetchProjectItemsLight.mockResolvedValue([
|
|
447
|
+
buildLightItem(
|
|
448
|
+
'stale',
|
|
449
|
+
'https://github.com/o/r/issues/1',
|
|
450
|
+
'2026-07-07T00:10:00.000Z',
|
|
451
|
+
),
|
|
452
|
+
]);
|
|
453
|
+
localStorageCacheRepository.setSingle.mockResolvedValue();
|
|
454
|
+
|
|
455
|
+
const result = await repository.getAllIssues('cached-project');
|
|
456
|
+
|
|
457
|
+
expect(
|
|
458
|
+
graphqlProjectItemRepository.fetchProjectItemsByIds,
|
|
459
|
+
).not.toHaveBeenCalled();
|
|
460
|
+
expect(result.cacheUsed).toBe(true);
|
|
461
|
+
expect(result.issues).toHaveLength(1);
|
|
462
|
+
expect(result.issues[0].title).toBe('unchanged title');
|
|
463
|
+
const cacheWrite = localStorageCacheRepository.setSingle.mock.calls[0][1];
|
|
464
|
+
expect(cacheWrite).toEqual(
|
|
465
|
+
expect.objectContaining({
|
|
466
|
+
lastFetchedAt: '2026-07-07T00:45:00.000Z',
|
|
467
|
+
lastFullFetchAt: '2026-07-07T00:00:00.000Z',
|
|
468
|
+
}),
|
|
469
|
+
);
|
|
302
470
|
});
|
|
303
471
|
|
|
304
472
|
it('performs a full fetch when the hourly gate has elapsed', async () => {
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
} from './githubRateLimitRetry';
|
|
35
35
|
|
|
36
36
|
export const FULL_ISSUE_FETCH_INTERVAL_MS = 60 * 60 * 1000;
|
|
37
|
+
export const INCREMENTAL_FETCH_SKEW_BUFFER_MS = 5 * 60 * 1000;
|
|
37
38
|
|
|
38
39
|
export type CachedProjectIssues = {
|
|
39
40
|
lastFetchedAt: string;
|
|
@@ -497,6 +498,8 @@ export class ApiV3CheerioRestIssueRepository
|
|
|
497
498
|
readonly graphqlProjectItemRepository: Pick<
|
|
498
499
|
GraphqlProjectItemRepository,
|
|
499
500
|
| 'fetchProjectItems'
|
|
501
|
+
| 'fetchProjectItemsLight'
|
|
502
|
+
| 'fetchProjectItemsByIds'
|
|
500
503
|
| 'fetchProjectItemByUrl'
|
|
501
504
|
| 'updateProjectField'
|
|
502
505
|
| 'clearProjectField'
|
|
@@ -726,19 +729,30 @@ export class ApiV3CheerioRestIssueRepository
|
|
|
726
729
|
}
|
|
727
730
|
|
|
728
731
|
const project = cache.project;
|
|
729
|
-
const
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
732
|
+
const lastFetchedAt = new Date(cache.lastFetchedAt);
|
|
733
|
+
const cutoff = new Date(
|
|
734
|
+
lastFetchedAt.getTime() - INCREMENTAL_FETCH_SKEW_BUFFER_MS,
|
|
735
|
+
);
|
|
736
|
+
const lightItems =
|
|
737
|
+
await this.graphqlProjectItemRepository.fetchProjectItemsLight(
|
|
733
738
|
projectId,
|
|
734
|
-
`updated:>=${this.toDateString(
|
|
739
|
+
`updated:>=${this.toDateString(cutoff)}`,
|
|
735
740
|
);
|
|
741
|
+
const changedItemIds = lightItems
|
|
742
|
+
.filter((item) => new Date(item.updatedAt).getTime() >= cutoff.getTime())
|
|
743
|
+
.map((item) => item.id);
|
|
736
744
|
const issuesByUrl = new Map<string, Issue>(
|
|
737
745
|
cache.issues.map((issue) => [issue.url, issue]),
|
|
738
746
|
);
|
|
739
|
-
|
|
740
|
-
const
|
|
741
|
-
|
|
747
|
+
if (changedItemIds.length > 0) {
|
|
748
|
+
const changedItems =
|
|
749
|
+
await this.graphqlProjectItemRepository.fetchProjectItemsByIds(
|
|
750
|
+
changedItemIds,
|
|
751
|
+
);
|
|
752
|
+
for (const item of changedItems) {
|
|
753
|
+
const issue = this.convertProjectItemToIssue(item);
|
|
754
|
+
issuesByUrl.set(issue.url, issue);
|
|
755
|
+
}
|
|
742
756
|
}
|
|
743
757
|
const issues = Array.from(issuesByUrl.values());
|
|
744
758
|
await this.localStorageCacheRepository.setSingle(cacheKey, {
|
|
@@ -604,6 +604,300 @@ describe('GraphqlProjectItemRepository', () => {
|
|
|
604
604
|
}, 30000);
|
|
605
605
|
});
|
|
606
606
|
|
|
607
|
+
const extractRequestedVariablesFromMockCall = (
|
|
608
|
+
call: unknown,
|
|
609
|
+
): Record<string, unknown> | undefined => {
|
|
610
|
+
if (!Array.isArray(call)) {
|
|
611
|
+
return undefined;
|
|
612
|
+
}
|
|
613
|
+
const second: unknown = call[1];
|
|
614
|
+
if (!isRecord(second)) {
|
|
615
|
+
return undefined;
|
|
616
|
+
}
|
|
617
|
+
const json: unknown = second.json;
|
|
618
|
+
if (!isRecord(json)) {
|
|
619
|
+
return undefined;
|
|
620
|
+
}
|
|
621
|
+
const variables: unknown = json.variables;
|
|
622
|
+
return isRecord(variables) ? variables : undefined;
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
describe('fetchProjectItemsLight', () => {
|
|
626
|
+
const makeLightPageResponse = (
|
|
627
|
+
hasNextPage: boolean,
|
|
628
|
+
endCursor: string,
|
|
629
|
+
nodes: {
|
|
630
|
+
id: string;
|
|
631
|
+
updatedAt: string;
|
|
632
|
+
content: { url: string; number: number } | null;
|
|
633
|
+
}[],
|
|
634
|
+
totalCount = nodes.length,
|
|
635
|
+
) =>
|
|
636
|
+
mockJsonResponse({
|
|
637
|
+
data: {
|
|
638
|
+
node: {
|
|
639
|
+
items: {
|
|
640
|
+
totalCount,
|
|
641
|
+
pageInfo: { endCursor, hasNextPage },
|
|
642
|
+
nodes,
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
},
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
beforeEach(() => {
|
|
649
|
+
jest.useFakeTimers();
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
afterEach(() => {
|
|
653
|
+
jest.useRealTimers();
|
|
654
|
+
mockPost.mockReset();
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it('requests only id, updatedAt and content url/number without fieldValues and passes the query filter', async () => {
|
|
658
|
+
const repository = new GraphqlProjectItemRepository(
|
|
659
|
+
new LocalStorageRepository(),
|
|
660
|
+
'dummy-token',
|
|
661
|
+
);
|
|
662
|
+
mockPost.mockReturnValueOnce(
|
|
663
|
+
makeLightPageResponse(false, 'cursor-1', [
|
|
664
|
+
{
|
|
665
|
+
id: 'PVTI_1',
|
|
666
|
+
updatedAt: '2026-07-07T10:00:00Z',
|
|
667
|
+
content: {
|
|
668
|
+
url: 'https://github.com/o/r/issues/1',
|
|
669
|
+
number: 1,
|
|
670
|
+
},
|
|
671
|
+
},
|
|
672
|
+
]),
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
const result = await repository.fetchProjectItemsLight(
|
|
676
|
+
'test-project-id',
|
|
677
|
+
'updated:>=2026-07-07',
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
const sentQuery = extractRequestedQueryFromMockCall(
|
|
681
|
+
mockPost.mock.calls[0],
|
|
682
|
+
);
|
|
683
|
+
expect(sentQuery).toContain('updatedAt');
|
|
684
|
+
expect(sentQuery).toContain('url');
|
|
685
|
+
expect(sentQuery).toContain('number');
|
|
686
|
+
expect(sentQuery).not.toContain('fieldValues');
|
|
687
|
+
const sentVariables = extractRequestedVariablesFromMockCall(
|
|
688
|
+
mockPost.mock.calls[0],
|
|
689
|
+
);
|
|
690
|
+
expect(sentVariables?.query).toBe('updated:>=2026-07-07');
|
|
691
|
+
expect(result).toEqual([
|
|
692
|
+
{
|
|
693
|
+
id: 'PVTI_1',
|
|
694
|
+
updatedAt: '2026-07-07T10:00:00Z',
|
|
695
|
+
url: 'https://github.com/o/r/issues/1',
|
|
696
|
+
number: 1,
|
|
697
|
+
},
|
|
698
|
+
]);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it('paginates all pages and accumulates every light item', async () => {
|
|
702
|
+
const repository = new GraphqlProjectItemRepository(
|
|
703
|
+
new LocalStorageRepository(),
|
|
704
|
+
'dummy-token',
|
|
705
|
+
);
|
|
706
|
+
mockPost
|
|
707
|
+
.mockReturnValueOnce(
|
|
708
|
+
makeLightPageResponse(
|
|
709
|
+
true,
|
|
710
|
+
'cursor-1',
|
|
711
|
+
[
|
|
712
|
+
{
|
|
713
|
+
id: 'PVTI_1',
|
|
714
|
+
updatedAt: '2026-07-07T10:00:00Z',
|
|
715
|
+
content: { url: 'https://github.com/o/r/issues/1', number: 1 },
|
|
716
|
+
},
|
|
717
|
+
],
|
|
718
|
+
2,
|
|
719
|
+
),
|
|
720
|
+
)
|
|
721
|
+
.mockReturnValueOnce(
|
|
722
|
+
makeLightPageResponse(
|
|
723
|
+
false,
|
|
724
|
+
'cursor-2',
|
|
725
|
+
[
|
|
726
|
+
{
|
|
727
|
+
id: 'PVTI_2',
|
|
728
|
+
updatedAt: '2026-07-07T11:00:00Z',
|
|
729
|
+
content: { url: 'https://github.com/o/r/issues/2', number: 2 },
|
|
730
|
+
},
|
|
731
|
+
],
|
|
732
|
+
2,
|
|
733
|
+
),
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
const resultPromise = repository.fetchProjectItemsLight(
|
|
737
|
+
'test-project-id',
|
|
738
|
+
'updated:>=2026-07-07',
|
|
739
|
+
);
|
|
740
|
+
await jest.advanceTimersByTimeAsync(PAGINATION_DELAY_MS);
|
|
741
|
+
const result = await resultPromise;
|
|
742
|
+
|
|
743
|
+
expect(mockPost).toHaveBeenCalledTimes(2);
|
|
744
|
+
expect(result.map((item) => item.id)).toEqual(['PVTI_1', 'PVTI_2']);
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it('skips nodes whose content has no url (draft items) but still counts them for totalCount', async () => {
|
|
748
|
+
const repository = new GraphqlProjectItemRepository(
|
|
749
|
+
new LocalStorageRepository(),
|
|
750
|
+
'dummy-token',
|
|
751
|
+
);
|
|
752
|
+
mockPost.mockReturnValueOnce(
|
|
753
|
+
makeLightPageResponse(
|
|
754
|
+
false,
|
|
755
|
+
'cursor-1',
|
|
756
|
+
[
|
|
757
|
+
{
|
|
758
|
+
id: 'PVTI_draft',
|
|
759
|
+
updatedAt: '2026-07-07T10:00:00Z',
|
|
760
|
+
content: null,
|
|
761
|
+
},
|
|
762
|
+
{
|
|
763
|
+
id: 'PVTI_1',
|
|
764
|
+
updatedAt: '2026-07-07T10:00:00Z',
|
|
765
|
+
content: { url: 'https://github.com/o/r/issues/1', number: 1 },
|
|
766
|
+
},
|
|
767
|
+
],
|
|
768
|
+
2,
|
|
769
|
+
),
|
|
770
|
+
);
|
|
771
|
+
|
|
772
|
+
const result = await repository.fetchProjectItemsLight(
|
|
773
|
+
'test-project-id',
|
|
774
|
+
'updated:>=2026-07-07',
|
|
775
|
+
);
|
|
776
|
+
|
|
777
|
+
expect(result.map((item) => item.id)).toEqual(['PVTI_1']);
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
describe('fetchProjectItemsByIds', () => {
|
|
782
|
+
const makeByIdsResponse = (nodes: (Record<string, unknown> | null)[]) =>
|
|
783
|
+
mockJsonResponse({
|
|
784
|
+
data: {
|
|
785
|
+
nodes,
|
|
786
|
+
},
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
const makeDetailNode = (id: string, url: string, title: string) => ({
|
|
790
|
+
id,
|
|
791
|
+
fieldValues: {
|
|
792
|
+
nodes: [
|
|
793
|
+
{
|
|
794
|
+
name: 'In Progress',
|
|
795
|
+
field: { name: 'Status' },
|
|
796
|
+
},
|
|
797
|
+
],
|
|
798
|
+
},
|
|
799
|
+
content: {
|
|
800
|
+
repository: { nameWithOwner: 'o/r' },
|
|
801
|
+
number: 1,
|
|
802
|
+
title,
|
|
803
|
+
state: 'OPEN',
|
|
804
|
+
url,
|
|
805
|
+
createdAt: '2026-07-01T00:00:00Z',
|
|
806
|
+
updatedAt: '2026-07-07T10:00:00Z',
|
|
807
|
+
author: { login: 'octocat' },
|
|
808
|
+
labels: { nodes: [{ name: 'bug' }] },
|
|
809
|
+
assignees: { nodes: [{ login: 'octocat' }] },
|
|
810
|
+
},
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
beforeEach(() => {
|
|
814
|
+
jest.useFakeTimers();
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
afterEach(() => {
|
|
818
|
+
jest.useRealTimers();
|
|
819
|
+
mockPost.mockReset();
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it('returns an empty array and makes no request when ids is empty', async () => {
|
|
823
|
+
const repository = new GraphqlProjectItemRepository(
|
|
824
|
+
new LocalStorageRepository(),
|
|
825
|
+
'dummy-token',
|
|
826
|
+
);
|
|
827
|
+
|
|
828
|
+
const result = await repository.fetchProjectItemsByIds([]);
|
|
829
|
+
|
|
830
|
+
expect(result).toEqual([]);
|
|
831
|
+
expect(mockPost).not.toHaveBeenCalled();
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
it('fetches full detail via nodes(ids:) and maps each to a ProjectItem', async () => {
|
|
835
|
+
const repository = new GraphqlProjectItemRepository(
|
|
836
|
+
new LocalStorageRepository(),
|
|
837
|
+
'dummy-token',
|
|
838
|
+
);
|
|
839
|
+
mockPost.mockReturnValueOnce(
|
|
840
|
+
makeByIdsResponse([
|
|
841
|
+
makeDetailNode('PVTI_1', 'https://github.com/o/r/issues/1', 'first'),
|
|
842
|
+
null,
|
|
843
|
+
]),
|
|
844
|
+
);
|
|
845
|
+
|
|
846
|
+
const result = await repository.fetchProjectItemsByIds(['PVTI_1', 'bad']);
|
|
847
|
+
|
|
848
|
+
const sentQuery = extractRequestedQueryFromMockCall(
|
|
849
|
+
mockPost.mock.calls[0],
|
|
850
|
+
);
|
|
851
|
+
expect(sentQuery).toContain('nodes(ids: $ids)');
|
|
852
|
+
expect(sentQuery).toContain('... on ProjectV2Item');
|
|
853
|
+
expect(sentQuery).toContain('fieldValues');
|
|
854
|
+
const sentVariables = extractRequestedVariablesFromMockCall(
|
|
855
|
+
mockPost.mock.calls[0],
|
|
856
|
+
);
|
|
857
|
+
expect(sentVariables?.ids).toEqual(['PVTI_1', 'bad']);
|
|
858
|
+
expect(result).toHaveLength(1);
|
|
859
|
+
expect(result[0]).toEqual(
|
|
860
|
+
expect.objectContaining({
|
|
861
|
+
id: 'PVTI_1',
|
|
862
|
+
url: 'https://github.com/o/r/issues/1',
|
|
863
|
+
title: 'first',
|
|
864
|
+
body: null,
|
|
865
|
+
labels: ['bug'],
|
|
866
|
+
assignees: ['octocat'],
|
|
867
|
+
customFields: [{ name: 'Status', value: 'In Progress' }],
|
|
868
|
+
}),
|
|
869
|
+
);
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
it('batches ids into groups of at most 100', async () => {
|
|
873
|
+
const repository = new GraphqlProjectItemRepository(
|
|
874
|
+
new LocalStorageRepository(),
|
|
875
|
+
'dummy-token',
|
|
876
|
+
);
|
|
877
|
+
const ids = Array.from(
|
|
878
|
+
{ length: 150 },
|
|
879
|
+
(_unused, index) => `PVTI_${index}`,
|
|
880
|
+
);
|
|
881
|
+
mockPost
|
|
882
|
+
.mockReturnValueOnce(makeByIdsResponse([]))
|
|
883
|
+
.mockReturnValueOnce(makeByIdsResponse([]));
|
|
884
|
+
|
|
885
|
+
const resultPromise = repository.fetchProjectItemsByIds(ids);
|
|
886
|
+
await jest.advanceTimersByTimeAsync(PAGINATION_DELAY_MS);
|
|
887
|
+
await resultPromise;
|
|
888
|
+
|
|
889
|
+
expect(mockPost).toHaveBeenCalledTimes(2);
|
|
890
|
+
const firstBatch = extractRequestedVariablesFromMockCall(
|
|
891
|
+
mockPost.mock.calls[0],
|
|
892
|
+
);
|
|
893
|
+
const secondBatch = extractRequestedVariablesFromMockCall(
|
|
894
|
+
mockPost.mock.calls[1],
|
|
895
|
+
);
|
|
896
|
+
expect(firstBatch?.ids).toHaveLength(100);
|
|
897
|
+
expect(secondBatch?.ids).toHaveLength(50);
|
|
898
|
+
});
|
|
899
|
+
});
|
|
900
|
+
|
|
607
901
|
describe('callWithRateLimitRetry', () => {
|
|
608
902
|
beforeEach(() => {
|
|
609
903
|
jest.useFakeTimers();
|