github-issue-tower-defence-management 1.122.0 → 1.122.2

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.
Files changed (29) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +4 -1
  3. package/bin/adapter/entry-points/console/consoleDoneStore.js +1 -0
  4. package/bin/adapter/entry-points/console/consoleDoneStore.js.map +1 -1
  5. package/bin/adapter/entry-points/console/ui-dist/assets/{index-syKbd_lW.js → index-CC-RYye2.js} +24 -24
  6. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  7. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +14 -7
  8. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  9. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +297 -137
  10. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
  11. package/package.json +1 -1
  12. package/src/adapter/entry-points/console/consoleDataDelivery.test.ts +22 -0
  13. package/src/adapter/entry-points/console/consoleDoneStore.test.ts +8 -0
  14. package/src/adapter/entry-points/console/consoleDoneStore.ts +1 -0
  15. package/src/adapter/entry-points/console/ui/src/features/console/logic/overlay.test.ts +10 -43
  16. package/src/adapter/entry-points/console/ui/src/features/console/logic/overlay.ts +0 -17
  17. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +5 -6
  18. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +5 -10
  19. package/src/adapter/entry-points/console/ui-dist/assets/{index-syKbd_lW.js → index-CC-RYye2.js} +24 -24
  20. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  21. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +175 -7
  22. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +22 -8
  23. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +294 -0
  24. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +400 -165
  25. package/types/adapter/entry-points/console/consoleDoneStore.d.ts.map +1 -1
  26. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +3 -2
  27. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  28. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts +10 -0
  29. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>TDPM Console</title>
7
- <script type="module" crossorigin src="/assets/index-syKbd_lW.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-CC-RYye2.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-D0RwhyOM.css">
9
9
  </head>
10
10
  <body>
@@ -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('reuses the cached project, upserts changed issues by url, and marks cacheUsed', async () => {
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:30:00Z'));
278
+ dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:45:00Z'));
267
279
  localStorageCacheRepository.getSingle.mockResolvedValue({
268
- lastFetchedAt: '2026-07-07T00:00:00.000Z',
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.fetchProjectItems.mockResolvedValue([
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 call = graphqlProjectItemRepository.fetchProjectItems.mock.calls[0];
290
- expect(call[0]).toBe('cached-project');
291
- expect(call[1]).toBe('updated:>=2026-07-06');
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 overlapStartDate = new Date(cache.lastFetchedAt);
730
- overlapStartDate.setUTCDate(overlapStartDate.getUTCDate() - 1);
731
- const changedItems =
732
- await this.graphqlProjectItemRepository.fetchProjectItems(
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(overlapStartDate)}`,
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
- for (const item of changedItems) {
740
- const issue = this.convertProjectItemToIssue(item);
741
- issuesByUrl.set(issue.url, issue);
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();