github-issue-tower-defence-management 2.10.1 → 2.11.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 +14 -0
- package/README.md +9 -1
- package/bin/adapter/entry-points/cli/fleetConfig.js +7 -1
- package/bin/adapter/entry-points/cli/fleetConfig.js.map +1 -1
- package/bin/adapter/entry-points/cli/index.js +11 -3
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +2 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/repositories/CliGitHubGraphqlRateLimitRepository.js +24 -0
- package/bin/adapter/repositories/CliGitHubGraphqlRateLimitRepository.js.map +1 -0
- package/bin/adapter/repositories/KySlackRepository.js +2 -1
- package/bin/adapter/repositories/KySlackRepository.js.map +1 -1
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +18 -0
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
- package/bin/domain/usecases/ConflictedIssueRevertUseCase.js +6 -3
- package/bin/domain/usecases/ConflictedIssueRevertUseCase.js.map +1 -1
- package/bin/domain/usecases/StartPreparationUseCase.js +22 -1
- package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/adapter-interfaces/GitHubGraphqlRateLimitRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/GitHubGraphqlRateLimitRepository.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/fleetConfig.test.ts +99 -0
- package/src/adapter/entry-points/cli/fleetConfig.ts +25 -0
- package/src/adapter/entry-points/cli/index.test.ts +4 -0
- package/src/adapter/entry-points/cli/index.ts +16 -2
- package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +1 -0
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +2 -0
- package/src/adapter/repositories/CliGitHubGraphqlRateLimitRepository.test.ts +71 -0
- package/src/adapter/repositories/CliGitHubGraphqlRateLimitRepository.ts +21 -0
- package/src/adapter/repositories/KySlackRepository.test.ts +49 -1
- package/src/adapter/repositories/KySlackRepository.ts +3 -2
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +24 -0
- package/src/domain/usecases/ConflictedIssueRevertUseCase.test.ts +262 -5
- package/src/domain/usecases/ConflictedIssueRevertUseCase.ts +12 -4
- package/src/domain/usecases/StartPreparationUseCase.test.ts +229 -0
- package/src/domain/usecases/StartPreparationUseCase.ts +35 -0
- package/src/domain/usecases/adapter-interfaces/GitHubGraphqlRateLimitRepository.ts +3 -0
- package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +1 -0
- package/src/domain/usecases/issueReactivationTriggerIsPending.test.ts +1 -0
- package/types/adapter/entry-points/cli/fleetConfig.d.ts +4 -0
- package/types/adapter/entry-points/cli/fleetConfig.d.ts.map +1 -1
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/repositories/CliGitHubGraphqlRateLimitRepository.d.ts +8 -0
- package/types/adapter/repositories/CliGitHubGraphqlRateLimitRepository.d.ts.map +1 -0
- package/types/adapter/repositories/KySlackRepository.d.ts +1 -1
- package/types/adapter/repositories/KySlackRepository.d.ts.map +1 -1
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +1 -0
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
- package/types/domain/usecases/ConflictedIssueRevertUseCase.d.ts +1 -1
- package/types/domain/usecases/ConflictedIssueRevertUseCase.d.ts.map +1 -1
- package/types/domain/usecases/StartPreparationUseCase.d.ts +5 -1
- package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/GitHubGraphqlRateLimitRepository.d.ts +4 -0
- package/types/domain/usecases/adapter-interfaces/GitHubGraphqlRateLimitRepository.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +1 -0
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
|
@@ -242,9 +242,108 @@ describe('loadPreparationWorkerSettings', () => {
|
|
|
242
242
|
|
|
243
243
|
expect(loadPreparationWorkerSettings(fleetConfigFilePath)).toEqual({
|
|
244
244
|
normalConcurrentLimit: 10,
|
|
245
|
+
maxConcurrentWorkers:
|
|
246
|
+
DEFAULT_PREPARATION_WORKER_SETTINGS.maxConcurrentWorkers,
|
|
247
|
+
graphqlRateLimitFloor:
|
|
248
|
+
DEFAULT_PREPARATION_WORKER_SETTINGS.graphqlRateLimitFloor,
|
|
245
249
|
});
|
|
246
250
|
});
|
|
247
251
|
|
|
252
|
+
it('reads maxConcurrentWorkers from the fleet config file', () => {
|
|
253
|
+
const fleetConfigFilePath = writeFleetConfig(
|
|
254
|
+
['preparationWorker:', ' maxConcurrentWorkers: 25'].join('\n'),
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
expect(loadPreparationWorkerSettings(fleetConfigFilePath)).toEqual({
|
|
258
|
+
normalConcurrentLimit:
|
|
259
|
+
DEFAULT_PREPARATION_WORKER_SETTINGS.normalConcurrentLimit,
|
|
260
|
+
maxConcurrentWorkers: 25,
|
|
261
|
+
graphqlRateLimitFloor:
|
|
262
|
+
DEFAULT_PREPARATION_WORKER_SETTINGS.graphqlRateLimitFloor,
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('reads graphqlRateLimitFloor from the fleet config file', () => {
|
|
267
|
+
const fleetConfigFilePath = writeFleetConfig(
|
|
268
|
+
['preparationWorker:', ' graphqlRateLimitFloor: 300'].join('\n'),
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
expect(loadPreparationWorkerSettings(fleetConfigFilePath)).toEqual({
|
|
272
|
+
normalConcurrentLimit:
|
|
273
|
+
DEFAULT_PREPARATION_WORKER_SETTINGS.normalConcurrentLimit,
|
|
274
|
+
maxConcurrentWorkers:
|
|
275
|
+
DEFAULT_PREPARATION_WORKER_SETTINGS.maxConcurrentWorkers,
|
|
276
|
+
graphqlRateLimitFloor: 300,
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('reads all three worker settings from the fleet config file', () => {
|
|
281
|
+
const fleetConfigFilePath = writeFleetConfig(
|
|
282
|
+
[
|
|
283
|
+
'preparationWorker:',
|
|
284
|
+
' normalConcurrentLimit: 10',
|
|
285
|
+
' maxConcurrentWorkers: 60',
|
|
286
|
+
' graphqlRateLimitFloor: 200',
|
|
287
|
+
].join('\n'),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
expect(loadPreparationWorkerSettings(fleetConfigFilePath)).toEqual({
|
|
291
|
+
normalConcurrentLimit: 10,
|
|
292
|
+
maxConcurrentWorkers: 60,
|
|
293
|
+
graphqlRateLimitFloor: 200,
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('throws when maxConcurrentWorkers is zero', () => {
|
|
298
|
+
const fleetConfigFilePath = writeFleetConfig(
|
|
299
|
+
['preparationWorker:', ' maxConcurrentWorkers: 0'].join('\n'),
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
expect(() => loadPreparationWorkerSettings(fleetConfigFilePath)).toThrow(
|
|
303
|
+
'maxConcurrentWorkers',
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('throws when maxConcurrentWorkers is not an integer', () => {
|
|
308
|
+
const fleetConfigFilePath = writeFleetConfig(
|
|
309
|
+
['preparationWorker:', ' maxConcurrentWorkers: 10.5'].join('\n'),
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
expect(() => loadPreparationWorkerSettings(fleetConfigFilePath)).toThrow(
|
|
313
|
+
'maxConcurrentWorkers',
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('accepts graphqlRateLimitFloor of zero', () => {
|
|
318
|
+
const fleetConfigFilePath = writeFleetConfig(
|
|
319
|
+
['preparationWorker:', ' graphqlRateLimitFloor: 0'].join('\n'),
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
expect(loadPreparationWorkerSettings(fleetConfigFilePath)).toMatchObject({
|
|
323
|
+
graphqlRateLimitFloor: 0,
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('throws when graphqlRateLimitFloor is negative', () => {
|
|
328
|
+
const fleetConfigFilePath = writeFleetConfig(
|
|
329
|
+
['preparationWorker:', ' graphqlRateLimitFloor: -1'].join('\n'),
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
expect(() => loadPreparationWorkerSettings(fleetConfigFilePath)).toThrow(
|
|
333
|
+
'graphqlRateLimitFloor',
|
|
334
|
+
);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('throws when graphqlRateLimitFloor is not an integer', () => {
|
|
338
|
+
const fleetConfigFilePath = writeFleetConfig(
|
|
339
|
+
['preparationWorker:', ' graphqlRateLimitFloor: 100.5'].join('\n'),
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
expect(() => loadPreparationWorkerSettings(fleetConfigFilePath)).toThrow(
|
|
343
|
+
'graphqlRateLimitFloor',
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
|
|
248
347
|
it('keeps the built-in value when the preparationWorker section is absent', () => {
|
|
249
348
|
const fleetConfigFilePath = writeFleetConfig('inTmuxLauncherCommand: cl\n');
|
|
250
349
|
|
|
@@ -15,12 +15,19 @@ export const START_PREPARATION_SECTION_KEY = 'startPreparation';
|
|
|
15
15
|
|
|
16
16
|
export const DEFAULT_FLEET_MAXIMUM_PREPARING_ISSUES_COUNT = 80;
|
|
17
17
|
|
|
18
|
+
export const DEFAULT_MAX_CONCURRENT_WORKERS = 40;
|
|
19
|
+
export const DEFAULT_GRAPHQL_RATE_LIMIT_FLOOR = 500;
|
|
20
|
+
|
|
18
21
|
export type PreparationWorkerSettings = {
|
|
19
22
|
normalConcurrentLimit: number;
|
|
23
|
+
maxConcurrentWorkers: number;
|
|
24
|
+
graphqlRateLimitFloor: number;
|
|
20
25
|
};
|
|
21
26
|
|
|
22
27
|
export const DEFAULT_PREPARATION_WORKER_SETTINGS: PreparationWorkerSettings = {
|
|
23
28
|
normalConcurrentLimit: NORMAL_CONCURRENT_LIMIT,
|
|
29
|
+
maxConcurrentWorkers: DEFAULT_MAX_CONCURRENT_WORKERS,
|
|
30
|
+
graphqlRateLimitFloor: DEFAULT_GRAPHQL_RATE_LIMIT_FLOOR,
|
|
24
31
|
};
|
|
25
32
|
|
|
26
33
|
export type StartPreparationFleetSettings = {
|
|
@@ -178,6 +185,24 @@ export const loadPreparationWorkerSettings = (
|
|
|
178
185
|
(value) => Number.isInteger(value) && value >= 1,
|
|
179
186
|
'integer of at least 1',
|
|
180
187
|
),
|
|
188
|
+
maxConcurrentWorkers: readBoundedNumber(
|
|
189
|
+
section,
|
|
190
|
+
PREPARATION_WORKER_SECTION_KEY,
|
|
191
|
+
'maxConcurrentWorkers',
|
|
192
|
+
fleetConfigFilePath,
|
|
193
|
+
DEFAULT_PREPARATION_WORKER_SETTINGS.maxConcurrentWorkers,
|
|
194
|
+
(value) => Number.isInteger(value) && value >= 1,
|
|
195
|
+
'integer of at least 1',
|
|
196
|
+
),
|
|
197
|
+
graphqlRateLimitFloor: readBoundedNumber(
|
|
198
|
+
section,
|
|
199
|
+
PREPARATION_WORKER_SECTION_KEY,
|
|
200
|
+
'graphqlRateLimitFloor',
|
|
201
|
+
fleetConfigFilePath,
|
|
202
|
+
DEFAULT_PREPARATION_WORKER_SETTINGS.graphqlRateLimitFloor,
|
|
203
|
+
(value) => Number.isInteger(value) && value >= 0,
|
|
204
|
+
'non-negative integer',
|
|
205
|
+
),
|
|
181
206
|
};
|
|
182
207
|
};
|
|
183
208
|
|
|
@@ -801,6 +801,8 @@ mysteryKey: 'value'
|
|
|
801
801
|
labelsAsLlmAgentName: null,
|
|
802
802
|
agents: null,
|
|
803
803
|
normalConcurrentLimit: 6,
|
|
804
|
+
maxConcurrentWorkers: 40,
|
|
805
|
+
graphqlRateLimitFloor: 500,
|
|
804
806
|
});
|
|
805
807
|
});
|
|
806
808
|
|
|
@@ -876,6 +878,8 @@ mysteryKey: 'value'
|
|
|
876
878
|
labelsAsLlmAgentName: null,
|
|
877
879
|
agents: null,
|
|
878
880
|
normalConcurrentLimit: 6,
|
|
881
|
+
maxConcurrentWorkers: 40,
|
|
882
|
+
graphqlRateLimitFloor: 500,
|
|
879
883
|
});
|
|
880
884
|
});
|
|
881
885
|
|
|
@@ -34,6 +34,7 @@ import { projectCacheDirectory } from '../../repositories/localStorageCacheDirec
|
|
|
34
34
|
import { NodeLocalCommandRunner } from '../../repositories/NodeLocalCommandRunner';
|
|
35
35
|
import { NodeTmuxSessionRepository } from '../../repositories/NodeTmuxSessionRepository';
|
|
36
36
|
import { ProcTakeOwnershipSpawnRepository } from '../../repositories/ProcTakeOwnershipSpawnRepository';
|
|
37
|
+
import { CliGitHubGraphqlRateLimitRepository } from '../../repositories/CliGitHubGraphqlRateLimitRepository';
|
|
37
38
|
import { DEFAULT_THRESHOLD_FOR_DISPATCH_LOOP } from '../../../domain/usecases/resolveNextStepAgentDispatchRepetition';
|
|
38
39
|
import { ProxyClaudeTokenUsageRepository } from '../../repositories/ProxyClaudeTokenUsageRepository';
|
|
39
40
|
import { SystemDateRepository } from '../../repositories/SystemDateRepository';
|
|
@@ -324,7 +325,7 @@ program
|
|
|
324
325
|
)
|
|
325
326
|
.option(
|
|
326
327
|
'--fleetConfigFilePath <path>',
|
|
327
|
-
'Path to the fleet-wide YAML config file holding the preparationWorker mapping (normalConcurrentLimit); falls back to the TDPM_FLEET_CONFIG environment variable, and to the built-in values when neither is set',
|
|
328
|
+
'Path to the fleet-wide YAML config file holding the preparationWorker mapping (normalConcurrentLimit, maxConcurrentWorkers, graphqlRateLimitFloor); falls back to the TDPM_FLEET_CONFIG environment variable, and to the built-in values when neither is set',
|
|
328
329
|
)
|
|
329
330
|
.action(async (options: StartDaemonOptions) => {
|
|
330
331
|
const token = process.env.GH_TOKEN;
|
|
@@ -423,8 +424,18 @@ program
|
|
|
423
424
|
);
|
|
424
425
|
const preparationWorkerSettings =
|
|
425
426
|
loadPreparationWorkerSettings(fleetConfigFilePath);
|
|
427
|
+
const fleetConfigSource =
|
|
428
|
+
fleetConfigFilePath !== null
|
|
429
|
+
? ' (source: fleetConfig)'
|
|
430
|
+
: ' (source: built-in default)';
|
|
426
431
|
console.log(
|
|
427
|
-
`Effective normalConcurrentLimit: ${preparationWorkerSettings.normalConcurrentLimit}${
|
|
432
|
+
`Effective normalConcurrentLimit: ${preparationWorkerSettings.normalConcurrentLimit}${fleetConfigSource}`,
|
|
433
|
+
);
|
|
434
|
+
console.log(
|
|
435
|
+
`Effective maxConcurrentWorkers: ${preparationWorkerSettings.maxConcurrentWorkers}${fleetConfigSource}`,
|
|
436
|
+
);
|
|
437
|
+
console.log(
|
|
438
|
+
`Effective graphqlRateLimitFloor: ${preparationWorkerSettings.graphqlRateLimitFloor}${fleetConfigSource}`,
|
|
428
439
|
);
|
|
429
440
|
|
|
430
441
|
const projectName = config.projectName ?? 'default';
|
|
@@ -496,6 +507,7 @@ program
|
|
|
496
507
|
localCommandRunner,
|
|
497
508
|
claudeTokenUsageRepository,
|
|
498
509
|
new ProcTakeOwnershipSpawnRepository(),
|
|
510
|
+
new CliGitHubGraphqlRateLimitRepository(localCommandRunner),
|
|
499
511
|
);
|
|
500
512
|
|
|
501
513
|
const rawAllowedIssueAuthors = config.allowedIssueAuthors;
|
|
@@ -545,6 +557,8 @@ program
|
|
|
545
557
|
labelsAsLlmAgentName: config.labelsAsLlmAgentName ?? null,
|
|
546
558
|
agents: config.agents ?? null,
|
|
547
559
|
normalConcurrentLimit: preparationWorkerSettings.normalConcurrentLimit,
|
|
560
|
+
maxConcurrentWorkers: preparationWorkerSettings.maxConcurrentWorkers,
|
|
561
|
+
graphqlRateLimitFloor: preparationWorkerSettings.graphqlRateLimitFloor,
|
|
548
562
|
});
|
|
549
563
|
if (preparationResult.rotationOrder !== null) {
|
|
550
564
|
writeRotationOrderFile(preparationResult.rotationOrder);
|
|
@@ -68,6 +68,7 @@ import { UpdateIssueStatusByLabelUseCase } from '../../../domain/usecases/Update
|
|
|
68
68
|
import { IssueNoStatusUpdateUseCase } from '../../../domain/usecases/IssueNoStatusUpdateUseCase';
|
|
69
69
|
import { StartPreparationUseCase } from '../../../domain/usecases/StartPreparationUseCase';
|
|
70
70
|
import { NodeLocalCommandRunner } from '../../repositories/NodeLocalCommandRunner';
|
|
71
|
+
import { CliGitHubGraphqlRateLimitRepository } from '../../repositories/CliGitHubGraphqlRateLimitRepository';
|
|
71
72
|
import { ProcTakeOwnershipSpawnRepository } from '../../repositories/ProcTakeOwnershipSpawnRepository';
|
|
72
73
|
import { ProxyClaudeTokenUsageRepository } from '../../repositories/ProxyClaudeTokenUsageRepository';
|
|
73
74
|
import { ProxyRateLimitCacheRepository } from '../../repositories/ProxyRateLimitCacheRepository';
|
|
@@ -499,6 +500,7 @@ export class HandleScheduledEventUseCaseHandler {
|
|
|
499
500
|
nodeLocalCommandRunner,
|
|
500
501
|
claudeTokenUsageRepository,
|
|
501
502
|
new ProcTakeOwnershipSpawnRepository(),
|
|
503
|
+
new CliGitHubGraphqlRateLimitRepository(nodeLocalCommandRunner),
|
|
502
504
|
);
|
|
503
505
|
const proxyRateLimitCacheRepository = new ProxyRateLimitCacheRepository(
|
|
504
506
|
mergedInput.claudeCodeOauthTokenListJsonPath ?? null,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
|
|
2
|
+
import { CliGitHubGraphqlRateLimitRepository } from './CliGitHubGraphqlRateLimitRepository';
|
|
3
|
+
|
|
4
|
+
type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
|
|
5
|
+
|
|
6
|
+
describe('CliGitHubGraphqlRateLimitRepository', () => {
|
|
7
|
+
let mockLocalCommandRunner: Mocked<LocalCommandRunner>;
|
|
8
|
+
let repository: CliGitHubGraphqlRateLimitRepository;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
jest.resetAllMocks();
|
|
12
|
+
mockLocalCommandRunner = {
|
|
13
|
+
runCommand: jest.fn(),
|
|
14
|
+
spawnInteractive: jest.fn(),
|
|
15
|
+
};
|
|
16
|
+
repository = new CliGitHubGraphqlRateLimitRepository(
|
|
17
|
+
mockLocalCommandRunner,
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns the remaining GraphQL request count when gh succeeds', async () => {
|
|
22
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
23
|
+
stdout: '4873\n',
|
|
24
|
+
stderr: '',
|
|
25
|
+
exitCode: 0,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const result = await repository.getRemainingRequestCount();
|
|
29
|
+
|
|
30
|
+
expect(result).toBe(4873);
|
|
31
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toEqual([
|
|
32
|
+
['gh', ['api', 'rate_limit', '--jq', '.resources.graphql.remaining']],
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns null when gh exits with a non-zero code', async () => {
|
|
37
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
38
|
+
stdout: '',
|
|
39
|
+
stderr: 'error: authentication required',
|
|
40
|
+
exitCode: 1,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const result = await repository.getRemainingRequestCount();
|
|
44
|
+
|
|
45
|
+
expect(result).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns null when the output is not a valid integer', async () => {
|
|
49
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
50
|
+
stdout: 'not-a-number\n',
|
|
51
|
+
stderr: '',
|
|
52
|
+
exitCode: 0,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const result = await repository.getRemainingRequestCount();
|
|
56
|
+
|
|
57
|
+
expect(result).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('returns zero when the remaining count is zero', async () => {
|
|
61
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
62
|
+
stdout: '0\n',
|
|
63
|
+
stderr: '',
|
|
64
|
+
exitCode: 0,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const result = await repository.getRemainingRequestCount();
|
|
68
|
+
|
|
69
|
+
expect(result).toBe(0);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { GitHubGraphqlRateLimitRepository } from '../../domain/usecases/adapter-interfaces/GitHubGraphqlRateLimitRepository';
|
|
2
|
+
import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
|
|
3
|
+
|
|
4
|
+
export class CliGitHubGraphqlRateLimitRepository implements GitHubGraphqlRateLimitRepository {
|
|
5
|
+
constructor(private readonly localCommandRunner: LocalCommandRunner) {}
|
|
6
|
+
|
|
7
|
+
getRemainingRequestCount = async (): Promise<number | null> => {
|
|
8
|
+
const result = await this.localCommandRunner.runCommand('gh', [
|
|
9
|
+
'api',
|
|
10
|
+
'rate_limit',
|
|
11
|
+
'--jq',
|
|
12
|
+
'.resources.graphql.remaining',
|
|
13
|
+
]);
|
|
14
|
+
if (result.exitCode !== 0) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const trimmed = result.stdout.trim();
|
|
18
|
+
const parsed = Number(trimmed);
|
|
19
|
+
return trimmed !== '' && Number.isInteger(parsed) ? parsed : null;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -1,8 +1,56 @@
|
|
|
1
1
|
import dotenv from 'dotenv';
|
|
2
|
-
import { KySlackRepository } from './KySlackRepository';
|
|
3
2
|
import fs from 'fs';
|
|
4
3
|
import https from 'https';
|
|
5
4
|
import path from 'path';
|
|
5
|
+
import { KySlackRepository } from './KySlackRepository';
|
|
6
|
+
|
|
7
|
+
describe('KySlackRepository', () => {
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
jest.useRealTimers();
|
|
10
|
+
jest.restoreAllMocks();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('postMessageToDirectMessage', () => {
|
|
14
|
+
it('does not hang on 429 Retry-After responses from Slack', async () => {
|
|
15
|
+
jest.useFakeTimers();
|
|
16
|
+
|
|
17
|
+
jest.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
18
|
+
new Response('{"ok":false}', {
|
|
19
|
+
status: 429,
|
|
20
|
+
headers: { 'Retry-After': '30' },
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const slackRepository = new KySlackRepository('xoxp-test-token-unit');
|
|
25
|
+
|
|
26
|
+
// Attach .catch() immediately to prevent unhandled rejection when the
|
|
27
|
+
// promise rejects during jest.advanceTimersByTimeAsync below.
|
|
28
|
+
const settledValue: { value: unknown } = { value: 'not-yet-settled' };
|
|
29
|
+
slackRepository
|
|
30
|
+
.postMessageToDirectMessage('test message', 'non-existent-user')
|
|
31
|
+
.then(() => {
|
|
32
|
+
settledValue.value = 'resolved';
|
|
33
|
+
})
|
|
34
|
+
.catch((e: unknown) => {
|
|
35
|
+
settledValue.value = e;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Advance 60 seconds of fake time.
|
|
39
|
+
// With maxRetryAfter bounded (e.g. 10s), all 3 retry delays (3×10s=30s) fire
|
|
40
|
+
// within this window, exhausting retries and settling the promise.
|
|
41
|
+
// With the default maxRetryAfter=INFINITY, only 2 of 3 delays fire (2×30s=60s),
|
|
42
|
+
// leaving the promise pending — causing CI to hang for the full Jest timeout.
|
|
43
|
+
await jest.advanceTimersByTimeAsync(60000);
|
|
44
|
+
jest.clearAllTimers();
|
|
45
|
+
jest.useRealTimers();
|
|
46
|
+
|
|
47
|
+
// Drain microtasks so the .then()/.catch() callbacks above have run.
|
|
48
|
+
await Promise.resolve();
|
|
49
|
+
|
|
50
|
+
expect(settledValue.value).not.toBe('not-yet-settled');
|
|
51
|
+
}, 5000);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
6
54
|
|
|
7
55
|
dotenv.config();
|
|
8
56
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import ky from 'ky';
|
|
2
1
|
import fs from 'fs';
|
|
3
|
-
import
|
|
2
|
+
import ky from 'ky';
|
|
3
|
+
import type { SlackRepository } from '../../domain/usecases/adapter-interfaces/SlackRepository';
|
|
4
4
|
|
|
5
5
|
type ConversationsListResponse = {
|
|
6
6
|
ok: boolean;
|
|
@@ -87,6 +87,7 @@ export class KySlackRepository implements SlackRepository {
|
|
|
87
87
|
limit: 3,
|
|
88
88
|
methods: ['get', 'post'],
|
|
89
89
|
statusCodes: [429, 500, 502, 503, 504],
|
|
90
|
+
maxRetryAfter: 10000,
|
|
90
91
|
},
|
|
91
92
|
});
|
|
92
93
|
}
|
|
@@ -2786,6 +2786,30 @@ export class ApiV3CheerioRestIssueRepository
|
|
|
2786
2786
|
return `${status} ${reason}`;
|
|
2787
2787
|
};
|
|
2788
2788
|
|
|
2789
|
+
updateBranch = async (prUrl: string): Promise<boolean> => {
|
|
2790
|
+
const { owner, repo, issueNumber: prNumber } = this.parseIssueUrl(prUrl);
|
|
2791
|
+
const response = await this.fetchWithRateLimitRetry(() =>
|
|
2792
|
+
fetch(
|
|
2793
|
+
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${prNumber}/update-branch`,
|
|
2794
|
+
{
|
|
2795
|
+
method: 'PUT',
|
|
2796
|
+
headers: {
|
|
2797
|
+
Authorization: `Bearer ${this.ghToken}`,
|
|
2798
|
+
Accept: 'application/vnd.github+json',
|
|
2799
|
+
},
|
|
2800
|
+
},
|
|
2801
|
+
),
|
|
2802
|
+
);
|
|
2803
|
+
if (response.ok) {
|
|
2804
|
+
return true;
|
|
2805
|
+
}
|
|
2806
|
+
if (response.status === 422) {
|
|
2807
|
+
return false;
|
|
2808
|
+
}
|
|
2809
|
+
const reason = await this.formatGitHubErrorWithStatus(response);
|
|
2810
|
+
throw new Error(`Failed to update branch for PR ${prUrl}: ${reason}`);
|
|
2811
|
+
};
|
|
2812
|
+
|
|
2789
2813
|
deletePullRequestBranch = async (
|
|
2790
2814
|
prUrl: string,
|
|
2791
2815
|
branchName: string,
|