github-issue-tower-defence-management 2.10.2 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +9 -1
- package/bin/adapter/entry-points/cli/fleetConfig.js +7 -1
- package/bin/adapter/entry-points/cli/fleetConfig.js.map +1 -1
- package/bin/adapter/entry-points/cli/index.js +11 -3
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/consoleTabNames.js +1 -0
- package/bin/adapter/entry-points/console/consoleTabNames.js.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-CvJgSVoN.js +88 -0
- package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +2 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/repositories/CliGitHubGraphqlRateLimitRepository.js +24 -0
- package/bin/adapter/repositories/CliGitHubGraphqlRateLimitRepository.js.map +1 -0
- package/bin/adapter/repositories/KySlackRepository.js +2 -1
- package/bin/adapter/repositories/KySlackRepository.js.map +1 -1
- package/bin/domain/usecases/StartPreparationUseCase.js +22 -1
- package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/adapter-interfaces/GitHubGraphqlRateLimitRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/GitHubGraphqlRateLimitRepository.js.map +1 -0
- package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js +14 -0
- package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/fleetConfig.test.ts +99 -0
- package/src/adapter/entry-points/cli/fleetConfig.ts +25 -0
- package/src/adapter/entry-points/cli/index.test.ts +4 -0
- package/src/adapter/entry-points/cli/index.ts +16 -2
- package/src/adapter/entry-points/console/consoleTabNames.ts +1 -0
- package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +41 -0
- package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +52 -2
- package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.stories.tsx +4 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.test.tsx +1 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleItemList.stories.tsx +5 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleItemList.tsx +4 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleItemSummary.stories.tsx +9 -2
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleItemSummary.test.tsx +37 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleItemSummary.tsx +13 -2
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleQueuedItemRow.stories.tsx +50 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleQueuedItemRow.test.tsx +98 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleQueuedItemRow.tsx +66 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleQueuedList.stories.tsx +69 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleQueuedList.test.tsx +101 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleQueuedList.tsx +75 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleNavigation.test.ts +1 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.test.ts +71 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.ts +2 -0
- package/src/adapter/entry-points/console/ui/src/features/console/lib/airplaneSnapshot.test.ts +26 -0
- package/src/adapter/entry-points/console/ui/src/features/console/lib/airplaneSnapshot.ts +6 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/colors.ts +12 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/tabAdvance.test.ts +1 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/types.ts +11 -1
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +58 -0
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +25 -9
- package/src/adapter/entry-points/console/ui/src/features/console/testing/fixtures.ts +14 -0
- package/src/adapter/entry-points/console/ui-dist/assets/index-CvJgSVoN.js +88 -0
- package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +2 -0
- package/src/adapter/repositories/CliGitHubGraphqlRateLimitRepository.test.ts +71 -0
- package/src/adapter/repositories/CliGitHubGraphqlRateLimitRepository.ts +21 -0
- package/src/adapter/repositories/KySlackRepository.test.ts +49 -1
- package/src/adapter/repositories/KySlackRepository.ts +3 -2
- package/src/domain/usecases/ConflictedIssueRevertUseCase.test.ts +74 -0
- package/src/domain/usecases/StartPreparationUseCase.test.ts +229 -0
- package/src/domain/usecases/StartPreparationUseCase.ts +35 -0
- package/src/domain/usecases/adapter-interfaces/GitHubGraphqlRateLimitRepository.ts +3 -0
- package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +84 -0
- package/src/domain/usecases/console/GenerateConsoleListsUseCase.ts +39 -0
- package/src/domain/usecases/issueReactivationTriggerIsPending.test.ts +1 -0
- package/types/adapter/entry-points/cli/fleetConfig.d.ts +4 -0
- package/types/adapter/entry-points/cli/fleetConfig.d.ts.map +1 -1
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/console/consoleTabNames.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/repositories/CliGitHubGraphqlRateLimitRepository.d.ts +8 -0
- package/types/adapter/repositories/CliGitHubGraphqlRateLimitRepository.d.ts.map +1 -0
- package/types/adapter/repositories/KySlackRepository.d.ts +1 -1
- package/types/adapter/repositories/KySlackRepository.d.ts.map +1 -1
- package/types/domain/usecases/StartPreparationUseCase.d.ts +5 -1
- package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/GitHubGraphqlRateLimitRepository.d.ts +4 -0
- package/types/domain/usecases/adapter-interfaces/GitHubGraphqlRateLimitRepository.d.ts.map +1 -0
- package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts +13 -1
- package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-BSa2JTna.js +0 -88
- package/src/adapter/entry-points/console/ui-dist/assets/index-BSa2JTna.js +0 -88
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>TDPM Console</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-CvJgSVoN.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-CLHvrafO.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
@@ -68,6 +68,7 @@ import { UpdateIssueStatusByLabelUseCase } from '../../../domain/usecases/Update
|
|
|
68
68
|
import { IssueNoStatusUpdateUseCase } from '../../../domain/usecases/IssueNoStatusUpdateUseCase';
|
|
69
69
|
import { StartPreparationUseCase } from '../../../domain/usecases/StartPreparationUseCase';
|
|
70
70
|
import { NodeLocalCommandRunner } from '../../repositories/NodeLocalCommandRunner';
|
|
71
|
+
import { CliGitHubGraphqlRateLimitRepository } from '../../repositories/CliGitHubGraphqlRateLimitRepository';
|
|
71
72
|
import { ProcTakeOwnershipSpawnRepository } from '../../repositories/ProcTakeOwnershipSpawnRepository';
|
|
72
73
|
import { ProxyClaudeTokenUsageRepository } from '../../repositories/ProxyClaudeTokenUsageRepository';
|
|
73
74
|
import { ProxyRateLimitCacheRepository } from '../../repositories/ProxyRateLimitCacheRepository';
|
|
@@ -499,6 +500,7 @@ export class HandleScheduledEventUseCaseHandler {
|
|
|
499
500
|
nodeLocalCommandRunner,
|
|
500
501
|
claudeTokenUsageRepository,
|
|
501
502
|
new ProcTakeOwnershipSpawnRepository(),
|
|
503
|
+
new CliGitHubGraphqlRateLimitRepository(nodeLocalCommandRunner),
|
|
502
504
|
);
|
|
503
505
|
const proxyRateLimitCacheRepository = new ProxyRateLimitCacheRepository(
|
|
504
506
|
mergedInput.claudeCodeOauthTokenListJsonPath ?? null,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
|
|
2
|
+
import { CliGitHubGraphqlRateLimitRepository } from './CliGitHubGraphqlRateLimitRepository';
|
|
3
|
+
|
|
4
|
+
type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
|
|
5
|
+
|
|
6
|
+
describe('CliGitHubGraphqlRateLimitRepository', () => {
|
|
7
|
+
let mockLocalCommandRunner: Mocked<LocalCommandRunner>;
|
|
8
|
+
let repository: CliGitHubGraphqlRateLimitRepository;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
jest.resetAllMocks();
|
|
12
|
+
mockLocalCommandRunner = {
|
|
13
|
+
runCommand: jest.fn(),
|
|
14
|
+
spawnInteractive: jest.fn(),
|
|
15
|
+
};
|
|
16
|
+
repository = new CliGitHubGraphqlRateLimitRepository(
|
|
17
|
+
mockLocalCommandRunner,
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns the remaining GraphQL request count when gh succeeds', async () => {
|
|
22
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
23
|
+
stdout: '4873\n',
|
|
24
|
+
stderr: '',
|
|
25
|
+
exitCode: 0,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const result = await repository.getRemainingRequestCount();
|
|
29
|
+
|
|
30
|
+
expect(result).toBe(4873);
|
|
31
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toEqual([
|
|
32
|
+
['gh', ['api', 'rate_limit', '--jq', '.resources.graphql.remaining']],
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns null when gh exits with a non-zero code', async () => {
|
|
37
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
38
|
+
stdout: '',
|
|
39
|
+
stderr: 'error: authentication required',
|
|
40
|
+
exitCode: 1,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const result = await repository.getRemainingRequestCount();
|
|
44
|
+
|
|
45
|
+
expect(result).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns null when the output is not a valid integer', async () => {
|
|
49
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
50
|
+
stdout: 'not-a-number\n',
|
|
51
|
+
stderr: '',
|
|
52
|
+
exitCode: 0,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const result = await repository.getRemainingRequestCount();
|
|
56
|
+
|
|
57
|
+
expect(result).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('returns zero when the remaining count is zero', async () => {
|
|
61
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
62
|
+
stdout: '0\n',
|
|
63
|
+
stderr: '',
|
|
64
|
+
exitCode: 0,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const result = await repository.getRemainingRequestCount();
|
|
68
|
+
|
|
69
|
+
expect(result).toBe(0);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { GitHubGraphqlRateLimitRepository } from '../../domain/usecases/adapter-interfaces/GitHubGraphqlRateLimitRepository';
|
|
2
|
+
import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
|
|
3
|
+
|
|
4
|
+
export class CliGitHubGraphqlRateLimitRepository implements GitHubGraphqlRateLimitRepository {
|
|
5
|
+
constructor(private readonly localCommandRunner: LocalCommandRunner) {}
|
|
6
|
+
|
|
7
|
+
getRemainingRequestCount = async (): Promise<number | null> => {
|
|
8
|
+
const result = await this.localCommandRunner.runCommand('gh', [
|
|
9
|
+
'api',
|
|
10
|
+
'rate_limit',
|
|
11
|
+
'--jq',
|
|
12
|
+
'.resources.graphql.remaining',
|
|
13
|
+
]);
|
|
14
|
+
if (result.exitCode !== 0) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const trimmed = result.stdout.trim();
|
|
18
|
+
const parsed = Number(trimmed);
|
|
19
|
+
return trimmed !== '' && Number.isInteger(parsed) ? parsed : null;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -1,8 +1,56 @@
|
|
|
1
1
|
import dotenv from 'dotenv';
|
|
2
|
-
import { KySlackRepository } from './KySlackRepository';
|
|
3
2
|
import fs from 'fs';
|
|
4
3
|
import https from 'https';
|
|
5
4
|
import path from 'path';
|
|
5
|
+
import { KySlackRepository } from './KySlackRepository';
|
|
6
|
+
|
|
7
|
+
describe('KySlackRepository', () => {
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
jest.useRealTimers();
|
|
10
|
+
jest.restoreAllMocks();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('postMessageToDirectMessage', () => {
|
|
14
|
+
it('does not hang on 429 Retry-After responses from Slack', async () => {
|
|
15
|
+
jest.useFakeTimers();
|
|
16
|
+
|
|
17
|
+
jest.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
18
|
+
new Response('{"ok":false}', {
|
|
19
|
+
status: 429,
|
|
20
|
+
headers: { 'Retry-After': '30' },
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const slackRepository = new KySlackRepository('xoxp-test-token-unit');
|
|
25
|
+
|
|
26
|
+
// Attach .catch() immediately to prevent unhandled rejection when the
|
|
27
|
+
// promise rejects during jest.advanceTimersByTimeAsync below.
|
|
28
|
+
const settledValue: { value: unknown } = { value: 'not-yet-settled' };
|
|
29
|
+
slackRepository
|
|
30
|
+
.postMessageToDirectMessage('test message', 'non-existent-user')
|
|
31
|
+
.then(() => {
|
|
32
|
+
settledValue.value = 'resolved';
|
|
33
|
+
})
|
|
34
|
+
.catch((e: unknown) => {
|
|
35
|
+
settledValue.value = e;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Advance 60 seconds of fake time.
|
|
39
|
+
// With maxRetryAfter bounded (e.g. 10s), all 3 retry delays (3×10s=30s) fire
|
|
40
|
+
// within this window, exhausting retries and settling the promise.
|
|
41
|
+
// With the default maxRetryAfter=INFINITY, only 2 of 3 delays fire (2×30s=60s),
|
|
42
|
+
// leaving the promise pending — causing CI to hang for the full Jest timeout.
|
|
43
|
+
await jest.advanceTimersByTimeAsync(60000);
|
|
44
|
+
jest.clearAllTimers();
|
|
45
|
+
jest.useRealTimers();
|
|
46
|
+
|
|
47
|
+
// Drain microtasks so the .then()/.catch() callbacks above have run.
|
|
48
|
+
await Promise.resolve();
|
|
49
|
+
|
|
50
|
+
expect(settledValue.value).not.toBe('not-yet-settled');
|
|
51
|
+
}, 5000);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
6
54
|
|
|
7
55
|
dotenv.config();
|
|
8
56
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import ky from 'ky';
|
|
2
1
|
import fs from 'fs';
|
|
3
|
-
import
|
|
2
|
+
import ky from 'ky';
|
|
3
|
+
import type { SlackRepository } from '../../domain/usecases/adapter-interfaces/SlackRepository';
|
|
4
4
|
|
|
5
5
|
type ConversationsListResponse = {
|
|
6
6
|
ok: boolean;
|
|
@@ -87,6 +87,7 @@ export class KySlackRepository implements SlackRepository {
|
|
|
87
87
|
limit: 3,
|
|
88
88
|
methods: ['get', 'post'],
|
|
89
89
|
statusCodes: [429, 500, 502, 503, 504],
|
|
90
|
+
maxRetryAfter: 10000,
|
|
90
91
|
},
|
|
91
92
|
});
|
|
92
93
|
}
|
|
@@ -194,6 +194,9 @@ describe('ConflictedIssueRevertUseCase', () => {
|
|
|
194
194
|
|
|
195
195
|
await useCase.run({ projectUrl });
|
|
196
196
|
|
|
197
|
+
expect(mockIssueRepository.updateBranch).toHaveBeenCalledWith(
|
|
198
|
+
conflictedPr.url,
|
|
199
|
+
);
|
|
197
200
|
expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
|
|
198
201
|
mockProject,
|
|
199
202
|
issue,
|
|
@@ -205,6 +208,36 @@ describe('ConflictedIssueRevertUseCase', () => {
|
|
|
205
208
|
);
|
|
206
209
|
});
|
|
207
210
|
|
|
211
|
+
it('should not update status or post comment when Awaiting Workspace PR update-branch succeeds', async () => {
|
|
212
|
+
const issue = createMockIssue({ status: 'Awaiting Workspace' });
|
|
213
|
+
const prItem = createMockPrItem({
|
|
214
|
+
url: 'https://github.com/user/repo/pull/1',
|
|
215
|
+
closingIssueReferenceUrls: [issue.url],
|
|
216
|
+
});
|
|
217
|
+
mockIssueRepository.getAllIssues.mockResolvedValue({
|
|
218
|
+
project: mockProject,
|
|
219
|
+
issues: [issue, prItem],
|
|
220
|
+
cacheUsed: false,
|
|
221
|
+
});
|
|
222
|
+
const conflictedPr = createMockRelatedPullRequest({
|
|
223
|
+
url: prItem.url,
|
|
224
|
+
isConflicted: true,
|
|
225
|
+
mergeable: 'CONFLICTING',
|
|
226
|
+
});
|
|
227
|
+
mockIssueRepository.getOpenPullRequests.mockResolvedValue(
|
|
228
|
+
new Map([[conflictedPr.url, conflictedPr]]),
|
|
229
|
+
);
|
|
230
|
+
mockIssueRepository.updateBranch.mockResolvedValue(true);
|
|
231
|
+
|
|
232
|
+
await useCase.run({ projectUrl });
|
|
233
|
+
|
|
234
|
+
expect(mockIssueRepository.updateBranch).toHaveBeenCalledWith(
|
|
235
|
+
conflictedPr.url,
|
|
236
|
+
);
|
|
237
|
+
expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
|
|
238
|
+
expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
|
|
239
|
+
});
|
|
240
|
+
|
|
208
241
|
it('should skip PR items when scanning for target task issues', async () => {
|
|
209
242
|
const prIssue = createMockIssue({
|
|
210
243
|
url: 'https://github.com/user/repo/issues/1',
|
|
@@ -317,6 +350,9 @@ describe('ConflictedIssueRevertUseCase', () => {
|
|
|
317
350
|
|
|
318
351
|
await useCase.run({ projectUrl });
|
|
319
352
|
|
|
353
|
+
expect(mockIssueRepository.updateBranch).toHaveBeenCalledWith(
|
|
354
|
+
conflictedPr.url,
|
|
355
|
+
);
|
|
320
356
|
expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
|
|
321
357
|
mockProject,
|
|
322
358
|
issue,
|
|
@@ -328,6 +364,44 @@ describe('ConflictedIssueRevertUseCase', () => {
|
|
|
328
364
|
);
|
|
329
365
|
});
|
|
330
366
|
|
|
367
|
+
it('should propagate errors thrown by updateBranch', async () => {
|
|
368
|
+
const issue = createMockIssue({
|
|
369
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
370
|
+
status: 'Preparation',
|
|
371
|
+
});
|
|
372
|
+
const prItem = createMockPrItem({
|
|
373
|
+
url: 'https://github.com/user/repo/pull/1',
|
|
374
|
+
closingIssueReferenceUrls: [issue.url],
|
|
375
|
+
});
|
|
376
|
+
mockIssueRepository.getAllIssues.mockResolvedValue({
|
|
377
|
+
project: mockProject,
|
|
378
|
+
issues: [issue, prItem],
|
|
379
|
+
cacheUsed: false,
|
|
380
|
+
});
|
|
381
|
+
const conflictedPr = createMockRelatedPullRequest({
|
|
382
|
+
url: prItem.url,
|
|
383
|
+
isConflicted: true,
|
|
384
|
+
mergeable: 'CONFLICTING',
|
|
385
|
+
});
|
|
386
|
+
mockIssueRepository.getOpenPullRequests.mockResolvedValue(
|
|
387
|
+
new Map([[conflictedPr.url, conflictedPr]]),
|
|
388
|
+
);
|
|
389
|
+
mockIssueRepository.updateBranch.mockRejectedValue(
|
|
390
|
+
new Error(
|
|
391
|
+
'Failed to update branch for PR https://github.com/user/repo/pull/1: 500 Internal Server Error',
|
|
392
|
+
),
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
await expect(useCase.run({ projectUrl })).rejects.toThrow(
|
|
396
|
+
'Failed to update branch for PR',
|
|
397
|
+
);
|
|
398
|
+
expect(mockIssueRepository.updateBranch).toHaveBeenCalledWith(
|
|
399
|
+
conflictedPr.url,
|
|
400
|
+
);
|
|
401
|
+
expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
|
|
402
|
+
expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
|
|
403
|
+
});
|
|
404
|
+
|
|
331
405
|
it('should not update status or post comment when update-branch succeeds for the conflicted PR', async () => {
|
|
332
406
|
const issue = createMockIssue({
|
|
333
407
|
url: 'https://github.com/user/repo/issues/1',
|
|
@@ -10,6 +10,7 @@ import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
|
|
|
10
10
|
import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
|
|
11
11
|
import { ClaudeTokenUsageRepository } from './adapter-interfaces/ClaudeTokenUsageRepository';
|
|
12
12
|
import { TakeOwnershipSpawnRepository } from './adapter-interfaces/TakeOwnershipSpawnRepository';
|
|
13
|
+
import { GitHubGraphqlRateLimitRepository } from './adapter-interfaces/GitHubGraphqlRateLimitRepository';
|
|
13
14
|
import { ClaudeTokenUsage } from '../entities/ClaudeTokenUsage';
|
|
14
15
|
import { Issue } from '../entities/Issue';
|
|
15
16
|
import { FieldOption, Project } from '../entities/Project';
|
|
@@ -109,6 +110,7 @@ describe('StartPreparationUseCase', () => {
|
|
|
109
110
|
let mockLocalCommandRunner: Mocked<LocalCommandRunner>;
|
|
110
111
|
let mockClaudeTokenUsageRepository: Mocked<ClaudeTokenUsageRepository>;
|
|
111
112
|
let mockTakeOwnershipSpawnRepository: Mocked<TakeOwnershipSpawnRepository>;
|
|
113
|
+
let mockGitHubGraphqlRateLimitRepository: Mocked<GitHubGraphqlRateLimitRepository>;
|
|
112
114
|
let mockProject: Project;
|
|
113
115
|
beforeEach(() => {
|
|
114
116
|
jest.resetAllMocks();
|
|
@@ -144,12 +146,16 @@ describe('StartPreparationUseCase', () => {
|
|
|
144
146
|
listSpawns: jest.fn().mockReturnValue([]),
|
|
145
147
|
listRunningIssueUrls: jest.fn().mockReturnValue([]),
|
|
146
148
|
};
|
|
149
|
+
mockGitHubGraphqlRateLimitRepository = {
|
|
150
|
+
getRemainingRequestCount: jest.fn().mockResolvedValue(null),
|
|
151
|
+
};
|
|
147
152
|
useCase = new StartPreparationUseCase(
|
|
148
153
|
mockProjectRepository,
|
|
149
154
|
mockIssueRepository,
|
|
150
155
|
mockLocalCommandRunner,
|
|
151
156
|
mockClaudeTokenUsageRepository,
|
|
152
157
|
mockTakeOwnershipSpawnRepository,
|
|
158
|
+
mockGitHubGraphqlRateLimitRepository,
|
|
153
159
|
);
|
|
154
160
|
});
|
|
155
161
|
it('should run aw command for awaiting workspace issues', async () => {
|
|
@@ -627,6 +633,225 @@ describe('StartPreparationUseCase', () => {
|
|
|
627
633
|
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
|
|
628
634
|
});
|
|
629
635
|
|
|
636
|
+
it('skips all spawning when active worker count meets the maxConcurrentWorkers cap', async () => {
|
|
637
|
+
const awaitingIssues: Issue[] = [
|
|
638
|
+
createMockIssue({
|
|
639
|
+
url: 'https://github.com/user/repo/issues/10',
|
|
640
|
+
status: 'Awaiting Workspace',
|
|
641
|
+
author: 'testuser',
|
|
642
|
+
}),
|
|
643
|
+
createMockIssue({
|
|
644
|
+
number: 2,
|
|
645
|
+
url: 'https://github.com/user/repo/issues/11',
|
|
646
|
+
status: 'Awaiting Workspace',
|
|
647
|
+
author: 'testuser',
|
|
648
|
+
}),
|
|
649
|
+
];
|
|
650
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
651
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
652
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
653
|
+
);
|
|
654
|
+
mockTakeOwnershipSpawnRepository.listRunningIssueUrls.mockReturnValue([
|
|
655
|
+
'https://github.com/user/repo/issues/1',
|
|
656
|
+
'https://github.com/user/repo/issues/2',
|
|
657
|
+
]);
|
|
658
|
+
|
|
659
|
+
await useCase.run({
|
|
660
|
+
projectUrl: 'https://github.com/user/repo',
|
|
661
|
+
defaultAgentName: 'agent1',
|
|
662
|
+
defaultLlmModelName: 'claude-opus',
|
|
663
|
+
fallbackLlmModelName: null,
|
|
664
|
+
defaultLlmAgentName: null,
|
|
665
|
+
configFilePath: '/path/to/config.yml',
|
|
666
|
+
maximumPreparingIssuesCount: null,
|
|
667
|
+
utilizationPercentageThreshold: 90,
|
|
668
|
+
allowedIssueAuthors: ['testuser'],
|
|
669
|
+
manager: 'manager-user',
|
|
670
|
+
codexHomeCandidates: null,
|
|
671
|
+
labelsAsLlmAgentName: null,
|
|
672
|
+
maxConcurrentWorkers: 2,
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
|
|
676
|
+
expect(
|
|
677
|
+
mockLocalCommandRunner.runCommand.mock.calls.map((c) => c[0]),
|
|
678
|
+
).not.toContain('aw');
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
it('stops spawning mid-loop when workers started in the run bring the count to the cap', async () => {
|
|
682
|
+
const awaitingIssues: Issue[] = [
|
|
683
|
+
createMockIssue({
|
|
684
|
+
url: 'https://github.com/user/repo/issues/10',
|
|
685
|
+
status: 'Awaiting Workspace',
|
|
686
|
+
author: 'testuser',
|
|
687
|
+
}),
|
|
688
|
+
createMockIssue({
|
|
689
|
+
number: 2,
|
|
690
|
+
url: 'https://github.com/user/repo/issues/11',
|
|
691
|
+
status: 'Awaiting Workspace',
|
|
692
|
+
author: 'testuser',
|
|
693
|
+
}),
|
|
694
|
+
];
|
|
695
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
696
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
697
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
698
|
+
);
|
|
699
|
+
mockIssueRepository.getAllOpened.mockResolvedValue([]);
|
|
700
|
+
mockTakeOwnershipSpawnRepository.listRunningIssueUrls.mockReturnValue([]);
|
|
701
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
702
|
+
stdout: '',
|
|
703
|
+
stderr: '',
|
|
704
|
+
exitCode: 0,
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
await useCase.run({
|
|
708
|
+
projectUrl: 'https://github.com/user/repo',
|
|
709
|
+
defaultAgentName: 'agent1',
|
|
710
|
+
defaultLlmModelName: 'claude-opus',
|
|
711
|
+
fallbackLlmModelName: null,
|
|
712
|
+
defaultLlmAgentName: null,
|
|
713
|
+
configFilePath: '/path/to/config.yml',
|
|
714
|
+
maximumPreparingIssuesCount: null,
|
|
715
|
+
utilizationPercentageThreshold: 90,
|
|
716
|
+
allowedIssueAuthors: ['testuser'],
|
|
717
|
+
manager: 'manager-user',
|
|
718
|
+
codexHomeCandidates: null,
|
|
719
|
+
labelsAsLlmAgentName: null,
|
|
720
|
+
maxConcurrentWorkers: 1,
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
const awCalls = mockLocalCommandRunner.runCommand.mock.calls.filter(
|
|
724
|
+
(call) => call[0] === 'aw',
|
|
725
|
+
);
|
|
726
|
+
expect(awCalls).toHaveLength(1);
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
it('skips the preparation cycle when GraphQL rate limit is below the floor', async () => {
|
|
730
|
+
const awaitingIssues: Issue[] = [
|
|
731
|
+
createMockIssue({
|
|
732
|
+
url: 'https://github.com/user/repo/issues/10',
|
|
733
|
+
status: 'Awaiting Workspace',
|
|
734
|
+
author: 'testuser',
|
|
735
|
+
}),
|
|
736
|
+
];
|
|
737
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
738
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
739
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
740
|
+
);
|
|
741
|
+
mockGitHubGraphqlRateLimitRepository.getRemainingRequestCount.mockResolvedValue(
|
|
742
|
+
499,
|
|
743
|
+
);
|
|
744
|
+
|
|
745
|
+
await useCase.run({
|
|
746
|
+
projectUrl: 'https://github.com/user/repo',
|
|
747
|
+
defaultAgentName: 'agent1',
|
|
748
|
+
defaultLlmModelName: 'claude-opus',
|
|
749
|
+
fallbackLlmModelName: null,
|
|
750
|
+
defaultLlmAgentName: null,
|
|
751
|
+
configFilePath: '/path/to/config.yml',
|
|
752
|
+
maximumPreparingIssuesCount: null,
|
|
753
|
+
utilizationPercentageThreshold: 90,
|
|
754
|
+
allowedIssueAuthors: ['testuser'],
|
|
755
|
+
manager: 'manager-user',
|
|
756
|
+
codexHomeCandidates: null,
|
|
757
|
+
labelsAsLlmAgentName: null,
|
|
758
|
+
graphqlRateLimitFloor: 500,
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
|
|
762
|
+
expect(
|
|
763
|
+
mockLocalCommandRunner.runCommand.mock.calls.map((c) => c[0]),
|
|
764
|
+
).not.toContain('aw');
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it('proceeds with spawning when GraphQL remaining equals the floor exactly', async () => {
|
|
768
|
+
const awaitingIssues: Issue[] = [
|
|
769
|
+
createMockIssue({
|
|
770
|
+
url: 'https://github.com/user/repo/issues/10',
|
|
771
|
+
status: 'Awaiting Workspace',
|
|
772
|
+
author: 'testuser',
|
|
773
|
+
}),
|
|
774
|
+
];
|
|
775
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
776
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
777
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
778
|
+
);
|
|
779
|
+
mockIssueRepository.getAllOpened.mockResolvedValue([]);
|
|
780
|
+
mockGitHubGraphqlRateLimitRepository.getRemainingRequestCount.mockResolvedValue(
|
|
781
|
+
500,
|
|
782
|
+
);
|
|
783
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
784
|
+
stdout: '',
|
|
785
|
+
stderr: '',
|
|
786
|
+
exitCode: 0,
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
await useCase.run({
|
|
790
|
+
projectUrl: 'https://github.com/user/repo',
|
|
791
|
+
defaultAgentName: 'agent1',
|
|
792
|
+
defaultLlmModelName: 'claude-opus',
|
|
793
|
+
fallbackLlmModelName: null,
|
|
794
|
+
defaultLlmAgentName: null,
|
|
795
|
+
configFilePath: '/path/to/config.yml',
|
|
796
|
+
maximumPreparingIssuesCount: null,
|
|
797
|
+
utilizationPercentageThreshold: 90,
|
|
798
|
+
allowedIssueAuthors: ['testuser'],
|
|
799
|
+
manager: 'manager-user',
|
|
800
|
+
codexHomeCandidates: null,
|
|
801
|
+
labelsAsLlmAgentName: null,
|
|
802
|
+
graphqlRateLimitFloor: 500,
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
const awCalls = mockLocalCommandRunner.runCommand.mock.calls.filter(
|
|
806
|
+
(call) => call[0] === 'aw',
|
|
807
|
+
);
|
|
808
|
+
expect(awCalls).toHaveLength(1);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
it('proceeds with spawning when GraphQL rate limit check returns null', async () => {
|
|
812
|
+
const awaitingIssues: Issue[] = [
|
|
813
|
+
createMockIssue({
|
|
814
|
+
url: 'https://github.com/user/repo/issues/10',
|
|
815
|
+
status: 'Awaiting Workspace',
|
|
816
|
+
author: 'testuser',
|
|
817
|
+
}),
|
|
818
|
+
];
|
|
819
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
820
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
821
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
822
|
+
);
|
|
823
|
+
mockIssueRepository.getAllOpened.mockResolvedValue([]);
|
|
824
|
+
mockGitHubGraphqlRateLimitRepository.getRemainingRequestCount.mockResolvedValue(
|
|
825
|
+
null,
|
|
826
|
+
);
|
|
827
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
828
|
+
stdout: '',
|
|
829
|
+
stderr: '',
|
|
830
|
+
exitCode: 0,
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
await useCase.run({
|
|
834
|
+
projectUrl: 'https://github.com/user/repo',
|
|
835
|
+
defaultAgentName: 'agent1',
|
|
836
|
+
defaultLlmModelName: 'claude-opus',
|
|
837
|
+
fallbackLlmModelName: null,
|
|
838
|
+
defaultLlmAgentName: null,
|
|
839
|
+
configFilePath: '/path/to/config.yml',
|
|
840
|
+
maximumPreparingIssuesCount: null,
|
|
841
|
+
utilizationPercentageThreshold: 90,
|
|
842
|
+
allowedIssueAuthors: ['testuser'],
|
|
843
|
+
manager: 'manager-user',
|
|
844
|
+
codexHomeCandidates: null,
|
|
845
|
+
labelsAsLlmAgentName: null,
|
|
846
|
+
graphqlRateLimitFloor: 500,
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
const awCalls = mockLocalCommandRunner.runCommand.mock.calls.filter(
|
|
850
|
+
(call) => call[0] === 'aw',
|
|
851
|
+
);
|
|
852
|
+
expect(awCalls).toHaveLength(1);
|
|
853
|
+
});
|
|
854
|
+
|
|
630
855
|
it('should pass --branch to aw command when issue has an existing linked PR', async () => {
|
|
631
856
|
const awaitingIssues: Issue[] = [
|
|
632
857
|
createMockIssue({
|
|
@@ -7055,6 +7280,7 @@ describe('StartPreparationUseCase.buildRotationOrder', () => {
|
|
|
7055
7280
|
listSpawns: jest.fn().mockReturnValue([]),
|
|
7056
7281
|
listRunningIssueUrls: jest.fn().mockReturnValue([]),
|
|
7057
7282
|
},
|
|
7283
|
+
{ getRemainingRequestCount: jest.fn().mockResolvedValue(null) },
|
|
7058
7284
|
);
|
|
7059
7285
|
|
|
7060
7286
|
it('lists selected tokens first in ascending 7-day reset deadline order then excluded tokens', () => {
|
|
@@ -7354,6 +7580,7 @@ describe('StartPreparationUseCase.getTokenConcurrentLimit', () => {
|
|
|
7354
7580
|
listSpawns: jest.fn().mockReturnValue([]),
|
|
7355
7581
|
listRunningIssueUrls: jest.fn().mockReturnValue([]),
|
|
7356
7582
|
},
|
|
7583
|
+
{ getRemainingRequestCount: jest.fn().mockResolvedValue(null) },
|
|
7357
7584
|
);
|
|
7358
7585
|
});
|
|
7359
7586
|
|
|
@@ -7465,6 +7692,7 @@ describe('StartPreparationUseCase.run normalConcurrentLimit', () => {
|
|
|
7465
7692
|
mockLocalCommandRunner,
|
|
7466
7693
|
mockClaudeTokenUsageRepository,
|
|
7467
7694
|
mockTakeOwnershipSpawnRepository,
|
|
7695
|
+
{ getRemainingRequestCount: jest.fn().mockResolvedValue(null) },
|
|
7468
7696
|
);
|
|
7469
7697
|
|
|
7470
7698
|
await useCase.run({
|
|
@@ -7542,6 +7770,7 @@ describe('StartPreparationUseCase.fetchSpawnCandidateBranchSources', () => {
|
|
|
7542
7770
|
listSpawns: jest.fn().mockReturnValue([]),
|
|
7543
7771
|
listRunningIssueUrls: jest.fn().mockReturnValue([]),
|
|
7544
7772
|
},
|
|
7773
|
+
{ getRemainingRequestCount: jest.fn().mockResolvedValue(null) },
|
|
7545
7774
|
);
|
|
7546
7775
|
|
|
7547
7776
|
it('looks up related open pull requests for issue urls concurrently up to the configured limit', async () => {
|
|
@@ -7,6 +7,7 @@ import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
|
|
|
7
7
|
import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
|
|
8
8
|
import { ClaudeTokenUsageRepository } from './adapter-interfaces/ClaudeTokenUsageRepository';
|
|
9
9
|
import { TakeOwnershipSpawnRepository } from './adapter-interfaces/TakeOwnershipSpawnRepository';
|
|
10
|
+
import { GitHubGraphqlRateLimitRepository } from './adapter-interfaces/GitHubGraphqlRateLimitRepository';
|
|
10
11
|
import { ClaudeTokenUsage } from '../entities/ClaudeTokenUsage';
|
|
11
12
|
import { DEFAULT_SELECTION_WEIGHT } from './OauthTokenSelectUseCase';
|
|
12
13
|
import {
|
|
@@ -74,6 +75,7 @@ export class StartPreparationUseCase {
|
|
|
74
75
|
private readonly localCommandRunner: LocalCommandRunner,
|
|
75
76
|
private readonly claudeTokenUsageRepository: ClaudeTokenUsageRepository,
|
|
76
77
|
private readonly takeOwnershipSpawnRepository: TakeOwnershipSpawnRepository,
|
|
78
|
+
private readonly gitHubGraphqlRateLimitRepository: GitHubGraphqlRateLimitRepository,
|
|
77
79
|
) {}
|
|
78
80
|
|
|
79
81
|
private weeklyLimitTypeForModel = (modelName: string | null): string => {
|
|
@@ -421,6 +423,8 @@ export class StartPreparationUseCase {
|
|
|
421
423
|
labelsAsLlmAgentName: string[] | null;
|
|
422
424
|
agents?: string[] | null;
|
|
423
425
|
normalConcurrentLimit?: number;
|
|
426
|
+
maxConcurrentWorkers?: number | null;
|
|
427
|
+
graphqlRateLimitFloor?: number | null;
|
|
424
428
|
}): Promise<{ rotationOrder: RotationOrderEntry[] | null }> => {
|
|
425
429
|
const normalConcurrentLimit =
|
|
426
430
|
params.normalConcurrentLimit ?? NORMAL_CONCURRENT_LIMIT;
|
|
@@ -537,6 +541,28 @@ export class StartPreparationUseCase {
|
|
|
537
541
|
|
|
538
542
|
const now = new Date();
|
|
539
543
|
|
|
544
|
+
const maxConcurrentWorkers = params.maxConcurrentWorkers ?? null;
|
|
545
|
+
const graphqlRateLimitFloor = params.graphqlRateLimitFloor ?? null;
|
|
546
|
+
|
|
547
|
+
if (graphqlRateLimitFloor !== null) {
|
|
548
|
+
const graphqlRemaining =
|
|
549
|
+
await this.gitHubGraphqlRateLimitRepository.getRemainingRequestCount();
|
|
550
|
+
if (
|
|
551
|
+
graphqlRemaining !== null &&
|
|
552
|
+
graphqlRemaining < graphqlRateLimitFloor
|
|
553
|
+
) {
|
|
554
|
+
console.warn(
|
|
555
|
+
`GraphQL rate limit low (${graphqlRemaining} remaining, floor: ${graphqlRateLimitFloor}); skipping preparation cycle.`,
|
|
556
|
+
);
|
|
557
|
+
return { rotationOrder };
|
|
558
|
+
}
|
|
559
|
+
if (graphqlRemaining === null) {
|
|
560
|
+
console.warn(
|
|
561
|
+
'GraphQL rate limit check failed; proceeding with spawning.',
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
540
566
|
const branchSourceByIssueUrl = await this.fetchSpawnCandidateBranchSources(
|
|
541
567
|
awaitingWorkspaceIssues
|
|
542
568
|
.filter(
|
|
@@ -567,6 +593,15 @@ export class StartPreparationUseCase {
|
|
|
567
593
|
console.warn(`Skipping ${issue.url}: worker already running.`);
|
|
568
594
|
continue;
|
|
569
595
|
}
|
|
596
|
+
if (maxConcurrentWorkers !== null) {
|
|
597
|
+
const activeWorkerCount = runningIssueUrls.size + startedInThisRunCount;
|
|
598
|
+
if (activeWorkerCount >= maxConcurrentWorkers) {
|
|
599
|
+
console.warn(
|
|
600
|
+
`Spawn cap reached (${activeWorkerCount}/${maxConcurrentWorkers} active workers); skipping remaining candidates.`,
|
|
601
|
+
);
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
570
605
|
const exclusionReason = this.spawnCandidateExclusionReasonOf(
|
|
571
606
|
issue,
|
|
572
607
|
params.allowedIssueAuthors,
|