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.
Files changed (52) hide show
  1. package/.github/workflows/umino-project.yml +5 -4
  2. package/CHANGELOG.md +13 -0
  3. package/README.md +21 -9
  4. package/bin/adapter/entry-points/cli/index.js +45 -10
  5. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  6. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +32 -8
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  8. package/bin/adapter/repositories/NodeLocalCommandRunner.js +3 -3
  9. package/bin/adapter/repositories/NodeLocalCommandRunner.js.map +1 -1
  10. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +412 -177
  11. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  12. package/bin/domain/usecases/HandleScheduledEventUseCase.js +5 -2
  13. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  14. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +7 -2
  15. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  16. package/bin/domain/usecases/StartPreparationUseCase.js +107 -72
  17. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  18. package/package.json +1 -1
  19. package/src/adapter/entry-points/cli/index.test.ts +26 -13
  20. package/src/adapter/entry-points/cli/index.ts +74 -13
  21. package/src/adapter/repositories/NodeLocalCommandRunner.test.ts +12 -12
  22. package/src/adapter/repositories/NodeLocalCommandRunner.ts +7 -4
  23. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +3 -0
  24. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +626 -265
  25. package/src/adapter/repositories/issue/RestIssueRepository.test.ts +3 -0
  26. package/src/domain/entities/Issue.ts +1 -0
  27. package/src/domain/usecases/GetStoryObjectMapUseCase.test.ts +1 -0
  28. package/src/domain/usecases/HandleScheduledEventUseCase.ts +11 -3
  29. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +1 -0
  30. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +22 -9
  31. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +8 -3
  32. package/src/domain/usecases/StartPreparationUseCase.test.ts +1696 -290
  33. package/src/domain/usecases/StartPreparationUseCase.ts +171 -126
  34. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +2 -1
  35. package/src/domain/usecases/adapter-interfaces/LocalCommandRunner.ts +4 -1
  36. package/types/adapter/entry-points/cli/index.d.ts +4 -1
  37. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  38. package/types/adapter/repositories/NodeLocalCommandRunner.d.ts +1 -1
  39. package/types/adapter/repositories/NodeLocalCommandRunner.d.ts.map +1 -1
  40. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +5 -3
  41. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  42. package/types/domain/entities/Issue.d.ts +1 -0
  43. package/types/domain/entities/Issue.d.ts.map +1 -1
  44. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +5 -1
  45. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  46. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
  47. package/types/domain/usecases/StartPreparationUseCase.d.ts +10 -18
  48. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  49. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +2 -1
  50. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
  51. package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts +1 -1
  52. package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts.map +1 -1
@@ -1,19 +1,38 @@
1
1
  import { StartPreparationUseCase } from './StartPreparationUseCase';
2
- import { IssueRepository } from './adapter-interfaces/IssueRepository';
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: 'Default 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/orgs/user/projects/1',
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, 'findProjectIdByUrl' | 'getProject'>
81
+ Pick<ProjectRepository, 'getByUrl' | 'prepareStatus'>
78
82
  >;
79
83
  let mockIssueRepository: Mocked<
80
- Pick<IssueRepository, 'getAllIssues' | 'updateStatus'>
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
- findProjectIdByUrl: jest.fn().mockResolvedValue('project-1'),
90
- getProject: jest.fn(),
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
- getAllIssues: jest
94
- .fn()
95
- .mockResolvedValue({ issues: [], cacheUsed: false }),
96
- updateStatus: jest.fn(),
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.getProject.mockResolvedValue(mockProject);
121
- mockIssueRepository.getAllIssues.mockResolvedValue({
122
- issues: awaitingIssues,
123
- cacheUsed: false,
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
- allowIssueCacheMinutes: 60,
192
+ utilizationPercentageThreshold: 90,
193
+ allowedIssueAuthors: null,
137
194
  });
138
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
139
- expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
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][0]).toBe(
145
- 'aw url1 impl https://github.com/user/repo',
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.getProject.mockResolvedValue(mockProject);
164
- mockIssueRepository.getAllIssues.mockResolvedValue({
165
- issues: awaitingIssues,
166
- cacheUsed: false,
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
- allowIssueCacheMinutes: 60,
600
+ utilizationPercentageThreshold: 90,
601
+ allowedIssueAuthors: null,
180
602
  });
181
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(2);
182
- expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
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.updateStatus.mock.calls[1][1]).toMatchObject({
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.getProject.mockResolvedValue(mockProject);
208
- mockIssueRepository.getAllIssues.mockResolvedValue({
209
- issues: [...preparationIssues, ...awaitingIssues],
210
- cacheUsed: false,
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
- allowIssueCacheMinutes: 60,
657
+ utilizationPercentageThreshold: 90,
658
+ allowedIssueAuthors: null,
224
659
  });
225
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
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 append logFilePath to aw command when provided', async () => {
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.getProject.mockResolvedValue(mockProject);
238
- mockIssueRepository.getAllIssues.mockResolvedValue({
239
- issues: awaitingIssues,
240
- cacheUsed: false,
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
- logFilePath: '/path/to/log.txt',
688
+ defaultLlmModelName: 'claude-opus',
689
+ defaultLlmAgentName: null,
690
+ configFilePath: '/path/to/config.yml',
253
691
  maximumPreparingIssuesCount: null,
254
- allowIssueCacheMinutes: 60,
692
+ utilizationPercentageThreshold: 90,
693
+ allowedIssueAuthors: null,
255
694
  });
256
695
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
257
- expect(mockLocalCommandRunner.runCommand.mock.calls[0][0]).toBe(
258
- 'aw url1 impl https://github.com/user/repo --logFilePath /path/to/log.txt',
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 not append logFilePath to aw command when not provided', async () => {
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.getProject.mockResolvedValue(mockProject);
271
- mockIssueRepository.getAllIssues.mockResolvedValue({
272
- issues: awaitingIssues,
273
- cacheUsed: false,
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
- allowIssueCacheMinutes: 60,
737
+ utilizationPercentageThreshold: 90,
738
+ allowedIssueAuthors: null,
287
739
  });
288
740
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
289
- expect(mockLocalCommandRunner.runCommand.mock.calls[0][0]).toBe(
290
- 'aw url1 impl https://github.com/user/repo',
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 handle no awaiting workspace issues gracefully', async () => {
294
- const preparationIssues: Issue[] = [
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: 'Preparation',
759
+ labels: ['llm-agent:research', 'category:impl'],
760
+ status: 'Awaiting Workspace',
300
761
  }),
301
762
  ];
302
- mockProjectRepository.getProject.mockResolvedValue(mockProject);
303
- mockIssueRepository.getAllIssues.mockResolvedValue({
304
- issues: preparationIssues,
305
- cacheUsed: false,
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
- allowIssueCacheMinutes: 60,
782
+ utilizationPercentageThreshold: 90,
783
+ allowedIssueAuthors: null,
319
784
  });
320
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
321
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
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 custom maximumPreparingIssuesCount when provided', async () => {
324
- const awaitingIssues: Issue[] = Array.from({ length: 10 }, (_, i) =>
799
+ it('should use category label over defaultLlmAgentName when no llm-agent label', async () => {
800
+ const awaitingIssues: Issue[] = [
325
801
  createMockIssue({
326
- url: `url${i + 1}`,
327
- title: `Issue ${i + 1}`,
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
- mockProjectRepository.getProject.mockResolvedValue(mockProject);
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
- maximumPreparingIssuesCount: 3,
348
- allowIssueCacheMinutes: 60,
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(mockIssueRepository.updateStatus.mock.calls).toHaveLength(3);
351
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(3);
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 default maximumPreparingIssuesCount of 6 when null is provided', async () => {
354
- const awaitingIssues: Issue[] = Array.from({ length: 12 }, (_, i) =>
844
+ it('should use defaultLlmAgentName over defaultAgentName when no label', async () => {
845
+ const awaitingIssues: Issue[] = [
355
846
  createMockIssue({
356
- url: `url${i + 1}`,
357
- title: `Issue ${i + 1}`,
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
- mockProjectRepository.getProject.mockResolvedValue(mockProject);
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
- allowIssueCacheMinutes: 60,
872
+ utilizationPercentageThreshold: 90,
873
+ allowedIssueAuthors: null,
379
874
  });
380
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(6);
381
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(6);
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
- it('should skip issues from blocked repositories (not the blocker issue itself)', async () => {
385
- const blockerIssue = createMockIssue({
386
- url: 'https://github.com/user/repo/issues/100',
387
- title: 'Blocker Issue',
388
- labels: [],
389
- status: 'Awaiting Workspace',
390
- state: 'OPEN',
391
- story: 'Workflow blocker',
392
- });
393
-
394
- const blockedIssue = createMockIssue({
395
- url: 'https://github.com/user/repo/issues/101',
396
- title: 'Blocked Issue',
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
- allowIssueCacheMinutes: 60,
917
+ utilizationPercentageThreshold: 90,
918
+ allowedIssueAuthors: null,
447
919
  });
448
-
449
- const blockerUpdateCalls =
450
- mockIssueRepository.updateStatus.mock.calls.filter(
451
- (call) => call[1].url === 'https://github.com/user/repo/issues/100',
452
- );
453
- expect(blockerUpdateCalls).toHaveLength(1);
454
-
455
- const blockedUpdateCalls =
456
- mockIssueRepository.updateStatus.mock.calls.filter(
457
- (call) => call[1].url === blockedIssue.url,
458
- );
459
- expect(blockedUpdateCalls).toHaveLength(0);
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
- it('should process the blocker issue even when repository is blocked', async () => {
471
- const blockerIssue = createMockIssue({
472
- url: 'https://github.com/user/repo/issues/100',
473
- title: 'Blocker Issue',
474
- labels: [],
475
- status: 'Awaiting Workspace',
476
- state: 'OPEN',
477
- story: 'Workflow blocker',
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
- const projectWithBlocker = {
481
- ...createMockProject(),
482
- story: {
483
- name: 'Story',
484
- fieldId: 'story-field-id',
485
- databaseId: 1,
486
- stories: [
487
- {
488
- id: 'story-blocker',
489
- name: 'Workflow blocker',
490
- color: 'RED' as const,
491
- description: '',
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
- allowIssueCacheMinutes: 60,
1013
+ utilizationPercentageThreshold: 90,
1014
+ allowedIssueAuthors: null,
519
1015
  });
520
-
521
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
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 process awaiting issue when workflow blocker story has no open blocker issues', async () => {
528
- const awaitingIssue = createMockIssue({
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: 'Awaiting Issue',
1150
+ title: 'Issue in blocked repo',
531
1151
  labels: [],
532
1152
  status: 'Awaiting Workspace',
533
1153
  state: 'OPEN',
534
1154
  });
535
1155
 
536
- const projectWithBlocker = {
537
- ...createMockProject(),
1156
+ const workflowBlockerMap: StoryObjectMap = new Map();
1157
+ workflowBlockerMap.set('Workflow blocker', {
538
1158
  story: {
539
- name: 'Story',
540
- fieldId: 'story-field-id',
541
- databaseId: 1,
542
- stories: [
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
- mockProjectRepository.getProject.mockResolvedValue(projectWithBlocker);
564
- mockIssueRepository.getAllIssues.mockResolvedValue({
565
- issues: [awaitingIssue],
566
- cacheUsed: false,
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
- allowIssueCacheMinutes: 60,
1199
+ utilizationPercentageThreshold: 90,
1200
+ allowedIssueAuthors: null,
581
1201
  });
582
1202
 
583
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
584
- expect(mockIssueRepository.updateStatus.mock.calls[0][1].url).toBe(
585
- 'https://github.com/user/repo/issues/101',
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.getProject.mockResolvedValue(mockProject);
603
- mockIssueRepository.getAllIssues.mockResolvedValue({
604
- issues: awaitingIssues,
605
- cacheUsed: false,
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
- allowIssueCacheMinutes: 60,
1239
+ utilizationPercentageThreshold: 90,
1240
+ allowedIssueAuthors: null,
615
1241
  });
616
1242
 
617
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
1243
+ expect(mockIssueRepository.update.mock.calls).toHaveLength(0);
618
1244
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
619
- expect(mockProjectRepository.findProjectIdByUrl).not.toHaveBeenCalled();
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.getProject.mockResolvedValue(mockProject);
637
- mockIssueRepository.getAllIssues.mockResolvedValue({
638
- issues: awaitingIssues,
639
- cacheUsed: false,
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
- allowIssueCacheMinutes: 60,
1282
+ utilizationPercentageThreshold: 90,
1283
+ allowedIssueAuthors: null,
654
1284
  });
655
1285
 
656
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
1286
+ expect(mockIssueRepository.update.mock.calls).toHaveLength(1);
657
1287
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
658
1288
  });
659
1289
 
660
- it('should skip preparation when any Claude usage window exceeds 90%', async () => {
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
- allowIssueCacheMinutes: 60,
1324
+ utilizationPercentageThreshold: 90,
1325
+ allowedIssueAuthors: null,
673
1326
  });
674
1327
 
675
- expect(mockProjectRepository.findProjectIdByUrl).not.toHaveBeenCalled();
676
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
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 proceed with preparation when Claude usage check fails', async () => {
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.getProject.mockResolvedValue(mockProject);
694
- mockIssueRepository.getAllIssues.mockResolvedValue({
695
- issues: awaitingIssues,
696
- cacheUsed: false,
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
- allowIssueCacheMinutes: 60,
2115
+ utilizationPercentageThreshold: 90,
2116
+ allowedIssueAuthors: ['user1'],
711
2117
  });
712
2118
 
713
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
2119
+ expect(mockIssueRepository.update.mock.calls).toHaveLength(1);
714
2120
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
715
2121
  });
716
2122
  });