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
@@ -97,6 +97,7 @@ describe('ConflictedIssueRevertUseCase', () => {
97
97
  getAllIssues: jest.Mock;
98
98
  getOpenPullRequests: jest.Mock;
99
99
  updateStatus: jest.Mock;
100
+ updateBranch: jest.Mock;
100
101
  };
101
102
  let mockIssueCommentRepository: {
102
103
  createComment: jest.Mock;
@@ -122,6 +123,7 @@ describe('ConflictedIssueRevertUseCase', () => {
122
123
  }),
123
124
  getOpenPullRequests: jest.fn().mockResolvedValue(new Map()),
124
125
  updateStatus: jest.fn().mockResolvedValue(undefined),
126
+ updateBranch: jest.fn().mockResolvedValue(false),
125
127
  };
126
128
 
127
129
  mockIssueCommentRepository = {
@@ -144,7 +146,6 @@ describe('ConflictedIssueRevertUseCase', () => {
144
146
  });
145
147
 
146
148
  const excludedStatuses = [
147
- 'Awaiting Workspace',
148
149
  'Done',
149
150
  'Icebox',
150
151
  'Failed Preparation',
@@ -170,6 +171,73 @@ describe('ConflictedIssueRevertUseCase', () => {
170
171
  });
171
172
  });
172
173
 
174
+ it('should process Awaiting Workspace issues when their linked PR is conflicted and update-branch fails', async () => {
175
+ const issue = createMockIssue({ status: 'Awaiting Workspace' });
176
+ const prItem = createMockPrItem({
177
+ url: 'https://github.com/user/repo/pull/1',
178
+ closingIssueReferenceUrls: [issue.url],
179
+ });
180
+ mockIssueRepository.getAllIssues.mockResolvedValue({
181
+ project: mockProject,
182
+ issues: [issue, prItem],
183
+ cacheUsed: false,
184
+ });
185
+ const conflictedPr = createMockRelatedPullRequest({
186
+ url: prItem.url,
187
+ isConflicted: true,
188
+ mergeable: 'CONFLICTING',
189
+ });
190
+ mockIssueRepository.getOpenPullRequests.mockResolvedValue(
191
+ new Map([[conflictedPr.url, conflictedPr]]),
192
+ );
193
+ mockIssueRepository.updateBranch.mockResolvedValue(false);
194
+
195
+ await useCase.run({ projectUrl });
196
+
197
+ expect(mockIssueRepository.updateBranch).toHaveBeenCalledWith(
198
+ conflictedPr.url,
199
+ );
200
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
201
+ mockProject,
202
+ issue,
203
+ 'awaiting-workspace-id',
204
+ );
205
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
206
+ issue,
207
+ 'conflict',
208
+ );
209
+ });
210
+
211
+ it('should not update status or post comment when Awaiting Workspace PR update-branch succeeds', async () => {
212
+ const issue = createMockIssue({ status: 'Awaiting Workspace' });
213
+ const prItem = createMockPrItem({
214
+ url: 'https://github.com/user/repo/pull/1',
215
+ closingIssueReferenceUrls: [issue.url],
216
+ });
217
+ mockIssueRepository.getAllIssues.mockResolvedValue({
218
+ project: mockProject,
219
+ issues: [issue, prItem],
220
+ cacheUsed: false,
221
+ });
222
+ const conflictedPr = createMockRelatedPullRequest({
223
+ url: prItem.url,
224
+ isConflicted: true,
225
+ mergeable: 'CONFLICTING',
226
+ });
227
+ mockIssueRepository.getOpenPullRequests.mockResolvedValue(
228
+ new Map([[conflictedPr.url, conflictedPr]]),
229
+ );
230
+ mockIssueRepository.updateBranch.mockResolvedValue(true);
231
+
232
+ await useCase.run({ projectUrl });
233
+
234
+ expect(mockIssueRepository.updateBranch).toHaveBeenCalledWith(
235
+ conflictedPr.url,
236
+ );
237
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
238
+ expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
239
+ });
240
+
173
241
  it('should skip PR items when scanning for target task issues', async () => {
174
242
  const prIssue = createMockIssue({
175
243
  url: 'https://github.com/user/repo/issues/1',
@@ -195,6 +263,7 @@ describe('ConflictedIssueRevertUseCase', () => {
195
263
  mockIssueRepository.getOpenPullRequests.mockResolvedValue(
196
264
  new Map([[conflictedPr.url, conflictedPr]]),
197
265
  );
266
+ mockIssueRepository.updateBranch.mockResolvedValue(false);
198
267
 
199
268
  await useCase.run({ projectUrl });
200
269
 
@@ -255,7 +324,85 @@ describe('ConflictedIssueRevertUseCase', () => {
255
324
  expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
256
325
  });
257
326
 
258
- it('should update status and post conflict comment when linked PR is conflicted', async () => {
327
+ it('should update status and post conflict comment when linked PR is conflicted and update-branch fails', async () => {
328
+ const issue = createMockIssue({
329
+ url: 'https://github.com/user/repo/issues/1',
330
+ status: 'Preparation',
331
+ });
332
+ const prItem = createMockPrItem({
333
+ url: 'https://github.com/user/repo/pull/1',
334
+ closingIssueReferenceUrls: [issue.url],
335
+ });
336
+ mockIssueRepository.getAllIssues.mockResolvedValue({
337
+ project: mockProject,
338
+ issues: [issue, prItem],
339
+ cacheUsed: false,
340
+ });
341
+ const conflictedPr = createMockRelatedPullRequest({
342
+ url: prItem.url,
343
+ isConflicted: true,
344
+ mergeable: 'CONFLICTING',
345
+ });
346
+ mockIssueRepository.getOpenPullRequests.mockResolvedValue(
347
+ new Map([[conflictedPr.url, conflictedPr]]),
348
+ );
349
+ mockIssueRepository.updateBranch.mockResolvedValue(false);
350
+
351
+ await useCase.run({ projectUrl });
352
+
353
+ expect(mockIssueRepository.updateBranch).toHaveBeenCalledWith(
354
+ conflictedPr.url,
355
+ );
356
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
357
+ mockProject,
358
+ issue,
359
+ 'awaiting-workspace-id',
360
+ );
361
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
362
+ issue,
363
+ 'conflict',
364
+ );
365
+ });
366
+
367
+ it('should propagate errors thrown by updateBranch', async () => {
368
+ const issue = createMockIssue({
369
+ url: 'https://github.com/user/repo/issues/1',
370
+ status: 'Preparation',
371
+ });
372
+ const prItem = createMockPrItem({
373
+ url: 'https://github.com/user/repo/pull/1',
374
+ closingIssueReferenceUrls: [issue.url],
375
+ });
376
+ mockIssueRepository.getAllIssues.mockResolvedValue({
377
+ project: mockProject,
378
+ issues: [issue, prItem],
379
+ cacheUsed: false,
380
+ });
381
+ const conflictedPr = createMockRelatedPullRequest({
382
+ url: prItem.url,
383
+ isConflicted: true,
384
+ mergeable: 'CONFLICTING',
385
+ });
386
+ mockIssueRepository.getOpenPullRequests.mockResolvedValue(
387
+ new Map([[conflictedPr.url, conflictedPr]]),
388
+ );
389
+ mockIssueRepository.updateBranch.mockRejectedValue(
390
+ new Error(
391
+ 'Failed to update branch for PR https://github.com/user/repo/pull/1: 500 Internal Server Error',
392
+ ),
393
+ );
394
+
395
+ await expect(useCase.run({ projectUrl })).rejects.toThrow(
396
+ 'Failed to update branch for PR',
397
+ );
398
+ expect(mockIssueRepository.updateBranch).toHaveBeenCalledWith(
399
+ conflictedPr.url,
400
+ );
401
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
402
+ expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
403
+ });
404
+
405
+ it('should not update status or post comment when update-branch succeeds for the conflicted PR', async () => {
259
406
  const issue = createMockIssue({
260
407
  url: 'https://github.com/user/repo/issues/1',
261
408
  status: 'Preparation',
@@ -277,6 +424,105 @@ describe('ConflictedIssueRevertUseCase', () => {
277
424
  mockIssueRepository.getOpenPullRequests.mockResolvedValue(
278
425
  new Map([[conflictedPr.url, conflictedPr]]),
279
426
  );
427
+ mockIssueRepository.updateBranch.mockResolvedValue(true);
428
+
429
+ await useCase.run({ projectUrl });
430
+
431
+ expect(mockIssueRepository.updateBranch).toHaveBeenCalledWith(
432
+ conflictedPr.url,
433
+ );
434
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
435
+ expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
436
+ });
437
+
438
+ it('should call update-branch only for conflicted PRs, not for all linked PRs', async () => {
439
+ const issue = createMockIssue({
440
+ url: 'https://github.com/user/repo/issues/1',
441
+ status: 'Preparation',
442
+ });
443
+ const prItem1 = createMockPrItem({
444
+ url: 'https://github.com/user/repo/pull/1',
445
+ number: 1,
446
+ closingIssueReferenceUrls: [issue.url],
447
+ });
448
+ const prItem2 = createMockPrItem({
449
+ url: 'https://github.com/user/repo/pull/2',
450
+ number: 2,
451
+ closingIssueReferenceUrls: [issue.url],
452
+ });
453
+ mockIssueRepository.getAllIssues.mockResolvedValue({
454
+ project: mockProject,
455
+ issues: [issue, prItem1, prItem2],
456
+ cacheUsed: false,
457
+ });
458
+ const conflictedPr = createMockRelatedPullRequest({
459
+ url: prItem1.url,
460
+ isConflicted: true,
461
+ mergeable: 'CONFLICTING',
462
+ });
463
+ const mergeablePr = createMockRelatedPullRequest({
464
+ url: prItem2.url,
465
+ isConflicted: false,
466
+ mergeable: 'MERGEABLE',
467
+ });
468
+ mockIssueRepository.getOpenPullRequests.mockResolvedValue(
469
+ new Map([
470
+ [conflictedPr.url, conflictedPr],
471
+ [mergeablePr.url, mergeablePr],
472
+ ]),
473
+ );
474
+ mockIssueRepository.updateBranch.mockResolvedValue(true);
475
+
476
+ await useCase.run({ projectUrl });
477
+
478
+ expect(mockIssueRepository.updateBranch).toHaveBeenCalledTimes(1);
479
+ expect(mockIssueRepository.updateBranch).toHaveBeenCalledWith(
480
+ conflictedPr.url,
481
+ );
482
+ expect(mockIssueRepository.updateBranch).not.toHaveBeenCalledWith(
483
+ mergeablePr.url,
484
+ );
485
+ });
486
+
487
+ it('should fall back to status update when any conflicted PR update-branch fails', async () => {
488
+ const issue = createMockIssue({
489
+ url: 'https://github.com/user/repo/issues/1',
490
+ status: 'Preparation',
491
+ });
492
+ const prItem1 = createMockPrItem({
493
+ url: 'https://github.com/user/repo/pull/1',
494
+ number: 1,
495
+ closingIssueReferenceUrls: [issue.url],
496
+ });
497
+ const prItem2 = createMockPrItem({
498
+ url: 'https://github.com/user/repo/pull/2',
499
+ number: 2,
500
+ closingIssueReferenceUrls: [issue.url],
501
+ });
502
+ mockIssueRepository.getAllIssues.mockResolvedValue({
503
+ project: mockProject,
504
+ issues: [issue, prItem1, prItem2],
505
+ cacheUsed: false,
506
+ });
507
+ const conflictedPr1 = createMockRelatedPullRequest({
508
+ url: prItem1.url,
509
+ isConflicted: true,
510
+ mergeable: 'CONFLICTING',
511
+ });
512
+ const conflictedPr2 = createMockRelatedPullRequest({
513
+ url: prItem2.url,
514
+ isConflicted: true,
515
+ mergeable: 'CONFLICTING',
516
+ });
517
+ mockIssueRepository.getOpenPullRequests.mockResolvedValue(
518
+ new Map([
519
+ [conflictedPr1.url, conflictedPr1],
520
+ [conflictedPr2.url, conflictedPr2],
521
+ ]),
522
+ );
523
+ mockIssueRepository.updateBranch
524
+ .mockResolvedValueOnce(true)
525
+ .mockResolvedValueOnce(false);
280
526
 
281
527
  await useCase.run({ projectUrl });
282
528
 
@@ -313,6 +559,7 @@ describe('ConflictedIssueRevertUseCase', () => {
313
559
  mockIssueRepository.getOpenPullRequests.mockResolvedValue(
314
560
  new Map([[conflictedPr.url, conflictedPr]]),
315
561
  );
562
+ mockIssueRepository.updateBranch.mockResolvedValue(false);
316
563
 
317
564
  await useCase.run({ projectUrl });
318
565
 
@@ -430,6 +677,7 @@ describe('ConflictedIssueRevertUseCase', () => {
430
677
  [mergeablePr.url, mergeablePr],
431
678
  ]),
432
679
  );
680
+ mockIssueRepository.updateBranch.mockResolvedValue(false);
433
681
 
434
682
  await useCase.run({ projectUrl });
435
683
 
@@ -491,7 +739,7 @@ describe('ConflictedIssueRevertUseCase', () => {
491
739
  expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
492
740
  });
493
741
 
494
- it('should perform status update before comment posting', async () => {
742
+ it('should call update-branch before status update and comment when update-branch fails', async () => {
495
743
  const callOrder: string[] = [];
496
744
  const issue = createMockIssue({
497
745
  url: 'https://github.com/user/repo/issues/1',
@@ -514,6 +762,10 @@ describe('ConflictedIssueRevertUseCase', () => {
514
762
  mockIssueRepository.getOpenPullRequests.mockResolvedValue(
515
763
  new Map([[conflictedPr.url, conflictedPr]]),
516
764
  );
765
+ mockIssueRepository.updateBranch.mockImplementation(() => {
766
+ callOrder.push('updateBranch');
767
+ return Promise.resolve(false);
768
+ });
517
769
  mockIssueRepository.updateStatus.mockImplementation(() => {
518
770
  callOrder.push('updateStatus');
519
771
  return Promise.resolve();
@@ -525,7 +777,11 @@ describe('ConflictedIssueRevertUseCase', () => {
525
777
 
526
778
  await useCase.run({ projectUrl });
527
779
 
528
- expect(callOrder).toEqual(['updateStatus', 'createComment']);
780
+ expect(callOrder).toEqual([
781
+ 'updateBranch',
782
+ 'updateStatus',
783
+ 'createComment',
784
+ ]);
529
785
  });
530
786
 
531
787
  it('should fetch all PR URLs in a single batched call', async () => {
@@ -567,7 +823,7 @@ describe('ConflictedIssueRevertUseCase', () => {
567
823
  );
568
824
  });
569
825
 
570
- it('should process multiple conflicted issues in one run', async () => {
826
+ it('should process multiple conflicted issues in one run when update-branch fails for both', async () => {
571
827
  const issue1 = createMockIssue({
572
828
  url: 'https://github.com/user/repo/issues/1',
573
829
  number: 1,
@@ -609,6 +865,7 @@ describe('ConflictedIssueRevertUseCase', () => {
609
865
  [conflictedPr2.url, conflictedPr2],
610
866
  ]),
611
867
  );
868
+ mockIssueRepository.updateBranch.mockResolvedValue(false);
612
869
 
613
870
  await useCase.run({ projectUrl });
614
871
 
@@ -11,7 +11,6 @@ import {
11
11
  } from '../entities/WorkflowStatus';
12
12
 
13
13
  const EXCLUDED_STATUSES = new Set([
14
- AWAITING_WORKSPACE_STATUS_NAME,
15
14
  DONE_STATUS_NAME,
16
15
  ICEBOX_STATUS_NAME,
17
16
  FAILED_PREPARATION_STATUS_NAME,
@@ -26,7 +25,7 @@ export class ConflictedIssueRevertUseCase {
26
25
  >,
27
26
  private readonly issueRepository: Pick<
28
27
  IssueRepository,
29
- 'getAllIssues' | 'getOpenPullRequests' | 'updateStatus'
28
+ 'getAllIssues' | 'getOpenPullRequests' | 'updateStatus' | 'updateBranch'
30
29
  >,
31
30
  private readonly issueCommentRepository: Pick<
32
31
  IssueCommentRepository,
@@ -98,8 +97,17 @@ export class ConflictedIssueRevertUseCase {
98
97
  continue;
99
98
  }
100
99
 
101
- const hasConflict = relatedPrs.some((pr) => pr.isConflicted);
102
- if (!hasConflict) {
100
+ const conflictedPrs = relatedPrs.filter((pr) => pr.isConflicted);
101
+ if (conflictedPrs.length === 0) {
102
+ continue;
103
+ }
104
+
105
+ const allBranchesUpdated = (
106
+ await Promise.all(
107
+ conflictedPrs.map((pr) => this.issueRepository.updateBranch(pr.url)),
108
+ )
109
+ ).every(Boolean);
110
+ if (allBranchesUpdated) {
103
111
  continue;
104
112
  }
105
113
 
@@ -10,6 +10,7 @@ import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
10
10
  import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
11
11
  import { ClaudeTokenUsageRepository } from './adapter-interfaces/ClaudeTokenUsageRepository';
12
12
  import { TakeOwnershipSpawnRepository } from './adapter-interfaces/TakeOwnershipSpawnRepository';
13
+ import { GitHubGraphqlRateLimitRepository } from './adapter-interfaces/GitHubGraphqlRateLimitRepository';
13
14
  import { ClaudeTokenUsage } from '../entities/ClaudeTokenUsage';
14
15
  import { Issue } from '../entities/Issue';
15
16
  import { FieldOption, Project } from '../entities/Project';
@@ -109,6 +110,7 @@ describe('StartPreparationUseCase', () => {
109
110
  let mockLocalCommandRunner: Mocked<LocalCommandRunner>;
110
111
  let mockClaudeTokenUsageRepository: Mocked<ClaudeTokenUsageRepository>;
111
112
  let mockTakeOwnershipSpawnRepository: Mocked<TakeOwnershipSpawnRepository>;
113
+ let mockGitHubGraphqlRateLimitRepository: Mocked<GitHubGraphqlRateLimitRepository>;
112
114
  let mockProject: Project;
113
115
  beforeEach(() => {
114
116
  jest.resetAllMocks();
@@ -144,12 +146,16 @@ describe('StartPreparationUseCase', () => {
144
146
  listSpawns: jest.fn().mockReturnValue([]),
145
147
  listRunningIssueUrls: jest.fn().mockReturnValue([]),
146
148
  };
149
+ mockGitHubGraphqlRateLimitRepository = {
150
+ getRemainingRequestCount: jest.fn().mockResolvedValue(null),
151
+ };
147
152
  useCase = new StartPreparationUseCase(
148
153
  mockProjectRepository,
149
154
  mockIssueRepository,
150
155
  mockLocalCommandRunner,
151
156
  mockClaudeTokenUsageRepository,
152
157
  mockTakeOwnershipSpawnRepository,
158
+ mockGitHubGraphqlRateLimitRepository,
153
159
  );
154
160
  });
155
161
  it('should run aw command for awaiting workspace issues', async () => {
@@ -627,6 +633,225 @@ describe('StartPreparationUseCase', () => {
627
633
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
628
634
  });
629
635
 
636
+ it('skips all spawning when active worker count meets the maxConcurrentWorkers cap', async () => {
637
+ const awaitingIssues: Issue[] = [
638
+ createMockIssue({
639
+ url: 'https://github.com/user/repo/issues/10',
640
+ status: 'Awaiting Workspace',
641
+ author: 'testuser',
642
+ }),
643
+ createMockIssue({
644
+ number: 2,
645
+ url: 'https://github.com/user/repo/issues/11',
646
+ status: 'Awaiting Workspace',
647
+ author: 'testuser',
648
+ }),
649
+ ];
650
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
651
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
652
+ createMockStoryObjectMap(awaitingIssues),
653
+ );
654
+ mockTakeOwnershipSpawnRepository.listRunningIssueUrls.mockReturnValue([
655
+ 'https://github.com/user/repo/issues/1',
656
+ 'https://github.com/user/repo/issues/2',
657
+ ]);
658
+
659
+ await useCase.run({
660
+ projectUrl: 'https://github.com/user/repo',
661
+ defaultAgentName: 'agent1',
662
+ defaultLlmModelName: 'claude-opus',
663
+ fallbackLlmModelName: null,
664
+ defaultLlmAgentName: null,
665
+ configFilePath: '/path/to/config.yml',
666
+ maximumPreparingIssuesCount: null,
667
+ utilizationPercentageThreshold: 90,
668
+ allowedIssueAuthors: ['testuser'],
669
+ manager: 'manager-user',
670
+ codexHomeCandidates: null,
671
+ labelsAsLlmAgentName: null,
672
+ maxConcurrentWorkers: 2,
673
+ });
674
+
675
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
676
+ expect(
677
+ mockLocalCommandRunner.runCommand.mock.calls.map((c) => c[0]),
678
+ ).not.toContain('aw');
679
+ });
680
+
681
+ it('stops spawning mid-loop when workers started in the run bring the count to the cap', async () => {
682
+ const awaitingIssues: Issue[] = [
683
+ createMockIssue({
684
+ url: 'https://github.com/user/repo/issues/10',
685
+ status: 'Awaiting Workspace',
686
+ author: 'testuser',
687
+ }),
688
+ createMockIssue({
689
+ number: 2,
690
+ url: 'https://github.com/user/repo/issues/11',
691
+ status: 'Awaiting Workspace',
692
+ author: 'testuser',
693
+ }),
694
+ ];
695
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
696
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
697
+ createMockStoryObjectMap(awaitingIssues),
698
+ );
699
+ mockIssueRepository.getAllOpened.mockResolvedValue([]);
700
+ mockTakeOwnershipSpawnRepository.listRunningIssueUrls.mockReturnValue([]);
701
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
702
+ stdout: '',
703
+ stderr: '',
704
+ exitCode: 0,
705
+ });
706
+
707
+ await useCase.run({
708
+ projectUrl: 'https://github.com/user/repo',
709
+ defaultAgentName: 'agent1',
710
+ defaultLlmModelName: 'claude-opus',
711
+ fallbackLlmModelName: null,
712
+ defaultLlmAgentName: null,
713
+ configFilePath: '/path/to/config.yml',
714
+ maximumPreparingIssuesCount: null,
715
+ utilizationPercentageThreshold: 90,
716
+ allowedIssueAuthors: ['testuser'],
717
+ manager: 'manager-user',
718
+ codexHomeCandidates: null,
719
+ labelsAsLlmAgentName: null,
720
+ maxConcurrentWorkers: 1,
721
+ });
722
+
723
+ const awCalls = mockLocalCommandRunner.runCommand.mock.calls.filter(
724
+ (call) => call[0] === 'aw',
725
+ );
726
+ expect(awCalls).toHaveLength(1);
727
+ });
728
+
729
+ it('skips the preparation cycle when GraphQL rate limit is below the floor', async () => {
730
+ const awaitingIssues: Issue[] = [
731
+ createMockIssue({
732
+ url: 'https://github.com/user/repo/issues/10',
733
+ status: 'Awaiting Workspace',
734
+ author: 'testuser',
735
+ }),
736
+ ];
737
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
738
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
739
+ createMockStoryObjectMap(awaitingIssues),
740
+ );
741
+ mockGitHubGraphqlRateLimitRepository.getRemainingRequestCount.mockResolvedValue(
742
+ 499,
743
+ );
744
+
745
+ await useCase.run({
746
+ projectUrl: 'https://github.com/user/repo',
747
+ defaultAgentName: 'agent1',
748
+ defaultLlmModelName: 'claude-opus',
749
+ fallbackLlmModelName: null,
750
+ defaultLlmAgentName: null,
751
+ configFilePath: '/path/to/config.yml',
752
+ maximumPreparingIssuesCount: null,
753
+ utilizationPercentageThreshold: 90,
754
+ allowedIssueAuthors: ['testuser'],
755
+ manager: 'manager-user',
756
+ codexHomeCandidates: null,
757
+ labelsAsLlmAgentName: null,
758
+ graphqlRateLimitFloor: 500,
759
+ });
760
+
761
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
762
+ expect(
763
+ mockLocalCommandRunner.runCommand.mock.calls.map((c) => c[0]),
764
+ ).not.toContain('aw');
765
+ });
766
+
767
+ it('proceeds with spawning when GraphQL remaining equals the floor exactly', async () => {
768
+ const awaitingIssues: Issue[] = [
769
+ createMockIssue({
770
+ url: 'https://github.com/user/repo/issues/10',
771
+ status: 'Awaiting Workspace',
772
+ author: 'testuser',
773
+ }),
774
+ ];
775
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
776
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
777
+ createMockStoryObjectMap(awaitingIssues),
778
+ );
779
+ mockIssueRepository.getAllOpened.mockResolvedValue([]);
780
+ mockGitHubGraphqlRateLimitRepository.getRemainingRequestCount.mockResolvedValue(
781
+ 500,
782
+ );
783
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
784
+ stdout: '',
785
+ stderr: '',
786
+ exitCode: 0,
787
+ });
788
+
789
+ await useCase.run({
790
+ projectUrl: 'https://github.com/user/repo',
791
+ defaultAgentName: 'agent1',
792
+ defaultLlmModelName: 'claude-opus',
793
+ fallbackLlmModelName: null,
794
+ defaultLlmAgentName: null,
795
+ configFilePath: '/path/to/config.yml',
796
+ maximumPreparingIssuesCount: null,
797
+ utilizationPercentageThreshold: 90,
798
+ allowedIssueAuthors: ['testuser'],
799
+ manager: 'manager-user',
800
+ codexHomeCandidates: null,
801
+ labelsAsLlmAgentName: null,
802
+ graphqlRateLimitFloor: 500,
803
+ });
804
+
805
+ const awCalls = mockLocalCommandRunner.runCommand.mock.calls.filter(
806
+ (call) => call[0] === 'aw',
807
+ );
808
+ expect(awCalls).toHaveLength(1);
809
+ });
810
+
811
+ it('proceeds with spawning when GraphQL rate limit check returns null', async () => {
812
+ const awaitingIssues: Issue[] = [
813
+ createMockIssue({
814
+ url: 'https://github.com/user/repo/issues/10',
815
+ status: 'Awaiting Workspace',
816
+ author: 'testuser',
817
+ }),
818
+ ];
819
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
820
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
821
+ createMockStoryObjectMap(awaitingIssues),
822
+ );
823
+ mockIssueRepository.getAllOpened.mockResolvedValue([]);
824
+ mockGitHubGraphqlRateLimitRepository.getRemainingRequestCount.mockResolvedValue(
825
+ null,
826
+ );
827
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
828
+ stdout: '',
829
+ stderr: '',
830
+ exitCode: 0,
831
+ });
832
+
833
+ await useCase.run({
834
+ projectUrl: 'https://github.com/user/repo',
835
+ defaultAgentName: 'agent1',
836
+ defaultLlmModelName: 'claude-opus',
837
+ fallbackLlmModelName: null,
838
+ defaultLlmAgentName: null,
839
+ configFilePath: '/path/to/config.yml',
840
+ maximumPreparingIssuesCount: null,
841
+ utilizationPercentageThreshold: 90,
842
+ allowedIssueAuthors: ['testuser'],
843
+ manager: 'manager-user',
844
+ codexHomeCandidates: null,
845
+ labelsAsLlmAgentName: null,
846
+ graphqlRateLimitFloor: 500,
847
+ });
848
+
849
+ const awCalls = mockLocalCommandRunner.runCommand.mock.calls.filter(
850
+ (call) => call[0] === 'aw',
851
+ );
852
+ expect(awCalls).toHaveLength(1);
853
+ });
854
+
630
855
  it('should pass --branch to aw command when issue has an existing linked PR', async () => {
631
856
  const awaitingIssues: Issue[] = [
632
857
  createMockIssue({
@@ -7055,6 +7280,7 @@ describe('StartPreparationUseCase.buildRotationOrder', () => {
7055
7280
  listSpawns: jest.fn().mockReturnValue([]),
7056
7281
  listRunningIssueUrls: jest.fn().mockReturnValue([]),
7057
7282
  },
7283
+ { getRemainingRequestCount: jest.fn().mockResolvedValue(null) },
7058
7284
  );
7059
7285
 
7060
7286
  it('lists selected tokens first in ascending 7-day reset deadline order then excluded tokens', () => {
@@ -7354,6 +7580,7 @@ describe('StartPreparationUseCase.getTokenConcurrentLimit', () => {
7354
7580
  listSpawns: jest.fn().mockReturnValue([]),
7355
7581
  listRunningIssueUrls: jest.fn().mockReturnValue([]),
7356
7582
  },
7583
+ { getRemainingRequestCount: jest.fn().mockResolvedValue(null) },
7357
7584
  );
7358
7585
  });
7359
7586
 
@@ -7465,6 +7692,7 @@ describe('StartPreparationUseCase.run normalConcurrentLimit', () => {
7465
7692
  mockLocalCommandRunner,
7466
7693
  mockClaudeTokenUsageRepository,
7467
7694
  mockTakeOwnershipSpawnRepository,
7695
+ { getRemainingRequestCount: jest.fn().mockResolvedValue(null) },
7468
7696
  );
7469
7697
 
7470
7698
  await useCase.run({
@@ -7542,6 +7770,7 @@ describe('StartPreparationUseCase.fetchSpawnCandidateBranchSources', () => {
7542
7770
  listSpawns: jest.fn().mockReturnValue([]),
7543
7771
  listRunningIssueUrls: jest.fn().mockReturnValue([]),
7544
7772
  },
7773
+ { getRemainingRequestCount: jest.fn().mockResolvedValue(null) },
7545
7774
  );
7546
7775
 
7547
7776
  it('looks up related open pull requests for issue urls concurrently up to the configured limit', async () => {