github-issue-tower-defence-management 1.60.1 → 1.63.1

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 (123) hide show
  1. package/.github/workflows/publish.yml +13 -0
  2. package/.github/workflows/test.yml +0 -4
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +53 -10
  5. package/bin/adapter/entry-points/cli/index.js +11 -11
  6. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js +3 -22
  8. package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js.map +1 -1
  9. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +8 -22
  10. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  11. package/bin/adapter/entry-points/handlers/rotationOrderFileWriter.js +56 -0
  12. package/bin/adapter/entry-points/handlers/rotationOrderFileWriter.js.map +1 -0
  13. package/bin/adapter/entry-points/handlers/situationFileWriter.js +5 -0
  14. package/bin/adapter/entry-points/handlers/situationFileWriter.js.map +1 -1
  15. package/bin/adapter/proxy/TokenListLoader.js +21 -6
  16. package/bin/adapter/proxy/TokenListLoader.js.map +1 -1
  17. package/bin/adapter/proxy/proxyEntry.js +1 -0
  18. package/bin/adapter/proxy/proxyEntry.js.map +1 -1
  19. package/bin/adapter/repositories/BaseGitHubRepository.js +1 -113
  20. package/bin/adapter/repositories/BaseGitHubRepository.js.map +1 -1
  21. package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js +5 -3
  22. package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js.map +1 -1
  23. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +8 -7
  24. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  25. package/bin/domain/usecases/CreateNewStoryByLabelUseCase.js +19 -9
  26. package/bin/domain/usecases/CreateNewStoryByLabelUseCase.js.map +1 -1
  27. package/bin/domain/usecases/HandleScheduledEventUseCase.js +15 -3
  28. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  29. package/bin/domain/usecases/IssueRejectionEvaluator.js +8 -1
  30. package/bin/domain/usecases/IssueRejectionEvaluator.js.map +1 -1
  31. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +5 -1
  32. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  33. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +1 -1
  34. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  35. package/bin/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.js +32 -1
  36. package/bin/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.js.map +1 -1
  37. package/bin/domain/usecases/StartPreparationUseCase.js +91 -12
  38. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  39. package/package.json +1 -4
  40. package/src/adapter/entry-points/cli/index.test.ts +16 -16
  41. package/src/adapter/entry-points/cli/index.ts +8 -11
  42. package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.test.ts +2 -55
  43. package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.ts +1 -11
  44. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +6 -56
  45. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +7 -11
  46. package/src/adapter/entry-points/handlers/rotationOrderFileWriter.test.ts +177 -0
  47. package/src/adapter/entry-points/handlers/rotationOrderFileWriter.ts +20 -0
  48. package/src/adapter/entry-points/handlers/situationFileWriter.test.ts +36 -0
  49. package/src/adapter/entry-points/handlers/situationFileWriter.ts +8 -0
  50. package/src/adapter/proxy/TokenListLoader.test.ts +50 -1
  51. package/src/adapter/proxy/TokenListLoader.ts +25 -5
  52. package/src/adapter/proxy/proxyEntry.test.ts +270 -1
  53. package/src/adapter/proxy/proxyEntry.ts +2 -1
  54. package/src/adapter/repositories/BaseGitHubRepository.test.ts +1 -186
  55. package/src/adapter/repositories/BaseGitHubRepository.ts +1 -139
  56. package/src/adapter/repositories/GraphqlProjectRepository.errorHandling.test.ts +0 -1
  57. package/src/adapter/repositories/GraphqlProjectRepository.fetchProjectId.test.ts +4 -1
  58. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.test.ts +60 -19
  59. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.ts +6 -4
  60. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +23 -13
  61. package/src/adapter/repositories/issue/ApiV3IssueRepository.test.ts +0 -1
  62. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +0 -8
  63. package/src/adapter/repositories/issue/RestIssueRepository.test.ts +0 -1
  64. package/src/domain/entities/ClaudeTokenUsage.ts +1 -0
  65. package/src/domain/usecases/CreateNewStoryByLabelUseCase.test.ts +196 -11
  66. package/src/domain/usecases/CreateNewStoryByLabelUseCase.ts +32 -15
  67. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +4 -0
  68. package/src/domain/usecases/HandleScheduledEventUseCase.ts +21 -5
  69. package/src/domain/usecases/IssueRejectionEvaluator.test.ts +153 -0
  70. package/src/domain/usecases/IssueRejectionEvaluator.ts +8 -0
  71. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +175 -31
  72. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +7 -1
  73. package/src/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.test.ts +32 -0
  74. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +39 -5
  75. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +1 -1
  76. package/src/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.test.ts +139 -20
  77. package/src/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.ts +62 -2
  78. package/src/domain/usecases/StartPreparationUseCase.test.ts +404 -21
  79. package/src/domain/usecases/StartPreparationUseCase.ts +152 -16
  80. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +16 -0
  81. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  82. package/types/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.d.ts.map +1 -1
  83. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  84. package/types/adapter/entry-points/handlers/rotationOrderFileWriter.d.ts +3 -0
  85. package/types/adapter/entry-points/handlers/rotationOrderFileWriter.d.ts.map +1 -0
  86. package/types/adapter/entry-points/handlers/situationFileWriter.d.ts +1 -0
  87. package/types/adapter/entry-points/handlers/situationFileWriter.d.ts.map +1 -1
  88. package/types/adapter/proxy/TokenListLoader.d.ts +5 -0
  89. package/types/adapter/proxy/TokenListLoader.d.ts.map +1 -1
  90. package/types/adapter/proxy/proxyEntry.d.ts +2 -1
  91. package/types/adapter/proxy/proxyEntry.d.ts.map +1 -1
  92. package/types/adapter/repositories/BaseGitHubRepository.d.ts +1 -23
  93. package/types/adapter/repositories/BaseGitHubRepository.d.ts.map +1 -1
  94. package/types/adapter/repositories/ProxyClaudeTokenUsageRepository.d.ts.map +1 -1
  95. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +14 -5
  96. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  97. package/types/domain/entities/ClaudeTokenUsage.d.ts +1 -0
  98. package/types/domain/entities/ClaudeTokenUsage.d.ts.map +1 -1
  99. package/types/domain/usecases/CreateNewStoryByLabelUseCase.d.ts +4 -2
  100. package/types/domain/usecases/CreateNewStoryByLabelUseCase.d.ts.map +1 -1
  101. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +5 -2
  102. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  103. package/types/domain/usecases/IssueRejectionEvaluator.d.ts +1 -1
  104. package/types/domain/usecases/IssueRejectionEvaluator.d.ts.map +1 -1
  105. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  106. package/types/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.d.ts +5 -2
  107. package/types/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.d.ts.map +1 -1
  108. package/types/domain/usecases/StartPreparationUseCase.d.ts +15 -1
  109. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  110. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +14 -0
  111. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
  112. package/bin/adapter/repositories/issue/CheerioIssueRepository.js +0 -136
  113. package/bin/adapter/repositories/issue/CheerioIssueRepository.js.map +0 -1
  114. package/bin/adapter/repositories/issue/InternalGraphqlIssueRepository.js +0 -1606
  115. package/bin/adapter/repositories/issue/InternalGraphqlIssueRepository.js.map +0 -1
  116. package/src/adapter/repositories/issue/CheerioIssueRepository.test.ts +0 -6552
  117. package/src/adapter/repositories/issue/CheerioIssueRepository.ts +0 -142
  118. package/src/adapter/repositories/issue/InternalGraphqlIssueRepository.test.ts +0 -118
  119. package/src/adapter/repositories/issue/InternalGraphqlIssueRepository.ts +0 -584
  120. package/types/adapter/repositories/issue/CheerioIssueRepository.d.ts +0 -40
  121. package/types/adapter/repositories/issue/CheerioIssueRepository.d.ts.map +0 -1
  122. package/types/adapter/repositories/issue/InternalGraphqlIssueRepository.d.ts +0 -220
  123. package/types/adapter/repositories/issue/InternalGraphqlIssueRepository.d.ts.map +0 -1
@@ -187,6 +187,7 @@ describe('StartPreparationUseCase', () => {
187
187
  url: 'https://github.com/user/repo/pull/42',
188
188
  branchName: 'i1',
189
189
  createdAt: new Date('2024-01-01'),
190
+ isDraft: false,
190
191
  isConflicted: false,
191
192
  isPassedAllCiJob: false,
192
193
  isCiStateSuccess: false,
@@ -247,6 +248,7 @@ describe('StartPreparationUseCase', () => {
247
248
  url: 'https://github.com/user/repo/pull/354',
248
249
  branchName: 'dependabot/npm_and_yarn/multi-cc382f683c',
249
250
  createdAt: new Date('2024-01-01'),
251
+ isDraft: false,
250
252
  isConflicted: false,
251
253
  isPassedAllCiJob: false,
252
254
  isCiStateSuccess: false,
@@ -343,6 +345,7 @@ describe('StartPreparationUseCase', () => {
343
345
  url: 'https://github.com/user/repo/pull/999',
344
346
  branchName: null,
345
347
  createdAt: new Date('2024-01-01'),
348
+ isDraft: false,
346
349
  isConflicted: false,
347
350
  isPassedAllCiJob: false,
348
351
  isCiStateSuccess: false,
@@ -389,6 +392,7 @@ describe('StartPreparationUseCase', () => {
389
392
  url: 'https://github.com/user/repo/pull/999',
390
393
  branchName: 'evil$(rm -rf /)',
391
394
  createdAt: new Date('2024-01-01'),
395
+ isDraft: false,
392
396
  isConflicted: false,
393
397
  isPassedAllCiJob: false,
394
398
  isCiStateSuccess: false,
@@ -431,6 +435,7 @@ describe('StartPreparationUseCase', () => {
431
435
  url: 'https://github.com/user/repo/pull/42',
432
436
  branchName: 'i1',
433
437
  createdAt: new Date('2024-01-01T00:00:00Z'),
438
+ isDraft: false,
434
439
  isConflicted: false,
435
440
  isPassedAllCiJob: false,
436
441
  isCiStateSuccess: false,
@@ -442,6 +447,7 @@ describe('StartPreparationUseCase', () => {
442
447
  url: 'https://github.com/user/repo/pull/43',
443
448
  branchName: 'i1-fix',
444
449
  createdAt: new Date('2024-01-02T00:00:00Z'),
450
+ isDraft: false,
445
451
  isConflicted: false,
446
452
  isPassedAllCiJob: false,
447
453
  isCiStateSuccess: false,
@@ -519,6 +525,7 @@ describe('StartPreparationUseCase', () => {
519
525
  url: 'https://github.com/user/repo/pull/42',
520
526
  branchName: null,
521
527
  createdAt: new Date('2024-01-01T00:00:00Z'),
528
+ isDraft: false,
522
529
  isConflicted: false,
523
530
  isPassedAllCiJob: false,
524
531
  isCiStateSuccess: false,
@@ -530,6 +537,7 @@ describe('StartPreparationUseCase', () => {
530
537
  url: 'https://github.com/user/repo/pull/43',
531
538
  branchName: 'i1-fix',
532
539
  createdAt: new Date('2024-01-02T00:00:00Z'),
540
+ isDraft: false,
533
541
  isConflicted: false,
534
542
  isPassedAllCiJob: false,
535
543
  isCiStateSuccess: false,
@@ -592,6 +600,7 @@ describe('StartPreparationUseCase', () => {
592
600
  url: 'https://github.com/user/repo/pull/42',
593
601
  branchName: null,
594
602
  createdAt: new Date('2024-01-01'),
603
+ isDraft: false,
595
604
  isConflicted: false,
596
605
  isPassedAllCiJob: false,
597
606
  isCiStateSuccess: false,
@@ -1187,6 +1196,123 @@ describe('StartPreparationUseCase', () => {
1187
1196
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(6);
1188
1197
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(6);
1189
1198
  });
1199
+ it('should allow six preparing processes per available Claude OAuth token when maximumPreparingIssuesCount is null', async () => {
1200
+ const awaitingIssues: Issue[] = Array.from({ length: 20 }, (_, i) =>
1201
+ createMockIssue({
1202
+ url: `url${i + 1}`,
1203
+ title: `Issue ${i + 1}`,
1204
+ labels: [],
1205
+ status: 'Awaiting Workspace',
1206
+ number: i + 1,
1207
+ itemId: `item-${i + 1}`,
1208
+ }),
1209
+ );
1210
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1211
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1212
+ createMockStoryObjectMap(awaitingIssues),
1213
+ );
1214
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
1215
+ stdout: '',
1216
+ stderr: '',
1217
+ exitCode: 0,
1218
+ });
1219
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
1220
+ {
1221
+ name: 'token-a',
1222
+ token: 'token-a',
1223
+ fiveHourUtilization: 0,
1224
+ blocked: false,
1225
+ rejected: false,
1226
+ modelWeeklyLimits: {},
1227
+ },
1228
+ {
1229
+ name: 'token-b',
1230
+ token: 'token-b',
1231
+ fiveHourUtilization: 0,
1232
+ blocked: false,
1233
+ rejected: false,
1234
+ modelWeeklyLimits: {},
1235
+ },
1236
+ ]);
1237
+
1238
+ await useCase.run({
1239
+ projectUrl: 'https://github.com/user/repo',
1240
+ defaultAgentName: 'agent1',
1241
+ defaultLlmModelName: 'claude-sonnet-4-6',
1242
+ defaultLlmAgentName: null,
1243
+ configFilePath: '/path/to/config.yml',
1244
+ maximumPreparingIssuesCount: null,
1245
+ utilizationPercentageThreshold: 90,
1246
+ allowedIssueAuthors: null,
1247
+ codexHomeCandidates: null,
1248
+ allowIssueCacheMinutes: 0,
1249
+ });
1250
+
1251
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(12);
1252
+ const spawnedTokens = mockLocalCommandRunner.runCommand.mock.calls.map(
1253
+ (call) => call[2]?.env?.CLAUDE_CODE_OAUTH_TOKEN,
1254
+ );
1255
+ expect(spawnedTokens.filter((token) => token === 'token-a')).toHaveLength(
1256
+ 6,
1257
+ );
1258
+ expect(spawnedTokens.filter((token) => token === 'token-b')).toHaveLength(
1259
+ 6,
1260
+ );
1261
+ });
1262
+ it('should cap configured maximumPreparingIssuesCount to six per available Claude OAuth token', async () => {
1263
+ const awaitingIssues: Issue[] = Array.from({ length: 20 }, (_, i) =>
1264
+ createMockIssue({
1265
+ url: `url${i + 1}`,
1266
+ title: `Issue ${i + 1}`,
1267
+ labels: [],
1268
+ status: 'Awaiting Workspace',
1269
+ number: i + 1,
1270
+ itemId: `item-${i + 1}`,
1271
+ }),
1272
+ );
1273
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1274
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1275
+ createMockStoryObjectMap(awaitingIssues),
1276
+ );
1277
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
1278
+ stdout: '',
1279
+ stderr: '',
1280
+ exitCode: 0,
1281
+ });
1282
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
1283
+ {
1284
+ name: 'token-a',
1285
+ token: 'token-a',
1286
+ fiveHourUtilization: 0,
1287
+ blocked: false,
1288
+ rejected: false,
1289
+ modelWeeklyLimits: {},
1290
+ },
1291
+ {
1292
+ name: 'token-b',
1293
+ token: 'token-b',
1294
+ fiveHourUtilization: 0,
1295
+ blocked: false,
1296
+ rejected: false,
1297
+ modelWeeklyLimits: {},
1298
+ },
1299
+ ]);
1300
+
1301
+ await useCase.run({
1302
+ projectUrl: 'https://github.com/user/repo',
1303
+ defaultAgentName: 'agent1',
1304
+ defaultLlmModelName: 'claude-sonnet-4-6',
1305
+ defaultLlmAgentName: null,
1306
+ configFilePath: '/path/to/config.yml',
1307
+ maximumPreparingIssuesCount: 20,
1308
+ utilizationPercentageThreshold: 90,
1309
+ allowedIssueAuthors: null,
1310
+ codexHomeCandidates: null,
1311
+ allowIssueCacheMinutes: 0,
1312
+ });
1313
+
1314
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(12);
1315
+ });
1190
1316
 
1191
1317
  it('should not skip issues from repositories with workflow blockers', async () => {
1192
1318
  const blockerIssue = createMockIssue({
@@ -2149,6 +2275,7 @@ describe('StartPreparationUseCase', () => {
2149
2275
  });
2150
2276
  mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2151
2277
  {
2278
+ name: 'token-a',
2152
2279
  token: 'token-a',
2153
2280
  fiveHourUtilization: 0,
2154
2281
  blocked: false,
@@ -2223,6 +2350,7 @@ describe('StartPreparationUseCase', () => {
2223
2350
  });
2224
2351
  mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2225
2352
  {
2353
+ name: 'token-a',
2226
2354
  token: 'token-a',
2227
2355
  fiveHourUtilization: 0,
2228
2356
  blocked: false,
@@ -2230,6 +2358,7 @@ describe('StartPreparationUseCase', () => {
2230
2358
  modelWeeklyLimits: {},
2231
2359
  },
2232
2360
  {
2361
+ name: 'token-b',
2233
2362
  token: 'token-b',
2234
2363
  fiveHourUtilization: 0,
2235
2364
  blocked: false,
@@ -2334,6 +2463,7 @@ describe('StartPreparationUseCase', () => {
2334
2463
  });
2335
2464
  mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2336
2465
  {
2466
+ name: 'token-high',
2337
2467
  token: 'token-high',
2338
2468
  fiveHourUtilization: 0.8,
2339
2469
  blocked: false,
@@ -2341,6 +2471,7 @@ describe('StartPreparationUseCase', () => {
2341
2471
  modelWeeklyLimits: {},
2342
2472
  },
2343
2473
  {
2474
+ name: 'token-low',
2344
2475
  token: 'token-low',
2345
2476
  fiveHourUtilization: 0.1,
2346
2477
  blocked: false,
@@ -2391,6 +2522,7 @@ describe('StartPreparationUseCase', () => {
2391
2522
  });
2392
2523
  mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2393
2524
  {
2525
+ name: 'token-blocked',
2394
2526
  token: 'token-blocked',
2395
2527
  fiveHourUtilization: 0.05,
2396
2528
  blocked: true,
@@ -2398,6 +2530,7 @@ describe('StartPreparationUseCase', () => {
2398
2530
  modelWeeklyLimits: {},
2399
2531
  },
2400
2532
  {
2533
+ name: 'token-ok',
2401
2534
  token: 'token-ok',
2402
2535
  fiveHourUtilization: 0.5,
2403
2536
  blocked: false,
@@ -2448,6 +2581,7 @@ describe('StartPreparationUseCase', () => {
2448
2581
  });
2449
2582
  mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2450
2583
  {
2584
+ name: 'token-a',
2451
2585
  token: 'token-a',
2452
2586
  fiveHourUtilization: 0.05,
2453
2587
  blocked: true,
@@ -2455,6 +2589,7 @@ describe('StartPreparationUseCase', () => {
2455
2589
  modelWeeklyLimits: {},
2456
2590
  },
2457
2591
  {
2592
+ name: 'token-b',
2458
2593
  token: 'token-b',
2459
2594
  fiveHourUtilization: 0.08,
2460
2595
  blocked: true,
@@ -2491,7 +2626,72 @@ describe('StartPreparationUseCase', () => {
2491
2626
  consoleWarnSpy.mockRestore();
2492
2627
  });
2493
2628
 
2494
- it('should return all tokens sorted ascending when all are below the threshold', async () => {
2629
+ it('should skip preparation when every configured token is at or above 95 percent 5h utilization', async () => {
2630
+ const awaitingIssue = createMockIssue({
2631
+ url: 'url1',
2632
+ title: 'Issue 1',
2633
+ labels: ['category:impl'],
2634
+ status: 'Awaiting Workspace',
2635
+ number: 1,
2636
+ itemId: 'item-1',
2637
+ });
2638
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2639
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
2640
+ createMockStoryObjectMap([awaitingIssue]),
2641
+ );
2642
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
2643
+ stdout: '',
2644
+ stderr: '',
2645
+ exitCode: 0,
2646
+ });
2647
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2648
+ {
2649
+ name: 'token-a',
2650
+ token: 'token-a',
2651
+ fiveHourUtilization: 0.95,
2652
+ blocked: false,
2653
+ rejected: false,
2654
+ modelWeeklyLimits: {},
2655
+ },
2656
+ {
2657
+ name: 'token-b',
2658
+ token: 'token-b',
2659
+ fiveHourUtilization: 0.97,
2660
+ blocked: false,
2661
+ rejected: false,
2662
+ modelWeeklyLimits: {},
2663
+ },
2664
+ ]);
2665
+ const consoleWarnSpy = jest
2666
+ .spyOn(console, 'warn')
2667
+ .mockImplementation(() => {});
2668
+
2669
+ await useCase.run({
2670
+ projectUrl: 'https://github.com/user/repo',
2671
+ defaultAgentName: 'agent1',
2672
+ defaultLlmModelName: 'claude-opus',
2673
+ defaultLlmAgentName: null,
2674
+ configFilePath: '/path/to/config.yml',
2675
+ maximumPreparingIssuesCount: null,
2676
+ utilizationPercentageThreshold: 90,
2677
+ allowedIssueAuthors: null,
2678
+ codexHomeCandidates: null,
2679
+ allowIssueCacheMinutes: 0,
2680
+ });
2681
+
2682
+ expect(
2683
+ mockClaudeTokenUsageRepository.ensureObservable.mock.calls,
2684
+ ).toHaveLength(0);
2685
+ expect(mockProjectRepository.getByUrl).not.toHaveBeenCalled();
2686
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
2687
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
2688
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
2689
+ expect.stringContaining('5h utilization >= 95%'),
2690
+ );
2691
+ consoleWarnSpy.mockRestore();
2692
+ });
2693
+
2694
+ it('should return all tokens sorted ascending when all have full process capacity', async () => {
2495
2695
  const awaitingIssues: Issue[] = [
2496
2696
  createMockIssue({
2497
2697
  url: 'url1',
@@ -2529,6 +2729,7 @@ describe('StartPreparationUseCase', () => {
2529
2729
  });
2530
2730
  mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2531
2731
  {
2732
+ name: 'token-mid',
2532
2733
  token: 'token-mid',
2533
2734
  fiveHourUtilization: 0.5,
2534
2735
  blocked: false,
@@ -2536,6 +2737,7 @@ describe('StartPreparationUseCase', () => {
2536
2737
  modelWeeklyLimits: {},
2537
2738
  },
2538
2739
  {
2740
+ name: 'token-low',
2539
2741
  token: 'token-low',
2540
2742
  fiveHourUtilization: 0.1,
2541
2743
  blocked: false,
@@ -2543,6 +2745,7 @@ describe('StartPreparationUseCase', () => {
2543
2745
  modelWeeklyLimits: {},
2544
2746
  },
2545
2747
  {
2748
+ name: 'token-high',
2546
2749
  token: 'token-high',
2547
2750
  fiveHourUtilization: 0.8,
2548
2751
  blocked: false,
@@ -2576,18 +2779,20 @@ describe('StartPreparationUseCase', () => {
2576
2779
  });
2577
2780
  });
2578
2781
 
2579
- it('should exclude a token whose 5h utilization is at or above the threshold', async () => {
2580
- const awaitingIssue = createMockIssue({
2581
- url: 'url1',
2582
- title: 'Issue 1',
2583
- labels: ['category:impl'],
2584
- status: 'Awaiting Workspace',
2585
- number: 1,
2586
- itemId: 'item-1',
2587
- });
2782
+ it('should reduce token process capacity exponentially above 80 percent 5h utilization', async () => {
2783
+ const awaitingIssues: Issue[] = Array.from({ length: 10 }, (_, i) =>
2784
+ createMockIssue({
2785
+ url: `url${i + 1}`,
2786
+ title: `Issue ${i + 1}`,
2787
+ labels: ['category:impl'],
2788
+ status: 'Awaiting Workspace',
2789
+ number: i + 1,
2790
+ itemId: `item-${i + 1}`,
2791
+ }),
2792
+ );
2588
2793
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2589
2794
  mockIssueRepository.getStoryObjectMap.mockResolvedValue(
2590
- createMockStoryObjectMap([awaitingIssue]),
2795
+ createMockStoryObjectMap(awaitingIssues),
2591
2796
  );
2592
2797
  mockLocalCommandRunner.runCommand.mockResolvedValue({
2593
2798
  stdout: '',
@@ -2596,15 +2801,33 @@ describe('StartPreparationUseCase', () => {
2596
2801
  });
2597
2802
  mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2598
2803
  {
2599
- token: 'token-at-threshold',
2804
+ name: 'token-full-capacity',
2805
+ token: 'token-full-capacity',
2806
+ fiveHourUtilization: 0.8,
2807
+ blocked: false,
2808
+ rejected: false,
2809
+ modelWeeklyLimits: {},
2810
+ },
2811
+ {
2812
+ name: 'token-two-slots',
2813
+ token: 'token-two-slots',
2814
+ fiveHourUtilization: 0.85,
2815
+ blocked: false,
2816
+ rejected: false,
2817
+ modelWeeklyLimits: {},
2818
+ },
2819
+ {
2820
+ name: 'token-one-slot',
2821
+ token: 'token-one-slot',
2600
2822
  fiveHourUtilization: 0.9,
2601
2823
  blocked: false,
2602
2824
  rejected: false,
2603
2825
  modelWeeklyLimits: {},
2604
2826
  },
2605
2827
  {
2606
- token: 'token-below',
2607
- fiveHourUtilization: 0.4,
2828
+ name: 'token-zero-slots',
2829
+ token: 'token-zero-slots',
2830
+ fiveHourUtilization: 0.95,
2608
2831
  blocked: false,
2609
2832
  rejected: false,
2610
2833
  modelWeeklyLimits: {},
@@ -2624,13 +2847,20 @@ describe('StartPreparationUseCase', () => {
2624
2847
  allowIssueCacheMinutes: 0,
2625
2848
  });
2626
2849
 
2627
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2628
- expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
2629
- env: {
2630
- CLAUDE_CODE_OAUTH_TOKEN: 'token-below',
2631
- ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
2632
- },
2633
- });
2850
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(9);
2851
+ const spawnedTokens = mockLocalCommandRunner.runCommand.mock.calls.map(
2852
+ (call) => call[2]?.env?.CLAUDE_CODE_OAUTH_TOKEN,
2853
+ );
2854
+ expect(
2855
+ spawnedTokens.filter((token) => token === 'token-full-capacity'),
2856
+ ).toHaveLength(6);
2857
+ expect(
2858
+ spawnedTokens.filter((token) => token === 'token-two-slots'),
2859
+ ).toHaveLength(2);
2860
+ expect(
2861
+ spawnedTokens.filter((token) => token === 'token-one-slot'),
2862
+ ).toHaveLength(1);
2863
+ expect(spawnedTokens).not.toContain('token-zero-slots');
2634
2864
  });
2635
2865
 
2636
2866
  it('should exclude a rejected token from rotation', async () => {
@@ -2653,6 +2883,7 @@ describe('StartPreparationUseCase', () => {
2653
2883
  });
2654
2884
  mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2655
2885
  {
2886
+ name: 'token-rejected',
2656
2887
  token: 'token-rejected',
2657
2888
  fiveHourUtilization: 0.1,
2658
2889
  blocked: false,
@@ -2660,6 +2891,7 @@ describe('StartPreparationUseCase', () => {
2660
2891
  modelWeeklyLimits: {},
2661
2892
  },
2662
2893
  {
2894
+ name: 'token-ok',
2663
2895
  token: 'token-ok',
2664
2896
  fiveHourUtilization: 0.5,
2665
2897
  blocked: false,
@@ -2710,6 +2942,7 @@ describe('StartPreparationUseCase', () => {
2710
2942
  });
2711
2943
  mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2712
2944
  {
2945
+ name: 'token-reset',
2713
2946
  token: 'token-reset',
2714
2947
  fiveHourUtilization: 0,
2715
2948
  blocked: false,
@@ -2717,6 +2950,7 @@ describe('StartPreparationUseCase', () => {
2717
2950
  modelWeeklyLimits: {},
2718
2951
  },
2719
2952
  {
2953
+ name: 'token-busy',
2720
2954
  token: 'token-busy',
2721
2955
  fiveHourUtilization: 0.5,
2722
2956
  blocked: false,
@@ -2767,6 +3001,7 @@ describe('StartPreparationUseCase', () => {
2767
3001
  });
2768
3002
  mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2769
3003
  {
3004
+ name: 'token-saturated',
2770
3005
  token: 'token-saturated',
2771
3006
  fiveHourUtilization: 0.95,
2772
3007
  blocked: false,
@@ -2774,6 +3009,7 @@ describe('StartPreparationUseCase', () => {
2774
3009
  modelWeeklyLimits: {},
2775
3010
  },
2776
3011
  {
3012
+ name: 'token-ok',
2777
3013
  token: 'token-ok',
2778
3014
  fiveHourUtilization: 0.2,
2779
3015
  blocked: false,
@@ -2824,6 +3060,7 @@ describe('StartPreparationUseCase', () => {
2824
3060
  });
2825
3061
  mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2826
3062
  {
3063
+ name: 'token-a',
2827
3064
  token: 'token-a',
2828
3065
  fiveHourUtilization: 0.1,
2829
3066
  blocked: false,
@@ -2831,6 +3068,7 @@ describe('StartPreparationUseCase', () => {
2831
3068
  modelWeeklyLimits: {},
2832
3069
  },
2833
3070
  {
3071
+ name: 'token-b',
2834
3072
  token: 'token-b',
2835
3073
  fiveHourUtilization: 0.2,
2836
3074
  blocked: false,
@@ -2931,6 +3169,7 @@ describe('StartPreparationUseCase', () => {
2931
3169
  const futureReset = Math.floor(Date.now() / 1000) + 3600;
2932
3170
  mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2933
3171
  {
3172
+ name: 'token-sonnet-exhausted',
2934
3173
  token: 'token-sonnet-exhausted',
2935
3174
  fiveHourUtilization: 0.1,
2936
3175
  blocked: false,
@@ -2940,6 +3179,7 @@ describe('StartPreparationUseCase', () => {
2940
3179
  },
2941
3180
  },
2942
3181
  {
3182
+ name: 'token-ok',
2943
3183
  token: 'token-ok',
2944
3184
  fiveHourUtilization: 0.5,
2945
3185
  blocked: false,
@@ -2991,6 +3231,7 @@ describe('StartPreparationUseCase', () => {
2991
3231
  const pastReset = Math.floor(Date.now() / 1000) - 3600;
2992
3232
  mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2993
3233
  {
3234
+ name: 'token-recovered',
2994
3235
  token: 'token-recovered',
2995
3236
  fiveHourUtilization: 0.1,
2996
3237
  blocked: false,
@@ -3000,6 +3241,7 @@ describe('StartPreparationUseCase', () => {
3000
3241
  },
3001
3242
  },
3002
3243
  {
3244
+ name: 'token-busy',
3003
3245
  token: 'token-busy',
3004
3246
  fiveHourUtilization: 0.5,
3005
3247
  blocked: false,
@@ -3051,6 +3293,7 @@ describe('StartPreparationUseCase', () => {
3051
3293
  const futureReset = Math.floor(Date.now() / 1000) + 3600;
3052
3294
  mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
3053
3295
  {
3296
+ name: 'token-sonnet-exhausted',
3054
3297
  token: 'token-sonnet-exhausted',
3055
3298
  fiveHourUtilization: 0.1,
3056
3299
  blocked: false,
@@ -3060,6 +3303,7 @@ describe('StartPreparationUseCase', () => {
3060
3303
  },
3061
3304
  },
3062
3305
  {
3306
+ name: 'token-higher-util',
3063
3307
  token: 'token-higher-util',
3064
3308
  fiveHourUtilization: 0.5,
3065
3309
  blocked: false,
@@ -3111,6 +3355,7 @@ describe('StartPreparationUseCase', () => {
3111
3355
  const futureReset = Math.floor(Date.now() / 1000) + 3600;
3112
3356
  mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
3113
3357
  {
3358
+ name: 'token-weekly-exhausted',
3114
3359
  token: 'token-weekly-exhausted',
3115
3360
  fiveHourUtilization: 0.1,
3116
3361
  blocked: false,
@@ -3120,6 +3365,7 @@ describe('StartPreparationUseCase', () => {
3120
3365
  },
3121
3366
  },
3122
3367
  {
3368
+ name: 'token-ok',
3123
3369
  token: 'token-ok',
3124
3370
  fiveHourUtilization: 0.5,
3125
3371
  blocked: false,
@@ -3150,3 +3396,140 @@ describe('StartPreparationUseCase', () => {
3150
3396
  });
3151
3397
  });
3152
3398
  });
3399
+
3400
+ describe('StartPreparationUseCase.buildRotationOrder', () => {
3401
+ const mockProjectRepositoryForRotation: Mocked<
3402
+ Pick<ProjectRepository, 'getByUrl'>
3403
+ > = {
3404
+ getByUrl: jest.fn(),
3405
+ };
3406
+ const mockIssueRepositoryForRotation: Mocked<
3407
+ Pick<
3408
+ IssueRepository,
3409
+ | 'getStoryObjectMap'
3410
+ | 'updateStatus'
3411
+ | 'findRelatedOpenPRs'
3412
+ | 'getOpenPullRequest'
3413
+ | 'closePullRequest'
3414
+ | 'deletePullRequestBranch'
3415
+ | 'createCommentByUrl'
3416
+ >
3417
+ > = {
3418
+ getStoryObjectMap: jest.fn(),
3419
+ updateStatus: jest.fn(),
3420
+ findRelatedOpenPRs: jest.fn(),
3421
+ getOpenPullRequest: jest.fn(),
3422
+ closePullRequest: jest.fn(),
3423
+ deletePullRequestBranch: jest.fn(),
3424
+ createCommentByUrl: jest.fn(),
3425
+ };
3426
+ const mockLocalCommandRunnerForRotation: Mocked<LocalCommandRunner> = {
3427
+ runCommand: jest.fn(),
3428
+ };
3429
+ const mockClaudeTokenUsageRepositoryForRotation: Mocked<ClaudeTokenUsageRepository> =
3430
+ {
3431
+ ensureObservable: jest.fn(),
3432
+ getAvailableTokenUsages: jest.fn(),
3433
+ proxyBaseUrl: jest.fn(),
3434
+ };
3435
+
3436
+ const useCase = new StartPreparationUseCase(
3437
+ mockProjectRepositoryForRotation,
3438
+ mockIssueRepositoryForRotation,
3439
+ mockLocalCommandRunnerForRotation,
3440
+ mockClaudeTokenUsageRepositoryForRotation,
3441
+ );
3442
+
3443
+ it('lists selected tokens first in ascending utilization order then excluded tokens', () => {
3444
+ const tokenUsages = [
3445
+ {
3446
+ name: 'high-util',
3447
+ token: 'sk-ant-high',
3448
+ fiveHourUtilization: 0.8,
3449
+ blocked: false,
3450
+ rejected: false,
3451
+ modelWeeklyLimits: {},
3452
+ },
3453
+ {
3454
+ name: 'low-util',
3455
+ token: 'sk-ant-low',
3456
+ fiveHourUtilization: 0.1,
3457
+ blocked: false,
3458
+ rejected: false,
3459
+ modelWeeklyLimits: {},
3460
+ },
3461
+ {
3462
+ name: 'blocked-token',
3463
+ token: 'sk-ant-blocked',
3464
+ fiveHourUtilization: 0.0,
3465
+ blocked: true,
3466
+ rejected: false,
3467
+ modelWeeklyLimits: {},
3468
+ },
3469
+ ];
3470
+ const result = useCase.buildRotationOrder(tokenUsages, 90, null);
3471
+
3472
+ expect(result[0].name).toBe('low-util');
3473
+ expect(result[1].name).toBe('high-util');
3474
+ expect(result[2].name).toBe('blocked-token');
3475
+ expect(result[2].blocked).toBe(true);
3476
+ expect(result[2].thresholdExcluded).toBe(false);
3477
+ });
3478
+
3479
+ it('does not include raw token strings in output entries', () => {
3480
+ const tokenUsages = [
3481
+ {
3482
+ name: 'my-token',
3483
+ token: 'sk-ant-secret-value',
3484
+ fiveHourUtilization: 0.1,
3485
+ blocked: false,
3486
+ rejected: false,
3487
+ modelWeeklyLimits: {},
3488
+ },
3489
+ ];
3490
+ const result = useCase.buildRotationOrder(tokenUsages, 90, null);
3491
+ const serialized = JSON.stringify(result);
3492
+
3493
+ expect(serialized).not.toContain('sk-ant-secret-value');
3494
+ expect(result[0].name).toBe('my-token');
3495
+ });
3496
+
3497
+ it('marks thresholdExcluded true when token is at or above 95 percent utilization', () => {
3498
+ const tokenUsages = [
3499
+ {
3500
+ name: 'over-threshold',
3501
+ token: 'sk-ant-over',
3502
+ fiveHourUtilization: 0.95,
3503
+ blocked: false,
3504
+ rejected: false,
3505
+ modelWeeklyLimits: {},
3506
+ },
3507
+ ];
3508
+ const result = useCase.buildRotationOrder(tokenUsages, 90, null);
3509
+
3510
+ expect(result).toHaveLength(1);
3511
+ expect(result[0].thresholdExcluded).toBe(true);
3512
+ expect(result[0].blocked).toBe(false);
3513
+ expect(result[0].rejected).toBe(false);
3514
+ });
3515
+
3516
+ it('does not mark thresholdExcluded for tokens in the 90 to 94 percent utilization range because selectRotationTokens still assigns them slots', () => {
3517
+ const tokenUsages = [
3518
+ {
3519
+ name: 'mid-util',
3520
+ token: 'sk-ant-mid',
3521
+ fiveHourUtilization: 0.92,
3522
+ blocked: false,
3523
+ rejected: false,
3524
+ modelWeeklyLimits: {},
3525
+ },
3526
+ ];
3527
+ const result = useCase.buildRotationOrder(tokenUsages, 90, null);
3528
+
3529
+ expect(result).toHaveLength(1);
3530
+ expect(result[0].thresholdExcluded).toBe(false);
3531
+ expect(result[0].blocked).toBe(false);
3532
+ expect(result[0].rejected).toBe(false);
3533
+ expect(result[0].fiveHourUtilization).toBe(0.92);
3534
+ });
3535
+ });