github-issue-tower-defence-management 1.44.5 → 1.44.7

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 (27) hide show
  1. package/.github/workflows/umino-project.yml +33 -9
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +2 -2
  4. package/bin/adapter/entry-points/cli/index.js +3 -1
  5. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  6. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +1 -1
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  8. package/bin/domain/usecases/HandleScheduledEventUseCase.js +1 -0
  9. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  10. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +15 -0
  11. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  12. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +65 -3
  13. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  14. package/package.json +1 -1
  15. package/src/adapter/entry-points/cli/index.ts +5 -0
  16. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +1 -0
  17. package/src/domain/usecases/HandleScheduledEventUseCase.ts +2 -0
  18. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +56 -1
  19. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +45 -0
  20. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +316 -11
  21. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +107 -11
  22. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  23. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  24. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +1 -1
  25. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  26. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +8 -2
  27. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
@@ -1,5 +1,6 @@
1
1
  import { RevertOrphanedPreparationUseCase } from './RevertOrphanedPreparationUseCase';
2
2
  import { IssueRepository } from './adapter-interfaces/IssueRepository';
3
+ import { IssueCommentRepository } from './adapter-interfaces/IssueCommentRepository';
3
4
  import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
4
5
  import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
5
6
  import { Issue } from '../entities/Issue';
@@ -46,6 +47,12 @@ const createMockProject = (): Project => ({
46
47
  { id: '1', name: 'Awaiting Workspace', color: 'GRAY', description: '' },
47
48
  { id: '2', name: 'Preparation', color: 'YELLOW', description: '' },
48
49
  { id: '3', name: 'Done', color: 'GREEN', description: '' },
50
+ {
51
+ id: '4',
52
+ name: 'Awaiting Quality Check',
53
+ color: 'BLUE',
54
+ description: '',
55
+ },
49
56
  ],
50
57
  },
51
58
  nextActionDate: null,
@@ -72,13 +79,33 @@ const createMockProject = (): Project => ({
72
79
  completionDate50PercentConfidence: null,
73
80
  });
74
81
 
82
+ const createPassingPr = () => ({
83
+ url: 'https://github.com/user/repo/pull/5',
84
+ branchName: 'i1',
85
+ isConflicted: false,
86
+ isPassedAllCiJob: true,
87
+ isCiStateSuccess: true,
88
+ isResolvedAllReviewComments: true,
89
+ isBranchOutOfDate: false,
90
+ missingRequiredCheckNames: [],
91
+ });
92
+
75
93
  describe('RevertOrphanedPreparationUseCase', () => {
76
94
  let useCase: RevertOrphanedPreparationUseCase;
77
95
  let mockProjectRepository: Mocked<
78
96
  Pick<ProjectRepository, 'findProjectIdByUrl' | 'getProject'>
79
97
  >;
80
98
  let mockIssueRepository: Mocked<
81
- Pick<IssueRepository, 'getAllIssues' | 'updateStatus' | 'createComment'>
99
+ Pick<
100
+ IssueRepository,
101
+ | 'getAllIssues'
102
+ | 'updateStatus'
103
+ | 'findRelatedOpenPRs'
104
+ | 'getOpenPullRequest'
105
+ >
106
+ >;
107
+ let mockIssueCommentRepository: Mocked<
108
+ Pick<IssueCommentRepository, 'getCommentsFromIssue'>
82
109
  >;
83
110
  let mockLocalCommandRunner: Mocked<LocalCommandRunner>;
84
111
  let mockProject: Project;
@@ -95,7 +122,11 @@ describe('RevertOrphanedPreparationUseCase', () => {
95
122
  .fn()
96
123
  .mockResolvedValue({ issues: [], cacheUsed: false }),
97
124
  updateStatus: jest.fn().mockResolvedValue(undefined),
98
- createComment: jest.fn().mockResolvedValue(undefined),
125
+ findRelatedOpenPRs: jest.fn().mockResolvedValue([]),
126
+ getOpenPullRequest: jest.fn().mockResolvedValue(null),
127
+ };
128
+ mockIssueCommentRepository = {
129
+ getCommentsFromIssue: jest.fn().mockResolvedValue([]),
99
130
  };
100
131
  mockLocalCommandRunner = {
101
132
  runCommand: jest.fn(),
@@ -103,11 +134,12 @@ describe('RevertOrphanedPreparationUseCase', () => {
103
134
  useCase = new RevertOrphanedPreparationUseCase(
104
135
  mockProjectRepository,
105
136
  mockIssueRepository,
137
+ mockIssueCommentRepository,
106
138
  mockLocalCommandRunner,
107
139
  );
108
140
  });
109
141
 
110
- it('should revert stuck-Preparation issue to Awaiting Workspace when check command exits non-zero', async () => {
142
+ it('should revert stuck-Preparation issue to Awaiting Workspace when check command exits non-zero and no agent report present', async () => {
111
143
  const stuckIssue = createMockIssue({
112
144
  url: 'https://github.com/user/repo/issues/10',
113
145
  status: 'Preparation',
@@ -121,11 +153,13 @@ describe('RevertOrphanedPreparationUseCase', () => {
121
153
  stderr: '',
122
154
  exitCode: 1,
123
155
  });
156
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([]);
124
157
 
125
158
  await useCase.run({
126
159
  projectUrl: 'https://github.com/user/repo',
127
160
  preparationStatus: 'Preparation',
128
161
  awaitingWorkspaceStatus: 'Awaiting Workspace',
162
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
129
163
  allowIssueCacheMinutes: 60,
130
164
  preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
131
165
  });
@@ -134,8 +168,6 @@ describe('RevertOrphanedPreparationUseCase', () => {
134
168
  expect(mockIssueRepository.updateStatus.mock.calls[0][0]).toBe(mockProject);
135
169
  expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toBe(stuckIssue);
136
170
  expect(mockIssueRepository.updateStatus.mock.calls[0][2]).toBe('1');
137
- expect(mockIssueRepository.createComment.mock.calls).toHaveLength(1);
138
- expect(mockIssueRepository.createComment.mock.calls[0][0]).toBe(stuckIssue);
139
171
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
140
172
  expect(mockLocalCommandRunner.runCommand.mock.calls[0][0]).toBe('sh');
141
173
  expect(mockLocalCommandRunner.runCommand.mock.calls[0][1]).toEqual([
@@ -146,6 +178,259 @@ describe('RevertOrphanedPreparationUseCase', () => {
146
178
  ]);
147
179
  });
148
180
 
181
+ it('should advance orphaned issue to Awaiting Quality Check when agent report and passing PR are present', async () => {
182
+ const stuckIssue = createMockIssue({
183
+ url: 'https://github.com/user/repo/issues/10',
184
+ status: 'Preparation',
185
+ });
186
+ mockIssueRepository.getAllIssues.mockResolvedValue({
187
+ issues: [stuckIssue],
188
+ cacheUsed: false,
189
+ });
190
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
191
+ stdout: '',
192
+ stderr: '',
193
+ exitCode: 1,
194
+ });
195
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
196
+ {
197
+ author: 'bot',
198
+ content: 'From: agent report',
199
+ createdAt: new Date(),
200
+ },
201
+ ]);
202
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
203
+ createPassingPr(),
204
+ ]);
205
+
206
+ await useCase.run({
207
+ projectUrl: 'https://github.com/user/repo',
208
+ preparationStatus: 'Preparation',
209
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
210
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
211
+ allowIssueCacheMinutes: 60,
212
+ preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
213
+ });
214
+
215
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
216
+ expect(mockIssueRepository.updateStatus.mock.calls[0][2]).toBe('4');
217
+ });
218
+
219
+ it('should revert orphaned issue to Awaiting Workspace when agent report present but PR CI is failing', async () => {
220
+ const stuckIssue = createMockIssue({
221
+ url: 'https://github.com/user/repo/issues/10',
222
+ status: 'Preparation',
223
+ });
224
+ mockIssueRepository.getAllIssues.mockResolvedValue({
225
+ issues: [stuckIssue],
226
+ cacheUsed: false,
227
+ });
228
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
229
+ stdout: '',
230
+ stderr: '',
231
+ exitCode: 1,
232
+ });
233
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
234
+ {
235
+ author: 'bot',
236
+ content: 'From: agent report',
237
+ createdAt: new Date(),
238
+ },
239
+ ]);
240
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
241
+ {
242
+ ...createPassingPr(),
243
+ isPassedAllCiJob: false,
244
+ },
245
+ ]);
246
+
247
+ await useCase.run({
248
+ projectUrl: 'https://github.com/user/repo',
249
+ preparationStatus: 'Preparation',
250
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
251
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
252
+ allowIssueCacheMinutes: 60,
253
+ preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
254
+ });
255
+
256
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
257
+ expect(mockIssueRepository.updateStatus.mock.calls[0][2]).toBe('1');
258
+ });
259
+
260
+ it('should revert orphaned issue to Awaiting Workspace when agent report present but no PR found', async () => {
261
+ const stuckIssue = createMockIssue({
262
+ url: 'https://github.com/user/repo/issues/10',
263
+ status: 'Preparation',
264
+ });
265
+ mockIssueRepository.getAllIssues.mockResolvedValue({
266
+ issues: [stuckIssue],
267
+ cacheUsed: false,
268
+ });
269
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
270
+ stdout: '',
271
+ stderr: '',
272
+ exitCode: 1,
273
+ });
274
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
275
+ {
276
+ author: 'bot',
277
+ content: 'From: agent report',
278
+ createdAt: new Date(),
279
+ },
280
+ ]);
281
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
282
+
283
+ await useCase.run({
284
+ projectUrl: 'https://github.com/user/repo',
285
+ preparationStatus: 'Preparation',
286
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
287
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
288
+ allowIssueCacheMinutes: 60,
289
+ preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
290
+ });
291
+
292
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
293
+ expect(mockIssueRepository.updateStatus.mock.calls[0][2]).toBe('1');
294
+ });
295
+
296
+ it('should revert orphaned issue to Awaiting Workspace when awaitingQualityCheckStatus is not provided', async () => {
297
+ const stuckIssue = createMockIssue({
298
+ url: 'https://github.com/user/repo/issues/10',
299
+ status: 'Preparation',
300
+ });
301
+ mockIssueRepository.getAllIssues.mockResolvedValue({
302
+ issues: [stuckIssue],
303
+ cacheUsed: false,
304
+ });
305
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
306
+ stdout: '',
307
+ stderr: '',
308
+ exitCode: 1,
309
+ });
310
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
311
+ {
312
+ author: 'bot',
313
+ content: 'From: agent report',
314
+ createdAt: new Date(),
315
+ },
316
+ ]);
317
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
318
+ createPassingPr(),
319
+ ]);
320
+
321
+ await useCase.run({
322
+ projectUrl: 'https://github.com/user/repo',
323
+ preparationStatus: 'Preparation',
324
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
325
+ allowIssueCacheMinutes: 60,
326
+ preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
327
+ });
328
+
329
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
330
+ expect(mockIssueRepository.updateStatus.mock.calls[0][2]).toBe('1');
331
+ });
332
+
333
+ it('should advance orphaned issue with llm-agent label to Awaiting Quality Check without PR check', async () => {
334
+ const stuckIssue = createMockIssue({
335
+ url: 'https://github.com/user/repo/issues/10',
336
+ status: 'Preparation',
337
+ labels: ['llm-agent'],
338
+ });
339
+ mockIssueRepository.getAllIssues.mockResolvedValue({
340
+ issues: [stuckIssue],
341
+ cacheUsed: false,
342
+ });
343
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
344
+ stdout: '',
345
+ stderr: '',
346
+ exitCode: 1,
347
+ });
348
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
349
+ {
350
+ author: 'bot',
351
+ content: 'From: agent report',
352
+ createdAt: new Date(),
353
+ },
354
+ ]);
355
+
356
+ await useCase.run({
357
+ projectUrl: 'https://github.com/user/repo',
358
+ preparationStatus: 'Preparation',
359
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
360
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
361
+ allowIssueCacheMinutes: 60,
362
+ preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
363
+ });
364
+
365
+ expect(mockIssueRepository.findRelatedOpenPRs.mock.calls).toHaveLength(0);
366
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
367
+ expect(mockIssueRepository.updateStatus.mock.calls[0][2]).toBe('4');
368
+ });
369
+
370
+ it('should revert orphaned issue to Awaiting Workspace when report has nextStep set', async () => {
371
+ const stuckIssue = createMockIssue({
372
+ url: 'https://github.com/user/repo/issues/10',
373
+ status: 'Preparation',
374
+ });
375
+ mockIssueRepository.getAllIssues.mockResolvedValue({
376
+ issues: [stuckIssue],
377
+ cacheUsed: false,
378
+ });
379
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
380
+ stdout: '',
381
+ stderr: '',
382
+ exitCode: 1,
383
+ });
384
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
385
+ {
386
+ author: 'bot',
387
+ content:
388
+ 'From: agent report\n```json\n{"nextStep": "do something"}\n```',
389
+ createdAt: new Date(),
390
+ },
391
+ ]);
392
+
393
+ await useCase.run({
394
+ projectUrl: 'https://github.com/user/repo',
395
+ preparationStatus: 'Preparation',
396
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
397
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
398
+ allowIssueCacheMinutes: 60,
399
+ preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
400
+ });
401
+
402
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
403
+ expect(mockIssueRepository.updateStatus.mock.calls[0][2]).toBe('1');
404
+ });
405
+
406
+ it('should never post a comment regardless of orphan outcome', async () => {
407
+ const stuckIssue = createMockIssue({
408
+ url: 'https://github.com/user/repo/issues/10',
409
+ status: 'Preparation',
410
+ });
411
+ mockIssueRepository.getAllIssues.mockResolvedValue({
412
+ issues: [stuckIssue],
413
+ cacheUsed: false,
414
+ });
415
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
416
+ stdout: '',
417
+ stderr: '',
418
+ exitCode: 1,
419
+ });
420
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([]);
421
+
422
+ await useCase.run({
423
+ projectUrl: 'https://github.com/user/repo',
424
+ preparationStatus: 'Preparation',
425
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
426
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
427
+ allowIssueCacheMinutes: 60,
428
+ preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
429
+ });
430
+
431
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
432
+ });
433
+
149
434
  it('should leave in-flight Preparation issue untouched when check command exits zero', async () => {
150
435
  const inFlightIssue = createMockIssue({
151
436
  url: 'https://github.com/user/repo/issues/20',
@@ -165,12 +450,15 @@ describe('RevertOrphanedPreparationUseCase', () => {
165
450
  projectUrl: 'https://github.com/user/repo',
166
451
  preparationStatus: 'Preparation',
167
452
  awaitingWorkspaceStatus: 'Awaiting Workspace',
453
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
168
454
  allowIssueCacheMinutes: 60,
169
455
  preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
170
456
  });
171
457
 
172
458
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
173
- expect(mockIssueRepository.createComment.mock.calls).toHaveLength(0);
459
+ expect(
460
+ mockIssueCommentRepository.getCommentsFromIssue.mock.calls,
461
+ ).toHaveLength(0);
174
462
  });
175
463
 
176
464
  it('should only process issues in Preparation status and skip others', async () => {
@@ -191,11 +479,13 @@ describe('RevertOrphanedPreparationUseCase', () => {
191
479
  stderr: '',
192
480
  exitCode: 1,
193
481
  });
482
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([]);
194
483
 
195
484
  await useCase.run({
196
485
  projectUrl: 'https://github.com/user/repo',
197
486
  preparationStatus: 'Preparation',
198
487
  awaitingWorkspaceStatus: 'Awaiting Workspace',
488
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
199
489
  allowIssueCacheMinutes: 60,
200
490
  preparationProcessCheckCommand: 'check {URL}',
201
491
  });
@@ -231,18 +521,19 @@ describe('RevertOrphanedPreparationUseCase', () => {
231
521
  stderr: '',
232
522
  exitCode: 0,
233
523
  });
524
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([]);
234
525
 
235
526
  await useCase.run({
236
527
  projectUrl: 'https://github.com/user/repo',
237
528
  preparationStatus: 'Preparation',
238
529
  awaitingWorkspaceStatus: 'Awaiting Workspace',
530
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
239
531
  allowIssueCacheMinutes: 60,
240
532
  preparationProcessCheckCommand: 'check {URL}',
241
533
  });
242
534
 
243
535
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
244
536
  expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toBe(stuckIssue);
245
- expect(mockIssueRepository.createComment.mock.calls).toHaveLength(1);
246
537
  });
247
538
 
248
539
  it('should throw when project is not found by URL', async () => {
@@ -253,6 +544,7 @@ describe('RevertOrphanedPreparationUseCase', () => {
253
544
  projectUrl: 'https://github.com/user/repo',
254
545
  preparationStatus: 'Preparation',
255
546
  awaitingWorkspaceStatus: 'Awaiting Workspace',
547
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
256
548
  allowIssueCacheMinutes: 0,
257
549
  preparationProcessCheckCommand: 'check {URL}',
258
550
  }),
@@ -268,6 +560,7 @@ describe('RevertOrphanedPreparationUseCase', () => {
268
560
  projectUrl: 'https://github.com/user/repo',
269
561
  preparationStatus: 'Preparation',
270
562
  awaitingWorkspaceStatus: 'Awaiting Workspace',
563
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
271
564
  allowIssueCacheMinutes: 0,
272
565
  preparationProcessCheckCommand: 'check {URL}',
273
566
  }),
@@ -293,12 +586,12 @@ describe('RevertOrphanedPreparationUseCase', () => {
293
586
  projectUrl: 'https://github.com/user/repo',
294
587
  preparationStatus: 'Preparation',
295
588
  awaitingWorkspaceStatus: 'NonExistentStatus',
589
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
296
590
  allowIssueCacheMinutes: 0,
297
591
  preparationProcessCheckCommand: 'check {URL}',
298
592
  });
299
593
 
300
594
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
301
- expect(mockIssueRepository.createComment.mock.calls).toHaveLength(0);
302
595
  });
303
596
 
304
597
  it('should do nothing when there are no Preparation issues', async () => {
@@ -314,6 +607,7 @@ describe('RevertOrphanedPreparationUseCase', () => {
314
607
  projectUrl: 'https://github.com/user/repo',
315
608
  preparationStatus: 'Preparation',
316
609
  awaitingWorkspaceStatus: 'Awaiting Workspace',
610
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
317
611
  allowIssueCacheMinutes: 60,
318
612
  preparationProcessCheckCommand: 'check {URL}',
319
613
  });
@@ -346,11 +640,13 @@ describe('RevertOrphanedPreparationUseCase', () => {
346
640
  exitCode: 0,
347
641
  })
348
642
  .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
643
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([]);
349
644
 
350
645
  await useCase.run({
351
646
  projectUrl: 'https://github.com/user/repo',
352
647
  preparationStatus: 'Preparation',
353
648
  awaitingWorkspaceStatus: 'Awaiting Workspace',
649
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
354
650
  allowIssueCacheMinutes: 60,
355
651
  preparationProcessCheckCommand: 'pgrep -fa "Please handover {URL}"',
356
652
  awLogDirectoryPath: '/home/user/logs-aw',
@@ -358,7 +654,6 @@ describe('RevertOrphanedPreparationUseCase', () => {
358
654
  });
359
655
 
360
656
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
361
- expect(mockIssueRepository.createComment.mock.calls).toHaveLength(1);
362
657
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(3);
363
658
  expect(mockLocalCommandRunner.runCommand.mock.calls[1]).toEqual([
364
659
  'sh',
@@ -416,6 +711,7 @@ describe('RevertOrphanedPreparationUseCase', () => {
416
711
  projectUrl: 'https://github.com/user/repo',
417
712
  preparationStatus: 'Preparation',
418
713
  awaitingWorkspaceStatus: 'Awaiting Workspace',
714
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
419
715
  allowIssueCacheMinutes: 60,
420
716
  preparationProcessCheckCommand: 'pgrep -fa "Please handover {URL}"',
421
717
  awLogDirectoryPath: '/home/user/logs-aw',
@@ -423,7 +719,9 @@ describe('RevertOrphanedPreparationUseCase', () => {
423
719
  });
424
720
 
425
721
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
426
- expect(mockIssueRepository.createComment.mock.calls).toHaveLength(0);
722
+ expect(
723
+ mockIssueCommentRepository.getCommentsFromIssue.mock.calls,
724
+ ).toHaveLength(0);
427
725
  });
428
726
 
429
727
  it('should leave issue untouched when pgrep exits zero and no aw log files exist yet', async () => {
@@ -450,6 +748,7 @@ describe('RevertOrphanedPreparationUseCase', () => {
450
748
  projectUrl: 'https://github.com/user/repo',
451
749
  preparationStatus: 'Preparation',
452
750
  awaitingWorkspaceStatus: 'Awaiting Workspace',
751
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
453
752
  allowIssueCacheMinutes: 60,
454
753
  preparationProcessCheckCommand: 'pgrep -fa "Please handover {URL}"',
455
754
  awLogDirectoryPath: '/home/user/logs-aw',
@@ -457,7 +756,9 @@ describe('RevertOrphanedPreparationUseCase', () => {
457
756
  });
458
757
 
459
758
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
460
- expect(mockIssueRepository.createComment.mock.calls).toHaveLength(0);
759
+ expect(
760
+ mockIssueCommentRepository.getCommentsFromIssue.mock.calls,
761
+ ).toHaveLength(0);
461
762
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(2);
462
763
  });
463
764
 
@@ -480,6 +781,7 @@ describe('RevertOrphanedPreparationUseCase', () => {
480
781
  projectUrl: 'https://github.com/user/repo',
481
782
  preparationStatus: 'Preparation',
482
783
  awaitingWorkspaceStatus: 'Awaiting Workspace',
784
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
483
785
  allowIssueCacheMinutes: 60,
484
786
  preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
485
787
  });
@@ -505,11 +807,13 @@ describe('RevertOrphanedPreparationUseCase', () => {
505
807
  stderr: '',
506
808
  exitCode: 1,
507
809
  });
810
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([]);
508
811
 
509
812
  await useCase.run({
510
813
  projectUrl: 'https://github.com/user/repo',
511
814
  preparationStatus: 'Preparation',
512
815
  awaitingWorkspaceStatus: 'Awaiting Workspace',
816
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
513
817
  allowIssueCacheMinutes: 60,
514
818
  preparationProcessCheckCommand: 'pgrep -fa "Please handover {URL}"',
515
819
  awLogDirectoryPath: '/home/user/logs-aw',
@@ -539,6 +843,7 @@ describe('RevertOrphanedPreparationUseCase', () => {
539
843
  projectUrl: 'https://github.com/user/repo',
540
844
  preparationStatus: 'Preparation',
541
845
  awaitingWorkspaceStatus: 'Awaiting Workspace',
846
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
542
847
  allowIssueCacheMinutes: 0,
543
848
  preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
544
849
  });
@@ -1,4 +1,8 @@
1
- import { IssueRepository } from './adapter-interfaces/IssueRepository';
1
+ import {
2
+ IssueRepository,
3
+ RelatedPullRequest,
4
+ } from './adapter-interfaces/IssueRepository';
5
+ import { IssueCommentRepository } from './adapter-interfaces/IssueCommentRepository';
2
6
  import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
3
7
  import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
4
8
  import { Issue } from '../entities/Issue';
@@ -11,7 +15,14 @@ export class RevertOrphanedPreparationUseCase {
11
15
  >,
12
16
  readonly issueRepository: Pick<
13
17
  IssueRepository,
14
- 'getAllIssues' | 'updateStatus' | 'createComment'
18
+ | 'getAllIssues'
19
+ | 'updateStatus'
20
+ | 'findRelatedOpenPRs'
21
+ | 'getOpenPullRequest'
22
+ >,
23
+ readonly issueCommentRepository: Pick<
24
+ IssueCommentRepository,
25
+ 'getCommentsFromIssue'
15
26
  >,
16
27
  readonly localCommandRunner: LocalCommandRunner,
17
28
  ) {}
@@ -20,6 +31,7 @@ export class RevertOrphanedPreparationUseCase {
20
31
  projectUrl: string;
21
32
  preparationStatus: string;
22
33
  awaitingWorkspaceStatus: string;
34
+ awaitingQualityCheckStatus?: string;
23
35
  allowIssueCacheMinutes: number;
24
36
  preparationProcessCheckCommand: string;
25
37
  awLogDirectoryPath?: string;
@@ -53,22 +65,106 @@ export class RevertOrphanedPreparationUseCase {
53
65
  return;
54
66
  }
55
67
 
68
+ const awaitingQualityCheckStatusOption = params.awaitingQualityCheckStatus
69
+ ? project.status.statuses.find(
70
+ (s) => s.name === params.awaitingQualityCheckStatus,
71
+ )
72
+ : null;
73
+
56
74
  for (const issue of preparationIssues) {
57
75
  const isOrphaned = await this.isOrphanedIssue(issue, params);
58
76
  if (isOrphaned) {
59
- await this.issueRepository.updateStatus(
60
- project,
61
- issue,
62
- awaitingWorkspaceStatusOption.id,
63
- );
64
- await this.issueRepository.createComment(
65
- issue,
66
- `Orphaned preparation detected: no live worker process found for ${issue.url}. Status reverted to ${params.awaitingWorkspaceStatus}.`,
67
- );
77
+ const hasRejections = await this.evaluateHasRejections(issue);
78
+ if (!hasRejections && awaitingQualityCheckStatusOption) {
79
+ await this.issueRepository.updateStatus(
80
+ project,
81
+ issue,
82
+ awaitingQualityCheckStatusOption.id,
83
+ );
84
+ } else {
85
+ await this.issueRepository.updateStatus(
86
+ project,
87
+ issue,
88
+ awaitingWorkspaceStatusOption.id,
89
+ );
90
+ }
68
91
  }
69
92
  }
70
93
  };
71
94
 
95
+ private evaluateHasRejections = async (issue: Issue): Promise<boolean> => {
96
+ const comments =
97
+ await this.issueCommentRepository.getCommentsFromIssue(issue);
98
+ const lastComment = comments[comments.length - 1];
99
+ if (!lastComment || !lastComment.content.startsWith('From:')) {
100
+ return true;
101
+ }
102
+ if (this.reportBodyHasNextStep(lastComment.content)) {
103
+ return true;
104
+ }
105
+
106
+ const categoryLabels = issue.labels.filter((label) =>
107
+ label.startsWith('category:'),
108
+ );
109
+ const hasLlmAgentLabel = issue.labels.some(
110
+ (l) => l === 'llm-agent' || l.startsWith('llm-agent:'),
111
+ );
112
+ if (
113
+ hasLlmAgentLabel ||
114
+ (categoryLabels.length > 0 && !categoryLabels.includes('category:e2e'))
115
+ ) {
116
+ return false;
117
+ }
118
+
119
+ const prsToCheck = issue.isPr
120
+ ? await this.resolveOpenPrsForPrItem(issue.url)
121
+ : await this.issueRepository.findRelatedOpenPRs(issue.url);
122
+
123
+ if (prsToCheck.length !== 1) {
124
+ return true;
125
+ }
126
+
127
+ const pr = prsToCheck[0];
128
+ return (
129
+ pr.isConflicted || !pr.isPassedAllCiJob || !pr.isResolvedAllReviewComments
130
+ );
131
+ };
132
+
133
+ private resolveOpenPrsForPrItem = async (
134
+ prUrl: string,
135
+ ): Promise<RelatedPullRequest[]> => {
136
+ const pr = await this.issueRepository.getOpenPullRequest(prUrl);
137
+ if (pr === null) {
138
+ return [];
139
+ }
140
+ return [pr];
141
+ };
142
+
143
+ private reportBodyHasNextStep = (body: string): boolean => {
144
+ const reportMatch = body.match(/```json\n([\s\S]*?)\n```/);
145
+ if (!reportMatch || reportMatch.length < 2) {
146
+ return false;
147
+ }
148
+ let reportJson: unknown;
149
+ try {
150
+ reportJson = JSON.parse(reportMatch[1]);
151
+ } catch (error) {
152
+ console.warn(
153
+ 'Invalid JSON in report body while checking nextStep:',
154
+ error,
155
+ );
156
+ return false;
157
+ }
158
+ if (typeof reportJson !== 'object' || reportJson === null) {
159
+ return false;
160
+ }
161
+ if (!('nextStep' in reportJson)) {
162
+ return false;
163
+ }
164
+ const nextStepValue = Reflect.get(reportJson, 'nextStep');
165
+ return nextStepValue !== null && nextStepValue !== undefined;
166
+ };
167
+
72
168
  private isOrphanedIssue = async (
73
169
  issue: Issue,
74
170
  params: {
@@ -1 +1 @@
1
- {"version":3,"file":"HandleScheduledEventUseCaseHandler.d.ts","sourceRoot":"","sources":["../../../../src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts"],"names":[],"mappings":"AAqBA,OAAO,EAAE,KAAK,EAAE,MAAM,gCAAgC,CAAC;AACvD,OAAO,EAAE,OAAO,EAAE,MAAM,kCAAkC,CAAC;AAqB3D,qBAAa,kCAAkC;IAC7C,MAAM,GACJ,gBAAgB,MAAM,EACtB,UAAU,OAAO,KAChB,OAAO,CAAC;QACT,OAAO,EAAE,OAAO,CAAC;QACjB,MAAM,EAAE,KAAK,EAAE,CAAC;QAChB,SAAS,EAAE,OAAO,CAAC;QACnB,eAAe,EAAE,IAAI,EAAE,CAAC;KACzB,GAAG,IAAI,CAAC,CAoQP;CACH"}
1
+ {"version":3,"file":"HandleScheduledEventUseCaseHandler.d.ts","sourceRoot":"","sources":["../../../../src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts"],"names":[],"mappings":"AAqBA,OAAO,EAAE,KAAK,EAAE,MAAM,gCAAgC,CAAC;AACvD,OAAO,EAAE,OAAO,EAAE,MAAM,kCAAkC,CAAC;AAqB3D,qBAAa,kCAAkC;IAC7C,MAAM,GACJ,gBAAgB,MAAM,EACtB,UAAU,OAAO,KAChB,OAAO,CAAC;QACT,OAAO,EAAE,OAAO,CAAC;QACjB,MAAM,EAAE,KAAK,EAAE,CAAC;QAChB,SAAS,EAAE,OAAO,CAAC;QACnB,eAAe,EAAE,IAAI,EAAE,CAAC;KACzB,GAAG,IAAI,CAAC,CAqQP;CACH"}