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.
Files changed (57) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +9 -1
  3. package/bin/adapter/entry-points/cli/fleetConfig.js +7 -1
  4. package/bin/adapter/entry-points/cli/fleetConfig.js.map +1 -1
  5. package/bin/adapter/entry-points/cli/index.js +11 -3
  6. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +2 -1
  8. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  9. package/bin/adapter/repositories/CliGitHubGraphqlRateLimitRepository.js +24 -0
  10. package/bin/adapter/repositories/CliGitHubGraphqlRateLimitRepository.js.map +1 -0
  11. package/bin/adapter/repositories/KySlackRepository.js +2 -1
  12. package/bin/adapter/repositories/KySlackRepository.js.map +1 -1
  13. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +18 -0
  14. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  15. package/bin/domain/usecases/ConflictedIssueRevertUseCase.js +6 -3
  16. package/bin/domain/usecases/ConflictedIssueRevertUseCase.js.map +1 -1
  17. package/bin/domain/usecases/StartPreparationUseCase.js +22 -1
  18. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  19. package/bin/domain/usecases/adapter-interfaces/GitHubGraphqlRateLimitRepository.js +3 -0
  20. package/bin/domain/usecases/adapter-interfaces/GitHubGraphqlRateLimitRepository.js.map +1 -0
  21. package/package.json +1 -1
  22. package/src/adapter/entry-points/cli/fleetConfig.test.ts +99 -0
  23. package/src/adapter/entry-points/cli/fleetConfig.ts +25 -0
  24. package/src/adapter/entry-points/cli/index.test.ts +4 -0
  25. package/src/adapter/entry-points/cli/index.ts +16 -2
  26. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +1 -0
  27. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +2 -0
  28. package/src/adapter/repositories/CliGitHubGraphqlRateLimitRepository.test.ts +71 -0
  29. package/src/adapter/repositories/CliGitHubGraphqlRateLimitRepository.ts +21 -0
  30. package/src/adapter/repositories/KySlackRepository.test.ts +49 -1
  31. package/src/adapter/repositories/KySlackRepository.ts +3 -2
  32. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +24 -0
  33. package/src/domain/usecases/ConflictedIssueRevertUseCase.test.ts +262 -5
  34. package/src/domain/usecases/ConflictedIssueRevertUseCase.ts +12 -4
  35. package/src/domain/usecases/StartPreparationUseCase.test.ts +229 -0
  36. package/src/domain/usecases/StartPreparationUseCase.ts +35 -0
  37. package/src/domain/usecases/adapter-interfaces/GitHubGraphqlRateLimitRepository.ts +3 -0
  38. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +1 -0
  39. package/src/domain/usecases/issueReactivationTriggerIsPending.test.ts +1 -0
  40. package/types/adapter/entry-points/cli/fleetConfig.d.ts +4 -0
  41. package/types/adapter/entry-points/cli/fleetConfig.d.ts.map +1 -1
  42. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  43. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  44. package/types/adapter/repositories/CliGitHubGraphqlRateLimitRepository.d.ts +8 -0
  45. package/types/adapter/repositories/CliGitHubGraphqlRateLimitRepository.d.ts.map +1 -0
  46. package/types/adapter/repositories/KySlackRepository.d.ts +1 -1
  47. package/types/adapter/repositories/KySlackRepository.d.ts.map +1 -1
  48. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +1 -0
  49. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  50. package/types/domain/usecases/ConflictedIssueRevertUseCase.d.ts +1 -1
  51. package/types/domain/usecases/ConflictedIssueRevertUseCase.d.ts.map +1 -1
  52. package/types/domain/usecases/StartPreparationUseCase.d.ts +5 -1
  53. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  54. package/types/domain/usecases/adapter-interfaces/GitHubGraphqlRateLimitRepository.d.ts +4 -0
  55. package/types/domain/usecases/adapter-interfaces/GitHubGraphqlRateLimitRepository.d.ts.map +1 -0
  56. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +1 -0
  57. 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}${fleetConfigFilePath !== null ? ' (source: fleetConfig)' : ' (source: built-in default)'}`,
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);
@@ -625,6 +625,7 @@ const createStubIssueRepository = (
625
625
  }
626
626
  : null,
627
627
  setIssueAgentField: async (): Promise<void> => undefined,
628
+ updateBranch: async (): Promise<boolean> => false,
628
629
  updateStoryOptionColor: async (
629
630
  _project,
630
631
  storyOptionId: string,
@@ -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 { SlackRepository } from '../../domain/usecases/adapter-interfaces/SlackRepository';
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,