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