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.
- package/CHANGELOG.md +20 -0
- package/README.md +8 -7
- package/bin/adapter/entry-points/cli/index.js +21 -13
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/{consoleServer.js → webServer.js} +11 -11
- package/bin/adapter/entry-points/console/webServer.js.map +1 -0
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +41 -2
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/dashboardRowWriter.js +34 -0
- package/bin/adapter/entry-points/handlers/dashboardRowWriter.js.map +1 -0
- package/bin/adapter/entry-points/handlers/machineStatusWriter.js +60 -0
- package/bin/adapter/entry-points/handlers/machineStatusWriter.js.map +1 -0
- package/bin/adapter/entry-points/handlers/tokenStatusWriter.js +98 -0
- package/bin/adapter/entry-points/handlers/tokenStatusWriter.js.map +1 -0
- package/bin/adapter/proxy/RateLimitCache.js +3 -0
- package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
- package/bin/adapter/repositories/ProcHostMetricsRepository.js +136 -0
- package/bin/adapter/repositories/ProcHostMetricsRepository.js.map +1 -0
- package/bin/adapter/repositories/ProcTakeOwnershipSpawnRepository.js +131 -0
- package/bin/adapter/repositories/ProcTakeOwnershipSpawnRepository.js.map +1 -0
- package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +93 -8
- package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
- package/bin/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.js.map +1 -0
- package/bin/domain/usecases/dashboard/GenerateDashboardRowUseCase.js +38 -0
- package/bin/domain/usecases/dashboard/GenerateDashboardRowUseCase.js.map +1 -0
- package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js +115 -0
- package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.test.ts +74 -21
- package/src/adapter/entry-points/cli/index.ts +146 -136
- package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +2 -2
- package/src/adapter/entry-points/console/{consoleServer.test.ts → webServer.test.ts} +32 -32
- package/src/adapter/entry-points/console/{consoleServer.ts → webServer.ts} +15 -17
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +52 -0
- package/src/adapter/entry-points/handlers/dashboardRowWriter.test.ts +111 -0
- package/src/adapter/entry-points/handlers/dashboardRowWriter.ts +51 -0
- package/src/adapter/entry-points/handlers/machineStatusWriter.test.ts +100 -0
- package/src/adapter/entry-points/handlers/machineStatusWriter.ts +79 -0
- package/src/adapter/entry-points/handlers/tokenStatusWriter.test.ts +176 -0
- package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +139 -0
- package/src/adapter/proxy/RateLimitCache.test.ts +32 -0
- package/src/adapter/proxy/RateLimitCache.ts +6 -0
- package/src/adapter/repositories/ProcHostMetricsRepository.test.ts +135 -0
- package/src/adapter/repositories/ProcHostMetricsRepository.ts +136 -0
- package/src/adapter/repositories/ProcTakeOwnershipSpawnRepository.test.ts +130 -0
- package/src/adapter/repositories/ProcTakeOwnershipSpawnRepository.ts +118 -0
- package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +267 -13
- package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +140 -47
- package/src/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.ts +8 -0
- package/src/domain/usecases/dashboard/GenerateDashboardRowUseCase.test.ts +159 -0
- package/src/domain/usecases/dashboard/GenerateDashboardRowUseCase.ts +72 -0
- package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.test.ts +209 -0
- package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.ts +195 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/console/{consoleServer.d.ts → webServer.d.ts} +7 -7
- package/types/adapter/entry-points/console/webServer.d.ts.map +1 -0
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/dashboardRowWriter.d.ts +15 -0
- package/types/adapter/entry-points/handlers/dashboardRowWriter.d.ts.map +1 -0
- package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts +16 -0
- package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts.map +1 -0
- package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts +21 -0
- package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -0
- package/types/adapter/proxy/RateLimitCache.d.ts +2 -0
- package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
- package/types/adapter/repositories/ProcHostMetricsRepository.d.ts +23 -0
- package/types/adapter/repositories/ProcHostMetricsRepository.d.ts.map +1 -0
- package/types/adapter/repositories/ProcTakeOwnershipSpawnRepository.d.ts +11 -0
- package/types/adapter/repositories/ProcTakeOwnershipSpawnRepository.d.ts.map +1 -0
- package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts +6 -1
- package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.d.ts +8 -0
- package/types/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.d.ts.map +1 -0
- package/types/domain/usecases/dashboard/GenerateDashboardRowUseCase.d.ts +19 -0
- package/types/domain/usecases/dashboard/GenerateDashboardRowUseCase.d.ts.map +1 -0
- package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts +53 -0
- package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts.map +1 -0
- package/bin/adapter/entry-points/console/consoleServer.js.map +0 -1
- 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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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 =
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
}
|