github-issue-tower-defence-management 1.104.3 → 1.105.0

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 (80) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +8 -7
  3. package/bin/adapter/entry-points/cli/index.js +21 -13
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/console/{consoleServer.js → webServer.js} +11 -11
  6. package/bin/adapter/entry-points/console/webServer.js.map +1 -0
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +41 -2
  8. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  9. package/bin/adapter/entry-points/handlers/dashboardRowWriter.js +34 -0
  10. package/bin/adapter/entry-points/handlers/dashboardRowWriter.js.map +1 -0
  11. package/bin/adapter/entry-points/handlers/machineStatusWriter.js +60 -0
  12. package/bin/adapter/entry-points/handlers/machineStatusWriter.js.map +1 -0
  13. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js +98 -0
  14. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js.map +1 -0
  15. package/bin/adapter/proxy/RateLimitCache.js +3 -0
  16. package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
  17. package/bin/adapter/repositories/ProcHostMetricsRepository.js +136 -0
  18. package/bin/adapter/repositories/ProcHostMetricsRepository.js.map +1 -0
  19. package/bin/adapter/repositories/ProcTakeOwnershipSpawnRepository.js +131 -0
  20. package/bin/adapter/repositories/ProcTakeOwnershipSpawnRepository.js.map +1 -0
  21. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +93 -8
  22. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
  23. package/bin/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.js +3 -0
  24. package/bin/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.js.map +1 -0
  25. package/bin/domain/usecases/dashboard/GenerateDashboardRowUseCase.js +38 -0
  26. package/bin/domain/usecases/dashboard/GenerateDashboardRowUseCase.js.map +1 -0
  27. package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js +115 -0
  28. package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js.map +1 -0
  29. package/package.json +1 -1
  30. package/src/adapter/entry-points/cli/index.test.ts +74 -21
  31. package/src/adapter/entry-points/cli/index.ts +146 -136
  32. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +2 -2
  33. package/src/adapter/entry-points/console/{consoleServer.test.ts → webServer.test.ts} +32 -32
  34. package/src/adapter/entry-points/console/{consoleServer.ts → webServer.ts} +15 -17
  35. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +52 -0
  36. package/src/adapter/entry-points/handlers/dashboardRowWriter.test.ts +111 -0
  37. package/src/adapter/entry-points/handlers/dashboardRowWriter.ts +51 -0
  38. package/src/adapter/entry-points/handlers/machineStatusWriter.test.ts +100 -0
  39. package/src/adapter/entry-points/handlers/machineStatusWriter.ts +79 -0
  40. package/src/adapter/entry-points/handlers/tokenStatusWriter.test.ts +176 -0
  41. package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +139 -0
  42. package/src/adapter/proxy/RateLimitCache.test.ts +32 -0
  43. package/src/adapter/proxy/RateLimitCache.ts +6 -0
  44. package/src/adapter/repositories/ProcHostMetricsRepository.test.ts +135 -0
  45. package/src/adapter/repositories/ProcHostMetricsRepository.ts +136 -0
  46. package/src/adapter/repositories/ProcTakeOwnershipSpawnRepository.test.ts +130 -0
  47. package/src/adapter/repositories/ProcTakeOwnershipSpawnRepository.ts +118 -0
  48. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +267 -13
  49. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +140 -47
  50. package/src/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.ts +8 -0
  51. package/src/domain/usecases/dashboard/GenerateDashboardRowUseCase.test.ts +159 -0
  52. package/src/domain/usecases/dashboard/GenerateDashboardRowUseCase.ts +72 -0
  53. package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.test.ts +209 -0
  54. package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.ts +195 -0
  55. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  56. package/types/adapter/entry-points/console/{consoleServer.d.ts → webServer.d.ts} +7 -7
  57. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -0
  58. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  59. package/types/adapter/entry-points/handlers/dashboardRowWriter.d.ts +15 -0
  60. package/types/adapter/entry-points/handlers/dashboardRowWriter.d.ts.map +1 -0
  61. package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts +16 -0
  62. package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts.map +1 -0
  63. package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts +21 -0
  64. package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -0
  65. package/types/adapter/proxy/RateLimitCache.d.ts +2 -0
  66. package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
  67. package/types/adapter/repositories/ProcHostMetricsRepository.d.ts +23 -0
  68. package/types/adapter/repositories/ProcHostMetricsRepository.d.ts.map +1 -0
  69. package/types/adapter/repositories/ProcTakeOwnershipSpawnRepository.d.ts +11 -0
  70. package/types/adapter/repositories/ProcTakeOwnershipSpawnRepository.d.ts.map +1 -0
  71. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts +6 -1
  72. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
  73. package/types/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.d.ts +8 -0
  74. package/types/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.d.ts.map +1 -0
  75. package/types/domain/usecases/dashboard/GenerateDashboardRowUseCase.d.ts +19 -0
  76. package/types/domain/usecases/dashboard/GenerateDashboardRowUseCase.d.ts.map +1 -0
  77. package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts +53 -0
  78. package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts.map +1 -0
  79. package/bin/adapter/entry-points/console/consoleServer.js.map +0 -1
  80. package/types/adapter/entry-points/console/consoleServer.d.ts.map +0 -1
@@ -0,0 +1,118 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import {
4
+ TakeOwnershipSpawn,
5
+ TakeOwnershipSpawnRepository,
6
+ } from '../../domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository';
7
+
8
+ const DEFAULT_PROC_DIRECTORY = '/proc';
9
+ const OAUTH_TOKEN_ENVIRON_KEY = 'CLAUDE_CODE_OAUTH_TOKEN';
10
+ const TAKE_OWNERSHIP_MARKER = 'Take ownership';
11
+ const LOG_PATH_PATTERN = /\/logs-aw\/[^\0"]+\.log/;
12
+
13
+ const parseCommandLineArguments = (cmdline: string): string[] =>
14
+ cmdline.split('\0').filter((argument) => argument.length > 0);
15
+
16
+ const isTakeOwnershipSpawn = (commandArguments: string[]): boolean =>
17
+ commandArguments.some((argument) => argument.includes(TAKE_OWNERSHIP_MARKER));
18
+
19
+ const extractLogPath = (cmdline: string): string | null => {
20
+ const match = cmdline.match(LOG_PATH_PATTERN);
21
+ return match === null ? null : match[0];
22
+ };
23
+
24
+ export class ProcTakeOwnershipSpawnRepository implements TakeOwnershipSpawnRepository {
25
+ constructor(
26
+ private readonly procDirectory: string = DEFAULT_PROC_DIRECTORY,
27
+ ) {}
28
+
29
+ listSpawns = (): TakeOwnershipSpawn[] => {
30
+ const spawns: TakeOwnershipSpawn[] = [];
31
+ for (const pidDirectory of this.listProcessIdDirectories()) {
32
+ const spawn = this.readSpawn(pidDirectory);
33
+ if (spawn !== null) {
34
+ spawns.push(spawn);
35
+ }
36
+ }
37
+ return spawns;
38
+ };
39
+
40
+ private listProcessIdDirectories = (): string[] => {
41
+ let entries: string[];
42
+ try {
43
+ entries = fs.readdirSync(this.procDirectory);
44
+ } catch {
45
+ return [];
46
+ }
47
+ return entries.filter((entry) => /^\d+$/.test(entry));
48
+ };
49
+
50
+ private readSpawn = (
51
+ processIdDirectory: string,
52
+ ): TakeOwnershipSpawn | null => {
53
+ const rawCmdline = this.readRawCmdline(processIdDirectory);
54
+ if (rawCmdline === null) {
55
+ return null;
56
+ }
57
+ const commandArguments = parseCommandLineArguments(rawCmdline);
58
+ if (!isTakeOwnershipSpawn(commandArguments)) {
59
+ return null;
60
+ }
61
+ const logPath = extractLogPath(rawCmdline);
62
+ if (logPath === null) {
63
+ return null;
64
+ }
65
+ const environ = this.readEnviron(processIdDirectory);
66
+ if (environ === null) {
67
+ return null;
68
+ }
69
+ const token = environ.get(OAUTH_TOKEN_ENVIRON_KEY);
70
+ if (token === undefined || token.length === 0) {
71
+ return null;
72
+ }
73
+ return { token, logPath };
74
+ };
75
+
76
+ private readRawCmdline = (processIdDirectory: string): string | null => {
77
+ const cmdlinePath = path.join(
78
+ this.procDirectory,
79
+ processIdDirectory,
80
+ 'cmdline',
81
+ );
82
+ try {
83
+ return fs.readFileSync(cmdlinePath, 'utf8');
84
+ } catch {
85
+ return null;
86
+ }
87
+ };
88
+
89
+ private readEnviron = (
90
+ processIdDirectory: string,
91
+ ): Map<string, string> | null => {
92
+ const environPath = path.join(
93
+ this.procDirectory,
94
+ processIdDirectory,
95
+ 'environ',
96
+ );
97
+ let raw: string;
98
+ try {
99
+ raw = fs.readFileSync(environPath, 'utf8');
100
+ } catch {
101
+ return null;
102
+ }
103
+ const environ = new Map<string, string>();
104
+ for (const entry of raw.split('\0')) {
105
+ if (entry.length === 0) {
106
+ continue;
107
+ }
108
+ const separatorIndex = entry.indexOf('=');
109
+ if (separatorIndex <= 0) {
110
+ continue;
111
+ }
112
+ const key = entry.slice(0, separatorIndex);
113
+ const value = entry.slice(separatorIndex + 1);
114
+ environ.set(key, value);
115
+ }
116
+ return environ;
117
+ };
118
+ }
@@ -1,22 +1,29 @@
1
1
  const mockPost = jest.fn();
2
2
 
3
- jest.mock('ky', () => ({
4
- default: {
5
- post: mockPost,
6
- get: jest.fn(),
7
- put: jest.fn(),
8
- patch: jest.fn(),
9
- delete: jest.fn(),
10
- extend: jest.fn(),
11
- create: jest.fn(),
12
- stop: jest.fn(),
13
- },
14
- __esModule: true,
15
- }));
3
+ jest.mock('ky', () => {
4
+ const actualKy: typeof import('ky') = jest.requireActual('ky');
5
+ return {
6
+ default: {
7
+ post: mockPost,
8
+ get: jest.fn(),
9
+ put: jest.fn(),
10
+ patch: jest.fn(),
11
+ delete: jest.fn(),
12
+ extend: jest.fn(),
13
+ create: jest.fn(),
14
+ stop: jest.fn(),
15
+ },
16
+ HTTPError: actualKy.HTTPError,
17
+ __esModule: true,
18
+ };
19
+ });
16
20
 
21
+ import { HTTPError } from 'ky';
17
22
  import {
18
23
  GraphqlProjectItemRepository,
19
24
  PAGINATION_DELAY_MS,
25
+ RATE_LIMIT_MAX_RETRIES,
26
+ callWithRateLimitRetry,
20
27
  } from './GraphqlProjectItemRepository';
21
28
  import { LocalStorageRepository } from '../LocalStorageRepository';
22
29
 
@@ -24,6 +31,30 @@ const mockJsonResponse = <T>(data: T) => ({
24
31
  json: jest.fn().mockResolvedValue(data),
25
32
  });
26
33
 
34
+ const makeHttpError = (
35
+ status: number,
36
+ headers: Record<string, string> = {},
37
+ ): HTTPError => {
38
+ const response = new Response('rate limited', {
39
+ status,
40
+ headers,
41
+ });
42
+ const request = new Request('https://api.github.com/graphql');
43
+ const normalizedOptions: ConstructorParameters<typeof HTTPError>[2] = {
44
+ method: 'post',
45
+ retry: {},
46
+ prefix: '',
47
+ onDownloadProgress: undefined,
48
+ onUploadProgress: undefined,
49
+ context: {},
50
+ };
51
+ return new HTTPError(response, request, normalizedOptions);
52
+ };
53
+
54
+ const mockRejectedJsonResponse = (error: HTTPError) => ({
55
+ json: jest.fn().mockRejectedValue(error),
56
+ });
57
+
27
58
  const isRecord = (value: unknown): value is Record<string, unknown> =>
28
59
  typeof value === 'object' && value !== null;
29
60
 
@@ -434,6 +465,229 @@ describe('GraphqlProjectItemRepository', () => {
434
465
  );
435
466
  expect(requestedFirstSeries).toEqual([100, 50, 25, 12, 6, 3, 1]);
436
467
  });
468
+
469
+ it('should not sleep the legacy 5000ms blanket delay between pages', async () => {
470
+ const localStorageRepository = new LocalStorageRepository();
471
+ const repository = new GraphqlProjectItemRepository(
472
+ localStorageRepository,
473
+ 'dummy-token',
474
+ );
475
+
476
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
477
+ mockPost
478
+ .mockReturnValueOnce(makePageResponse(true, 'cursor-1'))
479
+ .mockReturnValueOnce(makePageResponse(false, 'cursor-2'));
480
+
481
+ const resultPromise = repository.fetchProjectItems('test-project-id');
482
+ await jest.advanceTimersByTimeAsync(PAGINATION_DELAY_MS);
483
+ const result = await resultPromise;
484
+
485
+ expect(result).toHaveLength(2);
486
+ expect(PAGINATION_DELAY_MS).toBeLessThanOrEqual(500);
487
+ expect(setTimeoutSpy).not.toHaveBeenCalledWith(
488
+ expect.any(Function),
489
+ 5000,
490
+ );
491
+ setTimeoutSpy.mockRestore();
492
+ });
493
+
494
+ it('should back off and retry once when GitHub responds 429, then succeed', async () => {
495
+ const localStorageRepository = new LocalStorageRepository();
496
+ const repository = new GraphqlProjectItemRepository(
497
+ localStorageRepository,
498
+ 'dummy-token',
499
+ );
500
+
501
+ mockPost
502
+ .mockReturnValueOnce(
503
+ mockRejectedJsonResponse(makeHttpError(429, { 'retry-after': '3' })),
504
+ )
505
+ .mockReturnValueOnce(makePageResponse(false, 'cursor-1', 1));
506
+
507
+ const resultPromise = repository.fetchProjectItems('test-project-id');
508
+ await jest.advanceTimersByTimeAsync(3000);
509
+ const result = await resultPromise;
510
+
511
+ expect(result).toHaveLength(1);
512
+ expect(mockPost).toHaveBeenCalledTimes(2);
513
+ });
514
+
515
+ it('should honor the retry-after header duration before retrying after a 429', async () => {
516
+ const localStorageRepository = new LocalStorageRepository();
517
+ const repository = new GraphqlProjectItemRepository(
518
+ localStorageRepository,
519
+ 'dummy-token',
520
+ );
521
+
522
+ mockPost
523
+ .mockReturnValueOnce(
524
+ mockRejectedJsonResponse(makeHttpError(429, { 'retry-after': '7' })),
525
+ )
526
+ .mockReturnValueOnce(makePageResponse(false, 'cursor-1', 1));
527
+
528
+ const resultPromise = repository.fetchProjectItems('test-project-id');
529
+ await jest.advanceTimersByTimeAsync(6999);
530
+ expect(mockPost).toHaveBeenCalledTimes(1);
531
+ await jest.advanceTimersByTimeAsync(1);
532
+ const result = await resultPromise;
533
+
534
+ expect(result).toHaveLength(1);
535
+ expect(mockPost).toHaveBeenCalledTimes(2);
536
+ });
537
+
538
+ it('should wait until x-ratelimit-reset when 403 has no retry-after and remaining is 0', async () => {
539
+ const localStorageRepository = new LocalStorageRepository();
540
+ const repository = new GraphqlProjectItemRepository(
541
+ localStorageRepository,
542
+ 'dummy-token',
543
+ );
544
+
545
+ const resetEpochSeconds = Math.floor(Date.now() / 1000) + 4;
546
+ mockPost
547
+ .mockReturnValueOnce(
548
+ mockRejectedJsonResponse(
549
+ makeHttpError(403, {
550
+ 'x-ratelimit-remaining': '0',
551
+ 'x-ratelimit-reset': String(resetEpochSeconds),
552
+ }),
553
+ ),
554
+ )
555
+ .mockReturnValueOnce(makePageResponse(false, 'cursor-1', 1));
556
+
557
+ const resultPromise = repository.fetchProjectItems('test-project-id');
558
+ await jest.advanceTimersByTimeAsync(5000);
559
+ const result = await resultPromise;
560
+
561
+ expect(result).toHaveLength(1);
562
+ expect(mockPost).toHaveBeenCalledTimes(2);
563
+ });
564
+
565
+ it('should give up and rethrow after exhausting rate-limit retries', async () => {
566
+ const localStorageRepository = new LocalStorageRepository();
567
+ const repository = new GraphqlProjectItemRepository(
568
+ localStorageRepository,
569
+ 'dummy-token',
570
+ );
571
+
572
+ mockPost.mockReturnValue(
573
+ mockRejectedJsonResponse(makeHttpError(429, { 'retry-after': '1' })),
574
+ );
575
+
576
+ const resultPromise = repository
577
+ .fetchProjectItems('test-project-id')
578
+ .catch((error: unknown) => error);
579
+ await jest.runAllTimersAsync();
580
+ const caught = await resultPromise;
581
+
582
+ expect(caught).toBeInstanceOf(HTTPError);
583
+ expect(mockPost).toHaveBeenCalledTimes(RATE_LIMIT_MAX_RETRIES + 1);
584
+ }, 30000);
585
+
586
+ it('should not let the page-size halving fallback re-issue a rate-limit request', async () => {
587
+ const localStorageRepository = new LocalStorageRepository();
588
+ const repository = new GraphqlProjectItemRepository(
589
+ localStorageRepository,
590
+ 'dummy-token',
591
+ );
592
+
593
+ mockPost.mockReturnValue(
594
+ mockRejectedJsonResponse(makeHttpError(429, { 'retry-after': '1' })),
595
+ );
596
+
597
+ const resultPromise = repository
598
+ .fetchProjectItems('test-project-id')
599
+ .catch((error: unknown) => error);
600
+ await jest.runAllTimersAsync();
601
+ await resultPromise;
602
+
603
+ const requestedFirstSeries = mockPost.mock.calls.map((call) =>
604
+ extractRequestedFirstFromMockCall(call),
605
+ );
606
+ expect(requestedFirstSeries.every((first) => first === 100)).toBe(true);
607
+ }, 30000);
608
+ });
609
+
610
+ describe('callWithRateLimitRetry', () => {
611
+ beforeEach(() => {
612
+ jest.useFakeTimers();
613
+ });
614
+
615
+ afterEach(() => {
616
+ jest.useRealTimers();
617
+ });
618
+
619
+ it('should return the result without retrying when the request succeeds', async () => {
620
+ const request = jest.fn().mockResolvedValue('ok');
621
+
622
+ const result = await callWithRateLimitRetry(request);
623
+
624
+ expect(result).toBe('ok');
625
+ expect(request).toHaveBeenCalledTimes(1);
626
+ });
627
+
628
+ it('should retry on a 429 then return the eventual success value', async () => {
629
+ const request = jest
630
+ .fn()
631
+ .mockRejectedValueOnce(makeHttpError(429, { 'retry-after': '2' }))
632
+ .mockResolvedValueOnce('recovered');
633
+
634
+ const resultPromise = callWithRateLimitRetry(request);
635
+ await jest.advanceTimersByTimeAsync(2000);
636
+ const result = await resultPromise;
637
+
638
+ expect(result).toBe('recovered');
639
+ expect(request).toHaveBeenCalledTimes(2);
640
+ });
641
+
642
+ it('should retry on a 403 rate-limit response then succeed', async () => {
643
+ const resetEpochSeconds = Math.floor(Date.now() / 1000) + 3;
644
+ const request = jest
645
+ .fn()
646
+ .mockRejectedValueOnce(
647
+ makeHttpError(403, {
648
+ 'x-ratelimit-remaining': '0',
649
+ 'x-ratelimit-reset': String(resetEpochSeconds),
650
+ }),
651
+ )
652
+ .mockResolvedValueOnce('recovered');
653
+
654
+ const resultPromise = callWithRateLimitRetry(request);
655
+ await jest.advanceTimersByTimeAsync(4000);
656
+ const result = await resultPromise;
657
+
658
+ expect(result).toBe('recovered');
659
+ expect(request).toHaveBeenCalledTimes(2);
660
+ });
661
+
662
+ it('should not retry a non-rate-limit HTTP error such as 500', async () => {
663
+ const httpError = makeHttpError(500);
664
+ const request = jest.fn().mockRejectedValue(httpError);
665
+
666
+ await expect(callWithRateLimitRetry(request)).rejects.toBe(httpError);
667
+ expect(request).toHaveBeenCalledTimes(1);
668
+ });
669
+
670
+ it('should rethrow immediately for a non-HTTPError', async () => {
671
+ const plainError = new Error('boom');
672
+ const request = jest.fn().mockRejectedValue(plainError);
673
+
674
+ await expect(callWithRateLimitRetry(request)).rejects.toBe(plainError);
675
+ expect(request).toHaveBeenCalledTimes(1);
676
+ });
677
+
678
+ it('should stop after the maximum number of rate-limit retries', async () => {
679
+ const httpError = makeHttpError(429, { 'retry-after': '1' });
680
+ const request = jest.fn().mockRejectedValue(httpError);
681
+
682
+ const resultPromise = callWithRateLimitRetry(request).catch(
683
+ (error: unknown) => error,
684
+ );
685
+ await jest.runAllTimersAsync();
686
+ const caught = await resultPromise;
687
+
688
+ expect(caught).toBe(httpError);
689
+ expect(request).toHaveBeenCalledTimes(RATE_LIMIT_MAX_RETRIES + 1);
690
+ }, 30000);
437
691
  });
438
692
 
439
693
  describe('getProjectItemFields', () => {
@@ -1,4 +1,4 @@
1
- import ky from 'ky';
1
+ import ky, { HTTPError } from 'ky';
2
2
  import { BaseGitHubRepository } from '../BaseGitHubRepository';
3
3
  import { Project } from '../../../domain/entities/Project';
4
4
  import { Issue } from '../../../domain/entities/Issue';
@@ -20,9 +20,94 @@ export type ProjectItem = {
20
20
  value: string | null;
21
21
  }[];
22
22
  };
23
- export const PAGINATION_DELAY_MS = 5000;
23
+ export const PAGINATION_DELAY_MS = 500;
24
24
  export const FETCH_PROJECT_ITEMS_INITIAL_PAGE_SIZE = 100;
25
25
  export const FETCH_PROJECT_ITEMS_GRAPHQL_ERROR_PAYLOAD_MAX_LENGTH = 4000;
26
+ export const RATE_LIMIT_MAX_RETRIES = 6;
27
+ export const RATE_LIMIT_MIN_BACKOFF_MS = 1000;
28
+ export const RATE_LIMIT_DEFAULT_BACKOFF_MS = 60000;
29
+ export const RATE_LIMIT_MAX_BACKOFF_MS = 300000;
30
+
31
+ const sleep = (ms: number): Promise<void> =>
32
+ new Promise((resolve) => setTimeout(resolve, ms));
33
+
34
+ const parseNonNegativeIntegerHeader = (value: string | null): number | null => {
35
+ if (value === null) {
36
+ return null;
37
+ }
38
+ const trimmed = value.trim();
39
+ if (!/^\d+$/.test(trimmed)) {
40
+ return null;
41
+ }
42
+ const parsed = Number.parseInt(trimmed, 10);
43
+ return Number.isFinite(parsed) ? parsed : null;
44
+ };
45
+
46
+ const computeRateLimitBackoffMs = (
47
+ headers: Headers | undefined,
48
+ attempt: number,
49
+ nowMs: number,
50
+ ): number => {
51
+ const exponentialMs = Math.min(
52
+ RATE_LIMIT_MAX_BACKOFF_MS,
53
+ RATE_LIMIT_DEFAULT_BACKOFF_MS * Math.pow(2, attempt),
54
+ );
55
+ const retryAfterSeconds = parseNonNegativeIntegerHeader(
56
+ headers?.get('retry-after') ?? null,
57
+ );
58
+ if (retryAfterSeconds !== null) {
59
+ return Math.min(
60
+ RATE_LIMIT_MAX_BACKOFF_MS,
61
+ Math.max(RATE_LIMIT_MIN_BACKOFF_MS, retryAfterSeconds * 1000),
62
+ );
63
+ }
64
+ const remaining = parseNonNegativeIntegerHeader(
65
+ headers?.get('x-ratelimit-remaining') ?? null,
66
+ );
67
+ const resetEpochSeconds = parseNonNegativeIntegerHeader(
68
+ headers?.get('x-ratelimit-reset') ?? null,
69
+ );
70
+ if (remaining === 0 && resetEpochSeconds !== null) {
71
+ const waitMs = resetEpochSeconds * 1000 - nowMs;
72
+ return Math.min(
73
+ RATE_LIMIT_MAX_BACKOFF_MS,
74
+ Math.max(RATE_LIMIT_MIN_BACKOFF_MS, waitMs),
75
+ );
76
+ }
77
+ return Math.max(RATE_LIMIT_MIN_BACKOFF_MS, exponentialMs);
78
+ };
79
+
80
+ const isRateLimitStatus = (status: number): boolean =>
81
+ status === 429 || status === 403;
82
+
83
+ export const callWithRateLimitRetry = async <T>(
84
+ request: () => Promise<T>,
85
+ ): Promise<T> => {
86
+ let attempt = 0;
87
+ for (;;) {
88
+ try {
89
+ return await request();
90
+ } catch (error) {
91
+ if (
92
+ !(error instanceof HTTPError) ||
93
+ !isRateLimitStatus(error.response.status) ||
94
+ attempt >= RATE_LIMIT_MAX_RETRIES
95
+ ) {
96
+ throw error;
97
+ }
98
+ const backoffMs = computeRateLimitBackoffMs(
99
+ error.response.headers,
100
+ attempt,
101
+ Date.now(),
102
+ );
103
+ console.log(
104
+ `fetchProjectItems: GitHub returned ${error.response.status} (rate limit). Backing off ${backoffMs}ms before retry ${attempt + 1}/${RATE_LIMIT_MAX_RETRIES}.`,
105
+ );
106
+ await sleep(backoffMs);
107
+ attempt++;
108
+ }
109
+ }
110
+ };
26
111
 
27
112
  const stringifyGraphqlErrorsForLog = (
28
113
  errors: { message: string }[],
@@ -277,53 +362,55 @@ query GetProjectItems($projectId: ID!, $after: String, $first: Int!) {
277
362
  first: first,
278
363
  },
279
364
  };
280
- const response = await ky
281
- .post('https://api.github.com/graphql', {
282
- json: graphqlQuery,
283
- headers: {
284
- Authorization: `Bearer ${this.ghToken}`,
285
- },
286
- })
287
- .json<{
288
- data: {
289
- node: {
290
- items: {
291
- totalCount: number;
292
- pageInfo: {
293
- endCursor: string;
294
- startCursor: string;
295
- hasNextPage: boolean;
296
- };
297
- nodes: {
298
- id: string;
299
- fieldValues: {
300
- nodes: {
301
- text: string;
302
- number: number;
303
- date: string;
304
- field: {
305
- name: string;
306
- };
307
- }[];
365
+ const response = await callWithRateLimitRetry(() =>
366
+ ky
367
+ .post('https://api.github.com/graphql', {
368
+ json: graphqlQuery,
369
+ headers: {
370
+ Authorization: `Bearer ${this.ghToken}`,
371
+ },
372
+ })
373
+ .json<{
374
+ data: {
375
+ node: {
376
+ items: {
377
+ totalCount: number;
378
+ pageInfo: {
379
+ endCursor: string;
380
+ startCursor: string;
381
+ hasNextPage: boolean;
308
382
  };
309
- content: {
310
- repository: { nameWithOwner: string };
311
- number: number;
312
- title: string;
313
- state: string;
314
- url: string;
315
- createdAt: string;
316
- author: { login: string } | null;
317
- labels: { nodes: { name: string }[] };
318
- assignees: { nodes: { login: string }[] };
319
- closingIssuesReferences?: { nodes: { url: string }[] };
320
- };
321
- }[];
322
- };
383
+ nodes: {
384
+ id: string;
385
+ fieldValues: {
386
+ nodes: {
387
+ text: string;
388
+ number: number;
389
+ date: string;
390
+ field: {
391
+ name: string;
392
+ };
393
+ }[];
394
+ };
395
+ content: {
396
+ repository: { nameWithOwner: string };
397
+ number: number;
398
+ title: string;
399
+ state: string;
400
+ url: string;
401
+ createdAt: string;
402
+ author: { login: string } | null;
403
+ labels: { nodes: { name: string }[] };
404
+ assignees: { nodes: { login: string }[] };
405
+ closingIssuesReferences?: { nodes: { url: string }[] };
406
+ };
407
+ }[];
408
+ };
409
+ } | null;
323
410
  } | null;
324
- } | null;
325
- errors?: { message: string }[];
326
- }>();
411
+ errors?: { message: string }[];
412
+ }>(),
413
+ );
327
414
  if (response.errors && response.errors.length > 0) {
328
415
  throw new Error(
329
416
  `GitHub GraphQL errors: ${stringifyGraphqlErrorsForLog(response.errors)}`,
@@ -385,6 +472,12 @@ query GetProjectItems($projectId: ID!, $after: String, $first: Int!) {
385
472
  return await callGraphql(projectId, after, attemptFirst);
386
473
  } catch (error) {
387
474
  lastError = error;
475
+ if (
476
+ error instanceof HTTPError &&
477
+ isRateLimitStatus(error.response.status)
478
+ ) {
479
+ throw error;
480
+ }
388
481
  if (attemptFirst === 1) {
389
482
  throw error;
390
483
  }
@@ -0,0 +1,8 @@
1
+ export type TakeOwnershipSpawn = {
2
+ token: string;
3
+ logPath: string;
4
+ };
5
+
6
+ export interface TakeOwnershipSpawnRepository {
7
+ listSpawns: () => TakeOwnershipSpawn[];
8
+ }