github-issue-tower-defence-management 1.24.0 → 1.26.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 (69) hide show
  1. package/.github/workflows/test.yml +2 -2
  2. package/CHANGELOG.md +15 -0
  3. package/bin/adapter/repositories/GraphqlProjectRepository.js +12 -0
  4. package/bin/adapter/repositories/GraphqlProjectRepository.js.map +1 -1
  5. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +9 -0
  6. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  7. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +5 -1
  8. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
  9. package/bin/domain/entities/ClaudeWindowUsage.js +3 -0
  10. package/bin/domain/entities/ClaudeWindowUsage.js.map +1 -0
  11. package/bin/domain/entities/Comment.js +3 -0
  12. package/bin/domain/entities/Comment.js.map +1 -0
  13. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +81 -0
  14. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -0
  15. package/bin/domain/usecases/StartPreparationUseCase.js +104 -0
  16. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -0
  17. package/bin/domain/usecases/adapter-interfaces/ClaudeRepository.js +3 -0
  18. package/bin/domain/usecases/adapter-interfaces/ClaudeRepository.js.map +1 -0
  19. package/bin/domain/usecases/adapter-interfaces/IssueCommentRepository.js +3 -0
  20. package/bin/domain/usecases/adapter-interfaces/IssueCommentRepository.js.map +1 -0
  21. package/bin/domain/usecases/adapter-interfaces/LocalCommandRunner.js +3 -0
  22. package/bin/domain/usecases/adapter-interfaces/LocalCommandRunner.js.map +1 -0
  23. package/package.json +1 -1
  24. package/src/adapter/repositories/CheerioProjectRepository.test.ts +1 -0
  25. package/src/adapter/repositories/GraphqlProjectRepository.test.ts +1 -0
  26. package/src/adapter/repositories/GraphqlProjectRepository.ts +14 -1
  27. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +1 -0
  28. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +15 -1
  29. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +162 -34
  30. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +6 -0
  31. package/src/domain/entities/ClaudeWindowUsage.ts +5 -0
  32. package/src/domain/entities/Comment.ts +5 -0
  33. package/src/domain/entities/Project.ts +1 -0
  34. package/src/domain/usecases/GetStoryObjectMapUseCase.test.ts +1 -0
  35. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +669 -0
  36. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +132 -0
  37. package/src/domain/usecases/StartPreparationUseCase.test.ts +716 -0
  38. package/src/domain/usecases/StartPreparationUseCase.ts +191 -0
  39. package/src/domain/usecases/adapter-interfaces/ClaudeRepository.ts +5 -0
  40. package/src/domain/usecases/adapter-interfaces/IssueCommentRepository.ts +7 -0
  41. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +11 -0
  42. package/src/domain/usecases/adapter-interfaces/LocalCommandRunner.ts +7 -0
  43. package/src/domain/usecases/adapter-interfaces/ProjectRepository.ts +1 -0
  44. package/types/adapter/repositories/GraphqlProjectRepository.d.ts +2 -1
  45. package/types/adapter/repositories/GraphqlProjectRepository.d.ts.map +1 -1
  46. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +4 -1
  47. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  48. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts +1 -0
  49. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
  50. package/types/domain/entities/ClaudeWindowUsage.d.ts +6 -0
  51. package/types/domain/entities/ClaudeWindowUsage.d.ts.map +1 -0
  52. package/types/domain/entities/Comment.d.ts +6 -0
  53. package/types/domain/entities/Comment.d.ts.map +1 -0
  54. package/types/domain/entities/Project.d.ts +1 -0
  55. package/types/domain/entities/Project.d.ts.map +1 -1
  56. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +24 -0
  57. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -0
  58. package/types/domain/usecases/StartPreparationUseCase.d.ts +37 -0
  59. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -0
  60. package/types/domain/usecases/adapter-interfaces/ClaudeRepository.d.ts +5 -0
  61. package/types/domain/usecases/adapter-interfaces/ClaudeRepository.d.ts.map +1 -0
  62. package/types/domain/usecases/adapter-interfaces/IssueCommentRepository.d.ts +7 -0
  63. package/types/domain/usecases/adapter-interfaces/IssueCommentRepository.d.ts.map +1 -0
  64. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +10 -0
  65. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
  66. package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts +8 -0
  67. package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts.map +1 -0
  68. package/types/domain/usecases/adapter-interfaces/ProjectRepository.d.ts +1 -0
  69. package/types/domain/usecases/adapter-interfaces/ProjectRepository.d.ts.map +1 -1
@@ -0,0 +1,716 @@
1
+ import { StartPreparationUseCase } from './StartPreparationUseCase';
2
+ import { IssueRepository } from './adapter-interfaces/IssueRepository';
3
+ import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
4
+ import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
5
+ import { ClaudeRepository } from './adapter-interfaces/ClaudeRepository';
6
+ import { Issue } from '../entities/Issue';
7
+ import { Project } from '../entities/Project';
8
+ type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
9
+
10
+ const createMockIssue = (overrides: Partial<Issue> = {}): Issue => ({
11
+ nameWithOwner: 'user/repo',
12
+ number: 1,
13
+ title: 'Test Issue',
14
+ state: 'OPEN',
15
+ status: 'Backlog',
16
+ story: 'Default Story',
17
+ nextActionDate: null,
18
+ nextActionHour: null,
19
+ estimationMinutes: null,
20
+ dependedIssueUrls: [],
21
+ completionDate50PercentConfidence: null,
22
+ url: 'https://github.com/user/repo/issues/1',
23
+ assignees: [],
24
+ labels: [],
25
+ org: 'user',
26
+ repo: 'repo',
27
+ body: '',
28
+ itemId: 'item-1',
29
+ isPr: false,
30
+ isInProgress: false,
31
+ isClosed: false,
32
+ createdAt: new Date(),
33
+ ...overrides,
34
+ });
35
+
36
+ const createMockProject = (): Project => ({
37
+ id: 'project-1',
38
+ url: 'https://github.com/orgs/user/projects/1',
39
+ databaseId: 1,
40
+ name: 'Test Project',
41
+ status: {
42
+ name: 'Status',
43
+ fieldId: 'status-field-id',
44
+ statuses: [
45
+ { id: '1', name: 'Awaiting Workspace', color: 'GRAY', description: '' },
46
+ { id: '2', name: 'Preparation', color: 'YELLOW', description: '' },
47
+ { id: '3', name: 'Done', color: 'GREEN', description: '' },
48
+ ],
49
+ },
50
+ nextActionDate: null,
51
+ 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
+ },
69
+ remainingEstimationMinutes: null,
70
+ dependedIssueUrlSeparatedByComma: null,
71
+ completionDate50PercentConfidence: null,
72
+ });
73
+
74
+ describe('StartPreparationUseCase', () => {
75
+ let useCase: StartPreparationUseCase;
76
+ let mockProjectRepository: Mocked<
77
+ Pick<ProjectRepository, 'findProjectIdByUrl' | 'getProject'>
78
+ >;
79
+ let mockIssueRepository: Mocked<
80
+ Pick<IssueRepository, 'getAllIssues' | 'updateStatus'>
81
+ >;
82
+ let mockClaudeRepository: Mocked<Pick<ClaudeRepository, 'getUsage'>>;
83
+ let mockLocalCommandRunner: Mocked<LocalCommandRunner>;
84
+ let mockProject: Project;
85
+ beforeEach(() => {
86
+ jest.resetAllMocks();
87
+ mockProject = createMockProject();
88
+ mockProjectRepository = {
89
+ findProjectIdByUrl: jest.fn().mockResolvedValue('project-1'),
90
+ getProject: jest.fn(),
91
+ };
92
+ mockIssueRepository = {
93
+ getAllIssues: jest
94
+ .fn()
95
+ .mockResolvedValue({ issues: [], cacheUsed: false }),
96
+ updateStatus: jest.fn(),
97
+ };
98
+ mockClaudeRepository = {
99
+ getUsage: jest.fn().mockResolvedValue([]),
100
+ };
101
+ mockLocalCommandRunner = {
102
+ runCommand: jest.fn(),
103
+ };
104
+ useCase = new StartPreparationUseCase(
105
+ mockProjectRepository,
106
+ mockIssueRepository,
107
+ mockClaudeRepository,
108
+ mockLocalCommandRunner,
109
+ );
110
+ });
111
+ it('should run aw command for awaiting workspace issues', async () => {
112
+ const awaitingIssues: Issue[] = [
113
+ createMockIssue({
114
+ url: 'url1',
115
+ title: 'Issue 1',
116
+ labels: ['category:impl'],
117
+ status: 'Awaiting Workspace',
118
+ }),
119
+ ];
120
+ mockProjectRepository.getProject.mockResolvedValue(mockProject);
121
+ mockIssueRepository.getAllIssues.mockResolvedValue({
122
+ issues: awaitingIssues,
123
+ cacheUsed: false,
124
+ });
125
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
126
+ stdout: '',
127
+ stderr: '',
128
+ exitCode: 0,
129
+ });
130
+ await useCase.run({
131
+ projectUrl: 'https://github.com/user/repo',
132
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
133
+ preparationStatus: 'Preparation',
134
+ defaultAgentName: 'agent1',
135
+ maximumPreparingIssuesCount: null,
136
+ allowIssueCacheMinutes: 60,
137
+ });
138
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
139
+ expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
140
+ url: 'url1',
141
+ });
142
+ expect(mockIssueRepository.updateStatus.mock.calls[0][2]).toBe('2');
143
+ 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',
146
+ );
147
+ });
148
+ it('should assign workspace to awaiting issues', async () => {
149
+ const awaitingIssues: Issue[] = [
150
+ createMockIssue({
151
+ url: 'url1',
152
+ title: 'Issue 1',
153
+ labels: [],
154
+ status: 'Awaiting Workspace',
155
+ }),
156
+ createMockIssue({
157
+ url: 'url2',
158
+ title: 'Issue 2',
159
+ labels: [],
160
+ status: 'Awaiting Workspace',
161
+ }),
162
+ ];
163
+ mockProjectRepository.getProject.mockResolvedValue(mockProject);
164
+ mockIssueRepository.getAllIssues.mockResolvedValue({
165
+ issues: awaitingIssues,
166
+ cacheUsed: false,
167
+ });
168
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
169
+ stdout: '',
170
+ stderr: '',
171
+ exitCode: 0,
172
+ });
173
+ await useCase.run({
174
+ projectUrl: 'https://github.com/user/repo',
175
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
176
+ preparationStatus: 'Preparation',
177
+ defaultAgentName: 'agent1',
178
+ maximumPreparingIssuesCount: null,
179
+ allowIssueCacheMinutes: 60,
180
+ });
181
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(2);
182
+ expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
183
+ url: 'url1',
184
+ });
185
+ expect(mockIssueRepository.updateStatus.mock.calls[1][1]).toMatchObject({
186
+ url: 'url2',
187
+ });
188
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(2);
189
+ });
190
+ it('should stop assigning after maximum preparing issues count is reached', async () => {
191
+ const preparationIssues: Issue[] = Array.from({ length: 6 }, (_, i) =>
192
+ createMockIssue({
193
+ url: `url${i + 1}`,
194
+ title: `Issue ${i + 1}`,
195
+ labels: [],
196
+ status: 'Preparation',
197
+ }),
198
+ );
199
+ const awaitingIssues: Issue[] = [
200
+ createMockIssue({
201
+ url: 'url7',
202
+ title: 'Issue 7',
203
+ labels: [],
204
+ status: 'Awaiting Workspace',
205
+ }),
206
+ ];
207
+ mockProjectRepository.getProject.mockResolvedValue(mockProject);
208
+ mockIssueRepository.getAllIssues.mockResolvedValue({
209
+ issues: [...preparationIssues, ...awaitingIssues],
210
+ cacheUsed: false,
211
+ });
212
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
213
+ stdout: '',
214
+ stderr: '',
215
+ exitCode: 0,
216
+ });
217
+ await useCase.run({
218
+ projectUrl: 'https://github.com/user/repo',
219
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
220
+ preparationStatus: 'Preparation',
221
+ defaultAgentName: 'agent1',
222
+ maximumPreparingIssuesCount: null,
223
+ allowIssueCacheMinutes: 60,
224
+ });
225
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
226
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
227
+ });
228
+ it('should append logFilePath to aw command when provided', async () => {
229
+ const awaitingIssues: Issue[] = [
230
+ createMockIssue({
231
+ url: 'url1',
232
+ title: 'Issue 1',
233
+ labels: ['category:impl'],
234
+ status: 'Awaiting Workspace',
235
+ }),
236
+ ];
237
+ mockProjectRepository.getProject.mockResolvedValue(mockProject);
238
+ mockIssueRepository.getAllIssues.mockResolvedValue({
239
+ issues: awaitingIssues,
240
+ cacheUsed: false,
241
+ });
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
+ logFilePath: '/path/to/log.txt',
253
+ maximumPreparingIssuesCount: null,
254
+ allowIssueCacheMinutes: 60,
255
+ });
256
+ 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
+ );
260
+ });
261
+ it('should not append logFilePath to aw command when not provided', async () => {
262
+ const awaitingIssues: Issue[] = [
263
+ createMockIssue({
264
+ url: 'url1',
265
+ title: 'Issue 1',
266
+ labels: ['category:impl'],
267
+ status: 'Awaiting Workspace',
268
+ }),
269
+ ];
270
+ mockProjectRepository.getProject.mockResolvedValue(mockProject);
271
+ mockIssueRepository.getAllIssues.mockResolvedValue({
272
+ issues: awaitingIssues,
273
+ cacheUsed: false,
274
+ });
275
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
276
+ stdout: '',
277
+ stderr: '',
278
+ exitCode: 0,
279
+ });
280
+ await useCase.run({
281
+ projectUrl: 'https://github.com/user/repo',
282
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
283
+ preparationStatus: 'Preparation',
284
+ defaultAgentName: 'agent1',
285
+ maximumPreparingIssuesCount: null,
286
+ allowIssueCacheMinutes: 60,
287
+ });
288
+ 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
+ );
292
+ });
293
+ it('should handle no awaiting workspace issues gracefully', async () => {
294
+ const preparationIssues: Issue[] = [
295
+ createMockIssue({
296
+ url: 'url1',
297
+ title: 'Issue 1',
298
+ labels: [],
299
+ status: 'Preparation',
300
+ }),
301
+ ];
302
+ mockProjectRepository.getProject.mockResolvedValue(mockProject);
303
+ mockIssueRepository.getAllIssues.mockResolvedValue({
304
+ issues: preparationIssues,
305
+ cacheUsed: false,
306
+ });
307
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
308
+ stdout: '',
309
+ stderr: '',
310
+ exitCode: 0,
311
+ });
312
+ await useCase.run({
313
+ projectUrl: 'https://github.com/user/repo',
314
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
315
+ preparationStatus: 'Preparation',
316
+ defaultAgentName: 'agent1',
317
+ maximumPreparingIssuesCount: null,
318
+ allowIssueCacheMinutes: 60,
319
+ });
320
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
321
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
322
+ });
323
+ it('should use custom maximumPreparingIssuesCount when provided', async () => {
324
+ const awaitingIssues: Issue[] = Array.from({ length: 10 }, (_, i) =>
325
+ createMockIssue({
326
+ url: `url${i + 1}`,
327
+ title: `Issue ${i + 1}`,
328
+ labels: [],
329
+ status: 'Awaiting Workspace',
330
+ }),
331
+ );
332
+ mockProjectRepository.getProject.mockResolvedValue(mockProject);
333
+ mockIssueRepository.getAllIssues.mockResolvedValue({
334
+ issues: awaitingIssues,
335
+ cacheUsed: false,
336
+ });
337
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
338
+ stdout: '',
339
+ stderr: '',
340
+ exitCode: 0,
341
+ });
342
+ await useCase.run({
343
+ projectUrl: 'https://github.com/user/repo',
344
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
345
+ preparationStatus: 'Preparation',
346
+ defaultAgentName: 'agent1',
347
+ maximumPreparingIssuesCount: 3,
348
+ allowIssueCacheMinutes: 60,
349
+ });
350
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(3);
351
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(3);
352
+ });
353
+ it('should use default maximumPreparingIssuesCount of 6 when null is provided', async () => {
354
+ const awaitingIssues: Issue[] = Array.from({ length: 12 }, (_, i) =>
355
+ createMockIssue({
356
+ url: `url${i + 1}`,
357
+ title: `Issue ${i + 1}`,
358
+ labels: [],
359
+ status: 'Awaiting Workspace',
360
+ }),
361
+ );
362
+ mockProjectRepository.getProject.mockResolvedValue(mockProject);
363
+ mockIssueRepository.getAllIssues.mockResolvedValue({
364
+ issues: awaitingIssues,
365
+ cacheUsed: false,
366
+ });
367
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
368
+ stdout: '',
369
+ stderr: '',
370
+ exitCode: 0,
371
+ });
372
+ await useCase.run({
373
+ projectUrl: 'https://github.com/user/repo',
374
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
375
+ preparationStatus: 'Preparation',
376
+ defaultAgentName: 'agent1',
377
+ maximumPreparingIssuesCount: null,
378
+ allowIssueCacheMinutes: 60,
379
+ });
380
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(6);
381
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(6);
382
+ });
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
+ });
434
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
435
+ stdout: '',
436
+ stderr: '',
437
+ exitCode: 0,
438
+ });
439
+
440
+ await useCase.run({
441
+ projectUrl: 'https://github.com/user/repo',
442
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
443
+ preparationStatus: 'Preparation',
444
+ defaultAgentName: 'agent1',
445
+ maximumPreparingIssuesCount: null,
446
+ allowIssueCacheMinutes: 60,
447
+ });
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);
468
+ });
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',
478
+ });
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,
505
+ });
506
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
507
+ stdout: '',
508
+ stderr: '',
509
+ exitCode: 0,
510
+ });
511
+
512
+ await useCase.run({
513
+ projectUrl: 'https://github.com/user/repo',
514
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
515
+ preparationStatus: 'Preparation',
516
+ defaultAgentName: 'agent1',
517
+ maximumPreparingIssuesCount: null,
518
+ allowIssueCacheMinutes: 60,
519
+ });
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',
524
+ );
525
+ });
526
+
527
+ it('should process awaiting issue when workflow blocker story has no open blocker issues', async () => {
528
+ const awaitingIssue = createMockIssue({
529
+ url: 'https://github.com/user/repo/issues/101',
530
+ title: 'Awaiting Issue',
531
+ labels: [],
532
+ status: 'Awaiting Workspace',
533
+ state: 'OPEN',
534
+ });
535
+
536
+ const projectWithBlocker = {
537
+ ...createMockProject(),
538
+ 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
+ },
560
+ },
561
+ };
562
+
563
+ mockProjectRepository.getProject.mockResolvedValue(projectWithBlocker);
564
+ mockIssueRepository.getAllIssues.mockResolvedValue({
565
+ issues: [awaitingIssue],
566
+ cacheUsed: false,
567
+ });
568
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
569
+ stdout: '',
570
+ stderr: '',
571
+ exitCode: 0,
572
+ });
573
+
574
+ await useCase.run({
575
+ projectUrl: 'https://github.com/user/repo',
576
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
577
+ preparationStatus: 'Preparation',
578
+ defaultAgentName: 'agent1',
579
+ maximumPreparingIssuesCount: null,
580
+ allowIssueCacheMinutes: 60,
581
+ });
582
+
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',
586
+ );
587
+ });
588
+
589
+ it('should skip preparation when Claude usage is over 90%', async () => {
590
+ mockClaudeRepository.getUsage.mockResolvedValue([
591
+ { hour: 5, utilizationPercentage: 95, resetsAt: new Date() },
592
+ ]);
593
+
594
+ const awaitingIssues: Issue[] = [
595
+ createMockIssue({
596
+ url: 'url1',
597
+ title: 'Issue 1',
598
+ labels: [],
599
+ status: 'Awaiting Workspace',
600
+ }),
601
+ ];
602
+ mockProjectRepository.getProject.mockResolvedValue(mockProject);
603
+ mockIssueRepository.getAllIssues.mockResolvedValue({
604
+ issues: awaitingIssues,
605
+ cacheUsed: false,
606
+ });
607
+
608
+ await useCase.run({
609
+ projectUrl: 'https://github.com/user/repo',
610
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
611
+ preparationStatus: 'Preparation',
612
+ defaultAgentName: 'agent1',
613
+ maximumPreparingIssuesCount: null,
614
+ allowIssueCacheMinutes: 60,
615
+ });
616
+
617
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
618
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
619
+ expect(mockProjectRepository.findProjectIdByUrl).not.toHaveBeenCalled();
620
+ });
621
+
622
+ it('should proceed with preparation when Claude usage is under 90%', async () => {
623
+ mockClaudeRepository.getUsage.mockResolvedValue([
624
+ { hour: 5, utilizationPercentage: 50, resetsAt: new Date() },
625
+ { hour: 168, utilizationPercentage: 30, resetsAt: new Date() },
626
+ ]);
627
+
628
+ const awaitingIssues: Issue[] = [
629
+ createMockIssue({
630
+ url: 'url1',
631
+ title: 'Issue 1',
632
+ labels: ['category:impl'],
633
+ status: 'Awaiting Workspace',
634
+ }),
635
+ ];
636
+ mockProjectRepository.getProject.mockResolvedValue(mockProject);
637
+ mockIssueRepository.getAllIssues.mockResolvedValue({
638
+ issues: awaitingIssues,
639
+ cacheUsed: false,
640
+ });
641
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
642
+ stdout: '',
643
+ stderr: '',
644
+ exitCode: 0,
645
+ });
646
+
647
+ await useCase.run({
648
+ projectUrl: 'https://github.com/user/repo',
649
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
650
+ preparationStatus: 'Preparation',
651
+ defaultAgentName: 'agent1',
652
+ maximumPreparingIssuesCount: null,
653
+ allowIssueCacheMinutes: 60,
654
+ });
655
+
656
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
657
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
658
+ });
659
+
660
+ it('should skip preparation when any Claude usage window exceeds 90%', async () => {
661
+ mockClaudeRepository.getUsage.mockResolvedValue([
662
+ { hour: 5, utilizationPercentage: 50, resetsAt: new Date() },
663
+ { hour: 168, utilizationPercentage: 91, resetsAt: new Date() },
664
+ ]);
665
+
666
+ await useCase.run({
667
+ projectUrl: 'https://github.com/user/repo',
668
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
669
+ preparationStatus: 'Preparation',
670
+ defaultAgentName: 'agent1',
671
+ maximumPreparingIssuesCount: null,
672
+ allowIssueCacheMinutes: 60,
673
+ });
674
+
675
+ expect(mockProjectRepository.findProjectIdByUrl).not.toHaveBeenCalled();
676
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
677
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
678
+ });
679
+
680
+ it('should proceed with preparation when Claude usage check fails', async () => {
681
+ mockClaudeRepository.getUsage.mockRejectedValue(
682
+ new Error('Claude credentials file not found'),
683
+ );
684
+
685
+ const awaitingIssues: Issue[] = [
686
+ createMockIssue({
687
+ url: 'url1',
688
+ title: 'Issue 1',
689
+ labels: ['category:impl'],
690
+ status: 'Awaiting Workspace',
691
+ }),
692
+ ];
693
+ mockProjectRepository.getProject.mockResolvedValue(mockProject);
694
+ mockIssueRepository.getAllIssues.mockResolvedValue({
695
+ issues: awaitingIssues,
696
+ cacheUsed: false,
697
+ });
698
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
699
+ stdout: '',
700
+ stderr: '',
701
+ exitCode: 0,
702
+ });
703
+
704
+ await useCase.run({
705
+ projectUrl: 'https://github.com/user/repo',
706
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
707
+ preparationStatus: 'Preparation',
708
+ defaultAgentName: 'agent1',
709
+ maximumPreparingIssuesCount: null,
710
+ allowIssueCacheMinutes: 60,
711
+ });
712
+
713
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
714
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
715
+ });
716
+ });