github-issue-tower-defence-management 1.58.1 → 1.58.3
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 +16 -0
- package/README.md +3 -3
- package/bin/adapter/entry-points/cli/index.js +1 -3
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +1 -3
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/proxy/RateLimitCache.js +94 -3
- package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
- package/bin/adapter/proxy/proxyEntry.js +19 -0
- package/bin/adapter/proxy/proxyEntry.js.map +1 -1
- package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js +33 -2
- package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js.map +1 -1
- package/bin/domain/usecases/StartPreparationUseCase.js +34 -35
- package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.ts +0 -3
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +0 -3
- package/src/adapter/proxy/RateLimitCache.test.ts +190 -0
- package/src/adapter/proxy/RateLimitCache.ts +104 -2
- package/src/adapter/proxy/proxyEntry.ts +24 -1
- package/src/adapter/repositories/GraphqlProjectRepository.test.ts +2 -1
- package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.test.ts +354 -7
- package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.ts +42 -2
- package/src/domain/entities/ClaudeTokenUsage.ts +7 -0
- package/src/domain/usecases/StartPreparationUseCase.test.ts +1212 -887
- package/src/domain/usecases/StartPreparationUseCase.ts +47 -57
- 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/proxy/RateLimitCache.d.ts +11 -0
- package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
- package/types/adapter/proxy/proxyEntry.d.ts.map +1 -1
- package/types/adapter/repositories/ProxyClaudeTokenUsageRepository.d.ts.map +1 -1
- package/types/domain/entities/ClaudeTokenUsage.d.ts +6 -0
- package/types/domain/entities/ClaudeTokenUsage.d.ts.map +1 -1
- package/types/domain/usecases/StartPreparationUseCase.d.ts +3 -3
- package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
} from './adapter-interfaces/IssueRepository';
|
|
6
6
|
import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
|
|
7
7
|
import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
|
|
8
|
-
import { ClaudeRepository } from './adapter-interfaces/ClaudeRepository';
|
|
9
8
|
import { ClaudeTokenUsageRepository } from './adapter-interfaces/ClaudeTokenUsageRepository';
|
|
10
9
|
import { Issue } from '../entities/Issue';
|
|
11
10
|
import { Project } from '../entities/Project';
|
|
@@ -91,7 +90,6 @@ describe('StartPreparationUseCase', () => {
|
|
|
91
90
|
| 'createCommentByUrl'
|
|
92
91
|
>
|
|
93
92
|
>;
|
|
94
|
-
let mockClaudeRepository: Mocked<Pick<ClaudeRepository, 'getUsage'>>;
|
|
95
93
|
let mockLocalCommandRunner: Mocked<LocalCommandRunner>;
|
|
96
94
|
let mockClaudeTokenUsageRepository: Mocked<ClaudeTokenUsageRepository>;
|
|
97
95
|
let mockProject: Project;
|
|
@@ -110,9 +108,6 @@ describe('StartPreparationUseCase', () => {
|
|
|
110
108
|
deletePullRequestBranch: jest.fn().mockResolvedValue(undefined),
|
|
111
109
|
createCommentByUrl: jest.fn().mockResolvedValue(undefined),
|
|
112
110
|
};
|
|
113
|
-
mockClaudeRepository = {
|
|
114
|
-
getUsage: jest.fn().mockResolvedValue([]),
|
|
115
|
-
};
|
|
116
111
|
mockLocalCommandRunner = {
|
|
117
112
|
runCommand: jest.fn(),
|
|
118
113
|
};
|
|
@@ -124,7 +119,6 @@ describe('StartPreparationUseCase', () => {
|
|
|
124
119
|
useCase = new StartPreparationUseCase(
|
|
125
120
|
mockProjectRepository,
|
|
126
121
|
mockIssueRepository,
|
|
127
|
-
mockClaudeRepository,
|
|
128
122
|
mockLocalCommandRunner,
|
|
129
123
|
mockClaudeTokenUsageRepository,
|
|
130
124
|
);
|
|
@@ -1262,59 +1256,25 @@ describe('StartPreparationUseCase', () => {
|
|
|
1262
1256
|
expect(updatedUrls).toContain('https://github.com/user/repo/issues/101');
|
|
1263
1257
|
});
|
|
1264
1258
|
|
|
1265
|
-
it('should skip
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1280
|
-
createMockStoryObjectMap(awaitingIssues),
|
|
1281
|
-
);
|
|
1282
|
-
|
|
1283
|
-
await useCase.run({
|
|
1284
|
-
projectUrl: 'https://github.com/user/repo',
|
|
1285
|
-
defaultAgentName: 'agent1',
|
|
1286
|
-
defaultLlmModelName: null,
|
|
1287
|
-
defaultLlmAgentName: null,
|
|
1288
|
-
configFilePath: '/path/to/config.yml',
|
|
1289
|
-
maximumPreparingIssuesCount: null,
|
|
1290
|
-
utilizationPercentageThreshold: 90,
|
|
1291
|
-
allowedIssueAuthors: null,
|
|
1292
|
-
codexHomeCandidates: null,
|
|
1293
|
-
allowIssueCacheMinutes: 0,
|
|
1259
|
+
it('should skip issues that have dependedIssueUrls', async () => {
|
|
1260
|
+
const issueWithDependency = createMockIssue({
|
|
1261
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1262
|
+
title: 'Issue with dependency',
|
|
1263
|
+
labels: [],
|
|
1264
|
+
status: 'Awaiting Workspace',
|
|
1265
|
+
dependedIssueUrls: ['https://github.com/user/repo/issues/2'],
|
|
1266
|
+
});
|
|
1267
|
+
const issueWithoutDependency = createMockIssue({
|
|
1268
|
+
url: 'https://github.com/user/repo/issues/3',
|
|
1269
|
+
title: 'Issue without dependency',
|
|
1270
|
+
labels: [],
|
|
1271
|
+
status: 'Awaiting Workspace',
|
|
1272
|
+
dependedIssueUrls: [],
|
|
1294
1273
|
});
|
|
1295
1274
|
|
|
1296
|
-
expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
|
|
1297
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
|
|
1298
|
-
expect(mockProjectRepository.getByUrl).not.toHaveBeenCalled();
|
|
1299
|
-
});
|
|
1300
|
-
|
|
1301
|
-
it('should proceed with preparation when Claude usage is under 90%', async () => {
|
|
1302
|
-
mockClaudeRepository.getUsage.mockResolvedValue([
|
|
1303
|
-
{ hour: 5, utilizationPercentage: 50, resetsAt: new Date() },
|
|
1304
|
-
{ hour: 168, utilizationPercentage: 30, resetsAt: new Date() },
|
|
1305
|
-
]);
|
|
1306
|
-
|
|
1307
|
-
const awaitingIssues: Issue[] = [
|
|
1308
|
-
createMockIssue({
|
|
1309
|
-
url: 'url1',
|
|
1310
|
-
title: 'Issue 1',
|
|
1311
|
-
labels: ['category:impl'],
|
|
1312
|
-
status: 'Awaiting Workspace',
|
|
1313
|
-
}),
|
|
1314
|
-
];
|
|
1315
1275
|
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1316
1276
|
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1317
|
-
createMockStoryObjectMap(
|
|
1277
|
+
createMockStoryObjectMap([issueWithDependency, issueWithoutDependency]),
|
|
1318
1278
|
);
|
|
1319
1279
|
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1320
1280
|
stdout: '',
|
|
@@ -1336,232 +1296,201 @@ describe('StartPreparationUseCase', () => {
|
|
|
1336
1296
|
});
|
|
1337
1297
|
|
|
1338
1298
|
expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
|
|
1299
|
+
expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
|
|
1300
|
+
url: 'https://github.com/user/repo/issues/3',
|
|
1301
|
+
status: 'Preparation',
|
|
1302
|
+
});
|
|
1339
1303
|
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
1340
1304
|
});
|
|
1341
1305
|
|
|
1342
|
-
it('should
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
]);
|
|
1306
|
+
it('should skip issues where nextActionHour is in the future', async () => {
|
|
1307
|
+
jest.useFakeTimers();
|
|
1308
|
+
try {
|
|
1309
|
+
jest.setSystemTime(new Date('2024-01-01T10:00:00'));
|
|
1347
1310
|
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
title: `Issue ${i + 1}`,
|
|
1311
|
+
const issueWithFutureNextActionHour = createMockIssue({
|
|
1312
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1313
|
+
title: 'Issue with future next action hour',
|
|
1352
1314
|
labels: [],
|
|
1353
1315
|
status: 'Awaiting Workspace',
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
exitCode: 0,
|
|
1364
|
-
});
|
|
1316
|
+
nextActionHour: 15,
|
|
1317
|
+
});
|
|
1318
|
+
const issueWithoutNextActionHour = createMockIssue({
|
|
1319
|
+
url: 'https://github.com/user/repo/issues/2',
|
|
1320
|
+
title: 'Issue without next action hour',
|
|
1321
|
+
labels: [],
|
|
1322
|
+
status: 'Awaiting Workspace',
|
|
1323
|
+
nextActionHour: null,
|
|
1324
|
+
});
|
|
1365
1325
|
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1326
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1327
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1328
|
+
createMockStoryObjectMap([
|
|
1329
|
+
issueWithFutureNextActionHour,
|
|
1330
|
+
issueWithoutNextActionHour,
|
|
1331
|
+
]),
|
|
1332
|
+
);
|
|
1333
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1334
|
+
stdout: '',
|
|
1335
|
+
stderr: '',
|
|
1336
|
+
exitCode: 0,
|
|
1337
|
+
});
|
|
1378
1338
|
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1339
|
+
await useCase.run({
|
|
1340
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1341
|
+
defaultAgentName: 'agent1',
|
|
1342
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
1343
|
+
defaultLlmAgentName: null,
|
|
1344
|
+
configFilePath: '/path/to/config.yml',
|
|
1345
|
+
maximumPreparingIssuesCount: null,
|
|
1346
|
+
utilizationPercentageThreshold: 90,
|
|
1347
|
+
allowedIssueAuthors: null,
|
|
1348
|
+
codexHomeCandidates: null,
|
|
1349
|
+
allowIssueCacheMinutes: 0,
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
|
|
1353
|
+
expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
|
|
1354
|
+
url: 'https://github.com/user/repo/issues/2',
|
|
1355
|
+
status: 'Preparation',
|
|
1356
|
+
});
|
|
1357
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
1358
|
+
} finally {
|
|
1359
|
+
jest.useRealTimers();
|
|
1360
|
+
}
|
|
1394
1361
|
});
|
|
1395
1362
|
|
|
1396
|
-
it('should skip
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1363
|
+
it('should skip issues where nextActionDate is tomorrow or more future', async () => {
|
|
1364
|
+
jest.useFakeTimers();
|
|
1365
|
+
try {
|
|
1366
|
+
jest.setSystemTime(new Date('2024-01-15T10:00:00'));
|
|
1400
1367
|
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1368
|
+
const issueWithFutureNextActionDate = createMockIssue({
|
|
1369
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1370
|
+
title: 'Issue with future next action date',
|
|
1404
1371
|
labels: [],
|
|
1405
1372
|
status: 'Awaiting Workspace',
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1373
|
+
nextActionDate: new Date('2024-01-16'),
|
|
1374
|
+
});
|
|
1375
|
+
const issueWithoutNextActionDate = createMockIssue({
|
|
1376
|
+
url: 'https://github.com/user/repo/issues/2',
|
|
1377
|
+
title: 'Issue without next action date',
|
|
1378
|
+
labels: [],
|
|
1379
|
+
status: 'Awaiting Workspace',
|
|
1380
|
+
nextActionDate: null,
|
|
1381
|
+
});
|
|
1412
1382
|
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1383
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1384
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1385
|
+
createMockStoryObjectMap([
|
|
1386
|
+
issueWithFutureNextActionDate,
|
|
1387
|
+
issueWithoutNextActionDate,
|
|
1388
|
+
]),
|
|
1389
|
+
);
|
|
1390
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1391
|
+
stdout: '',
|
|
1392
|
+
stderr: '',
|
|
1393
|
+
exitCode: 0,
|
|
1394
|
+
});
|
|
1425
1395
|
|
|
1426
|
-
|
|
1427
|
-
|
|
1396
|
+
await useCase.run({
|
|
1397
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1398
|
+
defaultAgentName: 'agent1',
|
|
1399
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
1400
|
+
defaultLlmAgentName: null,
|
|
1401
|
+
configFilePath: '/path/to/config.yml',
|
|
1402
|
+
maximumPreparingIssuesCount: null,
|
|
1403
|
+
utilizationPercentageThreshold: 90,
|
|
1404
|
+
allowedIssueAuthors: null,
|
|
1405
|
+
codexHomeCandidates: null,
|
|
1406
|
+
allowIssueCacheMinutes: 0,
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
|
|
1410
|
+
expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
|
|
1411
|
+
url: 'https://github.com/user/repo/issues/2',
|
|
1412
|
+
status: 'Preparation',
|
|
1413
|
+
});
|
|
1414
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
1415
|
+
} finally {
|
|
1416
|
+
jest.useRealTimers();
|
|
1417
|
+
}
|
|
1428
1418
|
});
|
|
1429
1419
|
|
|
1430
|
-
it('should not
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1420
|
+
it('should not skip issues where nextActionDate is today', async () => {
|
|
1421
|
+
jest.useFakeTimers();
|
|
1422
|
+
try {
|
|
1423
|
+
jest.setSystemTime(new Date('2024-01-15T10:00:00'));
|
|
1434
1424
|
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
title: `Issue ${i + 1}`,
|
|
1425
|
+
const issueWithTodayNextActionDate = createMockIssue({
|
|
1426
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1427
|
+
title: 'Issue with today next action date',
|
|
1439
1428
|
labels: [],
|
|
1440
1429
|
status: 'Awaiting Workspace',
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1444
|
-
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1445
|
-
createMockStoryObjectMap(awaitingIssues),
|
|
1446
|
-
);
|
|
1447
|
-
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1448
|
-
stdout: '',
|
|
1449
|
-
stderr: '',
|
|
1450
|
-
exitCode: 0,
|
|
1451
|
-
});
|
|
1452
|
-
|
|
1453
|
-
await useCase.run({
|
|
1454
|
-
projectUrl: 'https://github.com/user/repo',
|
|
1455
|
-
defaultAgentName: 'agent1',
|
|
1456
|
-
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
1457
|
-
defaultLlmAgentName: null,
|
|
1458
|
-
configFilePath: '/path/to/config.yml',
|
|
1459
|
-
maximumPreparingIssuesCount: null,
|
|
1460
|
-
utilizationPercentageThreshold: 100,
|
|
1461
|
-
allowedIssueAuthors: null,
|
|
1462
|
-
codexHomeCandidates: null,
|
|
1463
|
-
allowIssueCacheMinutes: 0,
|
|
1464
|
-
});
|
|
1430
|
+
nextActionDate: new Date('2024-01-15'),
|
|
1431
|
+
});
|
|
1465
1432
|
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
const awaitingIssues: Issue[] = [
|
|
1477
|
-
createMockIssue({
|
|
1478
|
-
url: 'url1',
|
|
1479
|
-
labels: [],
|
|
1480
|
-
status: 'Awaiting Workspace',
|
|
1481
|
-
}),
|
|
1482
|
-
];
|
|
1483
|
-
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1484
|
-
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1485
|
-
createMockStoryObjectMap(awaitingIssues),
|
|
1486
|
-
);
|
|
1433
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1434
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1435
|
+
createMockStoryObjectMap([issueWithTodayNextActionDate]),
|
|
1436
|
+
);
|
|
1437
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1438
|
+
stdout: '',
|
|
1439
|
+
stderr: '',
|
|
1440
|
+
exitCode: 0,
|
|
1441
|
+
});
|
|
1487
1442
|
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1443
|
+
await useCase.run({
|
|
1444
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1445
|
+
defaultAgentName: 'agent1',
|
|
1446
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
1447
|
+
defaultLlmAgentName: null,
|
|
1448
|
+
configFilePath: '/path/to/config.yml',
|
|
1449
|
+
maximumPreparingIssuesCount: null,
|
|
1450
|
+
utilizationPercentageThreshold: 90,
|
|
1451
|
+
allowedIssueAuthors: null,
|
|
1452
|
+
codexHomeCandidates: null,
|
|
1453
|
+
allowIssueCacheMinutes: 0,
|
|
1454
|
+
});
|
|
1500
1455
|
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1456
|
+
expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
|
|
1457
|
+
expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
|
|
1458
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1459
|
+
status: 'Preparation',
|
|
1460
|
+
});
|
|
1461
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
1462
|
+
} finally {
|
|
1463
|
+
jest.useRealTimers();
|
|
1464
|
+
}
|
|
1504
1465
|
});
|
|
1505
1466
|
|
|
1506
|
-
it('should
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
]);
|
|
1467
|
+
it('should not skip issues where nextActionDate is in the past', async () => {
|
|
1468
|
+
jest.useFakeTimers();
|
|
1469
|
+
try {
|
|
1470
|
+
jest.setSystemTime(new Date('2024-01-15T10:00:00'));
|
|
1511
1471
|
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
title: `Issue ${i + 1}`,
|
|
1472
|
+
const issueWithPastNextActionDate = createMockIssue({
|
|
1473
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1474
|
+
title: 'Issue with past next action date',
|
|
1516
1475
|
labels: [],
|
|
1517
1476
|
status: 'Awaiting Workspace',
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1521
|
-
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1522
|
-
createMockStoryObjectMap(awaitingIssues),
|
|
1523
|
-
);
|
|
1524
|
-
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1525
|
-
stdout: '',
|
|
1526
|
-
stderr: '',
|
|
1527
|
-
exitCode: 0,
|
|
1528
|
-
});
|
|
1529
|
-
|
|
1530
|
-
await useCase.run({
|
|
1531
|
-
projectUrl: 'https://github.com/user/repo',
|
|
1532
|
-
defaultAgentName: 'agent1',
|
|
1533
|
-
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
1534
|
-
defaultLlmAgentName: null,
|
|
1535
|
-
configFilePath: '/path/to/config.yml',
|
|
1536
|
-
maximumPreparingIssuesCount: null,
|
|
1537
|
-
utilizationPercentageThreshold: 90,
|
|
1538
|
-
allowedIssueAuthors: null,
|
|
1539
|
-
codexHomeCandidates: null,
|
|
1540
|
-
allowIssueCacheMinutes: 0,
|
|
1541
|
-
});
|
|
1542
|
-
|
|
1543
|
-
const normalizedUtilizationBeyondThreshold = (95 - 90) / (100 - 90);
|
|
1544
|
-
const expectedMax = Math.floor(
|
|
1545
|
-
6 * Math.pow(1 - normalizedUtilizationBeyondThreshold, 2),
|
|
1546
|
-
);
|
|
1547
|
-
expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(
|
|
1548
|
-
expectedMax,
|
|
1549
|
-
);
|
|
1550
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(
|
|
1551
|
-
expectedMax,
|
|
1552
|
-
);
|
|
1553
|
-
});
|
|
1477
|
+
nextActionDate: new Date('2024-01-14'),
|
|
1478
|
+
});
|
|
1554
1479
|
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1480
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1481
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1482
|
+
createMockStoryObjectMap([issueWithPastNextActionDate]),
|
|
1483
|
+
);
|
|
1484
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1485
|
+
stdout: '',
|
|
1486
|
+
stderr: '',
|
|
1487
|
+
exitCode: 0,
|
|
1488
|
+
});
|
|
1559
1489
|
|
|
1560
|
-
|
|
1561
|
-
useCase.run({
|
|
1490
|
+
await useCase.run({
|
|
1562
1491
|
projectUrl: 'https://github.com/user/repo',
|
|
1563
1492
|
defaultAgentName: 'agent1',
|
|
1564
|
-
defaultLlmModelName:
|
|
1493
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
1565
1494
|
defaultLlmAgentName: null,
|
|
1566
1495
|
configFilePath: '/path/to/config.yml',
|
|
1567
1496
|
maximumPreparingIssuesCount: null,
|
|
@@ -1569,65 +1498,88 @@ describe('StartPreparationUseCase', () => {
|
|
|
1569
1498
|
allowedIssueAuthors: null,
|
|
1570
1499
|
codexHomeCandidates: null,
|
|
1571
1500
|
allowIssueCacheMinutes: 0,
|
|
1572
|
-
})
|
|
1573
|
-
).rejects.toThrow('Claude credentials file not found');
|
|
1501
|
+
});
|
|
1574
1502
|
|
|
1575
|
-
|
|
1576
|
-
|
|
1503
|
+
expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
|
|
1504
|
+
expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
|
|
1505
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1506
|
+
status: 'Preparation',
|
|
1507
|
+
});
|
|
1508
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
1509
|
+
} finally {
|
|
1510
|
+
jest.useRealTimers();
|
|
1511
|
+
}
|
|
1577
1512
|
});
|
|
1578
1513
|
|
|
1579
|
-
it('should skip
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1514
|
+
it('should not skip issues where nextActionHour is in the past or current hour', async () => {
|
|
1515
|
+
jest.useFakeTimers();
|
|
1516
|
+
try {
|
|
1517
|
+
jest.setSystemTime(new Date('2024-01-01T15:00:00'));
|
|
1583
1518
|
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
title: 'Issue 1',
|
|
1519
|
+
const issueWithPastNextActionHour = createMockIssue({
|
|
1520
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1521
|
+
title: 'Issue with past next action hour',
|
|
1588
1522
|
labels: [],
|
|
1589
1523
|
status: 'Awaiting Workspace',
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1593
|
-
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1594
|
-
createMockStoryObjectMap(awaitingIssues),
|
|
1595
|
-
);
|
|
1524
|
+
nextActionHour: 10,
|
|
1525
|
+
});
|
|
1596
1526
|
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
codexHomeCandidates: null,
|
|
1607
|
-
allowIssueCacheMinutes: 0,
|
|
1608
|
-
});
|
|
1527
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1528
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1529
|
+
createMockStoryObjectMap([issueWithPastNextActionHour]),
|
|
1530
|
+
);
|
|
1531
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1532
|
+
stdout: '',
|
|
1533
|
+
stderr: '',
|
|
1534
|
+
exitCode: 0,
|
|
1535
|
+
});
|
|
1609
1536
|
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1537
|
+
await useCase.run({
|
|
1538
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1539
|
+
defaultAgentName: 'agent1',
|
|
1540
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
1541
|
+
defaultLlmAgentName: null,
|
|
1542
|
+
configFilePath: '/path/to/config.yml',
|
|
1543
|
+
maximumPreparingIssuesCount: null,
|
|
1544
|
+
utilizationPercentageThreshold: 90,
|
|
1545
|
+
allowedIssueAuthors: null,
|
|
1546
|
+
codexHomeCandidates: null,
|
|
1547
|
+
allowIssueCacheMinutes: 0,
|
|
1548
|
+
});
|
|
1549
|
+
|
|
1550
|
+
expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
|
|
1551
|
+
expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
|
|
1552
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1553
|
+
status: 'Preparation',
|
|
1554
|
+
});
|
|
1555
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
1556
|
+
} finally {
|
|
1557
|
+
jest.useRealTimers();
|
|
1558
|
+
}
|
|
1613
1559
|
});
|
|
1614
1560
|
|
|
1615
|
-
it('should
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1561
|
+
it('should skip issues from non-allowed authors', async () => {
|
|
1562
|
+
const issueFromAllowedAuthor = createMockIssue({
|
|
1563
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1564
|
+
title: 'Issue from allowed author',
|
|
1565
|
+
labels: [],
|
|
1566
|
+
status: 'Awaiting Workspace',
|
|
1567
|
+
author: 'user1',
|
|
1568
|
+
});
|
|
1569
|
+
const issueFromNonAllowedAuthor = createMockIssue({
|
|
1570
|
+
url: 'https://github.com/user/repo/issues/2',
|
|
1571
|
+
title: 'Issue from non-allowed author',
|
|
1572
|
+
labels: [],
|
|
1573
|
+
status: 'Awaiting Workspace',
|
|
1574
|
+
author: 'user3',
|
|
1575
|
+
});
|
|
1619
1576
|
|
|
1620
|
-
const awaitingIssues: Issue[] = [
|
|
1621
|
-
createMockIssue({
|
|
1622
|
-
url: 'url1',
|
|
1623
|
-
title: 'Issue 1',
|
|
1624
|
-
labels: ['category:impl'],
|
|
1625
|
-
status: 'Awaiting Workspace',
|
|
1626
|
-
}),
|
|
1627
|
-
];
|
|
1628
1577
|
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1629
1578
|
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1630
|
-
createMockStoryObjectMap(
|
|
1579
|
+
createMockStoryObjectMap([
|
|
1580
|
+
issueFromAllowedAuthor,
|
|
1581
|
+
issueFromNonAllowedAuthor,
|
|
1582
|
+
]),
|
|
1631
1583
|
);
|
|
1632
1584
|
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1633
1585
|
stdout: '',
|
|
@@ -1642,35 +1594,39 @@ describe('StartPreparationUseCase', () => {
|
|
|
1642
1594
|
defaultLlmAgentName: null,
|
|
1643
1595
|
configFilePath: '/path/to/config.yml',
|
|
1644
1596
|
maximumPreparingIssuesCount: null,
|
|
1645
|
-
utilizationPercentageThreshold:
|
|
1646
|
-
allowedIssueAuthors:
|
|
1597
|
+
utilizationPercentageThreshold: 90,
|
|
1598
|
+
allowedIssueAuthors: ['user1', 'user2'],
|
|
1647
1599
|
codexHomeCandidates: null,
|
|
1648
1600
|
allowIssueCacheMinutes: 0,
|
|
1649
1601
|
});
|
|
1650
1602
|
|
|
1651
1603
|
expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
|
|
1604
|
+
expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
|
|
1605
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1606
|
+
status: 'Preparation',
|
|
1607
|
+
});
|
|
1652
1608
|
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
1653
1609
|
});
|
|
1654
1610
|
|
|
1655
|
-
it('should
|
|
1656
|
-
const
|
|
1611
|
+
it('should process all issues when allowedIssueAuthors is null', async () => {
|
|
1612
|
+
const issue1 = createMockIssue({
|
|
1657
1613
|
url: 'https://github.com/user/repo/issues/1',
|
|
1658
|
-
title: 'Issue
|
|
1614
|
+
title: 'Issue 1',
|
|
1659
1615
|
labels: [],
|
|
1660
1616
|
status: 'Awaiting Workspace',
|
|
1661
|
-
|
|
1617
|
+
author: 'user1',
|
|
1662
1618
|
});
|
|
1663
|
-
const
|
|
1664
|
-
url: 'https://github.com/user/repo/issues/
|
|
1665
|
-
title: 'Issue
|
|
1619
|
+
const issue2 = createMockIssue({
|
|
1620
|
+
url: 'https://github.com/user/repo/issues/2',
|
|
1621
|
+
title: 'Issue 2',
|
|
1666
1622
|
labels: [],
|
|
1667
1623
|
status: 'Awaiting Workspace',
|
|
1668
|
-
|
|
1624
|
+
author: 'user2',
|
|
1669
1625
|
});
|
|
1670
1626
|
|
|
1671
1627
|
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1672
1628
|
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1673
|
-
createMockStoryObjectMap([
|
|
1629
|
+
createMockStoryObjectMap([issue1, issue2]),
|
|
1674
1630
|
);
|
|
1675
1631
|
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1676
1632
|
stdout: '',
|
|
@@ -1691,291 +1647,462 @@ describe('StartPreparationUseCase', () => {
|
|
|
1691
1647
|
allowIssueCacheMinutes: 0,
|
|
1692
1648
|
});
|
|
1693
1649
|
|
|
1694
|
-
expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(
|
|
1695
|
-
expect(
|
|
1696
|
-
url: 'https://github.com/user/repo/issues/3',
|
|
1697
|
-
status: 'Preparation',
|
|
1698
|
-
});
|
|
1699
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
1650
|
+
expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(2);
|
|
1651
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(2);
|
|
1700
1652
|
});
|
|
1701
1653
|
|
|
1702
|
-
it('should skip issues
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
nextActionHour: null,
|
|
1720
|
-
});
|
|
1654
|
+
it('should skip issues with empty author when allowedIssueAuthors is configured (deny-by-default)', async () => {
|
|
1655
|
+
const issueWithEmptyAuthor = createMockIssue({
|
|
1656
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1657
|
+
title: 'Issue with empty author',
|
|
1658
|
+
labels: [],
|
|
1659
|
+
status: 'Awaiting Workspace',
|
|
1660
|
+
author: '',
|
|
1661
|
+
});
|
|
1662
|
+
const issueWithKnownAuthor = createMockIssue({
|
|
1663
|
+
url: 'https://github.com/user/repo/issues/2',
|
|
1664
|
+
title: 'Issue with known author',
|
|
1665
|
+
labels: [],
|
|
1666
|
+
status: 'Awaiting Workspace',
|
|
1667
|
+
author: 'user1',
|
|
1668
|
+
number: 2,
|
|
1669
|
+
itemId: 'item-2',
|
|
1670
|
+
});
|
|
1721
1671
|
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
stderr: '',
|
|
1732
|
-
exitCode: 0,
|
|
1733
|
-
});
|
|
1672
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1673
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1674
|
+
createMockStoryObjectMap([issueWithEmptyAuthor, issueWithKnownAuthor]),
|
|
1675
|
+
);
|
|
1676
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1677
|
+
stdout: '',
|
|
1678
|
+
stderr: '',
|
|
1679
|
+
exitCode: 0,
|
|
1680
|
+
});
|
|
1734
1681
|
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1682
|
+
await useCase.run({
|
|
1683
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1684
|
+
defaultAgentName: 'agent1',
|
|
1685
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
1686
|
+
defaultLlmAgentName: null,
|
|
1687
|
+
configFilePath: '/path/to/config.yml',
|
|
1688
|
+
maximumPreparingIssuesCount: null,
|
|
1689
|
+
utilizationPercentageThreshold: 90,
|
|
1690
|
+
allowedIssueAuthors: ['user1', 'user2'],
|
|
1691
|
+
codexHomeCandidates: null,
|
|
1692
|
+
allowIssueCacheMinutes: 0,
|
|
1693
|
+
});
|
|
1747
1694
|
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
1754
|
-
} finally {
|
|
1755
|
-
jest.useRealTimers();
|
|
1756
|
-
}
|
|
1695
|
+
expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
|
|
1696
|
+
expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
|
|
1697
|
+
url: 'https://github.com/user/repo/issues/2',
|
|
1698
|
+
});
|
|
1699
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
1757
1700
|
});
|
|
1758
1701
|
|
|
1759
|
-
it('should skip
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
labels: [],
|
|
1768
|
-
status: 'Awaiting Workspace',
|
|
1769
|
-
nextActionDate: new Date('2024-01-16'),
|
|
1770
|
-
});
|
|
1771
|
-
const issueWithoutNextActionDate = createMockIssue({
|
|
1772
|
-
url: 'https://github.com/user/repo/issues/2',
|
|
1773
|
-
title: 'Issue without next action date',
|
|
1774
|
-
labels: [],
|
|
1775
|
-
status: 'Awaiting Workspace',
|
|
1776
|
-
nextActionDate: null,
|
|
1777
|
-
});
|
|
1702
|
+
it('should skip issue with empty author when allowedIssueAuthors is set', async () => {
|
|
1703
|
+
const issueWithEmptyAuthor = createMockIssue({
|
|
1704
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1705
|
+
title: 'Issue with empty author',
|
|
1706
|
+
labels: [],
|
|
1707
|
+
status: 'Awaiting Workspace',
|
|
1708
|
+
author: '',
|
|
1709
|
+
});
|
|
1778
1710
|
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
stderr: '',
|
|
1789
|
-
exitCode: 0,
|
|
1790
|
-
});
|
|
1711
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1712
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1713
|
+
createMockStoryObjectMap([issueWithEmptyAuthor]),
|
|
1714
|
+
);
|
|
1715
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1716
|
+
stdout: '',
|
|
1717
|
+
stderr: '',
|
|
1718
|
+
exitCode: 0,
|
|
1719
|
+
});
|
|
1791
1720
|
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1721
|
+
await useCase.run({
|
|
1722
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1723
|
+
defaultAgentName: 'agent1',
|
|
1724
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
1725
|
+
defaultLlmAgentName: null,
|
|
1726
|
+
configFilePath: '/path/to/config.yml',
|
|
1727
|
+
maximumPreparingIssuesCount: null,
|
|
1728
|
+
utilizationPercentageThreshold: 90,
|
|
1729
|
+
allowedIssueAuthors: ['user1'],
|
|
1730
|
+
codexHomeCandidates: null,
|
|
1731
|
+
allowIssueCacheMinutes: 0,
|
|
1732
|
+
});
|
|
1804
1733
|
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
url: 'https://github.com/user/repo/issues/2',
|
|
1808
|
-
status: 'Preparation',
|
|
1809
|
-
});
|
|
1810
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
1811
|
-
} finally {
|
|
1812
|
-
jest.useRealTimers();
|
|
1813
|
-
}
|
|
1734
|
+
expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
|
|
1735
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
|
|
1814
1736
|
});
|
|
1815
1737
|
|
|
1816
|
-
it('should not
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
url: 'https://github.com/user/repo/issues/1',
|
|
1823
|
-
title: 'Issue with today next action date',
|
|
1824
|
-
labels: [],
|
|
1738
|
+
it('should not pass --codexHome when codexHomeCandidates is null', async () => {
|
|
1739
|
+
const awaitingIssues: Issue[] = [
|
|
1740
|
+
createMockIssue({
|
|
1741
|
+
url: 'url1',
|
|
1742
|
+
title: 'Issue 1',
|
|
1743
|
+
labels: ['category:impl'],
|
|
1825
1744
|
status: 'Awaiting Workspace',
|
|
1826
|
-
|
|
1827
|
-
|
|
1745
|
+
}),
|
|
1746
|
+
];
|
|
1747
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1748
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1749
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
1750
|
+
);
|
|
1751
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1752
|
+
stdout: '',
|
|
1753
|
+
stderr: '',
|
|
1754
|
+
exitCode: 0,
|
|
1755
|
+
});
|
|
1828
1756
|
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1757
|
+
await useCase.run({
|
|
1758
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1759
|
+
defaultAgentName: 'agent1',
|
|
1760
|
+
defaultLlmModelName: 'claude-opus',
|
|
1761
|
+
defaultLlmAgentName: null,
|
|
1762
|
+
configFilePath: '/path/to/config.yml',
|
|
1763
|
+
maximumPreparingIssuesCount: null,
|
|
1764
|
+
utilizationPercentageThreshold: 90,
|
|
1765
|
+
allowedIssueAuthors: null,
|
|
1766
|
+
codexHomeCandidates: null,
|
|
1767
|
+
allowIssueCacheMinutes: 0,
|
|
1768
|
+
});
|
|
1838
1769
|
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1770
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
1771
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
|
|
1772
|
+
'aw',
|
|
1773
|
+
[
|
|
1774
|
+
'url1',
|
|
1775
|
+
'impl',
|
|
1776
|
+
'claude-opus',
|
|
1777
|
+
'--configFilePath',
|
|
1778
|
+
'/path/to/config.yml',
|
|
1779
|
+
'--branch',
|
|
1780
|
+
'i1',
|
|
1781
|
+
],
|
|
1782
|
+
]);
|
|
1783
|
+
});
|
|
1851
1784
|
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1785
|
+
it('should not pass --codexHome when codexHomeCandidates is empty array', async () => {
|
|
1786
|
+
const awaitingIssues: Issue[] = [
|
|
1787
|
+
createMockIssue({
|
|
1788
|
+
url: 'url1',
|
|
1789
|
+
title: 'Issue 1',
|
|
1790
|
+
labels: ['category:impl'],
|
|
1791
|
+
status: 'Awaiting Workspace',
|
|
1792
|
+
}),
|
|
1793
|
+
];
|
|
1794
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1795
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1796
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
1797
|
+
);
|
|
1798
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1799
|
+
stdout: '',
|
|
1800
|
+
stderr: '',
|
|
1801
|
+
exitCode: 0,
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1804
|
+
await useCase.run({
|
|
1805
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1806
|
+
defaultAgentName: 'agent1',
|
|
1807
|
+
defaultLlmModelName: 'claude-opus',
|
|
1808
|
+
defaultLlmAgentName: null,
|
|
1809
|
+
configFilePath: '/path/to/config.yml',
|
|
1810
|
+
maximumPreparingIssuesCount: null,
|
|
1811
|
+
utilizationPercentageThreshold: 90,
|
|
1812
|
+
allowedIssueAuthors: null,
|
|
1813
|
+
codexHomeCandidates: [],
|
|
1814
|
+
allowIssueCacheMinutes: 0,
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
1818
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
|
|
1819
|
+
'aw',
|
|
1820
|
+
[
|
|
1821
|
+
'url1',
|
|
1822
|
+
'impl',
|
|
1823
|
+
'claude-opus',
|
|
1824
|
+
'--configFilePath',
|
|
1825
|
+
'/path/to/config.yml',
|
|
1826
|
+
'--branch',
|
|
1827
|
+
'i1',
|
|
1828
|
+
],
|
|
1829
|
+
]);
|
|
1861
1830
|
});
|
|
1862
1831
|
|
|
1863
|
-
it('should
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1832
|
+
it('should pass --codexHome with the candidate when codexHomeCandidates has one entry', async () => {
|
|
1833
|
+
const awaitingIssues: Issue[] = [
|
|
1834
|
+
createMockIssue({
|
|
1835
|
+
url: 'url1',
|
|
1836
|
+
title: 'Issue 1',
|
|
1837
|
+
labels: ['category:impl'],
|
|
1838
|
+
status: 'Awaiting Workspace',
|
|
1839
|
+
}),
|
|
1840
|
+
];
|
|
1841
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1842
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1843
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
1844
|
+
);
|
|
1845
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1846
|
+
stdout: '',
|
|
1847
|
+
stderr: '',
|
|
1848
|
+
exitCode: 0,
|
|
1849
|
+
});
|
|
1867
1850
|
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1851
|
+
await useCase.run({
|
|
1852
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1853
|
+
defaultAgentName: 'agent1',
|
|
1854
|
+
defaultLlmModelName: 'claude-opus',
|
|
1855
|
+
defaultLlmAgentName: null,
|
|
1856
|
+
configFilePath: '/path/to/config.yml',
|
|
1857
|
+
maximumPreparingIssuesCount: null,
|
|
1858
|
+
utilizationPercentageThreshold: 90,
|
|
1859
|
+
allowedIssueAuthors: null,
|
|
1860
|
+
codexHomeCandidates: ['.codex-dev1'],
|
|
1861
|
+
allowIssueCacheMinutes: 0,
|
|
1862
|
+
});
|
|
1863
|
+
|
|
1864
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
1865
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
|
|
1866
|
+
'aw',
|
|
1867
|
+
[
|
|
1868
|
+
'url1',
|
|
1869
|
+
'impl',
|
|
1870
|
+
'claude-opus',
|
|
1871
|
+
'--configFilePath',
|
|
1872
|
+
'/path/to/config.yml',
|
|
1873
|
+
'--branch',
|
|
1874
|
+
'i1',
|
|
1875
|
+
'--codexHome',
|
|
1876
|
+
'.codex-dev1',
|
|
1877
|
+
],
|
|
1878
|
+
]);
|
|
1879
|
+
});
|
|
1880
|
+
|
|
1881
|
+
it('should cycle through codexHomeCandidates across multiple issues', async () => {
|
|
1882
|
+
const awaitingIssues: Issue[] = [
|
|
1883
|
+
createMockIssue({
|
|
1884
|
+
url: 'url1',
|
|
1885
|
+
title: 'Issue 1',
|
|
1886
|
+
labels: ['category:impl'],
|
|
1872
1887
|
status: 'Awaiting Workspace',
|
|
1873
|
-
|
|
1874
|
-
|
|
1888
|
+
number: 1,
|
|
1889
|
+
itemId: 'item-1',
|
|
1890
|
+
}),
|
|
1891
|
+
createMockIssue({
|
|
1892
|
+
url: 'url2',
|
|
1893
|
+
title: 'Issue 2',
|
|
1894
|
+
labels: ['category:impl'],
|
|
1895
|
+
status: 'Awaiting Workspace',
|
|
1896
|
+
number: 2,
|
|
1897
|
+
itemId: 'item-2',
|
|
1898
|
+
}),
|
|
1899
|
+
createMockIssue({
|
|
1900
|
+
url: 'url3',
|
|
1901
|
+
title: 'Issue 3',
|
|
1902
|
+
labels: ['category:impl'],
|
|
1903
|
+
status: 'Awaiting Workspace',
|
|
1904
|
+
number: 3,
|
|
1905
|
+
itemId: 'item-3',
|
|
1906
|
+
}),
|
|
1907
|
+
];
|
|
1908
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1909
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1910
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
1911
|
+
);
|
|
1912
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1913
|
+
stdout: '',
|
|
1914
|
+
stderr: '',
|
|
1915
|
+
exitCode: 0,
|
|
1916
|
+
});
|
|
1875
1917
|
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1918
|
+
await useCase.run({
|
|
1919
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1920
|
+
defaultAgentName: 'agent1',
|
|
1921
|
+
defaultLlmModelName: 'claude-opus',
|
|
1922
|
+
defaultLlmAgentName: null,
|
|
1923
|
+
configFilePath: '/path/to/config.yml',
|
|
1924
|
+
maximumPreparingIssuesCount: null,
|
|
1925
|
+
utilizationPercentageThreshold: 90,
|
|
1926
|
+
allowedIssueAuthors: null,
|
|
1927
|
+
codexHomeCandidates: ['.codex-dev1', '.codex-dev2'],
|
|
1928
|
+
allowIssueCacheMinutes: 0,
|
|
1929
|
+
});
|
|
1885
1930
|
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1931
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(3);
|
|
1932
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[0][1]).toContain(
|
|
1933
|
+
'--codexHome',
|
|
1934
|
+
);
|
|
1935
|
+
expect(
|
|
1936
|
+
mockLocalCommandRunner.runCommand.mock.calls[0][1][
|
|
1937
|
+
mockLocalCommandRunner.runCommand.mock.calls[0][1].indexOf(
|
|
1938
|
+
'--codexHome',
|
|
1939
|
+
) + 1
|
|
1940
|
+
],
|
|
1941
|
+
).toBe('.codex-dev1');
|
|
1942
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[1][1]).toContain(
|
|
1943
|
+
'--codexHome',
|
|
1944
|
+
);
|
|
1945
|
+
expect(
|
|
1946
|
+
mockLocalCommandRunner.runCommand.mock.calls[1][1][
|
|
1947
|
+
mockLocalCommandRunner.runCommand.mock.calls[1][1].indexOf(
|
|
1948
|
+
'--codexHome',
|
|
1949
|
+
) + 1
|
|
1950
|
+
],
|
|
1951
|
+
).toBe('.codex-dev2');
|
|
1952
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[2][1]).toContain(
|
|
1953
|
+
'--codexHome',
|
|
1954
|
+
);
|
|
1955
|
+
expect(
|
|
1956
|
+
mockLocalCommandRunner.runCommand.mock.calls[2][1][
|
|
1957
|
+
mockLocalCommandRunner.runCommand.mock.calls[2][1].indexOf(
|
|
1958
|
+
'--codexHome',
|
|
1959
|
+
) + 1
|
|
1960
|
+
],
|
|
1961
|
+
).toBe('.codex-dev1');
|
|
1962
|
+
});
|
|
1898
1963
|
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1964
|
+
it('should persist Preparation status via updateStatus with resolved status option id (regression for issue #519)', async () => {
|
|
1965
|
+
const awaitingIssue = createMockIssue({
|
|
1966
|
+
url: 'https://github.com/user/repo/issues/519',
|
|
1967
|
+
title: 'Regression issue',
|
|
1968
|
+
labels: ['category:impl'],
|
|
1969
|
+
status: 'Awaiting Workspace',
|
|
1970
|
+
itemId: 'item-regression',
|
|
1971
|
+
});
|
|
1972
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1973
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1974
|
+
createMockStoryObjectMap([awaitingIssue]),
|
|
1975
|
+
);
|
|
1976
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1977
|
+
stdout: '',
|
|
1978
|
+
stderr: '',
|
|
1979
|
+
exitCode: 0,
|
|
1980
|
+
});
|
|
1981
|
+
|
|
1982
|
+
await useCase.run({
|
|
1983
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1984
|
+
defaultAgentName: 'agent1',
|
|
1985
|
+
defaultLlmModelName: 'claude-opus',
|
|
1986
|
+
defaultLlmAgentName: null,
|
|
1987
|
+
configFilePath: '/path/to/config.yml',
|
|
1988
|
+
maximumPreparingIssuesCount: null,
|
|
1989
|
+
utilizationPercentageThreshold: 90,
|
|
1990
|
+
allowedIssueAuthors: null,
|
|
1991
|
+
codexHomeCandidates: null,
|
|
1992
|
+
allowIssueCacheMinutes: 0,
|
|
1993
|
+
});
|
|
1994
|
+
|
|
1995
|
+
expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
|
|
1996
|
+
expect(mockIssueRepository.updateStatus.mock.calls[0][0]).toBe(mockProject);
|
|
1997
|
+
expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
|
|
1998
|
+
url: 'https://github.com/user/repo/issues/519',
|
|
1999
|
+
itemId: 'item-regression',
|
|
2000
|
+
});
|
|
2001
|
+
expect(mockIssueRepository.updateStatus.mock.calls[0][2]).toBe('2');
|
|
2002
|
+
const updateStatusCallOrder =
|
|
2003
|
+
mockIssueRepository.updateStatus.mock.invocationCallOrder[0];
|
|
2004
|
+
const runCommandCallOrder =
|
|
2005
|
+
mockLocalCommandRunner.runCommand.mock.invocationCallOrder[0];
|
|
2006
|
+
expect(updateStatusCallOrder).toBeLessThan(runCommandCallOrder);
|
|
1908
2007
|
});
|
|
1909
2008
|
|
|
1910
|
-
it('should
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
2009
|
+
it('should return early and log an error when preparationStatus option is not in the project', async () => {
|
|
2010
|
+
const projectWithoutPreparation: Project = {
|
|
2011
|
+
...createMockProject(),
|
|
2012
|
+
status: {
|
|
2013
|
+
name: 'Status',
|
|
2014
|
+
fieldId: 'status-field-id',
|
|
2015
|
+
statuses: [
|
|
2016
|
+
{
|
|
2017
|
+
id: '1',
|
|
2018
|
+
name: 'Awaiting Workspace',
|
|
2019
|
+
color: 'GRAY',
|
|
2020
|
+
description: '',
|
|
2021
|
+
},
|
|
2022
|
+
{ id: '3', name: 'Done', color: 'GREEN', description: '' },
|
|
2023
|
+
],
|
|
2024
|
+
},
|
|
2025
|
+
};
|
|
2026
|
+
const awaitingIssue = createMockIssue({
|
|
2027
|
+
url: 'url-missing-option',
|
|
2028
|
+
title: 'Missing Preparation Option',
|
|
2029
|
+
labels: ['category:impl'],
|
|
2030
|
+
status: 'Awaiting Workspace',
|
|
2031
|
+
});
|
|
2032
|
+
mockProjectRepository.getByUrl.mockResolvedValue(projectWithoutPreparation);
|
|
2033
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
2034
|
+
createMockStoryObjectMap([awaitingIssue]),
|
|
2035
|
+
);
|
|
2036
|
+
const consoleErrorSpy = jest
|
|
2037
|
+
.spyOn(console, 'error')
|
|
2038
|
+
.mockImplementation(() => {});
|
|
2039
|
+
|
|
2040
|
+
await useCase.run({
|
|
2041
|
+
projectUrl: 'https://github.com/user/repo',
|
|
2042
|
+
defaultAgentName: 'agent1',
|
|
2043
|
+
defaultLlmModelName: 'claude-opus',
|
|
2044
|
+
defaultLlmAgentName: null,
|
|
2045
|
+
configFilePath: '/path/to/config.yml',
|
|
2046
|
+
maximumPreparingIssuesCount: null,
|
|
2047
|
+
utilizationPercentageThreshold: 90,
|
|
2048
|
+
allowedIssueAuthors: null,
|
|
2049
|
+
codexHomeCandidates: null,
|
|
2050
|
+
allowIssueCacheMinutes: 0,
|
|
2051
|
+
});
|
|
1914
2052
|
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
2053
|
+
expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
|
|
2054
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
|
|
2055
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
2056
|
+
"Preparation status option 'Preparation' not found in project.",
|
|
2057
|
+
);
|
|
2058
|
+
consoleErrorSpy.mockRestore();
|
|
2059
|
+
});
|
|
1922
2060
|
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
)
|
|
1927
|
-
|
|
1928
|
-
stdout: '',
|
|
1929
|
-
stderr: '',
|
|
1930
|
-
exitCode: 0,
|
|
1931
|
-
});
|
|
2061
|
+
it('should pass allowIssueCacheMinutes to getStoryObjectMap', async () => {
|
|
2062
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
2063
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
2064
|
+
createMockStoryObjectMap([]),
|
|
2065
|
+
);
|
|
1932
2066
|
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
2067
|
+
await useCase.run({
|
|
2068
|
+
projectUrl: 'https://github.com/user/repo',
|
|
2069
|
+
defaultAgentName: 'agent1',
|
|
2070
|
+
defaultLlmModelName: null,
|
|
2071
|
+
defaultLlmAgentName: null,
|
|
2072
|
+
configFilePath: '/path/to/config.yml',
|
|
2073
|
+
maximumPreparingIssuesCount: null,
|
|
2074
|
+
utilizationPercentageThreshold: 90,
|
|
2075
|
+
allowedIssueAuthors: null,
|
|
2076
|
+
codexHomeCandidates: null,
|
|
2077
|
+
allowIssueCacheMinutes: 5,
|
|
2078
|
+
});
|
|
1945
2079
|
|
|
1946
|
-
|
|
1947
|
-
expect(
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
});
|
|
1951
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
1952
|
-
} finally {
|
|
1953
|
-
jest.useRealTimers();
|
|
1954
|
-
}
|
|
2080
|
+
expect(mockIssueRepository.getStoryObjectMap).toHaveBeenCalledWith(
|
|
2081
|
+
expect.objectContaining({ id: 'project-1' }),
|
|
2082
|
+
5,
|
|
2083
|
+
);
|
|
1955
2084
|
});
|
|
1956
2085
|
|
|
1957
|
-
it('should skip issues
|
|
1958
|
-
const
|
|
2086
|
+
it('should skip closed issues in awaiting workspace status', async () => {
|
|
2087
|
+
const closedIssue = createMockIssue({
|
|
1959
2088
|
url: 'https://github.com/user/repo/issues/1',
|
|
1960
|
-
title: '
|
|
2089
|
+
title: 'Closed issue',
|
|
1961
2090
|
labels: [],
|
|
1962
2091
|
status: 'Awaiting Workspace',
|
|
1963
|
-
|
|
2092
|
+
isClosed: true,
|
|
1964
2093
|
});
|
|
1965
|
-
const
|
|
2094
|
+
const openIssue = createMockIssue({
|
|
1966
2095
|
url: 'https://github.com/user/repo/issues/2',
|
|
1967
|
-
|
|
2096
|
+
number: 2,
|
|
2097
|
+
title: 'Open issue',
|
|
1968
2098
|
labels: [],
|
|
1969
2099
|
status: 'Awaiting Workspace',
|
|
1970
|
-
|
|
2100
|
+
isClosed: false,
|
|
1971
2101
|
});
|
|
1972
2102
|
|
|
1973
2103
|
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1974
2104
|
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1975
|
-
createMockStoryObjectMap([
|
|
1976
|
-
issueFromAllowedAuthor,
|
|
1977
|
-
issueFromNonAllowedAuthor,
|
|
1978
|
-
]),
|
|
2105
|
+
createMockStoryObjectMap([closedIssue, openIssue]),
|
|
1979
2106
|
);
|
|
1980
2107
|
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1981
2108
|
stdout: '',
|
|
@@ -1991,49 +2118,49 @@ describe('StartPreparationUseCase', () => {
|
|
|
1991
2118
|
configFilePath: '/path/to/config.yml',
|
|
1992
2119
|
maximumPreparingIssuesCount: null,
|
|
1993
2120
|
utilizationPercentageThreshold: 90,
|
|
1994
|
-
allowedIssueAuthors:
|
|
2121
|
+
allowedIssueAuthors: null,
|
|
1995
2122
|
codexHomeCandidates: null,
|
|
1996
2123
|
allowIssueCacheMinutes: 0,
|
|
1997
2124
|
});
|
|
1998
2125
|
|
|
1999
2126
|
expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
|
|
2000
2127
|
expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
|
|
2001
|
-
url: 'https://github.com/user/repo/issues/
|
|
2002
|
-
status: 'Preparation',
|
|
2128
|
+
url: 'https://github.com/user/repo/issues/2',
|
|
2003
2129
|
});
|
|
2004
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
2005
2130
|
});
|
|
2006
2131
|
|
|
2007
|
-
it('should
|
|
2008
|
-
const
|
|
2009
|
-
url: '
|
|
2132
|
+
it('should pass CLAUDE_CODE_OAUTH_TOKEN and ANTHROPIC_BASE_URL to runCommand when tokens are available', async () => {
|
|
2133
|
+
const awaitingIssue = createMockIssue({
|
|
2134
|
+
url: 'url1',
|
|
2010
2135
|
title: 'Issue 1',
|
|
2011
|
-
labels: [],
|
|
2012
|
-
status: 'Awaiting Workspace',
|
|
2013
|
-
author: 'user1',
|
|
2014
|
-
});
|
|
2015
|
-
const issue2 = createMockIssue({
|
|
2016
|
-
url: 'https://github.com/user/repo/issues/2',
|
|
2017
|
-
title: 'Issue 2',
|
|
2018
|
-
labels: [],
|
|
2136
|
+
labels: ['category:impl'],
|
|
2019
2137
|
status: 'Awaiting Workspace',
|
|
2020
|
-
|
|
2138
|
+
number: 1,
|
|
2139
|
+
itemId: 'item-1',
|
|
2021
2140
|
});
|
|
2022
|
-
|
|
2023
2141
|
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
2024
2142
|
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
2025
|
-
createMockStoryObjectMap([
|
|
2143
|
+
createMockStoryObjectMap([awaitingIssue]),
|
|
2026
2144
|
);
|
|
2027
2145
|
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
2028
2146
|
stdout: '',
|
|
2029
2147
|
stderr: '',
|
|
2030
2148
|
exitCode: 0,
|
|
2031
2149
|
});
|
|
2150
|
+
mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
|
|
2151
|
+
{
|
|
2152
|
+
token: 'token-a',
|
|
2153
|
+
fiveHourUtilization: 0,
|
|
2154
|
+
blocked: false,
|
|
2155
|
+
rejected: false,
|
|
2156
|
+
modelWeeklyLimits: {},
|
|
2157
|
+
},
|
|
2158
|
+
]);
|
|
2032
2159
|
|
|
2033
2160
|
await useCase.run({
|
|
2034
2161
|
projectUrl: 'https://github.com/user/repo',
|
|
2035
2162
|
defaultAgentName: 'agent1',
|
|
2036
|
-
defaultLlmModelName: 'claude-
|
|
2163
|
+
defaultLlmModelName: 'claude-opus',
|
|
2037
2164
|
defaultLlmAgentName: null,
|
|
2038
2165
|
configFilePath: '/path/to/config.yml',
|
|
2039
2166
|
maximumPreparingIssuesCount: null,
|
|
@@ -2043,112 +2170,184 @@ describe('StartPreparationUseCase', () => {
|
|
|
2043
2170
|
allowIssueCacheMinutes: 0,
|
|
2044
2171
|
});
|
|
2045
2172
|
|
|
2046
|
-
expect(
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
const issueWithKnownAuthor = createMockIssue({
|
|
2059
|
-
url: 'https://github.com/user/repo/issues/2',
|
|
2060
|
-
title: 'Issue with known author',
|
|
2061
|
-
labels: [],
|
|
2062
|
-
status: 'Awaiting Workspace',
|
|
2063
|
-
author: 'user1',
|
|
2064
|
-
number: 2,
|
|
2065
|
-
itemId: 'item-2',
|
|
2173
|
+
expect(
|
|
2174
|
+
mockClaudeTokenUsageRepository.getAvailableTokenUsages.mock.calls,
|
|
2175
|
+
).toHaveLength(1);
|
|
2176
|
+
expect(
|
|
2177
|
+
mockClaudeTokenUsageRepository.ensureObservable.mock.calls,
|
|
2178
|
+
).toHaveLength(1);
|
|
2179
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
2180
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
|
|
2181
|
+
env: {
|
|
2182
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-a',
|
|
2183
|
+
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
|
|
2184
|
+
},
|
|
2066
2185
|
});
|
|
2186
|
+
});
|
|
2067
2187
|
|
|
2188
|
+
it('should rotate Claude OAuth tokens round-robin across multiple awaiting issues', async () => {
|
|
2189
|
+
const awaitingIssues: Issue[] = [
|
|
2190
|
+
createMockIssue({
|
|
2191
|
+
url: 'url1',
|
|
2192
|
+
title: 'Issue 1',
|
|
2193
|
+
labels: ['category:impl'],
|
|
2194
|
+
status: 'Awaiting Workspace',
|
|
2195
|
+
number: 1,
|
|
2196
|
+
itemId: 'item-1',
|
|
2197
|
+
}),
|
|
2198
|
+
createMockIssue({
|
|
2199
|
+
url: 'url2',
|
|
2200
|
+
title: 'Issue 2',
|
|
2201
|
+
labels: ['category:impl'],
|
|
2202
|
+
status: 'Awaiting Workspace',
|
|
2203
|
+
number: 2,
|
|
2204
|
+
itemId: 'item-2',
|
|
2205
|
+
}),
|
|
2206
|
+
createMockIssue({
|
|
2207
|
+
url: 'url3',
|
|
2208
|
+
title: 'Issue 3',
|
|
2209
|
+
labels: ['category:impl'],
|
|
2210
|
+
status: 'Awaiting Workspace',
|
|
2211
|
+
number: 3,
|
|
2212
|
+
itemId: 'item-3',
|
|
2213
|
+
}),
|
|
2214
|
+
];
|
|
2068
2215
|
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
2069
2216
|
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
2070
|
-
createMockStoryObjectMap(
|
|
2217
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
2071
2218
|
);
|
|
2072
2219
|
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
2073
2220
|
stdout: '',
|
|
2074
2221
|
stderr: '',
|
|
2075
2222
|
exitCode: 0,
|
|
2076
2223
|
});
|
|
2224
|
+
mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
|
|
2225
|
+
{
|
|
2226
|
+
token: 'token-a',
|
|
2227
|
+
fiveHourUtilization: 0,
|
|
2228
|
+
blocked: false,
|
|
2229
|
+
rejected: false,
|
|
2230
|
+
modelWeeklyLimits: {},
|
|
2231
|
+
},
|
|
2232
|
+
{
|
|
2233
|
+
token: 'token-b',
|
|
2234
|
+
fiveHourUtilization: 0,
|
|
2235
|
+
blocked: false,
|
|
2236
|
+
rejected: false,
|
|
2237
|
+
modelWeeklyLimits: {},
|
|
2238
|
+
},
|
|
2239
|
+
]);
|
|
2077
2240
|
|
|
2078
2241
|
await useCase.run({
|
|
2079
2242
|
projectUrl: 'https://github.com/user/repo',
|
|
2080
2243
|
defaultAgentName: 'agent1',
|
|
2081
|
-
defaultLlmModelName: 'claude-
|
|
2244
|
+
defaultLlmModelName: 'claude-opus',
|
|
2082
2245
|
defaultLlmAgentName: null,
|
|
2083
2246
|
configFilePath: '/path/to/config.yml',
|
|
2084
2247
|
maximumPreparingIssuesCount: null,
|
|
2085
2248
|
utilizationPercentageThreshold: 90,
|
|
2086
|
-
allowedIssueAuthors:
|
|
2249
|
+
allowedIssueAuthors: null,
|
|
2087
2250
|
codexHomeCandidates: null,
|
|
2088
2251
|
allowIssueCacheMinutes: 0,
|
|
2089
2252
|
});
|
|
2090
2253
|
|
|
2091
|
-
expect(
|
|
2092
|
-
expect(
|
|
2093
|
-
|
|
2254
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(3);
|
|
2255
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toMatchObject({
|
|
2256
|
+
env: {
|
|
2257
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-a',
|
|
2258
|
+
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
|
|
2259
|
+
},
|
|
2260
|
+
});
|
|
2261
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[1][2]).toMatchObject({
|
|
2262
|
+
env: {
|
|
2263
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-b',
|
|
2264
|
+
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
|
|
2265
|
+
},
|
|
2266
|
+
});
|
|
2267
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[2][2]).toMatchObject({
|
|
2268
|
+
env: {
|
|
2269
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-a',
|
|
2270
|
+
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
|
|
2271
|
+
},
|
|
2094
2272
|
});
|
|
2095
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
2096
2273
|
});
|
|
2097
2274
|
|
|
2098
|
-
it('should
|
|
2099
|
-
const
|
|
2100
|
-
url: '
|
|
2101
|
-
title: 'Issue
|
|
2102
|
-
labels: [],
|
|
2275
|
+
it('should not inject env when no tokens are available', async () => {
|
|
2276
|
+
const awaitingIssue = createMockIssue({
|
|
2277
|
+
url: 'url1',
|
|
2278
|
+
title: 'Issue 1',
|
|
2279
|
+
labels: ['category:impl'],
|
|
2103
2280
|
status: 'Awaiting Workspace',
|
|
2104
|
-
|
|
2281
|
+
number: 1,
|
|
2282
|
+
itemId: 'item-1',
|
|
2105
2283
|
});
|
|
2106
|
-
|
|
2107
2284
|
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
2108
2285
|
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
2109
|
-
createMockStoryObjectMap([
|
|
2286
|
+
createMockStoryObjectMap([awaitingIssue]),
|
|
2110
2287
|
);
|
|
2111
2288
|
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
2112
2289
|
stdout: '',
|
|
2113
2290
|
stderr: '',
|
|
2114
2291
|
exitCode: 0,
|
|
2115
2292
|
});
|
|
2293
|
+
mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue(
|
|
2294
|
+
[],
|
|
2295
|
+
);
|
|
2116
2296
|
|
|
2117
2297
|
await useCase.run({
|
|
2118
2298
|
projectUrl: 'https://github.com/user/repo',
|
|
2119
2299
|
defaultAgentName: 'agent1',
|
|
2120
|
-
defaultLlmModelName: 'claude-
|
|
2300
|
+
defaultLlmModelName: 'claude-opus',
|
|
2121
2301
|
defaultLlmAgentName: null,
|
|
2122
2302
|
configFilePath: '/path/to/config.yml',
|
|
2123
2303
|
maximumPreparingIssuesCount: null,
|
|
2124
2304
|
utilizationPercentageThreshold: 90,
|
|
2125
|
-
allowedIssueAuthors:
|
|
2305
|
+
allowedIssueAuthors: null,
|
|
2126
2306
|
codexHomeCandidates: null,
|
|
2127
2307
|
allowIssueCacheMinutes: 0,
|
|
2128
2308
|
});
|
|
2129
2309
|
|
|
2130
|
-
expect(
|
|
2131
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls).
|
|
2310
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
2311
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toBeUndefined();
|
|
2312
|
+
expect(
|
|
2313
|
+
mockClaudeTokenUsageRepository.ensureObservable.mock.calls,
|
|
2314
|
+
).toHaveLength(0);
|
|
2132
2315
|
});
|
|
2133
2316
|
|
|
2134
|
-
it('should
|
|
2135
|
-
const
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2317
|
+
it('should pick the least-utilized token first', async () => {
|
|
2318
|
+
const awaitingIssue = createMockIssue({
|
|
2319
|
+
url: 'url1',
|
|
2320
|
+
title: 'Issue 1',
|
|
2321
|
+
labels: ['category:impl'],
|
|
2322
|
+
status: 'Awaiting Workspace',
|
|
2323
|
+
number: 1,
|
|
2324
|
+
itemId: 'item-1',
|
|
2325
|
+
});
|
|
2143
2326
|
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
2144
2327
|
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
2145
|
-
createMockStoryObjectMap(
|
|
2328
|
+
createMockStoryObjectMap([awaitingIssue]),
|
|
2146
2329
|
);
|
|
2147
2330
|
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
2148
2331
|
stdout: '',
|
|
2149
2332
|
stderr: '',
|
|
2150
2333
|
exitCode: 0,
|
|
2151
2334
|
});
|
|
2335
|
+
mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
|
|
2336
|
+
{
|
|
2337
|
+
token: 'token-high',
|
|
2338
|
+
fiveHourUtilization: 0.8,
|
|
2339
|
+
blocked: false,
|
|
2340
|
+
rejected: false,
|
|
2341
|
+
modelWeeklyLimits: {},
|
|
2342
|
+
},
|
|
2343
|
+
{
|
|
2344
|
+
token: 'token-low',
|
|
2345
|
+
fiveHourUtilization: 0.1,
|
|
2346
|
+
blocked: false,
|
|
2347
|
+
rejected: false,
|
|
2348
|
+
modelWeeklyLimits: {},
|
|
2349
|
+
},
|
|
2350
|
+
]);
|
|
2152
2351
|
|
|
2153
2352
|
await useCase.run({
|
|
2154
2353
|
projectUrl: 'https://github.com/user/repo',
|
|
@@ -2164,38 +2363,48 @@ describe('StartPreparationUseCase', () => {
|
|
|
2164
2363
|
});
|
|
2165
2364
|
|
|
2166
2365
|
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
2167
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual(
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
'
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
'--configFilePath',
|
|
2174
|
-
'/path/to/config.yml',
|
|
2175
|
-
'--branch',
|
|
2176
|
-
'i1',
|
|
2177
|
-
],
|
|
2178
|
-
]);
|
|
2366
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
|
|
2367
|
+
env: {
|
|
2368
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-low',
|
|
2369
|
+
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
|
|
2370
|
+
},
|
|
2371
|
+
});
|
|
2179
2372
|
});
|
|
2180
2373
|
|
|
2181
|
-
it('should
|
|
2182
|
-
const
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2374
|
+
it('should exclude blocked tokens from rotation', async () => {
|
|
2375
|
+
const awaitingIssue = createMockIssue({
|
|
2376
|
+
url: 'url1',
|
|
2377
|
+
title: 'Issue 1',
|
|
2378
|
+
labels: ['category:impl'],
|
|
2379
|
+
status: 'Awaiting Workspace',
|
|
2380
|
+
number: 1,
|
|
2381
|
+
itemId: 'item-1',
|
|
2382
|
+
});
|
|
2190
2383
|
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
2191
2384
|
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
2192
|
-
createMockStoryObjectMap(
|
|
2385
|
+
createMockStoryObjectMap([awaitingIssue]),
|
|
2193
2386
|
);
|
|
2194
2387
|
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
2195
2388
|
stdout: '',
|
|
2196
2389
|
stderr: '',
|
|
2197
2390
|
exitCode: 0,
|
|
2198
2391
|
});
|
|
2392
|
+
mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
|
|
2393
|
+
{
|
|
2394
|
+
token: 'token-blocked',
|
|
2395
|
+
fiveHourUtilization: 0.05,
|
|
2396
|
+
blocked: true,
|
|
2397
|
+
rejected: false,
|
|
2398
|
+
modelWeeklyLimits: {},
|
|
2399
|
+
},
|
|
2400
|
+
{
|
|
2401
|
+
token: 'token-ok',
|
|
2402
|
+
fiveHourUtilization: 0.5,
|
|
2403
|
+
blocked: false,
|
|
2404
|
+
rejected: false,
|
|
2405
|
+
modelWeeklyLimits: {},
|
|
2406
|
+
},
|
|
2407
|
+
]);
|
|
2199
2408
|
|
|
2200
2409
|
await useCase.run({
|
|
2201
2410
|
projectUrl: 'https://github.com/user/repo',
|
|
@@ -2206,43 +2415,56 @@ describe('StartPreparationUseCase', () => {
|
|
|
2206
2415
|
maximumPreparingIssuesCount: null,
|
|
2207
2416
|
utilizationPercentageThreshold: 90,
|
|
2208
2417
|
allowedIssueAuthors: null,
|
|
2209
|
-
codexHomeCandidates:
|
|
2418
|
+
codexHomeCandidates: null,
|
|
2210
2419
|
allowIssueCacheMinutes: 0,
|
|
2211
2420
|
});
|
|
2212
2421
|
|
|
2213
2422
|
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
2214
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual(
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
'
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
'--configFilePath',
|
|
2221
|
-
'/path/to/config.yml',
|
|
2222
|
-
'--branch',
|
|
2223
|
-
'i1',
|
|
2224
|
-
],
|
|
2225
|
-
]);
|
|
2423
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
|
|
2424
|
+
env: {
|
|
2425
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-ok',
|
|
2426
|
+
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
|
|
2427
|
+
},
|
|
2428
|
+
});
|
|
2226
2429
|
});
|
|
2227
2430
|
|
|
2228
|
-
it('should
|
|
2229
|
-
const
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2431
|
+
it('should skip preparation when every configured token is blocked', async () => {
|
|
2432
|
+
const awaitingIssue = createMockIssue({
|
|
2433
|
+
url: 'url1',
|
|
2434
|
+
title: 'Issue 1',
|
|
2435
|
+
labels: ['category:impl'],
|
|
2436
|
+
status: 'Awaiting Workspace',
|
|
2437
|
+
number: 1,
|
|
2438
|
+
itemId: 'item-1',
|
|
2439
|
+
});
|
|
2237
2440
|
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
2238
2441
|
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
2239
|
-
createMockStoryObjectMap(
|
|
2442
|
+
createMockStoryObjectMap([awaitingIssue]),
|
|
2240
2443
|
);
|
|
2241
2444
|
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
2242
2445
|
stdout: '',
|
|
2243
2446
|
stderr: '',
|
|
2244
2447
|
exitCode: 0,
|
|
2245
2448
|
});
|
|
2449
|
+
mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
|
|
2450
|
+
{
|
|
2451
|
+
token: 'token-a',
|
|
2452
|
+
fiveHourUtilization: 0.05,
|
|
2453
|
+
blocked: true,
|
|
2454
|
+
rejected: false,
|
|
2455
|
+
modelWeeklyLimits: {},
|
|
2456
|
+
},
|
|
2457
|
+
{
|
|
2458
|
+
token: 'token-b',
|
|
2459
|
+
fiveHourUtilization: 0.08,
|
|
2460
|
+
blocked: true,
|
|
2461
|
+
rejected: false,
|
|
2462
|
+
modelWeeklyLimits: {},
|
|
2463
|
+
},
|
|
2464
|
+
]);
|
|
2465
|
+
const consoleWarnSpy = jest
|
|
2466
|
+
.spyOn(console, 'warn')
|
|
2467
|
+
.mockImplementation(() => {});
|
|
2246
2468
|
|
|
2247
2469
|
await useCase.run({
|
|
2248
2470
|
projectUrl: 'https://github.com/user/repo',
|
|
@@ -2253,28 +2475,23 @@ describe('StartPreparationUseCase', () => {
|
|
|
2253
2475
|
maximumPreparingIssuesCount: null,
|
|
2254
2476
|
utilizationPercentageThreshold: 90,
|
|
2255
2477
|
allowedIssueAuthors: null,
|
|
2256
|
-
codexHomeCandidates:
|
|
2478
|
+
codexHomeCandidates: null,
|
|
2257
2479
|
allowIssueCacheMinutes: 0,
|
|
2258
2480
|
});
|
|
2259
2481
|
|
|
2260
|
-
expect(
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
'i1',
|
|
2271
|
-
'--codexHome',
|
|
2272
|
-
'.codex-dev1',
|
|
2273
|
-
],
|
|
2274
|
-
]);
|
|
2482
|
+
expect(
|
|
2483
|
+
mockClaudeTokenUsageRepository.ensureObservable.mock.calls,
|
|
2484
|
+
).toHaveLength(0);
|
|
2485
|
+
expect(mockProjectRepository.getByUrl).not.toHaveBeenCalled();
|
|
2486
|
+
expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
|
|
2487
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
|
|
2488
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
2489
|
+
expect.stringContaining('Skipping starting preparation'),
|
|
2490
|
+
);
|
|
2491
|
+
consoleWarnSpy.mockRestore();
|
|
2275
2492
|
});
|
|
2276
2493
|
|
|
2277
|
-
it('should
|
|
2494
|
+
it('should return all tokens sorted ascending when all are below the threshold', async () => {
|
|
2278
2495
|
const awaitingIssues: Issue[] = [
|
|
2279
2496
|
createMockIssue({
|
|
2280
2497
|
url: 'url1',
|
|
@@ -2310,6 +2527,29 @@ describe('StartPreparationUseCase', () => {
|
|
|
2310
2527
|
stderr: '',
|
|
2311
2528
|
exitCode: 0,
|
|
2312
2529
|
});
|
|
2530
|
+
mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
|
|
2531
|
+
{
|
|
2532
|
+
token: 'token-mid',
|
|
2533
|
+
fiveHourUtilization: 0.5,
|
|
2534
|
+
blocked: false,
|
|
2535
|
+
rejected: false,
|
|
2536
|
+
modelWeeklyLimits: {},
|
|
2537
|
+
},
|
|
2538
|
+
{
|
|
2539
|
+
token: 'token-low',
|
|
2540
|
+
fiveHourUtilization: 0.1,
|
|
2541
|
+
blocked: false,
|
|
2542
|
+
rejected: false,
|
|
2543
|
+
modelWeeklyLimits: {},
|
|
2544
|
+
},
|
|
2545
|
+
{
|
|
2546
|
+
token: 'token-high',
|
|
2547
|
+
fiveHourUtilization: 0.8,
|
|
2548
|
+
blocked: false,
|
|
2549
|
+
rejected: false,
|
|
2550
|
+
modelWeeklyLimits: {},
|
|
2551
|
+
},
|
|
2552
|
+
]);
|
|
2313
2553
|
|
|
2314
2554
|
await useCase.run({
|
|
2315
2555
|
projectUrl: 'https://github.com/user/repo',
|
|
@@ -2320,118 +2560,56 @@ describe('StartPreparationUseCase', () => {
|
|
|
2320
2560
|
maximumPreparingIssuesCount: null,
|
|
2321
2561
|
utilizationPercentageThreshold: 90,
|
|
2322
2562
|
allowedIssueAuthors: null,
|
|
2323
|
-
codexHomeCandidates:
|
|
2563
|
+
codexHomeCandidates: null,
|
|
2324
2564
|
allowIssueCacheMinutes: 0,
|
|
2325
2565
|
});
|
|
2326
2566
|
|
|
2327
2567
|
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(3);
|
|
2328
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls[0][
|
|
2329
|
-
'
|
|
2330
|
-
);
|
|
2331
|
-
expect(
|
|
2332
|
-
mockLocalCommandRunner.runCommand.mock.calls[0][1][
|
|
2333
|
-
mockLocalCommandRunner.runCommand.mock.calls[0][1].indexOf(
|
|
2334
|
-
'--codexHome',
|
|
2335
|
-
) + 1
|
|
2336
|
-
],
|
|
2337
|
-
).toBe('.codex-dev1');
|
|
2338
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls[1][1]).toContain(
|
|
2339
|
-
'--codexHome',
|
|
2340
|
-
);
|
|
2341
|
-
expect(
|
|
2342
|
-
mockLocalCommandRunner.runCommand.mock.calls[1][1][
|
|
2343
|
-
mockLocalCommandRunner.runCommand.mock.calls[1][1].indexOf(
|
|
2344
|
-
'--codexHome',
|
|
2345
|
-
) + 1
|
|
2346
|
-
],
|
|
2347
|
-
).toBe('.codex-dev2');
|
|
2348
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls[2][1]).toContain(
|
|
2349
|
-
'--codexHome',
|
|
2350
|
-
);
|
|
2351
|
-
expect(
|
|
2352
|
-
mockLocalCommandRunner.runCommand.mock.calls[2][1][
|
|
2353
|
-
mockLocalCommandRunner.runCommand.mock.calls[2][1].indexOf(
|
|
2354
|
-
'--codexHome',
|
|
2355
|
-
) + 1
|
|
2356
|
-
],
|
|
2357
|
-
).toBe('.codex-dev1');
|
|
2358
|
-
});
|
|
2359
|
-
|
|
2360
|
-
it('should persist Preparation status via updateStatus with resolved status option id (regression for issue #519)', async () => {
|
|
2361
|
-
const awaitingIssue = createMockIssue({
|
|
2362
|
-
url: 'https://github.com/user/repo/issues/519',
|
|
2363
|
-
title: 'Regression issue',
|
|
2364
|
-
labels: ['category:impl'],
|
|
2365
|
-
status: 'Awaiting Workspace',
|
|
2366
|
-
itemId: 'item-regression',
|
|
2367
|
-
});
|
|
2368
|
-
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
2369
|
-
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
2370
|
-
createMockStoryObjectMap([awaitingIssue]),
|
|
2371
|
-
);
|
|
2372
|
-
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
2373
|
-
stdout: '',
|
|
2374
|
-
stderr: '',
|
|
2375
|
-
exitCode: 0,
|
|
2568
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toMatchObject({
|
|
2569
|
+
env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-low' },
|
|
2376
2570
|
});
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
projectUrl: 'https://github.com/user/repo',
|
|
2380
|
-
defaultAgentName: 'agent1',
|
|
2381
|
-
defaultLlmModelName: 'claude-opus',
|
|
2382
|
-
defaultLlmAgentName: null,
|
|
2383
|
-
configFilePath: '/path/to/config.yml',
|
|
2384
|
-
maximumPreparingIssuesCount: null,
|
|
2385
|
-
utilizationPercentageThreshold: 90,
|
|
2386
|
-
allowedIssueAuthors: null,
|
|
2387
|
-
codexHomeCandidates: null,
|
|
2388
|
-
allowIssueCacheMinutes: 0,
|
|
2571
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[1][2]).toMatchObject({
|
|
2572
|
+
env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-mid' },
|
|
2389
2573
|
});
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
expect(mockIssueRepository.updateStatus.mock.calls[0][0]).toBe(mockProject);
|
|
2393
|
-
expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
|
|
2394
|
-
url: 'https://github.com/user/repo/issues/519',
|
|
2395
|
-
itemId: 'item-regression',
|
|
2574
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[2][2]).toMatchObject({
|
|
2575
|
+
env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-high' },
|
|
2396
2576
|
});
|
|
2397
|
-
expect(mockIssueRepository.updateStatus.mock.calls[0][2]).toBe('2');
|
|
2398
|
-
const updateStatusCallOrder =
|
|
2399
|
-
mockIssueRepository.updateStatus.mock.invocationCallOrder[0];
|
|
2400
|
-
const runCommandCallOrder =
|
|
2401
|
-
mockLocalCommandRunner.runCommand.mock.invocationCallOrder[0];
|
|
2402
|
-
expect(updateStatusCallOrder).toBeLessThan(runCommandCallOrder);
|
|
2403
2577
|
});
|
|
2404
2578
|
|
|
2405
|
-
it('should
|
|
2406
|
-
const
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
name: 'Status',
|
|
2410
|
-
fieldId: 'status-field-id',
|
|
2411
|
-
statuses: [
|
|
2412
|
-
{
|
|
2413
|
-
id: '1',
|
|
2414
|
-
name: 'Awaiting Workspace',
|
|
2415
|
-
color: 'GRAY',
|
|
2416
|
-
description: '',
|
|
2417
|
-
},
|
|
2418
|
-
{ id: '3', name: 'Done', color: 'GREEN', description: '' },
|
|
2419
|
-
],
|
|
2420
|
-
},
|
|
2421
|
-
};
|
|
2422
|
-
const awaitingIssue = createMockIssue({
|
|
2423
|
-
url: 'url-missing-option',
|
|
2424
|
-
title: 'Missing Preparation Option',
|
|
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',
|
|
2425
2583
|
labels: ['category:impl'],
|
|
2426
2584
|
status: 'Awaiting Workspace',
|
|
2585
|
+
number: 1,
|
|
2586
|
+
itemId: 'item-1',
|
|
2427
2587
|
});
|
|
2428
|
-
mockProjectRepository.getByUrl.mockResolvedValue(
|
|
2588
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
2429
2589
|
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
2430
2590
|
createMockStoryObjectMap([awaitingIssue]),
|
|
2431
2591
|
);
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2592
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
2593
|
+
stdout: '',
|
|
2594
|
+
stderr: '',
|
|
2595
|
+
exitCode: 0,
|
|
2596
|
+
});
|
|
2597
|
+
mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
|
|
2598
|
+
{
|
|
2599
|
+
token: 'token-at-threshold',
|
|
2600
|
+
fiveHourUtilization: 0.9,
|
|
2601
|
+
blocked: false,
|
|
2602
|
+
rejected: false,
|
|
2603
|
+
modelWeeklyLimits: {},
|
|
2604
|
+
},
|
|
2605
|
+
{
|
|
2606
|
+
token: 'token-below',
|
|
2607
|
+
fiveHourUtilization: 0.4,
|
|
2608
|
+
blocked: false,
|
|
2609
|
+
rejected: false,
|
|
2610
|
+
modelWeeklyLimits: {},
|
|
2611
|
+
},
|
|
2612
|
+
]);
|
|
2435
2613
|
|
|
2436
2614
|
await useCase.run({
|
|
2437
2615
|
projectUrl: 'https://github.com/user/repo',
|
|
@@ -2446,70 +2624,111 @@ describe('StartPreparationUseCase', () => {
|
|
|
2446
2624
|
allowIssueCacheMinutes: 0,
|
|
2447
2625
|
});
|
|
2448
2626
|
|
|
2449
|
-
expect(
|
|
2450
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls).
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
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
|
+
});
|
|
2455
2634
|
});
|
|
2456
2635
|
|
|
2457
|
-
it('should
|
|
2636
|
+
it('should exclude a rejected token from rotation', async () => {
|
|
2637
|
+
const awaitingIssue = createMockIssue({
|
|
2638
|
+
url: 'url1',
|
|
2639
|
+
title: 'Issue 1',
|
|
2640
|
+
labels: ['category:impl'],
|
|
2641
|
+
status: 'Awaiting Workspace',
|
|
2642
|
+
number: 1,
|
|
2643
|
+
itemId: 'item-1',
|
|
2644
|
+
});
|
|
2458
2645
|
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
2459
2646
|
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
2460
|
-
createMockStoryObjectMap([]),
|
|
2647
|
+
createMockStoryObjectMap([awaitingIssue]),
|
|
2461
2648
|
);
|
|
2649
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
2650
|
+
stdout: '',
|
|
2651
|
+
stderr: '',
|
|
2652
|
+
exitCode: 0,
|
|
2653
|
+
});
|
|
2654
|
+
mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
|
|
2655
|
+
{
|
|
2656
|
+
token: 'token-rejected',
|
|
2657
|
+
fiveHourUtilization: 0.1,
|
|
2658
|
+
blocked: false,
|
|
2659
|
+
rejected: true,
|
|
2660
|
+
modelWeeklyLimits: {},
|
|
2661
|
+
},
|
|
2662
|
+
{
|
|
2663
|
+
token: 'token-ok',
|
|
2664
|
+
fiveHourUtilization: 0.5,
|
|
2665
|
+
blocked: false,
|
|
2666
|
+
rejected: false,
|
|
2667
|
+
modelWeeklyLimits: {},
|
|
2668
|
+
},
|
|
2669
|
+
]);
|
|
2462
2670
|
|
|
2463
2671
|
await useCase.run({
|
|
2464
2672
|
projectUrl: 'https://github.com/user/repo',
|
|
2465
2673
|
defaultAgentName: 'agent1',
|
|
2466
|
-
defaultLlmModelName:
|
|
2674
|
+
defaultLlmModelName: 'claude-opus',
|
|
2467
2675
|
defaultLlmAgentName: null,
|
|
2468
2676
|
configFilePath: '/path/to/config.yml',
|
|
2469
2677
|
maximumPreparingIssuesCount: null,
|
|
2470
2678
|
utilizationPercentageThreshold: 90,
|
|
2471
2679
|
allowedIssueAuthors: null,
|
|
2472
2680
|
codexHomeCandidates: null,
|
|
2473
|
-
allowIssueCacheMinutes:
|
|
2681
|
+
allowIssueCacheMinutes: 0,
|
|
2474
2682
|
});
|
|
2475
2683
|
|
|
2476
|
-
expect(
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2684
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
2685
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
|
|
2686
|
+
env: {
|
|
2687
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-ok',
|
|
2688
|
+
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
|
|
2689
|
+
},
|
|
2690
|
+
});
|
|
2480
2691
|
});
|
|
2481
2692
|
|
|
2482
|
-
it('should
|
|
2483
|
-
const
|
|
2484
|
-
url: '
|
|
2485
|
-
title: '
|
|
2486
|
-
labels: [],
|
|
2487
|
-
status: 'Awaiting Workspace',
|
|
2488
|
-
isClosed: true,
|
|
2489
|
-
});
|
|
2490
|
-
const openIssue = createMockIssue({
|
|
2491
|
-
url: 'https://github.com/user/repo/issues/2',
|
|
2492
|
-
number: 2,
|
|
2493
|
-
title: 'Open issue',
|
|
2494
|
-
labels: [],
|
|
2693
|
+
it('should re-admit a token after its 5h window reset normalizes utilization to 0 and clears rejection', async () => {
|
|
2694
|
+
const awaitingIssue = createMockIssue({
|
|
2695
|
+
url: 'url1',
|
|
2696
|
+
title: 'Issue 1',
|
|
2697
|
+
labels: ['category:impl'],
|
|
2495
2698
|
status: 'Awaiting Workspace',
|
|
2496
|
-
|
|
2699
|
+
number: 1,
|
|
2700
|
+
itemId: 'item-1',
|
|
2497
2701
|
});
|
|
2498
|
-
|
|
2499
2702
|
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
2500
2703
|
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
2501
|
-
createMockStoryObjectMap([
|
|
2704
|
+
createMockStoryObjectMap([awaitingIssue]),
|
|
2502
2705
|
);
|
|
2503
2706
|
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
2504
2707
|
stdout: '',
|
|
2505
2708
|
stderr: '',
|
|
2506
2709
|
exitCode: 0,
|
|
2507
2710
|
});
|
|
2711
|
+
mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
|
|
2712
|
+
{
|
|
2713
|
+
token: 'token-reset',
|
|
2714
|
+
fiveHourUtilization: 0,
|
|
2715
|
+
blocked: false,
|
|
2716
|
+
rejected: false,
|
|
2717
|
+
modelWeeklyLimits: {},
|
|
2718
|
+
},
|
|
2719
|
+
{
|
|
2720
|
+
token: 'token-busy',
|
|
2721
|
+
fiveHourUtilization: 0.5,
|
|
2722
|
+
blocked: false,
|
|
2723
|
+
rejected: false,
|
|
2724
|
+
modelWeeklyLimits: {},
|
|
2725
|
+
},
|
|
2726
|
+
]);
|
|
2508
2727
|
|
|
2509
2728
|
await useCase.run({
|
|
2510
2729
|
projectUrl: 'https://github.com/user/repo',
|
|
2511
2730
|
defaultAgentName: 'agent1',
|
|
2512
|
-
defaultLlmModelName: 'claude-
|
|
2731
|
+
defaultLlmModelName: 'claude-opus',
|
|
2513
2732
|
defaultLlmAgentName: null,
|
|
2514
2733
|
configFilePath: '/path/to/config.yml',
|
|
2515
2734
|
maximumPreparingIssuesCount: null,
|
|
@@ -2519,13 +2738,16 @@ describe('StartPreparationUseCase', () => {
|
|
|
2519
2738
|
allowIssueCacheMinutes: 0,
|
|
2520
2739
|
});
|
|
2521
2740
|
|
|
2522
|
-
expect(
|
|
2523
|
-
expect(
|
|
2524
|
-
|
|
2741
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
2742
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
|
|
2743
|
+
env: {
|
|
2744
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-reset',
|
|
2745
|
+
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
|
|
2746
|
+
},
|
|
2525
2747
|
});
|
|
2526
2748
|
});
|
|
2527
2749
|
|
|
2528
|
-
it('should
|
|
2750
|
+
it('should exclude a token whose 5h window has not reset and remains at or above threshold', async () => {
|
|
2529
2751
|
const awaitingIssue = createMockIssue({
|
|
2530
2752
|
url: 'url1',
|
|
2531
2753
|
title: 'Issue 1',
|
|
@@ -2544,7 +2766,20 @@ describe('StartPreparationUseCase', () => {
|
|
|
2544
2766
|
exitCode: 0,
|
|
2545
2767
|
});
|
|
2546
2768
|
mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
|
|
2547
|
-
{
|
|
2769
|
+
{
|
|
2770
|
+
token: 'token-saturated',
|
|
2771
|
+
fiveHourUtilization: 0.95,
|
|
2772
|
+
blocked: false,
|
|
2773
|
+
rejected: true,
|
|
2774
|
+
modelWeeklyLimits: {},
|
|
2775
|
+
},
|
|
2776
|
+
{
|
|
2777
|
+
token: 'token-ok',
|
|
2778
|
+
fiveHourUtilization: 0.2,
|
|
2779
|
+
blocked: false,
|
|
2780
|
+
rejected: false,
|
|
2781
|
+
modelWeeklyLimits: {},
|
|
2782
|
+
},
|
|
2548
2783
|
]);
|
|
2549
2784
|
|
|
2550
2785
|
await useCase.run({
|
|
@@ -2560,51 +2795,27 @@ describe('StartPreparationUseCase', () => {
|
|
|
2560
2795
|
allowIssueCacheMinutes: 0,
|
|
2561
2796
|
});
|
|
2562
2797
|
|
|
2563
|
-
expect(
|
|
2564
|
-
mockClaudeTokenUsageRepository.getAvailableTokenUsages.mock.calls,
|
|
2565
|
-
).toHaveLength(1);
|
|
2566
|
-
expect(
|
|
2567
|
-
mockClaudeTokenUsageRepository.ensureObservable.mock.calls,
|
|
2568
|
-
).toHaveLength(1);
|
|
2569
2798
|
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
2570
2799
|
expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
|
|
2571
2800
|
env: {
|
|
2572
|
-
CLAUDE_CODE_OAUTH_TOKEN: 'token-
|
|
2801
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-ok',
|
|
2573
2802
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
|
|
2574
2803
|
},
|
|
2575
2804
|
});
|
|
2576
2805
|
});
|
|
2577
2806
|
|
|
2578
|
-
it('should
|
|
2579
|
-
const
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
}),
|
|
2588
|
-
createMockIssue({
|
|
2589
|
-
url: 'url2',
|
|
2590
|
-
title: 'Issue 2',
|
|
2591
|
-
labels: ['category:impl'],
|
|
2592
|
-
status: 'Awaiting Workspace',
|
|
2593
|
-
number: 2,
|
|
2594
|
-
itemId: 'item-2',
|
|
2595
|
-
}),
|
|
2596
|
-
createMockIssue({
|
|
2597
|
-
url: 'url3',
|
|
2598
|
-
title: 'Issue 3',
|
|
2599
|
-
labels: ['category:impl'],
|
|
2600
|
-
status: 'Awaiting Workspace',
|
|
2601
|
-
number: 3,
|
|
2602
|
-
itemId: 'item-3',
|
|
2603
|
-
}),
|
|
2604
|
-
];
|
|
2807
|
+
it('should skip preparation when every configured token is rejected', async () => {
|
|
2808
|
+
const awaitingIssue = createMockIssue({
|
|
2809
|
+
url: 'url1',
|
|
2810
|
+
title: 'Issue 1',
|
|
2811
|
+
labels: ['category:impl'],
|
|
2812
|
+
status: 'Awaiting Workspace',
|
|
2813
|
+
number: 1,
|
|
2814
|
+
itemId: 'item-1',
|
|
2815
|
+
});
|
|
2605
2816
|
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
2606
2817
|
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
2607
|
-
createMockStoryObjectMap(
|
|
2818
|
+
createMockStoryObjectMap([awaitingIssue]),
|
|
2608
2819
|
);
|
|
2609
2820
|
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
2610
2821
|
stdout: '',
|
|
@@ -2612,9 +2823,24 @@ describe('StartPreparationUseCase', () => {
|
|
|
2612
2823
|
exitCode: 0,
|
|
2613
2824
|
});
|
|
2614
2825
|
mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
|
|
2615
|
-
{
|
|
2616
|
-
|
|
2826
|
+
{
|
|
2827
|
+
token: 'token-a',
|
|
2828
|
+
fiveHourUtilization: 0.1,
|
|
2829
|
+
blocked: false,
|
|
2830
|
+
rejected: true,
|
|
2831
|
+
modelWeeklyLimits: {},
|
|
2832
|
+
},
|
|
2833
|
+
{
|
|
2834
|
+
token: 'token-b',
|
|
2835
|
+
fiveHourUtilization: 0.2,
|
|
2836
|
+
blocked: false,
|
|
2837
|
+
rejected: true,
|
|
2838
|
+
modelWeeklyLimits: {},
|
|
2839
|
+
},
|
|
2617
2840
|
]);
|
|
2841
|
+
const consoleWarnSpy = jest
|
|
2842
|
+
.spyOn(console, 'warn')
|
|
2843
|
+
.mockImplementation(() => {});
|
|
2618
2844
|
|
|
2619
2845
|
await useCase.run({
|
|
2620
2846
|
projectUrl: 'https://github.com/user/repo',
|
|
@@ -2629,28 +2855,18 @@ describe('StartPreparationUseCase', () => {
|
|
|
2629
2855
|
allowIssueCacheMinutes: 0,
|
|
2630
2856
|
});
|
|
2631
2857
|
|
|
2632
|
-
expect(
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
CLAUDE_CODE_OAUTH_TOKEN: 'token-b',
|
|
2642
|
-
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
|
|
2643
|
-
},
|
|
2644
|
-
});
|
|
2645
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls[2][2]).toMatchObject({
|
|
2646
|
-
env: {
|
|
2647
|
-
CLAUDE_CODE_OAUTH_TOKEN: 'token-a',
|
|
2648
|
-
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
|
|
2649
|
-
},
|
|
2650
|
-
});
|
|
2858
|
+
expect(
|
|
2859
|
+
mockClaudeTokenUsageRepository.ensureObservable.mock.calls,
|
|
2860
|
+
).toHaveLength(0);
|
|
2861
|
+
expect(mockProjectRepository.getByUrl).not.toHaveBeenCalled();
|
|
2862
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
|
|
2863
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
2864
|
+
expect.stringContaining('Skipping starting preparation'),
|
|
2865
|
+
);
|
|
2866
|
+
consoleWarnSpy.mockRestore();
|
|
2651
2867
|
});
|
|
2652
2868
|
|
|
2653
|
-
it('should
|
|
2869
|
+
it('should proceed with legacy spawn without env injection when no token list is configured', async () => {
|
|
2654
2870
|
const awaitingIssue = createMockIssue({
|
|
2655
2871
|
url: 'url1',
|
|
2656
2872
|
title: 'Issue 1',
|
|
@@ -2685,14 +2901,76 @@ describe('StartPreparationUseCase', () => {
|
|
|
2685
2901
|
allowIssueCacheMinutes: 0,
|
|
2686
2902
|
});
|
|
2687
2903
|
|
|
2688
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
2689
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toBeUndefined();
|
|
2690
2904
|
expect(
|
|
2691
2905
|
mockClaudeTokenUsageRepository.ensureObservable.mock.calls,
|
|
2692
2906
|
).toHaveLength(0);
|
|
2907
|
+
expect(mockProjectRepository.getByUrl).toHaveBeenCalled();
|
|
2908
|
+
expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
|
|
2909
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
2910
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toBeUndefined();
|
|
2693
2911
|
});
|
|
2694
2912
|
|
|
2695
|
-
it('should
|
|
2913
|
+
it('should exclude a token whose seven_day_sonnet weekly limit is rejected when the model is sonnet', async () => {
|
|
2914
|
+
const awaitingIssue = createMockIssue({
|
|
2915
|
+
url: 'url1',
|
|
2916
|
+
title: 'Issue 1',
|
|
2917
|
+
labels: ['category:impl'],
|
|
2918
|
+
status: 'Awaiting Workspace',
|
|
2919
|
+
number: 1,
|
|
2920
|
+
itemId: 'item-1',
|
|
2921
|
+
});
|
|
2922
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
2923
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
2924
|
+
createMockStoryObjectMap([awaitingIssue]),
|
|
2925
|
+
);
|
|
2926
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
2927
|
+
stdout: '',
|
|
2928
|
+
stderr: '',
|
|
2929
|
+
exitCode: 0,
|
|
2930
|
+
});
|
|
2931
|
+
const futureReset = Math.floor(Date.now() / 1000) + 3600;
|
|
2932
|
+
mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
|
|
2933
|
+
{
|
|
2934
|
+
token: 'token-sonnet-exhausted',
|
|
2935
|
+
fiveHourUtilization: 0.1,
|
|
2936
|
+
blocked: false,
|
|
2937
|
+
rejected: false,
|
|
2938
|
+
modelWeeklyLimits: {
|
|
2939
|
+
seven_day_sonnet: { rejected: true, resetsAt: futureReset },
|
|
2940
|
+
},
|
|
2941
|
+
},
|
|
2942
|
+
{
|
|
2943
|
+
token: 'token-ok',
|
|
2944
|
+
fiveHourUtilization: 0.5,
|
|
2945
|
+
blocked: false,
|
|
2946
|
+
rejected: false,
|
|
2947
|
+
modelWeeklyLimits: {},
|
|
2948
|
+
},
|
|
2949
|
+
]);
|
|
2950
|
+
|
|
2951
|
+
await useCase.run({
|
|
2952
|
+
projectUrl: 'https://github.com/user/repo',
|
|
2953
|
+
defaultAgentName: 'agent1',
|
|
2954
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
2955
|
+
defaultLlmAgentName: null,
|
|
2956
|
+
configFilePath: '/path/to/config.yml',
|
|
2957
|
+
maximumPreparingIssuesCount: null,
|
|
2958
|
+
utilizationPercentageThreshold: 90,
|
|
2959
|
+
allowedIssueAuthors: null,
|
|
2960
|
+
codexHomeCandidates: null,
|
|
2961
|
+
allowIssueCacheMinutes: 0,
|
|
2962
|
+
});
|
|
2963
|
+
|
|
2964
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
2965
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
|
|
2966
|
+
env: {
|
|
2967
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-ok',
|
|
2968
|
+
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
|
|
2969
|
+
},
|
|
2970
|
+
});
|
|
2971
|
+
});
|
|
2972
|
+
|
|
2973
|
+
it('should re-admit a token whose seven_day_sonnet rejection has been cleared by stale-reset expiry', async () => {
|
|
2696
2974
|
const awaitingIssue = createMockIssue({
|
|
2697
2975
|
url: 'url1',
|
|
2698
2976
|
title: 'Issue 1',
|
|
@@ -2710,15 +2988,30 @@ describe('StartPreparationUseCase', () => {
|
|
|
2710
2988
|
stderr: '',
|
|
2711
2989
|
exitCode: 0,
|
|
2712
2990
|
});
|
|
2991
|
+
const pastReset = Math.floor(Date.now() / 1000) - 3600;
|
|
2713
2992
|
mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
|
|
2714
|
-
{
|
|
2715
|
-
|
|
2993
|
+
{
|
|
2994
|
+
token: 'token-recovered',
|
|
2995
|
+
fiveHourUtilization: 0.1,
|
|
2996
|
+
blocked: false,
|
|
2997
|
+
rejected: false,
|
|
2998
|
+
modelWeeklyLimits: {
|
|
2999
|
+
seven_day_sonnet: { rejected: false, resetsAt: pastReset },
|
|
3000
|
+
},
|
|
3001
|
+
},
|
|
3002
|
+
{
|
|
3003
|
+
token: 'token-busy',
|
|
3004
|
+
fiveHourUtilization: 0.5,
|
|
3005
|
+
blocked: false,
|
|
3006
|
+
rejected: false,
|
|
3007
|
+
modelWeeklyLimits: {},
|
|
3008
|
+
},
|
|
2716
3009
|
]);
|
|
2717
3010
|
|
|
2718
3011
|
await useCase.run({
|
|
2719
3012
|
projectUrl: 'https://github.com/user/repo',
|
|
2720
3013
|
defaultAgentName: 'agent1',
|
|
2721
|
-
defaultLlmModelName: 'claude-
|
|
3014
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
2722
3015
|
defaultLlmAgentName: null,
|
|
2723
3016
|
configFilePath: '/path/to/config.yml',
|
|
2724
3017
|
maximumPreparingIssuesCount: null,
|
|
@@ -2731,13 +3024,13 @@ describe('StartPreparationUseCase', () => {
|
|
|
2731
3024
|
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
2732
3025
|
expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
|
|
2733
3026
|
env: {
|
|
2734
|
-
CLAUDE_CODE_OAUTH_TOKEN: 'token-
|
|
3027
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-recovered',
|
|
2735
3028
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
|
|
2736
3029
|
},
|
|
2737
3030
|
});
|
|
2738
3031
|
});
|
|
2739
3032
|
|
|
2740
|
-
it('should exclude
|
|
3033
|
+
it('should not exclude a token for a sonnet-only rejection when the model is opus', async () => {
|
|
2741
3034
|
const awaitingIssue = createMockIssue({
|
|
2742
3035
|
url: 'url1',
|
|
2743
3036
|
title: 'Issue 1',
|
|
@@ -2755,9 +3048,24 @@ describe('StartPreparationUseCase', () => {
|
|
|
2755
3048
|
stderr: '',
|
|
2756
3049
|
exitCode: 0,
|
|
2757
3050
|
});
|
|
3051
|
+
const futureReset = Math.floor(Date.now() / 1000) + 3600;
|
|
2758
3052
|
mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
|
|
2759
|
-
{
|
|
2760
|
-
|
|
3053
|
+
{
|
|
3054
|
+
token: 'token-sonnet-exhausted',
|
|
3055
|
+
fiveHourUtilization: 0.1,
|
|
3056
|
+
blocked: false,
|
|
3057
|
+
rejected: false,
|
|
3058
|
+
modelWeeklyLimits: {
|
|
3059
|
+
seven_day_sonnet: { rejected: true, resetsAt: futureReset },
|
|
3060
|
+
},
|
|
3061
|
+
},
|
|
3062
|
+
{
|
|
3063
|
+
token: 'token-higher-util',
|
|
3064
|
+
fiveHourUtilization: 0.5,
|
|
3065
|
+
blocked: false,
|
|
3066
|
+
rejected: false,
|
|
3067
|
+
modelWeeklyLimits: {},
|
|
3068
|
+
},
|
|
2761
3069
|
]);
|
|
2762
3070
|
|
|
2763
3071
|
await useCase.run({
|
|
@@ -2776,13 +3084,13 @@ describe('StartPreparationUseCase', () => {
|
|
|
2776
3084
|
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
2777
3085
|
expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
|
|
2778
3086
|
env: {
|
|
2779
|
-
CLAUDE_CODE_OAUTH_TOKEN: 'token-
|
|
3087
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-sonnet-exhausted',
|
|
2780
3088
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
|
|
2781
3089
|
},
|
|
2782
3090
|
});
|
|
2783
3091
|
});
|
|
2784
3092
|
|
|
2785
|
-
it('should
|
|
3093
|
+
it('should exclude a token whose generic seven_day weekly limit is rejected regardless of model', async () => {
|
|
2786
3094
|
const awaitingIssue = createMockIssue({
|
|
2787
3095
|
url: 'url1',
|
|
2788
3096
|
title: 'Issue 1',
|
|
@@ -2800,9 +3108,24 @@ describe('StartPreparationUseCase', () => {
|
|
|
2800
3108
|
stderr: '',
|
|
2801
3109
|
exitCode: 0,
|
|
2802
3110
|
});
|
|
3111
|
+
const futureReset = Math.floor(Date.now() / 1000) + 3600;
|
|
2803
3112
|
mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
|
|
2804
|
-
{
|
|
2805
|
-
|
|
3113
|
+
{
|
|
3114
|
+
token: 'token-weekly-exhausted',
|
|
3115
|
+
fiveHourUtilization: 0.1,
|
|
3116
|
+
blocked: false,
|
|
3117
|
+
rejected: false,
|
|
3118
|
+
modelWeeklyLimits: {
|
|
3119
|
+
seven_day: { rejected: true, resetsAt: futureReset },
|
|
3120
|
+
},
|
|
3121
|
+
},
|
|
3122
|
+
{
|
|
3123
|
+
token: 'token-ok',
|
|
3124
|
+
fiveHourUtilization: 0.5,
|
|
3125
|
+
blocked: false,
|
|
3126
|
+
rejected: false,
|
|
3127
|
+
modelWeeklyLimits: {},
|
|
3128
|
+
},
|
|
2806
3129
|
]);
|
|
2807
3130
|
|
|
2808
3131
|
await useCase.run({
|
|
@@ -2818,10 +3141,12 @@ describe('StartPreparationUseCase', () => {
|
|
|
2818
3141
|
allowIssueCacheMinutes: 0,
|
|
2819
3142
|
});
|
|
2820
3143
|
|
|
2821
|
-
expect(
|
|
2822
|
-
mockClaudeTokenUsageRepository.ensureObservable.mock.calls,
|
|
2823
|
-
).toHaveLength(0);
|
|
2824
3144
|
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
2825
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).
|
|
3145
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
|
|
3146
|
+
env: {
|
|
3147
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-ok',
|
|
3148
|
+
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
|
|
3149
|
+
},
|
|
3150
|
+
});
|
|
2826
3151
|
});
|
|
2827
3152
|
});
|