github-issue-tower-defence-management 1.40.0 → 1.41.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/.github/workflows/umino-project.yml +5 -4
- package/CHANGELOG.md +13 -0
- package/README.md +21 -9
- package/bin/adapter/entry-points/cli/index.js +45 -10
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +32 -8
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/repositories/NodeLocalCommandRunner.js +3 -3
- package/bin/adapter/repositories/NodeLocalCommandRunner.js.map +1 -1
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +412 -177
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
- package/bin/domain/usecases/HandleScheduledEventUseCase.js +5 -2
- package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +7 -2
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/StartPreparationUseCase.js +107 -72
- package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.test.ts +26 -13
- package/src/adapter/entry-points/cli/index.ts +74 -13
- package/src/adapter/repositories/NodeLocalCommandRunner.test.ts +12 -12
- package/src/adapter/repositories/NodeLocalCommandRunner.ts +7 -4
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +3 -0
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +626 -265
- package/src/adapter/repositories/issue/RestIssueRepository.test.ts +3 -0
- package/src/domain/entities/Issue.ts +1 -0
- package/src/domain/usecases/GetStoryObjectMapUseCase.test.ts +1 -0
- package/src/domain/usecases/HandleScheduledEventUseCase.ts +11 -3
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +1 -0
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +22 -9
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +8 -3
- package/src/domain/usecases/StartPreparationUseCase.test.ts +1696 -290
- package/src/domain/usecases/StartPreparationUseCase.ts +171 -126
- package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +2 -1
- package/src/domain/usecases/adapter-interfaces/LocalCommandRunner.ts +4 -1
- package/types/adapter/entry-points/cli/index.d.ts +4 -1
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/repositories/NodeLocalCommandRunner.d.ts +1 -1
- package/types/adapter/repositories/NodeLocalCommandRunner.d.ts.map +1 -1
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +5 -3
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
- package/types/domain/entities/Issue.d.ts +1 -0
- package/types/domain/entities/Issue.d.ts.map +1 -1
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +5 -1
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
- package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/StartPreparationUseCase.d.ts +10 -18
- package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +2 -1
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts +1 -1
- package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts.map +1 -1
|
@@ -1,19 +1,38 @@
|
|
|
1
1
|
import { StartPreparationUseCase } from './StartPreparationUseCase';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
IssueRepository,
|
|
4
|
+
RelatedPullRequest,
|
|
5
|
+
} from './adapter-interfaces/IssueRepository';
|
|
3
6
|
import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
|
|
4
7
|
import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
|
|
5
8
|
import { ClaudeRepository } from './adapter-interfaces/ClaudeRepository';
|
|
6
9
|
import { Issue } from '../entities/Issue';
|
|
7
10
|
import { Project } from '../entities/Project';
|
|
11
|
+
import { StoryObjectMap } from '../entities/StoryObjectMap';
|
|
8
12
|
type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
|
|
9
13
|
|
|
14
|
+
const createMockStoryObjectMap = (issues: Issue[]): StoryObjectMap => {
|
|
15
|
+
const map: StoryObjectMap = new Map();
|
|
16
|
+
map.set('Default Story', {
|
|
17
|
+
story: {
|
|
18
|
+
id: 'story-1',
|
|
19
|
+
name: 'Default Story',
|
|
20
|
+
color: 'GRAY',
|
|
21
|
+
description: '',
|
|
22
|
+
},
|
|
23
|
+
storyIssue: null,
|
|
24
|
+
issues: issues,
|
|
25
|
+
});
|
|
26
|
+
return map;
|
|
27
|
+
};
|
|
28
|
+
|
|
10
29
|
const createMockIssue = (overrides: Partial<Issue> = {}): Issue => ({
|
|
11
30
|
nameWithOwner: 'user/repo',
|
|
12
31
|
number: 1,
|
|
13
32
|
title: 'Test Issue',
|
|
14
33
|
state: 'OPEN',
|
|
15
34
|
status: 'Backlog',
|
|
16
|
-
story:
|
|
35
|
+
story: null,
|
|
17
36
|
nextActionDate: null,
|
|
18
37
|
nextActionHour: null,
|
|
19
38
|
estimationMinutes: null,
|
|
@@ -30,12 +49,13 @@ const createMockIssue = (overrides: Partial<Issue> = {}): Issue => ({
|
|
|
30
49
|
isInProgress: false,
|
|
31
50
|
isClosed: false,
|
|
32
51
|
createdAt: new Date(),
|
|
52
|
+
author: 'testuser',
|
|
33
53
|
...overrides,
|
|
34
54
|
});
|
|
35
55
|
|
|
36
56
|
const createMockProject = (): Project => ({
|
|
37
57
|
id: 'project-1',
|
|
38
|
-
url: 'https://github.com/
|
|
58
|
+
url: 'https://github.com/users/user/projects/1',
|
|
39
59
|
databaseId: 1,
|
|
40
60
|
name: 'Test Project',
|
|
41
61
|
status: {
|
|
@@ -49,23 +69,7 @@ const createMockProject = (): Project => ({
|
|
|
49
69
|
},
|
|
50
70
|
nextActionDate: null,
|
|
51
71
|
nextActionHour: null,
|
|
52
|
-
story:
|
|
53
|
-
name: 'Story',
|
|
54
|
-
fieldId: 'story-field-id',
|
|
55
|
-
databaseId: 1,
|
|
56
|
-
stories: [
|
|
57
|
-
{
|
|
58
|
-
id: 'story-1',
|
|
59
|
-
name: 'Default Story',
|
|
60
|
-
color: 'GRAY',
|
|
61
|
-
description: '',
|
|
62
|
-
},
|
|
63
|
-
],
|
|
64
|
-
workflowManagementStory: {
|
|
65
|
-
id: 'wf-1',
|
|
66
|
-
name: 'Workflow Management',
|
|
67
|
-
},
|
|
68
|
-
},
|
|
72
|
+
story: null,
|
|
69
73
|
remainingEstimationMinutes: null,
|
|
70
74
|
dependedIssueUrlSeparatedByComma: null,
|
|
71
75
|
completionDate50PercentConfidence: null,
|
|
@@ -74,10 +78,17 @@ const createMockProject = (): Project => ({
|
|
|
74
78
|
describe('StartPreparationUseCase', () => {
|
|
75
79
|
let useCase: StartPreparationUseCase;
|
|
76
80
|
let mockProjectRepository: Mocked<
|
|
77
|
-
Pick<ProjectRepository, '
|
|
81
|
+
Pick<ProjectRepository, 'getByUrl' | 'prepareStatus'>
|
|
78
82
|
>;
|
|
79
83
|
let mockIssueRepository: Mocked<
|
|
80
|
-
Pick<
|
|
84
|
+
Pick<
|
|
85
|
+
IssueRepository,
|
|
86
|
+
| 'getAllOpened'
|
|
87
|
+
| 'getStoryObjectMap'
|
|
88
|
+
| 'update'
|
|
89
|
+
| 'findRelatedOpenPRs'
|
|
90
|
+
| 'getOpenPullRequest'
|
|
91
|
+
>
|
|
81
92
|
>;
|
|
82
93
|
let mockClaudeRepository: Mocked<Pick<ClaudeRepository, 'getUsage'>>;
|
|
83
94
|
let mockLocalCommandRunner: Mocked<LocalCommandRunner>;
|
|
@@ -86,14 +97,19 @@ describe('StartPreparationUseCase', () => {
|
|
|
86
97
|
jest.resetAllMocks();
|
|
87
98
|
mockProject = createMockProject();
|
|
88
99
|
mockProjectRepository = {
|
|
89
|
-
|
|
90
|
-
|
|
100
|
+
getByUrl: jest.fn(),
|
|
101
|
+
prepareStatus: jest
|
|
102
|
+
.fn()
|
|
103
|
+
.mockImplementation((_name: string, project: Project) =>
|
|
104
|
+
Promise.resolve(project),
|
|
105
|
+
),
|
|
91
106
|
};
|
|
92
107
|
mockIssueRepository = {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
108
|
+
getAllOpened: jest.fn(),
|
|
109
|
+
getStoryObjectMap: jest.fn().mockResolvedValue(new Map()),
|
|
110
|
+
update: jest.fn(),
|
|
111
|
+
findRelatedOpenPRs: jest.fn().mockResolvedValue([]),
|
|
112
|
+
getOpenPullRequest: jest.fn().mockResolvedValue(null),
|
|
97
113
|
};
|
|
98
114
|
mockClaudeRepository = {
|
|
99
115
|
getUsage: jest.fn().mockResolvedValue([]),
|
|
@@ -108,6 +124,43 @@ describe('StartPreparationUseCase', () => {
|
|
|
108
124
|
mockLocalCommandRunner,
|
|
109
125
|
);
|
|
110
126
|
});
|
|
127
|
+
it('should call prepareStatus for awaitingWorkspaceStatus and preparationStatus with chained project objects', async () => {
|
|
128
|
+
const projectAfterFirstPrepare = createMockProject();
|
|
129
|
+
const projectAfterSecondPrepare = createMockProject();
|
|
130
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
131
|
+
mockProjectRepository.prepareStatus
|
|
132
|
+
.mockResolvedValueOnce(projectAfterFirstPrepare)
|
|
133
|
+
.mockResolvedValueOnce(projectAfterSecondPrepare);
|
|
134
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
135
|
+
createMockStoryObjectMap([]),
|
|
136
|
+
);
|
|
137
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce([]);
|
|
138
|
+
|
|
139
|
+
await useCase.run({
|
|
140
|
+
projectUrl: 'https://github.com/user/repo',
|
|
141
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
142
|
+
preparationStatus: 'Preparation',
|
|
143
|
+
defaultAgentName: 'agent1',
|
|
144
|
+
defaultLlmModelName: null,
|
|
145
|
+
defaultLlmAgentName: null,
|
|
146
|
+
configFilePath: '/path/to/config.yml',
|
|
147
|
+
maximumPreparingIssuesCount: null,
|
|
148
|
+
utilizationPercentageThreshold: 90,
|
|
149
|
+
allowedIssueAuthors: null,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(mockProjectRepository.prepareStatus).toHaveBeenCalledTimes(2);
|
|
153
|
+
expect(mockProjectRepository.prepareStatus).toHaveBeenNthCalledWith(
|
|
154
|
+
1,
|
|
155
|
+
'Awaiting Workspace',
|
|
156
|
+
mockProject,
|
|
157
|
+
);
|
|
158
|
+
expect(mockProjectRepository.prepareStatus).toHaveBeenNthCalledWith(
|
|
159
|
+
2,
|
|
160
|
+
'Preparation',
|
|
161
|
+
projectAfterFirstPrepare,
|
|
162
|
+
);
|
|
163
|
+
});
|
|
111
164
|
it('should run aw command for awaiting workspace issues', async () => {
|
|
112
165
|
const awaitingIssues: Issue[] = [
|
|
113
166
|
createMockIssue({
|
|
@@ -117,11 +170,11 @@ describe('StartPreparationUseCase', () => {
|
|
|
117
170
|
status: 'Awaiting Workspace',
|
|
118
171
|
}),
|
|
119
172
|
];
|
|
120
|
-
mockProjectRepository.
|
|
121
|
-
mockIssueRepository.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
173
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
174
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
175
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
176
|
+
);
|
|
177
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
125
178
|
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
126
179
|
stdout: '',
|
|
127
180
|
stderr: '',
|
|
@@ -132,18 +185,383 @@ describe('StartPreparationUseCase', () => {
|
|
|
132
185
|
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
133
186
|
preparationStatus: 'Preparation',
|
|
134
187
|
defaultAgentName: 'agent1',
|
|
188
|
+
defaultLlmModelName: 'claude-opus',
|
|
189
|
+
defaultLlmAgentName: null,
|
|
190
|
+
configFilePath: '/path/to/config.yml',
|
|
135
191
|
maximumPreparingIssuesCount: null,
|
|
136
|
-
|
|
192
|
+
utilizationPercentageThreshold: 90,
|
|
193
|
+
allowedIssueAuthors: null,
|
|
137
194
|
});
|
|
138
|
-
expect(mockIssueRepository.
|
|
139
|
-
expect(mockIssueRepository.
|
|
195
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(1);
|
|
196
|
+
expect(mockIssueRepository.update.mock.calls[0][0]).toMatchObject({
|
|
140
197
|
url: 'url1',
|
|
198
|
+
status: 'Preparation',
|
|
199
|
+
});
|
|
200
|
+
expect(mockIssueRepository.update.mock.calls[0][1]).toBe(mockProject);
|
|
201
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
202
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
|
|
203
|
+
'aw',
|
|
204
|
+
[
|
|
205
|
+
'url1',
|
|
206
|
+
'impl',
|
|
207
|
+
'claude-opus',
|
|
208
|
+
'--configFilePath',
|
|
209
|
+
'/path/to/config.yml',
|
|
210
|
+
'--branch',
|
|
211
|
+
'i1',
|
|
212
|
+
],
|
|
213
|
+
]);
|
|
214
|
+
});
|
|
215
|
+
it('should pass --branch to aw command when issue has an existing linked PR', async () => {
|
|
216
|
+
const awaitingIssues: Issue[] = [
|
|
217
|
+
createMockIssue({
|
|
218
|
+
url: 'url1',
|
|
219
|
+
title: 'Issue 1',
|
|
220
|
+
labels: ['category:impl'],
|
|
221
|
+
status: 'Awaiting Workspace',
|
|
222
|
+
}),
|
|
223
|
+
];
|
|
224
|
+
const existingPR: RelatedPullRequest = {
|
|
225
|
+
url: 'https://github.com/user/repo/pull/42',
|
|
226
|
+
branchName: 'i1',
|
|
227
|
+
isConflicted: false,
|
|
228
|
+
isPassedAllCiJob: false,
|
|
229
|
+
isCiStateSuccess: false,
|
|
230
|
+
isResolvedAllReviewComments: false,
|
|
231
|
+
isBranchOutOfDate: false,
|
|
232
|
+
missingRequiredCheckNames: [],
|
|
233
|
+
};
|
|
234
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
235
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
236
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
237
|
+
);
|
|
238
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
239
|
+
mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([existingPR]);
|
|
240
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
241
|
+
stdout: '',
|
|
242
|
+
stderr: '',
|
|
243
|
+
exitCode: 0,
|
|
244
|
+
});
|
|
245
|
+
await useCase.run({
|
|
246
|
+
projectUrl: 'https://github.com/user/repo',
|
|
247
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
248
|
+
preparationStatus: 'Preparation',
|
|
249
|
+
defaultAgentName: 'agent1',
|
|
250
|
+
defaultLlmModelName: 'claude-opus',
|
|
251
|
+
defaultLlmAgentName: null,
|
|
252
|
+
configFilePath: '/path/to/config.yml',
|
|
253
|
+
maximumPreparingIssuesCount: null,
|
|
254
|
+
utilizationPercentageThreshold: 90,
|
|
255
|
+
allowedIssueAuthors: null,
|
|
256
|
+
});
|
|
257
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
258
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
|
|
259
|
+
'aw',
|
|
260
|
+
[
|
|
261
|
+
'url1',
|
|
262
|
+
'impl',
|
|
263
|
+
'claude-opus',
|
|
264
|
+
'--configFilePath',
|
|
265
|
+
'/path/to/config.yml',
|
|
266
|
+
'--branch',
|
|
267
|
+
'i1',
|
|
268
|
+
],
|
|
269
|
+
]);
|
|
270
|
+
});
|
|
271
|
+
it('should pass --branch with PR branch name when issue URL is a PR URL', async () => {
|
|
272
|
+
const awaitingIssues: Issue[] = [
|
|
273
|
+
createMockIssue({
|
|
274
|
+
url: 'https://github.com/user/repo/pull/354',
|
|
275
|
+
title: 'PR 354',
|
|
276
|
+
labels: ['category:impl'],
|
|
277
|
+
status: 'Awaiting Workspace',
|
|
278
|
+
}),
|
|
279
|
+
];
|
|
280
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
281
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
282
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
283
|
+
);
|
|
284
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
285
|
+
mockIssueRepository.getOpenPullRequest.mockResolvedValue({
|
|
286
|
+
url: 'https://github.com/user/repo/pull/354',
|
|
287
|
+
branchName: 'dependabot/npm_and_yarn/multi-cc382f683c',
|
|
288
|
+
isConflicted: false,
|
|
289
|
+
isPassedAllCiJob: false,
|
|
290
|
+
isCiStateSuccess: false,
|
|
291
|
+
isResolvedAllReviewComments: false,
|
|
292
|
+
isBranchOutOfDate: false,
|
|
293
|
+
missingRequiredCheckNames: [],
|
|
294
|
+
});
|
|
295
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
296
|
+
stdout: '',
|
|
297
|
+
stderr: '',
|
|
298
|
+
exitCode: 0,
|
|
299
|
+
});
|
|
300
|
+
await useCase.run({
|
|
301
|
+
projectUrl: 'https://github.com/user/repo',
|
|
302
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
303
|
+
preparationStatus: 'Preparation',
|
|
304
|
+
defaultAgentName: 'agent1',
|
|
305
|
+
defaultLlmModelName: 'claude-opus',
|
|
306
|
+
defaultLlmAgentName: null,
|
|
307
|
+
configFilePath: '/path/to/config.yml',
|
|
308
|
+
maximumPreparingIssuesCount: null,
|
|
309
|
+
utilizationPercentageThreshold: 90,
|
|
310
|
+
allowedIssueAuthors: null,
|
|
141
311
|
});
|
|
142
|
-
expect(mockIssueRepository.updateStatus.mock.calls[0][2]).toBe('2');
|
|
143
312
|
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
144
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls[0]
|
|
145
|
-
'aw
|
|
313
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
|
|
314
|
+
'aw',
|
|
315
|
+
[
|
|
316
|
+
'https://github.com/user/repo/pull/354',
|
|
317
|
+
'impl',
|
|
318
|
+
'claude-opus',
|
|
319
|
+
'--configFilePath',
|
|
320
|
+
'/path/to/config.yml',
|
|
321
|
+
'--branch',
|
|
322
|
+
'dependabot/npm_and_yarn/multi-cc382f683c',
|
|
323
|
+
],
|
|
324
|
+
]);
|
|
325
|
+
expect(mockIssueRepository.findRelatedOpenPRs).not.toHaveBeenCalled();
|
|
326
|
+
expect(mockIssueRepository.getOpenPullRequest).toHaveBeenCalledWith(
|
|
327
|
+
'https://github.com/user/repo/pull/354',
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
it('should skip and not call wrapper when PR URL returns null from getOpenPullRequest', async () => {
|
|
331
|
+
const awaitingIssues: Issue[] = [
|
|
332
|
+
createMockIssue({
|
|
333
|
+
url: 'https://github.com/user/repo/pull/999',
|
|
334
|
+
title: 'PR 999',
|
|
335
|
+
labels: ['category:impl'],
|
|
336
|
+
status: 'Awaiting Workspace',
|
|
337
|
+
}),
|
|
338
|
+
];
|
|
339
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
340
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
341
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
342
|
+
);
|
|
343
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
344
|
+
mockIssueRepository.getOpenPullRequest.mockResolvedValue(null);
|
|
345
|
+
const consoleWarnSpy = jest
|
|
346
|
+
.spyOn(console, 'warn')
|
|
347
|
+
.mockImplementation(() => {});
|
|
348
|
+
await useCase.run({
|
|
349
|
+
projectUrl: 'https://github.com/user/repo',
|
|
350
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
351
|
+
preparationStatus: 'Preparation',
|
|
352
|
+
defaultAgentName: 'agent1',
|
|
353
|
+
defaultLlmModelName: 'claude-opus',
|
|
354
|
+
defaultLlmAgentName: null,
|
|
355
|
+
configFilePath: '/path/to/config.yml',
|
|
356
|
+
maximumPreparingIssuesCount: null,
|
|
357
|
+
utilizationPercentageThreshold: 90,
|
|
358
|
+
allowedIssueAuthors: null,
|
|
359
|
+
});
|
|
360
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
|
|
361
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(0);
|
|
362
|
+
expect(mockIssueRepository.findRelatedOpenPRs).not.toHaveBeenCalled();
|
|
363
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
364
|
+
'Skipping non-OPEN PR https://github.com/user/repo/pull/999: wrapper requires an open PR.',
|
|
365
|
+
);
|
|
366
|
+
consoleWarnSpy.mockRestore();
|
|
367
|
+
});
|
|
368
|
+
it('should skip and not call wrapper when PR URL has open PR with null branchName', async () => {
|
|
369
|
+
const awaitingIssues: Issue[] = [
|
|
370
|
+
createMockIssue({
|
|
371
|
+
url: 'https://github.com/user/repo/pull/999',
|
|
372
|
+
title: 'PR 999',
|
|
373
|
+
labels: ['category:impl'],
|
|
374
|
+
status: 'Awaiting Workspace',
|
|
375
|
+
}),
|
|
376
|
+
];
|
|
377
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
378
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
379
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
380
|
+
);
|
|
381
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
382
|
+
mockIssueRepository.getOpenPullRequest.mockResolvedValue({
|
|
383
|
+
url: 'https://github.com/user/repo/pull/999',
|
|
384
|
+
branchName: null,
|
|
385
|
+
isConflicted: false,
|
|
386
|
+
isPassedAllCiJob: false,
|
|
387
|
+
isCiStateSuccess: false,
|
|
388
|
+
isResolvedAllReviewComments: false,
|
|
389
|
+
isBranchOutOfDate: false,
|
|
390
|
+
missingRequiredCheckNames: [],
|
|
391
|
+
});
|
|
392
|
+
const consoleWarnSpy = jest
|
|
393
|
+
.spyOn(console, 'warn')
|
|
394
|
+
.mockImplementation(() => {});
|
|
395
|
+
await useCase.run({
|
|
396
|
+
projectUrl: 'https://github.com/user/repo',
|
|
397
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
398
|
+
preparationStatus: 'Preparation',
|
|
399
|
+
defaultAgentName: 'agent1',
|
|
400
|
+
defaultLlmModelName: 'claude-opus',
|
|
401
|
+
defaultLlmAgentName: null,
|
|
402
|
+
configFilePath: '/path/to/config.yml',
|
|
403
|
+
maximumPreparingIssuesCount: null,
|
|
404
|
+
utilizationPercentageThreshold: 90,
|
|
405
|
+
allowedIssueAuthors: null,
|
|
406
|
+
});
|
|
407
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
|
|
408
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(0);
|
|
409
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
410
|
+
'Skipping PR https://github.com/user/repo/pull/999: head branch is unavailable.',
|
|
411
|
+
);
|
|
412
|
+
consoleWarnSpy.mockRestore();
|
|
413
|
+
});
|
|
414
|
+
it('should skip and not call wrapper when PR has branch name with shell-unsafe characters', async () => {
|
|
415
|
+
const awaitingIssues: Issue[] = [
|
|
416
|
+
createMockIssue({
|
|
417
|
+
url: 'https://github.com/user/repo/pull/999',
|
|
418
|
+
title: 'PR 999',
|
|
419
|
+
labels: ['category:impl'],
|
|
420
|
+
status: 'Awaiting Workspace',
|
|
421
|
+
}),
|
|
422
|
+
];
|
|
423
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
424
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
425
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
426
|
+
);
|
|
427
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
428
|
+
mockIssueRepository.getOpenPullRequest.mockResolvedValue({
|
|
429
|
+
url: 'https://github.com/user/repo/pull/999',
|
|
430
|
+
branchName: 'evil$(rm -rf /)',
|
|
431
|
+
isConflicted: false,
|
|
432
|
+
isPassedAllCiJob: false,
|
|
433
|
+
isCiStateSuccess: false,
|
|
434
|
+
isResolvedAllReviewComments: false,
|
|
435
|
+
isBranchOutOfDate: false,
|
|
436
|
+
missingRequiredCheckNames: [],
|
|
437
|
+
});
|
|
438
|
+
const consoleErrorSpy = jest
|
|
439
|
+
.spyOn(console, 'error')
|
|
440
|
+
.mockImplementation(() => {});
|
|
441
|
+
await useCase.run({
|
|
442
|
+
projectUrl: 'https://github.com/user/repo',
|
|
443
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
444
|
+
preparationStatus: 'Preparation',
|
|
445
|
+
defaultAgentName: 'agent1',
|
|
446
|
+
defaultLlmModelName: 'claude-opus',
|
|
447
|
+
defaultLlmAgentName: null,
|
|
448
|
+
configFilePath: '/path/to/config.yml',
|
|
449
|
+
maximumPreparingIssuesCount: null,
|
|
450
|
+
utilizationPercentageThreshold: 90,
|
|
451
|
+
allowedIssueAuthors: null,
|
|
452
|
+
});
|
|
453
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
|
|
454
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(0);
|
|
455
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
456
|
+
expect.stringContaining('branch name contains unexpected characters'),
|
|
457
|
+
);
|
|
458
|
+
consoleErrorSpy.mockRestore();
|
|
459
|
+
});
|
|
460
|
+
it('should skip and not call wrapper when issue has multiple related open PRs', async () => {
|
|
461
|
+
const awaitingIssues: Issue[] = [
|
|
462
|
+
createMockIssue({
|
|
463
|
+
url: 'url1',
|
|
464
|
+
title: 'Issue 1',
|
|
465
|
+
labels: ['category:impl'],
|
|
466
|
+
status: 'Awaiting Workspace',
|
|
467
|
+
}),
|
|
468
|
+
];
|
|
469
|
+
const pr1: RelatedPullRequest = {
|
|
470
|
+
url: 'https://github.com/user/repo/pull/42',
|
|
471
|
+
branchName: 'i1',
|
|
472
|
+
isConflicted: false,
|
|
473
|
+
isPassedAllCiJob: false,
|
|
474
|
+
isCiStateSuccess: false,
|
|
475
|
+
isResolvedAllReviewComments: false,
|
|
476
|
+
isBranchOutOfDate: false,
|
|
477
|
+
missingRequiredCheckNames: [],
|
|
478
|
+
};
|
|
479
|
+
const pr2: RelatedPullRequest = {
|
|
480
|
+
url: 'https://github.com/user/repo/pull/43',
|
|
481
|
+
branchName: 'i1-fix',
|
|
482
|
+
isConflicted: false,
|
|
483
|
+
isPassedAllCiJob: false,
|
|
484
|
+
isCiStateSuccess: false,
|
|
485
|
+
isResolvedAllReviewComments: false,
|
|
486
|
+
isBranchOutOfDate: false,
|
|
487
|
+
missingRequiredCheckNames: [],
|
|
488
|
+
};
|
|
489
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
490
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
491
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
492
|
+
);
|
|
493
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
494
|
+
mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([pr1, pr2]);
|
|
495
|
+
const consoleWarnSpy = jest
|
|
496
|
+
.spyOn(console, 'warn')
|
|
497
|
+
.mockImplementation(() => {});
|
|
498
|
+
await useCase.run({
|
|
499
|
+
projectUrl: 'https://github.com/user/repo',
|
|
500
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
501
|
+
preparationStatus: 'Preparation',
|
|
502
|
+
defaultAgentName: 'agent1',
|
|
503
|
+
defaultLlmModelName: 'claude-opus',
|
|
504
|
+
defaultLlmAgentName: null,
|
|
505
|
+
configFilePath: '/path/to/config.yml',
|
|
506
|
+
maximumPreparingIssuesCount: null,
|
|
507
|
+
utilizationPercentageThreshold: 90,
|
|
508
|
+
allowedIssueAuthors: null,
|
|
509
|
+
});
|
|
510
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
|
|
511
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(0);
|
|
512
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
513
|
+
'Skipping issue url1: 2 related open PRs found (ambiguous).',
|
|
514
|
+
);
|
|
515
|
+
consoleWarnSpy.mockRestore();
|
|
516
|
+
});
|
|
517
|
+
it('should skip and not call wrapper when issue has one related open PR with null branchName', async () => {
|
|
518
|
+
const awaitingIssues: Issue[] = [
|
|
519
|
+
createMockIssue({
|
|
520
|
+
url: 'url1',
|
|
521
|
+
title: 'Issue 1',
|
|
522
|
+
labels: ['category:impl'],
|
|
523
|
+
status: 'Awaiting Workspace',
|
|
524
|
+
}),
|
|
525
|
+
];
|
|
526
|
+
const prWithNullBranch: RelatedPullRequest = {
|
|
527
|
+
url: 'https://github.com/user/repo/pull/42',
|
|
528
|
+
branchName: null,
|
|
529
|
+
isConflicted: false,
|
|
530
|
+
isPassedAllCiJob: false,
|
|
531
|
+
isCiStateSuccess: false,
|
|
532
|
+
isResolvedAllReviewComments: false,
|
|
533
|
+
isBranchOutOfDate: false,
|
|
534
|
+
missingRequiredCheckNames: [],
|
|
535
|
+
};
|
|
536
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
537
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
538
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
539
|
+
);
|
|
540
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
541
|
+
mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
|
|
542
|
+
prWithNullBranch,
|
|
543
|
+
]);
|
|
544
|
+
const consoleWarnSpy = jest
|
|
545
|
+
.spyOn(console, 'warn')
|
|
546
|
+
.mockImplementation(() => {});
|
|
547
|
+
await useCase.run({
|
|
548
|
+
projectUrl: 'https://github.com/user/repo',
|
|
549
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
550
|
+
preparationStatus: 'Preparation',
|
|
551
|
+
defaultAgentName: 'agent1',
|
|
552
|
+
defaultLlmModelName: 'claude-opus',
|
|
553
|
+
defaultLlmAgentName: null,
|
|
554
|
+
configFilePath: '/path/to/config.yml',
|
|
555
|
+
maximumPreparingIssuesCount: null,
|
|
556
|
+
utilizationPercentageThreshold: 90,
|
|
557
|
+
allowedIssueAuthors: null,
|
|
558
|
+
});
|
|
559
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
|
|
560
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(0);
|
|
561
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
562
|
+
'Skipping issue url1: related open PR has unavailable head branch.',
|
|
146
563
|
);
|
|
564
|
+
consoleWarnSpy.mockRestore();
|
|
147
565
|
});
|
|
148
566
|
it('should assign workspace to awaiting issues', async () => {
|
|
149
567
|
const awaitingIssues: Issue[] = [
|
|
@@ -160,11 +578,11 @@ describe('StartPreparationUseCase', () => {
|
|
|
160
578
|
status: 'Awaiting Workspace',
|
|
161
579
|
}),
|
|
162
580
|
];
|
|
163
|
-
mockProjectRepository.
|
|
164
|
-
mockIssueRepository.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
581
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
582
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
583
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
584
|
+
);
|
|
585
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
168
586
|
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
169
587
|
stdout: '',
|
|
170
588
|
stderr: '',
|
|
@@ -175,19 +593,29 @@ describe('StartPreparationUseCase', () => {
|
|
|
175
593
|
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
176
594
|
preparationStatus: 'Preparation',
|
|
177
595
|
defaultAgentName: 'agent1',
|
|
596
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
597
|
+
defaultLlmAgentName: null,
|
|
598
|
+
configFilePath: '/path/to/config.yml',
|
|
178
599
|
maximumPreparingIssuesCount: null,
|
|
179
|
-
|
|
600
|
+
utilizationPercentageThreshold: 90,
|
|
601
|
+
allowedIssueAuthors: null,
|
|
180
602
|
});
|
|
181
|
-
|
|
182
|
-
expect(mockIssueRepository.
|
|
603
|
+
// Both awaiting issues should be updated (forward iteration: url1 first, then url2)
|
|
604
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(2);
|
|
605
|
+
expect(mockIssueRepository.update.mock.calls[0][0]).toMatchObject({
|
|
183
606
|
url: 'url1',
|
|
607
|
+
status: 'Preparation',
|
|
184
608
|
});
|
|
185
|
-
expect(mockIssueRepository.
|
|
609
|
+
expect(mockIssueRepository.update.mock.calls[1][0]).toMatchObject({
|
|
186
610
|
url: 'url2',
|
|
611
|
+
status: 'Preparation',
|
|
187
612
|
});
|
|
613
|
+
expect(mockIssueRepository.update.mock.calls[0][1]).toBe(mockProject);
|
|
188
614
|
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(2);
|
|
189
615
|
});
|
|
190
616
|
it('should stop assigning after maximum preparing issues count is reached', async () => {
|
|
617
|
+
// When we already have 6 preparation issues and max is 6 (default),
|
|
618
|
+
// the loop condition prevents processing any new issues
|
|
191
619
|
const preparationIssues: Issue[] = Array.from({ length: 6 }, (_, i) =>
|
|
192
620
|
createMockIssue({
|
|
193
621
|
url: `url${i + 1}`,
|
|
@@ -204,11 +632,14 @@ describe('StartPreparationUseCase', () => {
|
|
|
204
632
|
status: 'Awaiting Workspace',
|
|
205
633
|
}),
|
|
206
634
|
];
|
|
207
|
-
mockProjectRepository.
|
|
208
|
-
mockIssueRepository.
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
635
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
636
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
637
|
+
createMockStoryObjectMap([...preparationIssues, ...awaitingIssues]),
|
|
638
|
+
);
|
|
639
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce([
|
|
640
|
+
...preparationIssues,
|
|
641
|
+
...awaitingIssues,
|
|
642
|
+
]);
|
|
212
643
|
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
213
644
|
stdout: '',
|
|
214
645
|
stderr: '',
|
|
@@ -219,13 +650,18 @@ describe('StartPreparationUseCase', () => {
|
|
|
219
650
|
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
220
651
|
preparationStatus: 'Preparation',
|
|
221
652
|
defaultAgentName: 'agent1',
|
|
653
|
+
defaultLlmModelName: null,
|
|
654
|
+
defaultLlmAgentName: null,
|
|
655
|
+
configFilePath: '/path/to/config.yml',
|
|
222
656
|
maximumPreparingIssuesCount: null,
|
|
223
|
-
|
|
657
|
+
utilizationPercentageThreshold: 90,
|
|
658
|
+
allowedIssueAuthors: null,
|
|
224
659
|
});
|
|
225
|
-
|
|
660
|
+
// Loop doesn't run because we're already at max (6 >= 6)
|
|
661
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(0);
|
|
226
662
|
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
|
|
227
663
|
});
|
|
228
|
-
it('should
|
|
664
|
+
it('should pass configFilePath to aw command', async () => {
|
|
229
665
|
const awaitingIssues: Issue[] = [
|
|
230
666
|
createMockIssue({
|
|
231
667
|
url: 'url1',
|
|
@@ -234,11 +670,11 @@ describe('StartPreparationUseCase', () => {
|
|
|
234
670
|
status: 'Awaiting Workspace',
|
|
235
671
|
}),
|
|
236
672
|
];
|
|
237
|
-
mockProjectRepository.
|
|
238
|
-
mockIssueRepository.
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
673
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
674
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
675
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
676
|
+
);
|
|
677
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
242
678
|
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
243
679
|
stdout: '',
|
|
244
680
|
stderr: '',
|
|
@@ -249,16 +685,28 @@ describe('StartPreparationUseCase', () => {
|
|
|
249
685
|
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
250
686
|
preparationStatus: 'Preparation',
|
|
251
687
|
defaultAgentName: 'agent1',
|
|
252
|
-
|
|
688
|
+
defaultLlmModelName: 'claude-opus',
|
|
689
|
+
defaultLlmAgentName: null,
|
|
690
|
+
configFilePath: '/path/to/config.yml',
|
|
253
691
|
maximumPreparingIssuesCount: null,
|
|
254
|
-
|
|
692
|
+
utilizationPercentageThreshold: 90,
|
|
693
|
+
allowedIssueAuthors: null,
|
|
255
694
|
});
|
|
256
695
|
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
257
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls[0]
|
|
258
|
-
'aw
|
|
259
|
-
|
|
696
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
|
|
697
|
+
'aw',
|
|
698
|
+
[
|
|
699
|
+
'url1',
|
|
700
|
+
'impl',
|
|
701
|
+
'claude-opus',
|
|
702
|
+
'--configFilePath',
|
|
703
|
+
'/path/to/config.yml',
|
|
704
|
+
'--branch',
|
|
705
|
+
'i1',
|
|
706
|
+
],
|
|
707
|
+
]);
|
|
260
708
|
});
|
|
261
|
-
it('should
|
|
709
|
+
it('should use configFilePath in aw command', async () => {
|
|
262
710
|
const awaitingIssues: Issue[] = [
|
|
263
711
|
createMockIssue({
|
|
264
712
|
url: 'url1',
|
|
@@ -267,11 +715,11 @@ describe('StartPreparationUseCase', () => {
|
|
|
267
715
|
status: 'Awaiting Workspace',
|
|
268
716
|
}),
|
|
269
717
|
];
|
|
270
|
-
mockProjectRepository.
|
|
271
|
-
mockIssueRepository.
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
718
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
719
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
720
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
721
|
+
);
|
|
722
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
275
723
|
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
276
724
|
stdout: '',
|
|
277
725
|
stderr: '',
|
|
@@ -282,28 +730,41 @@ describe('StartPreparationUseCase', () => {
|
|
|
282
730
|
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
283
731
|
preparationStatus: 'Preparation',
|
|
284
732
|
defaultAgentName: 'agent1',
|
|
733
|
+
defaultLlmModelName: 'claude-opus',
|
|
734
|
+
defaultLlmAgentName: null,
|
|
735
|
+
configFilePath: '/path/to/config.yml',
|
|
285
736
|
maximumPreparingIssuesCount: null,
|
|
286
|
-
|
|
737
|
+
utilizationPercentageThreshold: 90,
|
|
738
|
+
allowedIssueAuthors: null,
|
|
287
739
|
});
|
|
288
740
|
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
289
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls[0]
|
|
290
|
-
'aw
|
|
291
|
-
|
|
741
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
|
|
742
|
+
'aw',
|
|
743
|
+
[
|
|
744
|
+
'url1',
|
|
745
|
+
'impl',
|
|
746
|
+
'claude-opus',
|
|
747
|
+
'--configFilePath',
|
|
748
|
+
'/path/to/config.yml',
|
|
749
|
+
'--branch',
|
|
750
|
+
'i1',
|
|
751
|
+
],
|
|
752
|
+
]);
|
|
292
753
|
});
|
|
293
|
-
it('should
|
|
294
|
-
const
|
|
754
|
+
it('should use llm-agent label over category label and defaultLlmAgentName', async () => {
|
|
755
|
+
const awaitingIssues: Issue[] = [
|
|
295
756
|
createMockIssue({
|
|
296
757
|
url: 'url1',
|
|
297
758
|
title: 'Issue 1',
|
|
298
|
-
labels: [],
|
|
299
|
-
status: '
|
|
759
|
+
labels: ['llm-agent:research', 'category:impl'],
|
|
760
|
+
status: 'Awaiting Workspace',
|
|
300
761
|
}),
|
|
301
762
|
];
|
|
302
|
-
mockProjectRepository.
|
|
303
|
-
mockIssueRepository.
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
763
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
764
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
765
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
766
|
+
);
|
|
767
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
307
768
|
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
308
769
|
stdout: '',
|
|
309
770
|
stderr: '',
|
|
@@ -314,26 +775,41 @@ describe('StartPreparationUseCase', () => {
|
|
|
314
775
|
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
315
776
|
preparationStatus: 'Preparation',
|
|
316
777
|
defaultAgentName: 'agent1',
|
|
778
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
779
|
+
defaultLlmAgentName: 'default-llm-agent',
|
|
780
|
+
configFilePath: '/path/to/config.yml',
|
|
317
781
|
maximumPreparingIssuesCount: null,
|
|
318
|
-
|
|
782
|
+
utilizationPercentageThreshold: 90,
|
|
783
|
+
allowedIssueAuthors: null,
|
|
319
784
|
});
|
|
320
|
-
expect(
|
|
321
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls).
|
|
785
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
786
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
|
|
787
|
+
'aw',
|
|
788
|
+
[
|
|
789
|
+
'url1',
|
|
790
|
+
'research',
|
|
791
|
+
'claude-sonnet-4-6',
|
|
792
|
+
'--configFilePath',
|
|
793
|
+
'/path/to/config.yml',
|
|
794
|
+
'--branch',
|
|
795
|
+
'i1',
|
|
796
|
+
],
|
|
797
|
+
]);
|
|
322
798
|
});
|
|
323
|
-
it('should use
|
|
324
|
-
const awaitingIssues: Issue[] =
|
|
799
|
+
it('should use category label over defaultLlmAgentName when no llm-agent label', async () => {
|
|
800
|
+
const awaitingIssues: Issue[] = [
|
|
325
801
|
createMockIssue({
|
|
326
|
-
url:
|
|
327
|
-
title:
|
|
328
|
-
labels: [],
|
|
802
|
+
url: 'url1',
|
|
803
|
+
title: 'Issue 1',
|
|
804
|
+
labels: ['category:impl'],
|
|
329
805
|
status: 'Awaiting Workspace',
|
|
330
806
|
}),
|
|
807
|
+
];
|
|
808
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
809
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
810
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
331
811
|
);
|
|
332
|
-
|
|
333
|
-
mockIssueRepository.getAllIssues.mockResolvedValue({
|
|
334
|
-
issues: awaitingIssues,
|
|
335
|
-
cacheUsed: false,
|
|
336
|
-
});
|
|
812
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
337
813
|
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
338
814
|
stdout: '',
|
|
339
815
|
stderr: '',
|
|
@@ -344,26 +820,41 @@ describe('StartPreparationUseCase', () => {
|
|
|
344
820
|
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
345
821
|
preparationStatus: 'Preparation',
|
|
346
822
|
defaultAgentName: 'agent1',
|
|
347
|
-
|
|
348
|
-
|
|
823
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
824
|
+
defaultLlmAgentName: 'default-llm-agent',
|
|
825
|
+
configFilePath: '/path/to/config.yml',
|
|
826
|
+
maximumPreparingIssuesCount: null,
|
|
827
|
+
utilizationPercentageThreshold: 90,
|
|
828
|
+
allowedIssueAuthors: null,
|
|
349
829
|
});
|
|
350
|
-
expect(
|
|
351
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls).
|
|
830
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
831
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
|
|
832
|
+
'aw',
|
|
833
|
+
[
|
|
834
|
+
'url1',
|
|
835
|
+
'impl',
|
|
836
|
+
'claude-sonnet-4-6',
|
|
837
|
+
'--configFilePath',
|
|
838
|
+
'/path/to/config.yml',
|
|
839
|
+
'--branch',
|
|
840
|
+
'i1',
|
|
841
|
+
],
|
|
842
|
+
]);
|
|
352
843
|
});
|
|
353
|
-
it('should use
|
|
354
|
-
const awaitingIssues: Issue[] =
|
|
844
|
+
it('should use defaultLlmAgentName over defaultAgentName when no label', async () => {
|
|
845
|
+
const awaitingIssues: Issue[] = [
|
|
355
846
|
createMockIssue({
|
|
356
|
-
url:
|
|
357
|
-
title:
|
|
847
|
+
url: 'url1',
|
|
848
|
+
title: 'Issue 1',
|
|
358
849
|
labels: [],
|
|
359
850
|
status: 'Awaiting Workspace',
|
|
360
851
|
}),
|
|
852
|
+
];
|
|
853
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
854
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
855
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
361
856
|
);
|
|
362
|
-
|
|
363
|
-
mockIssueRepository.getAllIssues.mockResolvedValue({
|
|
364
|
-
issues: awaitingIssues,
|
|
365
|
-
cacheUsed: false,
|
|
366
|
-
});
|
|
857
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
367
858
|
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
368
859
|
stdout: '',
|
|
369
860
|
stderr: '',
|
|
@@ -374,197 +865,322 @@ describe('StartPreparationUseCase', () => {
|
|
|
374
865
|
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
375
866
|
preparationStatus: 'Preparation',
|
|
376
867
|
defaultAgentName: 'agent1',
|
|
868
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
869
|
+
defaultLlmAgentName: 'default-llm-agent',
|
|
870
|
+
configFilePath: '/path/to/config.yml',
|
|
377
871
|
maximumPreparingIssuesCount: null,
|
|
378
|
-
|
|
872
|
+
utilizationPercentageThreshold: 90,
|
|
873
|
+
allowedIssueAuthors: null,
|
|
379
874
|
});
|
|
380
|
-
expect(
|
|
381
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls).
|
|
875
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
876
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
|
|
877
|
+
'aw',
|
|
878
|
+
[
|
|
879
|
+
'url1',
|
|
880
|
+
'default-llm-agent',
|
|
881
|
+
'claude-sonnet-4-6',
|
|
882
|
+
'--configFilePath',
|
|
883
|
+
'/path/to/config.yml',
|
|
884
|
+
'--branch',
|
|
885
|
+
'i1',
|
|
886
|
+
],
|
|
887
|
+
]);
|
|
382
888
|
});
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
labels: [],
|
|
398
|
-
status: 'Awaiting Workspace',
|
|
399
|
-
state: 'OPEN',
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
const projectWithBlocker = {
|
|
403
|
-
...createMockProject(),
|
|
404
|
-
story: {
|
|
405
|
-
name: 'Story',
|
|
406
|
-
fieldId: 'story-field-id',
|
|
407
|
-
databaseId: 1,
|
|
408
|
-
stories: [
|
|
409
|
-
{
|
|
410
|
-
id: 'story-blocker',
|
|
411
|
-
name: 'Workflow blocker',
|
|
412
|
-
color: 'RED' as const,
|
|
413
|
-
description: '',
|
|
414
|
-
},
|
|
415
|
-
{
|
|
416
|
-
id: 'story-1',
|
|
417
|
-
name: 'Default Story',
|
|
418
|
-
color: 'GRAY' as const,
|
|
419
|
-
description: '',
|
|
420
|
-
},
|
|
421
|
-
],
|
|
422
|
-
workflowManagementStory: {
|
|
423
|
-
id: 'wf-1',
|
|
424
|
-
name: 'Workflow Management',
|
|
425
|
-
},
|
|
426
|
-
},
|
|
427
|
-
};
|
|
428
|
-
|
|
429
|
-
mockProjectRepository.getProject.mockResolvedValue(projectWithBlocker);
|
|
430
|
-
mockIssueRepository.getAllIssues.mockResolvedValue({
|
|
431
|
-
issues: [blockerIssue, blockedIssue],
|
|
432
|
-
cacheUsed: false,
|
|
433
|
-
});
|
|
889
|
+
it('should use llm-model label over defaultLlmModelName', async () => {
|
|
890
|
+
const awaitingIssues: Issue[] = [
|
|
891
|
+
createMockIssue({
|
|
892
|
+
url: 'url1',
|
|
893
|
+
title: 'Issue 1',
|
|
894
|
+
labels: ['category:impl', 'llm-model:claude-sonnet'],
|
|
895
|
+
status: 'Awaiting Workspace',
|
|
896
|
+
}),
|
|
897
|
+
];
|
|
898
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
899
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
900
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
901
|
+
);
|
|
902
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
434
903
|
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
435
904
|
stdout: '',
|
|
436
905
|
stderr: '',
|
|
437
906
|
exitCode: 0,
|
|
438
907
|
});
|
|
439
|
-
|
|
440
908
|
await useCase.run({
|
|
441
909
|
projectUrl: 'https://github.com/user/repo',
|
|
442
910
|
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
443
911
|
preparationStatus: 'Preparation',
|
|
444
912
|
defaultAgentName: 'agent1',
|
|
913
|
+
defaultLlmModelName: 'claude-opus',
|
|
914
|
+
defaultLlmAgentName: null,
|
|
915
|
+
configFilePath: '/path/to/config.yml',
|
|
445
916
|
maximumPreparingIssuesCount: null,
|
|
446
|
-
|
|
917
|
+
utilizationPercentageThreshold: 90,
|
|
918
|
+
allowedIssueAuthors: null,
|
|
447
919
|
});
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
const blockedRunCommandCalls =
|
|
462
|
-
mockLocalCommandRunner.runCommand.mock.calls.filter((call) =>
|
|
463
|
-
call.some(
|
|
464
|
-
(arg) => typeof arg === 'string' && arg.includes(blockedIssue.url),
|
|
465
|
-
),
|
|
466
|
-
);
|
|
467
|
-
expect(blockedRunCommandCalls).toHaveLength(0);
|
|
920
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
921
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
|
|
922
|
+
'aw',
|
|
923
|
+
[
|
|
924
|
+
'url1',
|
|
925
|
+
'impl',
|
|
926
|
+
'claude-sonnet',
|
|
927
|
+
'--configFilePath',
|
|
928
|
+
'/path/to/config.yml',
|
|
929
|
+
'--branch',
|
|
930
|
+
'i1',
|
|
931
|
+
],
|
|
932
|
+
]);
|
|
468
933
|
});
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
934
|
+
it('should log error and skip issue when no llm-model label and no defaultLlmModelName', async () => {
|
|
935
|
+
const awaitingIssues: Issue[] = [
|
|
936
|
+
createMockIssue({
|
|
937
|
+
url: 'url1',
|
|
938
|
+
title: 'Issue 1',
|
|
939
|
+
labels: ['category:impl'],
|
|
940
|
+
status: 'Awaiting Workspace',
|
|
941
|
+
}),
|
|
942
|
+
];
|
|
943
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
944
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
945
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
946
|
+
);
|
|
947
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
948
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
949
|
+
stdout: '',
|
|
950
|
+
stderr: '',
|
|
951
|
+
exitCode: 0,
|
|
478
952
|
});
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
],
|
|
494
|
-
workflowManagementStory: {
|
|
495
|
-
id: 'wf-1',
|
|
496
|
-
name: 'Workflow Management',
|
|
497
|
-
},
|
|
498
|
-
},
|
|
499
|
-
};
|
|
500
|
-
|
|
501
|
-
mockProjectRepository.getProject.mockResolvedValue(projectWithBlocker);
|
|
502
|
-
mockIssueRepository.getAllIssues.mockResolvedValue({
|
|
503
|
-
issues: [blockerIssue],
|
|
504
|
-
cacheUsed: false,
|
|
953
|
+
const consoleErrorSpy = jest
|
|
954
|
+
.spyOn(console, 'error')
|
|
955
|
+
.mockImplementation(() => {});
|
|
956
|
+
await useCase.run({
|
|
957
|
+
projectUrl: 'https://github.com/user/repo',
|
|
958
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
959
|
+
preparationStatus: 'Preparation',
|
|
960
|
+
defaultAgentName: 'agent1',
|
|
961
|
+
defaultLlmModelName: null,
|
|
962
|
+
defaultLlmAgentName: null,
|
|
963
|
+
configFilePath: '/path/to/config.yml',
|
|
964
|
+
maximumPreparingIssuesCount: null,
|
|
965
|
+
utilizationPercentageThreshold: 90,
|
|
966
|
+
allowedIssueAuthors: null,
|
|
505
967
|
});
|
|
968
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
969
|
+
'No LLM model configured for issue url1. Provide --defaultLlmModelName or add an llm-model: label.',
|
|
970
|
+
);
|
|
971
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
|
|
972
|
+
consoleErrorSpy.mockRestore();
|
|
973
|
+
});
|
|
974
|
+
it('should continue processing subsequent issues when one issue has no model configured', async () => {
|
|
975
|
+
const awaitingIssues: Issue[] = [
|
|
976
|
+
createMockIssue({
|
|
977
|
+
url: 'url1',
|
|
978
|
+
title: 'Issue 1 (no model)',
|
|
979
|
+
labels: ['category:impl'],
|
|
980
|
+
status: 'Awaiting Workspace',
|
|
981
|
+
}),
|
|
982
|
+
createMockIssue({
|
|
983
|
+
url: 'url2',
|
|
984
|
+
title: 'Issue 2 (with model label)',
|
|
985
|
+
labels: ['category:impl', 'llm-model:claude-sonnet-4-6'],
|
|
986
|
+
status: 'Awaiting Workspace',
|
|
987
|
+
number: 2,
|
|
988
|
+
itemId: 'item-2',
|
|
989
|
+
}),
|
|
990
|
+
];
|
|
991
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
992
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
993
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
994
|
+
);
|
|
995
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
506
996
|
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
507
997
|
stdout: '',
|
|
508
998
|
stderr: '',
|
|
509
999
|
exitCode: 0,
|
|
510
1000
|
});
|
|
511
|
-
|
|
1001
|
+
const consoleErrorSpy = jest
|
|
1002
|
+
.spyOn(console, 'error')
|
|
1003
|
+
.mockImplementation(() => {});
|
|
512
1004
|
await useCase.run({
|
|
513
1005
|
projectUrl: 'https://github.com/user/repo',
|
|
514
1006
|
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
515
1007
|
preparationStatus: 'Preparation',
|
|
516
1008
|
defaultAgentName: 'agent1',
|
|
1009
|
+
defaultLlmModelName: null,
|
|
1010
|
+
defaultLlmAgentName: null,
|
|
1011
|
+
configFilePath: '/path/to/config.yml',
|
|
517
1012
|
maximumPreparingIssuesCount: null,
|
|
518
|
-
|
|
1013
|
+
utilizationPercentageThreshold: 90,
|
|
1014
|
+
allowedIssueAuthors: null,
|
|
519
1015
|
});
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
expect(mockIssueRepository.updateStatus.mock.calls[0][1].url).toBe(
|
|
523
|
-
'https://github.com/user/repo/issues/100',
|
|
1016
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
1017
|
+
'No LLM model configured for issue url1. Provide --defaultLlmModelName or add an llm-model: label.',
|
|
524
1018
|
);
|
|
1019
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
1020
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
|
|
1021
|
+
'aw',
|
|
1022
|
+
[
|
|
1023
|
+
'url2',
|
|
1024
|
+
'impl',
|
|
1025
|
+
'claude-sonnet-4-6',
|
|
1026
|
+
'--configFilePath',
|
|
1027
|
+
'/path/to/config.yml',
|
|
1028
|
+
'--branch',
|
|
1029
|
+
'i2',
|
|
1030
|
+
],
|
|
1031
|
+
]);
|
|
1032
|
+
consoleErrorSpy.mockRestore();
|
|
1033
|
+
});
|
|
1034
|
+
it('should handle no awaiting workspace issues gracefully', async () => {
|
|
1035
|
+
// Test that the loop handles an empty awaiting workspace issues array
|
|
1036
|
+
const preparationIssues: Issue[] = [
|
|
1037
|
+
createMockIssue({
|
|
1038
|
+
url: 'url1',
|
|
1039
|
+
title: 'Issue 1',
|
|
1040
|
+
labels: [],
|
|
1041
|
+
status: 'Preparation',
|
|
1042
|
+
}),
|
|
1043
|
+
];
|
|
1044
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1045
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1046
|
+
createMockStoryObjectMap(preparationIssues),
|
|
1047
|
+
);
|
|
1048
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(preparationIssues);
|
|
1049
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1050
|
+
stdout: '',
|
|
1051
|
+
stderr: '',
|
|
1052
|
+
exitCode: 0,
|
|
1053
|
+
});
|
|
1054
|
+
await useCase.run({
|
|
1055
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1056
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
1057
|
+
preparationStatus: 'Preparation',
|
|
1058
|
+
defaultAgentName: 'agent1',
|
|
1059
|
+
defaultLlmModelName: null,
|
|
1060
|
+
defaultLlmAgentName: null,
|
|
1061
|
+
configFilePath: '/path/to/config.yml',
|
|
1062
|
+
maximumPreparingIssuesCount: null,
|
|
1063
|
+
utilizationPercentageThreshold: 90,
|
|
1064
|
+
allowedIssueAuthors: null,
|
|
1065
|
+
});
|
|
1066
|
+
// No issues are in 'Awaiting Workspace' status, so no updates should happen
|
|
1067
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(0);
|
|
1068
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
|
|
1069
|
+
});
|
|
1070
|
+
it('should use custom maximumPreparingIssuesCount when provided', async () => {
|
|
1071
|
+
const awaitingIssues: Issue[] = Array.from({ length: 10 }, (_, i) =>
|
|
1072
|
+
createMockIssue({
|
|
1073
|
+
url: `url${i + 1}`,
|
|
1074
|
+
title: `Issue ${i + 1}`,
|
|
1075
|
+
labels: [],
|
|
1076
|
+
status: 'Awaiting Workspace',
|
|
1077
|
+
}),
|
|
1078
|
+
);
|
|
1079
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1080
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1081
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
1082
|
+
);
|
|
1083
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
1084
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1085
|
+
stdout: '',
|
|
1086
|
+
stderr: '',
|
|
1087
|
+
exitCode: 0,
|
|
1088
|
+
});
|
|
1089
|
+
await useCase.run({
|
|
1090
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1091
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
1092
|
+
preparationStatus: 'Preparation',
|
|
1093
|
+
defaultAgentName: 'agent1',
|
|
1094
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
1095
|
+
defaultLlmAgentName: null,
|
|
1096
|
+
configFilePath: '/path/to/config.yml',
|
|
1097
|
+
maximumPreparingIssuesCount: 3,
|
|
1098
|
+
utilizationPercentageThreshold: 90,
|
|
1099
|
+
allowedIssueAuthors: null,
|
|
1100
|
+
});
|
|
1101
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(3);
|
|
1102
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(3);
|
|
1103
|
+
});
|
|
1104
|
+
it('should use default maximumPreparingIssuesCount of 6 when null is provided', async () => {
|
|
1105
|
+
const awaitingIssues: Issue[] = Array.from({ length: 12 }, (_, i) =>
|
|
1106
|
+
createMockIssue({
|
|
1107
|
+
url: `url${i + 1}`,
|
|
1108
|
+
title: `Issue ${i + 1}`,
|
|
1109
|
+
labels: [],
|
|
1110
|
+
status: 'Awaiting Workspace',
|
|
1111
|
+
}),
|
|
1112
|
+
);
|
|
1113
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1114
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1115
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
1116
|
+
);
|
|
1117
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
1118
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1119
|
+
stdout: '',
|
|
1120
|
+
stderr: '',
|
|
1121
|
+
exitCode: 0,
|
|
1122
|
+
});
|
|
1123
|
+
await useCase.run({
|
|
1124
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1125
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
1126
|
+
preparationStatus: 'Preparation',
|
|
1127
|
+
defaultAgentName: 'agent1',
|
|
1128
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
1129
|
+
defaultLlmAgentName: null,
|
|
1130
|
+
configFilePath: '/path/to/config.yml',
|
|
1131
|
+
maximumPreparingIssuesCount: null,
|
|
1132
|
+
utilizationPercentageThreshold: 90,
|
|
1133
|
+
allowedIssueAuthors: null,
|
|
1134
|
+
});
|
|
1135
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(6);
|
|
1136
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(6);
|
|
525
1137
|
});
|
|
526
1138
|
|
|
527
|
-
it('should
|
|
528
|
-
const
|
|
1139
|
+
it('should not skip issues from repositories with workflow blockers', async () => {
|
|
1140
|
+
const blockerIssue = createMockIssue({
|
|
1141
|
+
url: 'https://github.com/user/repo/issues/100',
|
|
1142
|
+
title: 'Blocker Issue',
|
|
1143
|
+
labels: [],
|
|
1144
|
+
status: 'Awaiting Workspace',
|
|
1145
|
+
state: 'OPEN',
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
const issueInBlockedRepo = createMockIssue({
|
|
529
1149
|
url: 'https://github.com/user/repo/issues/101',
|
|
530
|
-
title: '
|
|
1150
|
+
title: 'Issue in blocked repo',
|
|
531
1151
|
labels: [],
|
|
532
1152
|
status: 'Awaiting Workspace',
|
|
533
1153
|
state: 'OPEN',
|
|
534
1154
|
});
|
|
535
1155
|
|
|
536
|
-
const
|
|
537
|
-
|
|
1156
|
+
const workflowBlockerMap: StoryObjectMap = new Map();
|
|
1157
|
+
workflowBlockerMap.set('Workflow blocker', {
|
|
538
1158
|
story: {
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
{
|
|
544
|
-
id: 'story-1',
|
|
545
|
-
name: 'Default Story',
|
|
546
|
-
color: 'GRAY' as const,
|
|
547
|
-
description: '',
|
|
548
|
-
},
|
|
549
|
-
{
|
|
550
|
-
id: 'story-blocker',
|
|
551
|
-
name: 'Workflow blocker',
|
|
552
|
-
color: 'RED' as const,
|
|
553
|
-
description: '',
|
|
554
|
-
},
|
|
555
|
-
],
|
|
556
|
-
workflowManagementStory: {
|
|
557
|
-
id: 'wf-1',
|
|
558
|
-
name: 'Workflow Management',
|
|
559
|
-
},
|
|
1159
|
+
id: 'story-blocker',
|
|
1160
|
+
name: 'Workflow blocker',
|
|
1161
|
+
color: 'RED',
|
|
1162
|
+
description: '',
|
|
560
1163
|
},
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
1164
|
+
storyIssue: null,
|
|
1165
|
+
issues: [blockerIssue],
|
|
1166
|
+
});
|
|
1167
|
+
workflowBlockerMap.set('Default Story', {
|
|
1168
|
+
story: {
|
|
1169
|
+
id: 'story-1',
|
|
1170
|
+
name: 'Default Story',
|
|
1171
|
+
color: 'GRAY',
|
|
1172
|
+
description: '',
|
|
1173
|
+
},
|
|
1174
|
+
storyIssue: null,
|
|
1175
|
+
issues: [issueInBlockedRepo],
|
|
567
1176
|
});
|
|
1177
|
+
|
|
1178
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1179
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(workflowBlockerMap);
|
|
1180
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce([
|
|
1181
|
+
blockerIssue,
|
|
1182
|
+
issueInBlockedRepo,
|
|
1183
|
+
]);
|
|
568
1184
|
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
569
1185
|
stdout: '',
|
|
570
1186
|
stderr: '',
|
|
@@ -576,14 +1192,20 @@ describe('StartPreparationUseCase', () => {
|
|
|
576
1192
|
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
577
1193
|
preparationStatus: 'Preparation',
|
|
578
1194
|
defaultAgentName: 'agent1',
|
|
1195
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
1196
|
+
defaultLlmAgentName: null,
|
|
1197
|
+
configFilePath: '/path/to/config.yml',
|
|
579
1198
|
maximumPreparingIssuesCount: null,
|
|
580
|
-
|
|
1199
|
+
utilizationPercentageThreshold: 90,
|
|
1200
|
+
allowedIssueAuthors: null,
|
|
581
1201
|
});
|
|
582
1202
|
|
|
583
|
-
expect(mockIssueRepository.
|
|
584
|
-
|
|
585
|
-
|
|
1203
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(2);
|
|
1204
|
+
const updatedUrls = mockIssueRepository.update.mock.calls.map(
|
|
1205
|
+
(call) => call[0].url,
|
|
586
1206
|
);
|
|
1207
|
+
expect(updatedUrls).toContain('https://github.com/user/repo/issues/100');
|
|
1208
|
+
expect(updatedUrls).toContain('https://github.com/user/repo/issues/101');
|
|
587
1209
|
});
|
|
588
1210
|
|
|
589
1211
|
it('should skip preparation when Claude usage is over 90%', async () => {
|
|
@@ -599,24 +1221,28 @@ describe('StartPreparationUseCase', () => {
|
|
|
599
1221
|
status: 'Awaiting Workspace',
|
|
600
1222
|
}),
|
|
601
1223
|
];
|
|
602
|
-
mockProjectRepository.
|
|
603
|
-
mockIssueRepository.
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
1224
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1225
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1226
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
1227
|
+
);
|
|
1228
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
607
1229
|
|
|
608
1230
|
await useCase.run({
|
|
609
1231
|
projectUrl: 'https://github.com/user/repo',
|
|
610
1232
|
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
611
1233
|
preparationStatus: 'Preparation',
|
|
612
1234
|
defaultAgentName: 'agent1',
|
|
1235
|
+
defaultLlmModelName: null,
|
|
1236
|
+
defaultLlmAgentName: null,
|
|
1237
|
+
configFilePath: '/path/to/config.yml',
|
|
613
1238
|
maximumPreparingIssuesCount: null,
|
|
614
|
-
|
|
1239
|
+
utilizationPercentageThreshold: 90,
|
|
1240
|
+
allowedIssueAuthors: null,
|
|
615
1241
|
});
|
|
616
1242
|
|
|
617
|
-
expect(mockIssueRepository.
|
|
1243
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(0);
|
|
618
1244
|
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
|
|
619
|
-
expect(mockProjectRepository.
|
|
1245
|
+
expect(mockProjectRepository.getByUrl).not.toHaveBeenCalled();
|
|
620
1246
|
});
|
|
621
1247
|
|
|
622
1248
|
it('should proceed with preparation when Claude usage is under 90%', async () => {
|
|
@@ -633,11 +1259,11 @@ describe('StartPreparationUseCase', () => {
|
|
|
633
1259
|
status: 'Awaiting Workspace',
|
|
634
1260
|
}),
|
|
635
1261
|
];
|
|
636
|
-
mockProjectRepository.
|
|
637
|
-
mockIssueRepository.
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
1262
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1263
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1264
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
1265
|
+
);
|
|
1266
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
641
1267
|
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
642
1268
|
stdout: '',
|
|
643
1269
|
stderr: '',
|
|
@@ -649,39 +1275,298 @@ describe('StartPreparationUseCase', () => {
|
|
|
649
1275
|
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
650
1276
|
preparationStatus: 'Preparation',
|
|
651
1277
|
defaultAgentName: 'agent1',
|
|
1278
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
1279
|
+
defaultLlmAgentName: null,
|
|
1280
|
+
configFilePath: '/path/to/config.yml',
|
|
652
1281
|
maximumPreparingIssuesCount: null,
|
|
653
|
-
|
|
1282
|
+
utilizationPercentageThreshold: 90,
|
|
1283
|
+
allowedIssueAuthors: null,
|
|
654
1284
|
});
|
|
655
1285
|
|
|
656
|
-
expect(mockIssueRepository.
|
|
1286
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(1);
|
|
657
1287
|
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
658
1288
|
});
|
|
659
1289
|
|
|
660
|
-
it('should
|
|
1290
|
+
it('should reduce maximumPreparingIssuesCount gradually when weekly usage window exceeds threshold', async () => {
|
|
661
1291
|
mockClaudeRepository.getUsage.mockResolvedValue([
|
|
662
1292
|
{ hour: 5, utilizationPercentage: 50, resetsAt: new Date() },
|
|
663
1293
|
{ hour: 168, utilizationPercentage: 91, resetsAt: new Date() },
|
|
664
1294
|
]);
|
|
665
1295
|
|
|
1296
|
+
const awaitingIssues: Issue[] = Array.from({ length: 10 }, (_, i) =>
|
|
1297
|
+
createMockIssue({
|
|
1298
|
+
url: `url${i + 1}`,
|
|
1299
|
+
title: `Issue ${i + 1}`,
|
|
1300
|
+
labels: [],
|
|
1301
|
+
status: 'Awaiting Workspace',
|
|
1302
|
+
}),
|
|
1303
|
+
);
|
|
1304
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1305
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1306
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
1307
|
+
);
|
|
1308
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
1309
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1310
|
+
stdout: '',
|
|
1311
|
+
stderr: '',
|
|
1312
|
+
exitCode: 0,
|
|
1313
|
+
});
|
|
1314
|
+
|
|
666
1315
|
await useCase.run({
|
|
667
1316
|
projectUrl: 'https://github.com/user/repo',
|
|
668
1317
|
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
669
1318
|
preparationStatus: 'Preparation',
|
|
670
1319
|
defaultAgentName: 'agent1',
|
|
1320
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
1321
|
+
defaultLlmAgentName: null,
|
|
1322
|
+
configFilePath: '/path/to/config.yml',
|
|
671
1323
|
maximumPreparingIssuesCount: null,
|
|
672
|
-
|
|
1324
|
+
utilizationPercentageThreshold: 90,
|
|
1325
|
+
allowedIssueAuthors: null,
|
|
673
1326
|
});
|
|
674
1327
|
|
|
675
|
-
|
|
676
|
-
|
|
1328
|
+
const weeklyUtilization = 91;
|
|
1329
|
+
const threshold = 90;
|
|
1330
|
+
const defaultMax = 6;
|
|
1331
|
+
const normalizedUtilizationBeyondThreshold =
|
|
1332
|
+
(weeklyUtilization - threshold) / (100 - threshold);
|
|
1333
|
+
const expectedMax = Math.floor(
|
|
1334
|
+
defaultMax * Math.pow(1 - normalizedUtilizationBeyondThreshold, 2),
|
|
1335
|
+
);
|
|
1336
|
+
expect(mockProjectRepository.getByUrl).toHaveBeenCalled();
|
|
1337
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(expectedMax);
|
|
1338
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(
|
|
1339
|
+
expectedMax,
|
|
1340
|
+
);
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
it('should skip all preparation when weekly window usage reduces maximumPreparingIssuesCount to 0', async () => {
|
|
1344
|
+
mockClaudeRepository.getUsage.mockResolvedValue([
|
|
1345
|
+
{ hour: 168, utilizationPercentage: 100, resetsAt: new Date() },
|
|
1346
|
+
]);
|
|
1347
|
+
|
|
1348
|
+
const awaitingIssues: Issue[] = [
|
|
1349
|
+
createMockIssue({
|
|
1350
|
+
url: 'url1',
|
|
1351
|
+
labels: [],
|
|
1352
|
+
status: 'Awaiting Workspace',
|
|
1353
|
+
}),
|
|
1354
|
+
];
|
|
1355
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1356
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1357
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
1358
|
+
);
|
|
1359
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
1360
|
+
|
|
1361
|
+
await useCase.run({
|
|
1362
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1363
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
1364
|
+
preparationStatus: 'Preparation',
|
|
1365
|
+
defaultAgentName: 'agent1',
|
|
1366
|
+
defaultLlmModelName: null,
|
|
1367
|
+
defaultLlmAgentName: null,
|
|
1368
|
+
configFilePath: '/path/to/config.yml',
|
|
1369
|
+
maximumPreparingIssuesCount: null,
|
|
1370
|
+
utilizationPercentageThreshold: 90,
|
|
1371
|
+
allowedIssueAuthors: null,
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(0);
|
|
1375
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
it('should not apply weekly reduction when threshold is 100', async () => {
|
|
1379
|
+
mockClaudeRepository.getUsage.mockResolvedValue([
|
|
1380
|
+
{ hour: 168, utilizationPercentage: 99, resetsAt: new Date() },
|
|
1381
|
+
]);
|
|
1382
|
+
|
|
1383
|
+
const awaitingIssues: Issue[] = Array.from({ length: 10 }, (_, i) =>
|
|
1384
|
+
createMockIssue({
|
|
1385
|
+
url: `url${i + 1}`,
|
|
1386
|
+
title: `Issue ${i + 1}`,
|
|
1387
|
+
labels: [],
|
|
1388
|
+
status: 'Awaiting Workspace',
|
|
1389
|
+
}),
|
|
1390
|
+
);
|
|
1391
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1392
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1393
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
1394
|
+
);
|
|
1395
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
1396
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1397
|
+
stdout: '',
|
|
1398
|
+
stderr: '',
|
|
1399
|
+
exitCode: 0,
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
await useCase.run({
|
|
1403
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1404
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
1405
|
+
preparationStatus: 'Preparation',
|
|
1406
|
+
defaultAgentName: 'agent1',
|
|
1407
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
1408
|
+
defaultLlmAgentName: null,
|
|
1409
|
+
configFilePath: '/path/to/config.yml',
|
|
1410
|
+
maximumPreparingIssuesCount: null,
|
|
1411
|
+
utilizationPercentageThreshold: 100,
|
|
1412
|
+
allowedIssueAuthors: null,
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(6);
|
|
1416
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(6);
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
it('should still skip immediately when non-weekly window exceeds threshold', async () => {
|
|
1420
|
+
mockClaudeRepository.getUsage.mockResolvedValue([
|
|
1421
|
+
{ hour: 5, utilizationPercentage: 95, resetsAt: new Date() },
|
|
1422
|
+
{ hour: 168, utilizationPercentage: 50, resetsAt: new Date() },
|
|
1423
|
+
]);
|
|
1424
|
+
|
|
1425
|
+
const awaitingIssues: Issue[] = [
|
|
1426
|
+
createMockIssue({
|
|
1427
|
+
url: 'url1',
|
|
1428
|
+
labels: [],
|
|
1429
|
+
status: 'Awaiting Workspace',
|
|
1430
|
+
}),
|
|
1431
|
+
];
|
|
1432
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1433
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1434
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
1435
|
+
);
|
|
1436
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
1437
|
+
|
|
1438
|
+
await useCase.run({
|
|
1439
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1440
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
1441
|
+
preparationStatus: 'Preparation',
|
|
1442
|
+
defaultAgentName: 'agent1',
|
|
1443
|
+
defaultLlmModelName: null,
|
|
1444
|
+
defaultLlmAgentName: null,
|
|
1445
|
+
configFilePath: '/path/to/config.yml',
|
|
1446
|
+
maximumPreparingIssuesCount: null,
|
|
1447
|
+
utilizationPercentageThreshold: 90,
|
|
1448
|
+
allowedIssueAuthors: null,
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
expect(mockProjectRepository.getByUrl).not.toHaveBeenCalled();
|
|
1452
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(0);
|
|
677
1453
|
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
|
|
678
1454
|
});
|
|
679
1455
|
|
|
680
|
-
it('should
|
|
1456
|
+
it('should use maximum utilization across multiple weekly window entries for reduction', async () => {
|
|
1457
|
+
mockClaudeRepository.getUsage.mockResolvedValue([
|
|
1458
|
+
{ hour: 168, utilizationPercentage: 91, resetsAt: new Date() },
|
|
1459
|
+
{ hour: 168, utilizationPercentage: 95, resetsAt: new Date() },
|
|
1460
|
+
]);
|
|
1461
|
+
|
|
1462
|
+
const awaitingIssues: Issue[] = Array.from({ length: 10 }, (_, i) =>
|
|
1463
|
+
createMockIssue({
|
|
1464
|
+
url: `url${i + 1}`,
|
|
1465
|
+
title: `Issue ${i + 1}`,
|
|
1466
|
+
labels: [],
|
|
1467
|
+
status: 'Awaiting Workspace',
|
|
1468
|
+
}),
|
|
1469
|
+
);
|
|
1470
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1471
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1472
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
1473
|
+
);
|
|
1474
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
1475
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1476
|
+
stdout: '',
|
|
1477
|
+
stderr: '',
|
|
1478
|
+
exitCode: 0,
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
await useCase.run({
|
|
1482
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1483
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
1484
|
+
preparationStatus: 'Preparation',
|
|
1485
|
+
defaultAgentName: 'agent1',
|
|
1486
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
1487
|
+
defaultLlmAgentName: null,
|
|
1488
|
+
configFilePath: '/path/to/config.yml',
|
|
1489
|
+
maximumPreparingIssuesCount: null,
|
|
1490
|
+
utilizationPercentageThreshold: 90,
|
|
1491
|
+
allowedIssueAuthors: null,
|
|
1492
|
+
});
|
|
1493
|
+
|
|
1494
|
+
const normalizedUtilizationBeyondThreshold = (95 - 90) / (100 - 90);
|
|
1495
|
+
const expectedMax = Math.floor(
|
|
1496
|
+
6 * Math.pow(1 - normalizedUtilizationBeyondThreshold, 2),
|
|
1497
|
+
);
|
|
1498
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(expectedMax);
|
|
1499
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(
|
|
1500
|
+
expectedMax,
|
|
1501
|
+
);
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1504
|
+
it('should throw error when Claude usage check fails', async () => {
|
|
681
1505
|
mockClaudeRepository.getUsage.mockRejectedValue(
|
|
682
1506
|
new Error('Claude credentials file not found'),
|
|
683
1507
|
);
|
|
684
1508
|
|
|
1509
|
+
await expect(
|
|
1510
|
+
useCase.run({
|
|
1511
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1512
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
1513
|
+
preparationStatus: 'Preparation',
|
|
1514
|
+
defaultAgentName: 'agent1',
|
|
1515
|
+
defaultLlmModelName: null,
|
|
1516
|
+
defaultLlmAgentName: null,
|
|
1517
|
+
configFilePath: '/path/to/config.yml',
|
|
1518
|
+
maximumPreparingIssuesCount: null,
|
|
1519
|
+
utilizationPercentageThreshold: 90,
|
|
1520
|
+
allowedIssueAuthors: null,
|
|
1521
|
+
}),
|
|
1522
|
+
).rejects.toThrow('Claude credentials file not found');
|
|
1523
|
+
|
|
1524
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(0);
|
|
1525
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
|
|
1526
|
+
});
|
|
1527
|
+
|
|
1528
|
+
it('should skip preparation when Claude usage exceeds custom threshold', async () => {
|
|
1529
|
+
mockClaudeRepository.getUsage.mockResolvedValue([
|
|
1530
|
+
{ hour: 5, utilizationPercentage: 75, resetsAt: new Date() },
|
|
1531
|
+
]);
|
|
1532
|
+
|
|
1533
|
+
const awaitingIssues: Issue[] = [
|
|
1534
|
+
createMockIssue({
|
|
1535
|
+
url: 'url1',
|
|
1536
|
+
title: 'Issue 1',
|
|
1537
|
+
labels: [],
|
|
1538
|
+
status: 'Awaiting Workspace',
|
|
1539
|
+
}),
|
|
1540
|
+
];
|
|
1541
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1542
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1543
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
1544
|
+
);
|
|
1545
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
1546
|
+
|
|
1547
|
+
await useCase.run({
|
|
1548
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1549
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
1550
|
+
preparationStatus: 'Preparation',
|
|
1551
|
+
defaultAgentName: 'agent1',
|
|
1552
|
+
defaultLlmModelName: null,
|
|
1553
|
+
defaultLlmAgentName: null,
|
|
1554
|
+
configFilePath: '/path/to/config.yml',
|
|
1555
|
+
maximumPreparingIssuesCount: null,
|
|
1556
|
+
utilizationPercentageThreshold: 70,
|
|
1557
|
+
allowedIssueAuthors: null,
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(0);
|
|
1561
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
|
|
1562
|
+
expect(mockProjectRepository.getByUrl).not.toHaveBeenCalled();
|
|
1563
|
+
});
|
|
1564
|
+
|
|
1565
|
+
it('should proceed with preparation when Claude usage is under custom threshold', async () => {
|
|
1566
|
+
mockClaudeRepository.getUsage.mockResolvedValue([
|
|
1567
|
+
{ hour: 5, utilizationPercentage: 75, resetsAt: new Date() },
|
|
1568
|
+
]);
|
|
1569
|
+
|
|
685
1570
|
const awaitingIssues: Issue[] = [
|
|
686
1571
|
createMockIssue({
|
|
687
1572
|
url: 'url1',
|
|
@@ -690,11 +1575,528 @@ describe('StartPreparationUseCase', () => {
|
|
|
690
1575
|
status: 'Awaiting Workspace',
|
|
691
1576
|
}),
|
|
692
1577
|
];
|
|
693
|
-
mockProjectRepository.
|
|
694
|
-
mockIssueRepository.
|
|
695
|
-
|
|
696
|
-
|
|
1578
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1579
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1580
|
+
createMockStoryObjectMap(awaitingIssues),
|
|
1581
|
+
);
|
|
1582
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(awaitingIssues);
|
|
1583
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1584
|
+
stdout: '',
|
|
1585
|
+
stderr: '',
|
|
1586
|
+
exitCode: 0,
|
|
1587
|
+
});
|
|
1588
|
+
|
|
1589
|
+
await useCase.run({
|
|
1590
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1591
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
1592
|
+
preparationStatus: 'Preparation',
|
|
1593
|
+
defaultAgentName: 'agent1',
|
|
1594
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
1595
|
+
defaultLlmAgentName: null,
|
|
1596
|
+
configFilePath: '/path/to/config.yml',
|
|
1597
|
+
maximumPreparingIssuesCount: null,
|
|
1598
|
+
utilizationPercentageThreshold: 80,
|
|
1599
|
+
allowedIssueAuthors: null,
|
|
1600
|
+
});
|
|
1601
|
+
|
|
1602
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(1);
|
|
1603
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
1604
|
+
});
|
|
1605
|
+
|
|
1606
|
+
it('should skip issues that have dependedIssueUrls', async () => {
|
|
1607
|
+
const issueWithDependency = createMockIssue({
|
|
1608
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1609
|
+
title: 'Issue with dependency',
|
|
1610
|
+
labels: [],
|
|
1611
|
+
status: 'Awaiting Workspace',
|
|
1612
|
+
dependedIssueUrls: ['https://github.com/user/repo/issues/2'],
|
|
1613
|
+
});
|
|
1614
|
+
const issueWithoutDependency = createMockIssue({
|
|
1615
|
+
url: 'https://github.com/user/repo/issues/3',
|
|
1616
|
+
title: 'Issue without dependency',
|
|
1617
|
+
labels: [],
|
|
1618
|
+
status: 'Awaiting Workspace',
|
|
1619
|
+
dependedIssueUrls: [],
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1623
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1624
|
+
createMockStoryObjectMap([issueWithDependency, issueWithoutDependency]),
|
|
1625
|
+
);
|
|
1626
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce([
|
|
1627
|
+
issueWithDependency,
|
|
1628
|
+
issueWithoutDependency,
|
|
1629
|
+
]);
|
|
1630
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1631
|
+
stdout: '',
|
|
1632
|
+
stderr: '',
|
|
1633
|
+
exitCode: 0,
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
await useCase.run({
|
|
1637
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1638
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
1639
|
+
preparationStatus: 'Preparation',
|
|
1640
|
+
defaultAgentName: 'agent1',
|
|
1641
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
1642
|
+
defaultLlmAgentName: null,
|
|
1643
|
+
configFilePath: '/path/to/config.yml',
|
|
1644
|
+
maximumPreparingIssuesCount: null,
|
|
1645
|
+
utilizationPercentageThreshold: 90,
|
|
1646
|
+
allowedIssueAuthors: null,
|
|
1647
|
+
});
|
|
1648
|
+
|
|
1649
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(1);
|
|
1650
|
+
expect(mockIssueRepository.update.mock.calls[0][0]).toMatchObject({
|
|
1651
|
+
url: 'https://github.com/user/repo/issues/3',
|
|
1652
|
+
status: 'Preparation',
|
|
697
1653
|
});
|
|
1654
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
1655
|
+
});
|
|
1656
|
+
|
|
1657
|
+
it('should skip issues where nextActionHour is in the future', async () => {
|
|
1658
|
+
jest.useFakeTimers();
|
|
1659
|
+
try {
|
|
1660
|
+
jest.setSystemTime(new Date('2024-01-01T10:00:00'));
|
|
1661
|
+
|
|
1662
|
+
const issueWithFutureNextActionHour = createMockIssue({
|
|
1663
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1664
|
+
title: 'Issue with future next action hour',
|
|
1665
|
+
labels: [],
|
|
1666
|
+
status: 'Awaiting Workspace',
|
|
1667
|
+
nextActionHour: 15,
|
|
1668
|
+
});
|
|
1669
|
+
const issueWithoutNextActionHour = createMockIssue({
|
|
1670
|
+
url: 'https://github.com/user/repo/issues/2',
|
|
1671
|
+
title: 'Issue without next action hour',
|
|
1672
|
+
labels: [],
|
|
1673
|
+
status: 'Awaiting Workspace',
|
|
1674
|
+
nextActionHour: null,
|
|
1675
|
+
});
|
|
1676
|
+
|
|
1677
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1678
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1679
|
+
createMockStoryObjectMap([
|
|
1680
|
+
issueWithFutureNextActionHour,
|
|
1681
|
+
issueWithoutNextActionHour,
|
|
1682
|
+
]),
|
|
1683
|
+
);
|
|
1684
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce([
|
|
1685
|
+
issueWithFutureNextActionHour,
|
|
1686
|
+
issueWithoutNextActionHour,
|
|
1687
|
+
]);
|
|
1688
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1689
|
+
stdout: '',
|
|
1690
|
+
stderr: '',
|
|
1691
|
+
exitCode: 0,
|
|
1692
|
+
});
|
|
1693
|
+
|
|
1694
|
+
await useCase.run({
|
|
1695
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1696
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
1697
|
+
preparationStatus: 'Preparation',
|
|
1698
|
+
defaultAgentName: 'agent1',
|
|
1699
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
1700
|
+
defaultLlmAgentName: null,
|
|
1701
|
+
configFilePath: '/path/to/config.yml',
|
|
1702
|
+
maximumPreparingIssuesCount: null,
|
|
1703
|
+
utilizationPercentageThreshold: 90,
|
|
1704
|
+
allowedIssueAuthors: null,
|
|
1705
|
+
});
|
|
1706
|
+
|
|
1707
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(1);
|
|
1708
|
+
expect(mockIssueRepository.update.mock.calls[0][0]).toMatchObject({
|
|
1709
|
+
url: 'https://github.com/user/repo/issues/2',
|
|
1710
|
+
status: 'Preparation',
|
|
1711
|
+
});
|
|
1712
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
1713
|
+
} finally {
|
|
1714
|
+
jest.useRealTimers();
|
|
1715
|
+
}
|
|
1716
|
+
});
|
|
1717
|
+
|
|
1718
|
+
it('should skip issues where nextActionDate is tomorrow or more future', async () => {
|
|
1719
|
+
jest.useFakeTimers();
|
|
1720
|
+
try {
|
|
1721
|
+
jest.setSystemTime(new Date('2024-01-15T10:00:00'));
|
|
1722
|
+
|
|
1723
|
+
const issueWithFutureNextActionDate = createMockIssue({
|
|
1724
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1725
|
+
title: 'Issue with future next action date',
|
|
1726
|
+
labels: [],
|
|
1727
|
+
status: 'Awaiting Workspace',
|
|
1728
|
+
nextActionDate: new Date('2024-01-16'),
|
|
1729
|
+
});
|
|
1730
|
+
const issueWithoutNextActionDate = createMockIssue({
|
|
1731
|
+
url: 'https://github.com/user/repo/issues/2',
|
|
1732
|
+
title: 'Issue without next action date',
|
|
1733
|
+
labels: [],
|
|
1734
|
+
status: 'Awaiting Workspace',
|
|
1735
|
+
nextActionDate: null,
|
|
1736
|
+
});
|
|
1737
|
+
|
|
1738
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1739
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1740
|
+
createMockStoryObjectMap([
|
|
1741
|
+
issueWithFutureNextActionDate,
|
|
1742
|
+
issueWithoutNextActionDate,
|
|
1743
|
+
]),
|
|
1744
|
+
);
|
|
1745
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce([
|
|
1746
|
+
issueWithFutureNextActionDate,
|
|
1747
|
+
issueWithoutNextActionDate,
|
|
1748
|
+
]);
|
|
1749
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1750
|
+
stdout: '',
|
|
1751
|
+
stderr: '',
|
|
1752
|
+
exitCode: 0,
|
|
1753
|
+
});
|
|
1754
|
+
|
|
1755
|
+
await useCase.run({
|
|
1756
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1757
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
1758
|
+
preparationStatus: 'Preparation',
|
|
1759
|
+
defaultAgentName: 'agent1',
|
|
1760
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
1761
|
+
defaultLlmAgentName: null,
|
|
1762
|
+
configFilePath: '/path/to/config.yml',
|
|
1763
|
+
maximumPreparingIssuesCount: null,
|
|
1764
|
+
utilizationPercentageThreshold: 90,
|
|
1765
|
+
allowedIssueAuthors: null,
|
|
1766
|
+
});
|
|
1767
|
+
|
|
1768
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(1);
|
|
1769
|
+
expect(mockIssueRepository.update.mock.calls[0][0]).toMatchObject({
|
|
1770
|
+
url: 'https://github.com/user/repo/issues/2',
|
|
1771
|
+
status: 'Preparation',
|
|
1772
|
+
});
|
|
1773
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
1774
|
+
} finally {
|
|
1775
|
+
jest.useRealTimers();
|
|
1776
|
+
}
|
|
1777
|
+
});
|
|
1778
|
+
|
|
1779
|
+
it('should not skip issues where nextActionDate is today', async () => {
|
|
1780
|
+
jest.useFakeTimers();
|
|
1781
|
+
try {
|
|
1782
|
+
jest.setSystemTime(new Date('2024-01-15T10:00:00'));
|
|
1783
|
+
|
|
1784
|
+
const issueWithTodayNextActionDate = createMockIssue({
|
|
1785
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1786
|
+
title: 'Issue with today next action date',
|
|
1787
|
+
labels: [],
|
|
1788
|
+
status: 'Awaiting Workspace',
|
|
1789
|
+
nextActionDate: new Date('2024-01-15'),
|
|
1790
|
+
});
|
|
1791
|
+
|
|
1792
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1793
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1794
|
+
createMockStoryObjectMap([issueWithTodayNextActionDate]),
|
|
1795
|
+
);
|
|
1796
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce([
|
|
1797
|
+
issueWithTodayNextActionDate,
|
|
1798
|
+
]);
|
|
1799
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1800
|
+
stdout: '',
|
|
1801
|
+
stderr: '',
|
|
1802
|
+
exitCode: 0,
|
|
1803
|
+
});
|
|
1804
|
+
|
|
1805
|
+
await useCase.run({
|
|
1806
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1807
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
1808
|
+
preparationStatus: 'Preparation',
|
|
1809
|
+
defaultAgentName: 'agent1',
|
|
1810
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
1811
|
+
defaultLlmAgentName: null,
|
|
1812
|
+
configFilePath: '/path/to/config.yml',
|
|
1813
|
+
maximumPreparingIssuesCount: null,
|
|
1814
|
+
utilizationPercentageThreshold: 90,
|
|
1815
|
+
allowedIssueAuthors: null,
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(1);
|
|
1819
|
+
expect(mockIssueRepository.update.mock.calls[0][0]).toMatchObject({
|
|
1820
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1821
|
+
status: 'Preparation',
|
|
1822
|
+
});
|
|
1823
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
1824
|
+
} finally {
|
|
1825
|
+
jest.useRealTimers();
|
|
1826
|
+
}
|
|
1827
|
+
});
|
|
1828
|
+
|
|
1829
|
+
it('should not skip issues where nextActionDate is in the past', async () => {
|
|
1830
|
+
jest.useFakeTimers();
|
|
1831
|
+
try {
|
|
1832
|
+
jest.setSystemTime(new Date('2024-01-15T10:00:00'));
|
|
1833
|
+
|
|
1834
|
+
const issueWithPastNextActionDate = createMockIssue({
|
|
1835
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1836
|
+
title: 'Issue with past next action date',
|
|
1837
|
+
labels: [],
|
|
1838
|
+
status: 'Awaiting Workspace',
|
|
1839
|
+
nextActionDate: new Date('2024-01-14'),
|
|
1840
|
+
});
|
|
1841
|
+
|
|
1842
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1843
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1844
|
+
createMockStoryObjectMap([issueWithPastNextActionDate]),
|
|
1845
|
+
);
|
|
1846
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce([
|
|
1847
|
+
issueWithPastNextActionDate,
|
|
1848
|
+
]);
|
|
1849
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1850
|
+
stdout: '',
|
|
1851
|
+
stderr: '',
|
|
1852
|
+
exitCode: 0,
|
|
1853
|
+
});
|
|
1854
|
+
|
|
1855
|
+
await useCase.run({
|
|
1856
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1857
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
1858
|
+
preparationStatus: 'Preparation',
|
|
1859
|
+
defaultAgentName: 'agent1',
|
|
1860
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
1861
|
+
defaultLlmAgentName: null,
|
|
1862
|
+
configFilePath: '/path/to/config.yml',
|
|
1863
|
+
maximumPreparingIssuesCount: null,
|
|
1864
|
+
utilizationPercentageThreshold: 90,
|
|
1865
|
+
allowedIssueAuthors: null,
|
|
1866
|
+
});
|
|
1867
|
+
|
|
1868
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(1);
|
|
1869
|
+
expect(mockIssueRepository.update.mock.calls[0][0]).toMatchObject({
|
|
1870
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1871
|
+
status: 'Preparation',
|
|
1872
|
+
});
|
|
1873
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
1874
|
+
} finally {
|
|
1875
|
+
jest.useRealTimers();
|
|
1876
|
+
}
|
|
1877
|
+
});
|
|
1878
|
+
|
|
1879
|
+
it('should not skip issues where nextActionHour is in the past or current hour', async () => {
|
|
1880
|
+
jest.useFakeTimers();
|
|
1881
|
+
try {
|
|
1882
|
+
jest.setSystemTime(new Date('2024-01-01T15:00:00'));
|
|
1883
|
+
|
|
1884
|
+
const issueWithPastNextActionHour = createMockIssue({
|
|
1885
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1886
|
+
title: 'Issue with past next action hour',
|
|
1887
|
+
labels: [],
|
|
1888
|
+
status: 'Awaiting Workspace',
|
|
1889
|
+
nextActionHour: 10,
|
|
1890
|
+
});
|
|
1891
|
+
|
|
1892
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1893
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1894
|
+
createMockStoryObjectMap([issueWithPastNextActionHour]),
|
|
1895
|
+
);
|
|
1896
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce([
|
|
1897
|
+
issueWithPastNextActionHour,
|
|
1898
|
+
]);
|
|
1899
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1900
|
+
stdout: '',
|
|
1901
|
+
stderr: '',
|
|
1902
|
+
exitCode: 0,
|
|
1903
|
+
});
|
|
1904
|
+
|
|
1905
|
+
await useCase.run({
|
|
1906
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1907
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
1908
|
+
preparationStatus: 'Preparation',
|
|
1909
|
+
defaultAgentName: 'agent1',
|
|
1910
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
1911
|
+
defaultLlmAgentName: null,
|
|
1912
|
+
configFilePath: '/path/to/config.yml',
|
|
1913
|
+
maximumPreparingIssuesCount: null,
|
|
1914
|
+
utilizationPercentageThreshold: 90,
|
|
1915
|
+
allowedIssueAuthors: null,
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1918
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(1);
|
|
1919
|
+
expect(mockIssueRepository.update.mock.calls[0][0]).toMatchObject({
|
|
1920
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1921
|
+
status: 'Preparation',
|
|
1922
|
+
});
|
|
1923
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
1924
|
+
} finally {
|
|
1925
|
+
jest.useRealTimers();
|
|
1926
|
+
}
|
|
1927
|
+
});
|
|
1928
|
+
|
|
1929
|
+
it('should skip issues from non-allowed authors', async () => {
|
|
1930
|
+
const issueFromAllowedAuthor = createMockIssue({
|
|
1931
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1932
|
+
title: 'Issue from allowed author',
|
|
1933
|
+
labels: [],
|
|
1934
|
+
status: 'Awaiting Workspace',
|
|
1935
|
+
author: 'user1',
|
|
1936
|
+
});
|
|
1937
|
+
const issueFromNonAllowedAuthor = createMockIssue({
|
|
1938
|
+
url: 'https://github.com/user/repo/issues/2',
|
|
1939
|
+
title: 'Issue from non-allowed author',
|
|
1940
|
+
labels: [],
|
|
1941
|
+
status: 'Awaiting Workspace',
|
|
1942
|
+
author: 'user3',
|
|
1943
|
+
});
|
|
1944
|
+
|
|
1945
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1946
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
1947
|
+
createMockStoryObjectMap([
|
|
1948
|
+
issueFromAllowedAuthor,
|
|
1949
|
+
issueFromNonAllowedAuthor,
|
|
1950
|
+
]),
|
|
1951
|
+
);
|
|
1952
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce([
|
|
1953
|
+
issueFromAllowedAuthor,
|
|
1954
|
+
issueFromNonAllowedAuthor,
|
|
1955
|
+
]);
|
|
1956
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
1957
|
+
stdout: '',
|
|
1958
|
+
stderr: '',
|
|
1959
|
+
exitCode: 0,
|
|
1960
|
+
});
|
|
1961
|
+
|
|
1962
|
+
await useCase.run({
|
|
1963
|
+
projectUrl: 'https://github.com/user/repo',
|
|
1964
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
1965
|
+
preparationStatus: 'Preparation',
|
|
1966
|
+
defaultAgentName: 'agent1',
|
|
1967
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
1968
|
+
defaultLlmAgentName: null,
|
|
1969
|
+
configFilePath: '/path/to/config.yml',
|
|
1970
|
+
maximumPreparingIssuesCount: null,
|
|
1971
|
+
utilizationPercentageThreshold: 90,
|
|
1972
|
+
allowedIssueAuthors: ['user1', 'user2'],
|
|
1973
|
+
});
|
|
1974
|
+
|
|
1975
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(1);
|
|
1976
|
+
expect(mockIssueRepository.update.mock.calls[0][0]).toMatchObject({
|
|
1977
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1978
|
+
status: 'Preparation',
|
|
1979
|
+
});
|
|
1980
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
1981
|
+
});
|
|
1982
|
+
|
|
1983
|
+
it('should process all issues when allowedIssueAuthors is null', async () => {
|
|
1984
|
+
const issue1 = createMockIssue({
|
|
1985
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1986
|
+
title: 'Issue 1',
|
|
1987
|
+
labels: [],
|
|
1988
|
+
status: 'Awaiting Workspace',
|
|
1989
|
+
author: 'user1',
|
|
1990
|
+
});
|
|
1991
|
+
const issue2 = createMockIssue({
|
|
1992
|
+
url: 'https://github.com/user/repo/issues/2',
|
|
1993
|
+
title: 'Issue 2',
|
|
1994
|
+
labels: [],
|
|
1995
|
+
status: 'Awaiting Workspace',
|
|
1996
|
+
author: 'user2',
|
|
1997
|
+
});
|
|
1998
|
+
|
|
1999
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
2000
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
2001
|
+
createMockStoryObjectMap([issue1, issue2]),
|
|
2002
|
+
);
|
|
2003
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce([issue1, issue2]);
|
|
2004
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
2005
|
+
stdout: '',
|
|
2006
|
+
stderr: '',
|
|
2007
|
+
exitCode: 0,
|
|
2008
|
+
});
|
|
2009
|
+
|
|
2010
|
+
await useCase.run({
|
|
2011
|
+
projectUrl: 'https://github.com/user/repo',
|
|
2012
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
2013
|
+
preparationStatus: 'Preparation',
|
|
2014
|
+
defaultAgentName: 'agent1',
|
|
2015
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
2016
|
+
defaultLlmAgentName: null,
|
|
2017
|
+
configFilePath: '/path/to/config.yml',
|
|
2018
|
+
maximumPreparingIssuesCount: null,
|
|
2019
|
+
utilizationPercentageThreshold: 90,
|
|
2020
|
+
allowedIssueAuthors: null,
|
|
2021
|
+
});
|
|
2022
|
+
|
|
2023
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(2);
|
|
2024
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(2);
|
|
2025
|
+
});
|
|
2026
|
+
|
|
2027
|
+
it('should not skip issues with empty author (tower defence issues)', async () => {
|
|
2028
|
+
const towerDefenceIssue = createMockIssue({
|
|
2029
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
2030
|
+
title: 'Tower defence issue',
|
|
2031
|
+
labels: [],
|
|
2032
|
+
status: 'Awaiting Workspace',
|
|
2033
|
+
author: '',
|
|
2034
|
+
});
|
|
2035
|
+
const normalIssue = createMockIssue({
|
|
2036
|
+
url: 'https://github.com/user/repo/issues/2',
|
|
2037
|
+
title: 'Normal issue',
|
|
2038
|
+
labels: [],
|
|
2039
|
+
status: 'Awaiting Workspace',
|
|
2040
|
+
author: 'user1',
|
|
2041
|
+
});
|
|
2042
|
+
|
|
2043
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
2044
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(
|
|
2045
|
+
createMockStoryObjectMap([towerDefenceIssue, normalIssue]),
|
|
2046
|
+
);
|
|
2047
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce([
|
|
2048
|
+
towerDefenceIssue,
|
|
2049
|
+
normalIssue,
|
|
2050
|
+
]);
|
|
2051
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
2052
|
+
stdout: '',
|
|
2053
|
+
stderr: '',
|
|
2054
|
+
exitCode: 0,
|
|
2055
|
+
});
|
|
2056
|
+
|
|
2057
|
+
await useCase.run({
|
|
2058
|
+
projectUrl: 'https://github.com/user/repo',
|
|
2059
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
2060
|
+
preparationStatus: 'Preparation',
|
|
2061
|
+
defaultAgentName: 'agent1',
|
|
2062
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
2063
|
+
defaultLlmAgentName: null,
|
|
2064
|
+
configFilePath: '/path/to/config.yml',
|
|
2065
|
+
maximumPreparingIssuesCount: null,
|
|
2066
|
+
utilizationPercentageThreshold: 90,
|
|
2067
|
+
allowedIssueAuthors: ['user1', 'user2'],
|
|
2068
|
+
});
|
|
2069
|
+
|
|
2070
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(2);
|
|
2071
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(2);
|
|
2072
|
+
});
|
|
2073
|
+
|
|
2074
|
+
it('should not skip issues without author property when allowedIssueAuthors is set', async () => {
|
|
2075
|
+
const issueWithoutAuthor = createMockIssue({
|
|
2076
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
2077
|
+
title: 'Issue without author property',
|
|
2078
|
+
labels: [],
|
|
2079
|
+
status: 'Awaiting Workspace',
|
|
2080
|
+
author: '',
|
|
2081
|
+
});
|
|
2082
|
+
|
|
2083
|
+
const storyObjectMap: StoryObjectMap = new Map();
|
|
2084
|
+
storyObjectMap.set('Default Story', {
|
|
2085
|
+
story: {
|
|
2086
|
+
id: 'story-1',
|
|
2087
|
+
name: 'Default Story',
|
|
2088
|
+
color: 'GRAY',
|
|
2089
|
+
description: '',
|
|
2090
|
+
},
|
|
2091
|
+
storyIssue: null,
|
|
2092
|
+
issues: [issueWithoutAuthor],
|
|
2093
|
+
});
|
|
2094
|
+
|
|
2095
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
2096
|
+
mockIssueRepository.getStoryObjectMap.mockResolvedValue(storyObjectMap);
|
|
2097
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce([
|
|
2098
|
+
issueWithoutAuthor,
|
|
2099
|
+
]);
|
|
698
2100
|
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
699
2101
|
stdout: '',
|
|
700
2102
|
stderr: '',
|
|
@@ -706,11 +2108,15 @@ describe('StartPreparationUseCase', () => {
|
|
|
706
2108
|
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
707
2109
|
preparationStatus: 'Preparation',
|
|
708
2110
|
defaultAgentName: 'agent1',
|
|
2111
|
+
defaultLlmModelName: 'claude-sonnet-4-6',
|
|
2112
|
+
defaultLlmAgentName: null,
|
|
2113
|
+
configFilePath: '/path/to/config.yml',
|
|
709
2114
|
maximumPreparingIssuesCount: null,
|
|
710
|
-
|
|
2115
|
+
utilizationPercentageThreshold: 90,
|
|
2116
|
+
allowedIssueAuthors: ['user1'],
|
|
711
2117
|
});
|
|
712
2118
|
|
|
713
|
-
expect(mockIssueRepository.
|
|
2119
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(1);
|
|
714
2120
|
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
715
2121
|
});
|
|
716
2122
|
});
|