github-issue-tower-defence-management 1.39.0 → 1.40.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.
@@ -65,8 +65,10 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
65
65
  let mockIssueRepository: {
66
66
  get: jest.Mock;
67
67
  update: jest.Mock;
68
+ updateNextActionDate: jest.Mock;
68
69
  findRelatedOpenPRs: jest.Mock;
69
70
  getStoryObjectMap: jest.Mock;
71
+ getOpenPullRequest: jest.Mock;
70
72
  };
71
73
  let mockIssueCommentRepository: {
72
74
  getCommentsFromIssue: jest.Mock;
@@ -92,10 +94,12 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
92
94
  };
93
95
 
94
96
  mockIssueRepository = {
95
- getStoryObjectMap: jest.fn(),
97
+ getStoryObjectMap: jest.fn().mockResolvedValue(new Map()),
96
98
  get: jest.fn(),
97
99
  update: jest.fn(),
100
+ updateNextActionDate: jest.fn(),
98
101
  findRelatedOpenPRs: jest.fn(),
102
+ getOpenPullRequest: jest.fn(),
99
103
  };
100
104
 
101
105
  mockIssueCommentRepository = {
@@ -174,6 +178,9 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
174
178
  });
175
179
 
176
180
  it('should update issue status from Preparation to Awaiting Quality Check when last comment starts with From:', async () => {
181
+ jest.useFakeTimers();
182
+ jest.setSystemTime(new Date('2026-01-01T00:00:00Z'));
183
+
177
184
  const issue = createMockIssue({
178
185
  url: 'https://github.com/user/repo/issues/1',
179
186
  status: 'Preparation',
@@ -214,6 +221,65 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
214
221
  }),
215
222
  mockProject,
216
223
  );
224
+
225
+ const expectedNextActionDate = new Date('2026-01-01T00:00:00Z');
226
+ expectedNextActionDate.setMonth(expectedNextActionDate.getMonth() + 1);
227
+ expect(mockIssueRepository.updateNextActionDate).toHaveBeenCalledWith(
228
+ 'https://github.com/user/repo/pull/1',
229
+ mockProject,
230
+ expectedNextActionDate,
231
+ );
232
+
233
+ jest.useRealTimers();
234
+ });
235
+
236
+ it('should set PR next action date to 1 month from now when approved', async () => {
237
+ jest.useFakeTimers();
238
+ const now = new Date('2026-03-15T12:00:00Z');
239
+ jest.setSystemTime(now);
240
+
241
+ const issue = createMockIssue({
242
+ url: 'https://github.com/user/repo/issues/1',
243
+ status: 'Preparation',
244
+ });
245
+ const prUrl = 'https://github.com/user/repo/pull/42';
246
+
247
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
248
+ mockIssueRepository.get.mockResolvedValue(issue);
249
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
250
+ createMockComment({ content: 'From: Agent report' }),
251
+ ]);
252
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
253
+ {
254
+ url: prUrl,
255
+ isConflicted: false,
256
+ isPassedAllCiJob: true,
257
+ isCiStateSuccess: true,
258
+ isResolvedAllReviewComments: true,
259
+ isBranchOutOfDate: false,
260
+ missingRequiredCheckNames: [],
261
+ },
262
+ ]);
263
+
264
+ await useCase.run({
265
+ projectUrl: 'https://github.com/users/user/projects/1',
266
+ issueUrl: 'https://github.com/user/repo/issues/1',
267
+ preparationStatus: 'Preparation',
268
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
269
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
270
+ thresholdForAutoReject: 3,
271
+ workflowBlockerResolvedWebhookUrl: null,
272
+ });
273
+
274
+ const expectedDate = new Date(now);
275
+ expectedDate.setMonth(expectedDate.getMonth() + 1);
276
+ expect(mockIssueRepository.updateNextActionDate).toHaveBeenCalledWith(
277
+ prUrl,
278
+ mockProject,
279
+ expectedDate,
280
+ );
281
+
282
+ jest.useRealTimers();
217
283
  });
218
284
 
219
285
  it('should throw IssueNotFoundError when issue does not exist', async () => {
@@ -259,30 +325,18 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
259
325
  );
260
326
  });
261
327
 
262
- it('should reject and set status to Awaiting Workspace when last comment starts with Auto Status Check:', async () => {
328
+ it('should set status to Awaiting Workspace when issue has dependent issue URLs', async () => {
263
329
  const issue = createMockIssue({
264
330
  url: 'https://github.com/user/repo/issues/1',
265
331
  status: 'Preparation',
332
+ dependedIssueUrls: [
333
+ 'https://github.com/user/repo/issues/2',
334
+ 'https://github.com/user/repo/issues/3',
335
+ ],
266
336
  });
267
337
 
268
338
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
269
339
  mockIssueRepository.get.mockResolvedValue(issue);
270
- mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
271
- createMockComment({
272
- content: 'Auto Status Check: REJECTED\n["NO_REPORT"]',
273
- }),
274
- ]);
275
- mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
276
- {
277
- url: 'https://github.com/user/repo/pull/1',
278
- isConflicted: false,
279
- isPassedAllCiJob: true,
280
- isCiStateSuccess: true,
281
- isResolvedAllReviewComments: true,
282
- isBranchOutOfDate: false,
283
- missingRequiredCheckNames: [],
284
- },
285
- ]);
286
340
 
287
341
  await useCase.run({
288
342
  projectUrl: 'https://github.com/users/user/projects/1',
@@ -295,41 +349,44 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
295
349
  });
296
350
 
297
351
  expect(mockIssueRepository.update).toHaveBeenCalledWith(
298
- expect.objectContaining({
299
- status: 'Awaiting Workspace',
300
- }),
352
+ expect.objectContaining({ status: 'Awaiting Workspace' }),
301
353
  mockProject,
302
354
  );
303
355
  expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
304
- expect.objectContaining({
305
- url: 'https://github.com/user/repo/issues/1',
306
- }),
307
- expect.stringContaining('Auto Status Check: REJECTED'),
356
+ expect.objectContaining({ url: 'https://github.com/user/repo/issues/1' }),
357
+ expect.stringContaining(
358
+ 'Issue has dependent issue URLs: https://github.com/user/repo/issues/2, https://github.com/user/repo/issues/3',
359
+ ),
308
360
  );
309
361
  });
310
362
 
311
- it('should reject when last comment does not start with From:', async () => {
363
+ it('should enrich dependedIssueUrls from storyObjectMap when issue has none', async () => {
312
364
  const issue = createMockIssue({
313
365
  url: 'https://github.com/user/repo/issues/1',
314
366
  status: 'Preparation',
367
+ dependedIssueUrls: [],
368
+ });
369
+
370
+ const storyObjectMap: StoryObjectMap = new Map();
371
+ storyObjectMap.set('Some Story', {
372
+ story: {
373
+ id: 'story-1',
374
+ name: 'Some Story',
375
+ color: 'GRAY',
376
+ description: '',
377
+ },
378
+ storyIssue: null,
379
+ issues: [
380
+ createMockIssue({
381
+ url: 'https://github.com/user/repo/issues/1',
382
+ dependedIssueUrls: ['https://github.com/user/repo/issues/5'],
383
+ }),
384
+ ],
315
385
  });
316
386
 
317
387
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
318
388
  mockIssueRepository.get.mockResolvedValue(issue);
319
- mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
320
- createMockComment({ content: 'Some other comment' }),
321
- ]);
322
- mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
323
- {
324
- url: 'https://github.com/user/repo/pull/1',
325
- isConflicted: false,
326
- isPassedAllCiJob: true,
327
- isCiStateSuccess: true,
328
- isResolvedAllReviewComments: true,
329
- isBranchOutOfDate: false,
330
- missingRequiredCheckNames: [],
331
- },
332
- ]);
389
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(storyObjectMap);
333
390
 
334
391
  await useCase.run({
335
392
  projectUrl: 'https://github.com/users/user/projects/1',
@@ -342,39 +399,24 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
342
399
  });
343
400
 
344
401
  expect(mockIssueRepository.update).toHaveBeenCalledWith(
345
- expect.objectContaining({
346
- status: 'Awaiting Workspace',
347
- }),
402
+ expect.objectContaining({ status: 'Awaiting Workspace' }),
348
403
  mockProject,
349
404
  );
350
405
  expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
351
- expect.objectContaining({
352
- url: 'https://github.com/user/repo/issues/1',
353
- }),
354
- expect.stringContaining('NO_REPORT_FROM_AGENT_BOT'),
406
+ expect.objectContaining({ url: 'https://github.com/user/repo/issues/1' }),
407
+ expect.stringContaining('Issue has dependent issue URLs:'),
355
408
  );
356
409
  });
357
410
 
358
- it('should reject and set status to Awaiting Workspace when no comments exist', async () => {
411
+ it('should set status to Awaiting Workspace when issue has nextActionDate set', async () => {
359
412
  const issue = createMockIssue({
360
413
  url: 'https://github.com/user/repo/issues/1',
361
414
  status: 'Preparation',
415
+ nextActionDate: new Date('2026-12-01'),
362
416
  });
363
417
 
364
418
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
365
419
  mockIssueRepository.get.mockResolvedValue(issue);
366
- mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([]);
367
- mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
368
- {
369
- url: 'https://github.com/user/repo/pull/1',
370
- isConflicted: false,
371
- isPassedAllCiJob: true,
372
- isCiStateSuccess: true,
373
- isResolvedAllReviewComments: true,
374
- isBranchOutOfDate: false,
375
- missingRequiredCheckNames: [],
376
- },
377
- ]);
378
420
 
379
421
  await useCase.run({
380
422
  projectUrl: 'https://github.com/users/user/projects/1',
@@ -387,27 +429,24 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
387
429
  });
388
430
 
389
431
  expect(mockIssueRepository.update).toHaveBeenCalledWith(
390
- expect.objectContaining({
391
- status: 'Awaiting Workspace',
392
- }),
432
+ expect.objectContaining({ status: 'Awaiting Workspace' }),
393
433
  mockProject,
394
434
  );
395
- expect(mockIssueCommentRepository.createComment).toHaveBeenCalled();
435
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
436
+ expect.objectContaining({ url: 'https://github.com/user/repo/issues/1' }),
437
+ expect.stringContaining('Issue has next action date or hour set:'),
438
+ );
396
439
  });
397
440
 
398
- it('should auto-escalate to Awaiting Quality Check after threshold rejections', async () => {
441
+ it('should set status to Awaiting Workspace when issue has nextActionHour set', async () => {
399
442
  const issue = createMockIssue({
400
443
  url: 'https://github.com/user/repo/issues/1',
401
444
  status: 'Preparation',
445
+ nextActionHour: 9,
402
446
  });
403
447
 
404
448
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
405
449
  mockIssueRepository.get.mockResolvedValue(issue);
406
- mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
407
- createMockComment({ content: 'Auto Status Check: REJECTED - first' }),
408
- createMockComment({ content: 'Auto Status Check: REJECTED - second' }),
409
- createMockComment({ content: 'Auto Status Check: REJECTED - third' }),
410
- ]);
411
450
 
412
451
  await useCase.run({
413
452
  projectUrl: 'https://github.com/users/user/projects/1',
@@ -420,22 +459,16 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
420
459
  });
421
460
 
422
461
  expect(mockIssueRepository.update).toHaveBeenCalledWith(
423
- expect.objectContaining({
424
- status: 'Awaiting Quality Check',
425
- }),
462
+ expect.objectContaining({ status: 'Awaiting Workspace' }),
426
463
  mockProject,
427
464
  );
428
465
  expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
429
- expect.objectContaining({
430
- url: 'https://github.com/user/repo/issues/1',
431
- }),
432
- expect.stringContaining(
433
- 'Failed to pass the check autimatically for 3 times',
434
- ),
466
+ expect.objectContaining({ url: 'https://github.com/user/repo/issues/1' }),
467
+ expect.stringContaining('nextActionHour=9'),
435
468
  );
436
469
  });
437
470
 
438
- it('should not auto-escalate when rejections are below threshold', async () => {
471
+ it('should reject and set status to Awaiting Workspace when last comment starts with Auto Status Check:', async () => {
439
472
  const issue = createMockIssue({
440
473
  url: 'https://github.com/user/repo/issues/1',
441
474
  status: 'Preparation',
@@ -444,8 +477,9 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
444
477
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
445
478
  mockIssueRepository.get.mockResolvedValue(issue);
446
479
  mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
447
- createMockComment({ content: 'Auto Status Check: REJECTED - first' }),
448
- createMockComment({ content: 'Auto Status Check: REJECTED - second' }),
480
+ createMockComment({
481
+ content: 'Auto Status Check: REJECTED\n["NO_REPORT"]',
482
+ }),
449
483
  ]);
450
484
  mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
451
485
  {
@@ -475,9 +509,15 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
475
509
  }),
476
510
  mockProject,
477
511
  );
512
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
513
+ expect.objectContaining({
514
+ url: 'https://github.com/user/repo/issues/1',
515
+ }),
516
+ expect.stringContaining('Auto Status Check: REJECTED'),
517
+ );
478
518
  });
479
519
 
480
- it('should not auto-escalate when retry comment exists even if threshold met', async () => {
520
+ it('should reject when last comment does not start with From:', async () => {
481
521
  const issue = createMockIssue({
482
522
  url: 'https://github.com/user/repo/issues/1',
483
523
  status: 'Preparation',
@@ -486,10 +526,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
486
526
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
487
527
  mockIssueRepository.get.mockResolvedValue(issue);
488
528
  mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
489
- createMockComment({ content: 'Auto Status Check: REJECTED - first' }),
490
- createMockComment({ content: 'Auto Status Check: REJECTED - second' }),
491
- createMockComment({ content: 'Auto Status Check: REJECTED - third' }),
492
- createMockComment({ content: 'retry' }),
529
+ createMockComment({ content: 'Some other comment' }),
493
530
  ]);
494
531
  mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
495
532
  {
@@ -513,15 +550,21 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
513
550
  workflowBlockerResolvedWebhookUrl: null,
514
551
  });
515
552
 
516
- expect(mockIssueRepository.update).not.toHaveBeenCalledWith(
553
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
517
554
  expect.objectContaining({
518
- status: 'Awaiting Quality Check',
555
+ status: 'Awaiting Workspace',
519
556
  }),
520
557
  mockProject,
521
558
  );
559
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
560
+ expect.objectContaining({
561
+ url: 'https://github.com/user/repo/issues/1',
562
+ }),
563
+ expect.stringContaining('NO_REPORT_FROM_AGENT_BOT'),
564
+ );
522
565
  });
523
566
 
524
- it('should handle case-insensitive retry comment', async () => {
567
+ it('should reject and set status to Awaiting Workspace when no comments exist', async () => {
525
568
  const issue = createMockIssue({
526
569
  url: 'https://github.com/user/repo/issues/1',
527
570
  status: 'Preparation',
@@ -529,12 +572,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
529
572
 
530
573
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
531
574
  mockIssueRepository.get.mockResolvedValue(issue);
532
- mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
533
- createMockComment({ content: 'Auto Status Check: REJECTED - first' }),
534
- createMockComment({ content: 'Auto Status Check: REJECTED - second' }),
535
- createMockComment({ content: 'Auto Status Check: REJECTED - third' }),
536
- createMockComment({ content: 'Retry please' }),
537
- ]);
575
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([]);
538
576
  mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
539
577
  {
540
578
  url: 'https://github.com/user/repo/pull/1',
@@ -557,15 +595,16 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
557
595
  workflowBlockerResolvedWebhookUrl: null,
558
596
  });
559
597
 
560
- expect(mockIssueRepository.update).not.toHaveBeenCalledWith(
598
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
561
599
  expect.objectContaining({
562
- status: 'Awaiting Quality Check',
600
+ status: 'Awaiting Workspace',
563
601
  }),
564
602
  mockProject,
565
603
  );
604
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalled();
566
605
  });
567
606
 
568
- it('should reject when PR is not found', async () => {
607
+ it('should reject when last comment has REPORT_HAS_NEXT_STEP', async () => {
569
608
  const issue = createMockIssue({
570
609
  url: 'https://github.com/user/repo/issues/1',
571
610
  status: 'Preparation',
@@ -574,9 +613,22 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
574
613
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
575
614
  mockIssueRepository.get.mockResolvedValue(issue);
576
615
  mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
577
- createMockComment({ content: 'From: Test report' }),
616
+ createMockComment({
617
+ content:
618
+ 'From: Agent report\n```json\n{"nextStep": "Fix the tests"}\n```',
619
+ }),
620
+ ]);
621
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
622
+ {
623
+ url: 'https://github.com/user/repo/pull/1',
624
+ isConflicted: false,
625
+ isPassedAllCiJob: true,
626
+ isCiStateSuccess: true,
627
+ isResolvedAllReviewComments: true,
628
+ isBranchOutOfDate: false,
629
+ missingRequiredCheckNames: [],
630
+ },
578
631
  ]);
579
- mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
580
632
 
581
633
  await useCase.run({
582
634
  projectUrl: 'https://github.com/users/user/projects/1',
@@ -589,20 +641,16 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
589
641
  });
590
642
 
591
643
  expect(mockIssueRepository.update).toHaveBeenCalledWith(
592
- expect.objectContaining({
593
- status: 'Awaiting Workspace',
594
- }),
644
+ expect.objectContaining({ status: 'Awaiting Workspace' }),
595
645
  mockProject,
596
646
  );
597
647
  expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
598
- expect.objectContaining({
599
- url: 'https://github.com/user/repo/issues/1',
600
- }),
601
- expect.stringContaining('PULL_REQUEST_NOT_FOUND'),
648
+ expect.objectContaining({ url: 'https://github.com/user/repo/issues/1' }),
649
+ expect.stringContaining('REPORT_HAS_NEXT_STEP'),
602
650
  );
603
651
  });
604
652
 
605
- it('should reject when multiple PRs are found', async () => {
653
+ it('should not reject when last comment has nextStep set to null', async () => {
606
654
  const issue = createMockIssue({
607
655
  url: 'https://github.com/user/repo/issues/1',
608
656
  status: 'Preparation',
@@ -611,7 +659,9 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
611
659
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
612
660
  mockIssueRepository.get.mockResolvedValue(issue);
613
661
  mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
614
- createMockComment({ content: 'From: Test report' }),
662
+ createMockComment({
663
+ content: 'From: Agent report\n```json\n{"nextStep": null}\n```',
664
+ }),
615
665
  ]);
616
666
  mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
617
667
  {
@@ -623,15 +673,6 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
623
673
  isBranchOutOfDate: false,
624
674
  missingRequiredCheckNames: [],
625
675
  },
626
- {
627
- url: 'https://github.com/user/repo/pull/2',
628
- isConflicted: false,
629
- isPassedAllCiJob: true,
630
- isCiStateSuccess: true,
631
- isResolvedAllReviewComments: true,
632
- isBranchOutOfDate: false,
633
- missingRequiredCheckNames: [],
634
- },
635
676
  ]);
636
677
 
637
678
  await useCase.run({
@@ -645,20 +686,12 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
645
686
  });
646
687
 
647
688
  expect(mockIssueRepository.update).toHaveBeenCalledWith(
648
- expect.objectContaining({
649
- status: 'Awaiting Workspace',
650
- }),
689
+ expect.objectContaining({ status: 'Awaiting Quality Check' }),
651
690
  mockProject,
652
691
  );
653
- expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
654
- expect.objectContaining({
655
- url: 'https://github.com/user/repo/issues/1',
656
- }),
657
- expect.stringContaining('MULTIPLE_PULL_REQUESTS_FOUND'),
658
- );
659
692
  });
660
693
 
661
- it('should reject when PR is conflicted', async () => {
694
+ it('should auto-escalate to Awaiting Quality Check after threshold rejections', async () => {
662
695
  const issue = createMockIssue({
663
696
  url: 'https://github.com/user/repo/issues/1',
664
697
  status: 'Preparation',
@@ -667,19 +700,11 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
667
700
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
668
701
  mockIssueRepository.get.mockResolvedValue(issue);
669
702
  mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
670
- createMockComment({ content: 'From: Test report' }),
671
- ]);
672
- mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
673
- {
674
- url: 'https://github.com/user/repo/pull/1',
675
- isConflicted: true,
676
- isPassedAllCiJob: true,
677
- isCiStateSuccess: true,
678
- isResolvedAllReviewComments: true,
679
- isBranchOutOfDate: false,
680
- missingRequiredCheckNames: [],
681
- },
703
+ createMockComment({ content: 'Auto Status Check: REJECTED - first' }),
704
+ createMockComment({ content: 'Auto Status Check: REJECTED - second' }),
705
+ createMockComment({ content: 'Auto Status Check: REJECTED - third' }),
682
706
  ]);
707
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
683
708
 
684
709
  await useCase.run({
685
710
  projectUrl: 'https://github.com/users/user/projects/1',
@@ -693,7 +718,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
693
718
 
694
719
  expect(mockIssueRepository.update).toHaveBeenCalledWith(
695
720
  expect.objectContaining({
696
- status: 'Awaiting Workspace',
721
+ status: 'Awaiting Quality Check',
697
722
  }),
698
723
  mockProject,
699
724
  );
@@ -701,27 +726,43 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
701
726
  expect.objectContaining({
702
727
  url: 'https://github.com/user/repo/issues/1',
703
728
  }),
704
- expect.stringContaining('PULL_REQUEST_CONFLICTED'),
729
+ expect.stringContaining('Auto Status Check:'),
730
+ );
731
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
732
+ expect.objectContaining({
733
+ url: 'https://github.com/user/repo/issues/1',
734
+ }),
735
+ expect.stringContaining(
736
+ 'Failed to pass the check automatically for 3 times',
737
+ ),
705
738
  );
706
739
  });
707
740
 
708
- it('should reject when CI job failed', async () => {
741
+ it('should use APPROVED escalation wording and set PR next action date when current check passes but threshold is met', async () => {
742
+ jest.useFakeTimers();
743
+ const now = new Date('2026-02-01T00:00:00Z');
744
+ jest.setSystemTime(now);
745
+
709
746
  const issue = createMockIssue({
710
747
  url: 'https://github.com/user/repo/issues/1',
711
748
  status: 'Preparation',
712
749
  });
750
+ const prUrl = 'https://github.com/user/repo/pull/1';
713
751
 
714
752
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
715
753
  mockIssueRepository.get.mockResolvedValue(issue);
716
754
  mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
717
- createMockComment({ content: 'From: Test report' }),
755
+ createMockComment({ content: 'Auto Status Check: REJECTED - first' }),
756
+ createMockComment({ content: 'Auto Status Check: REJECTED - second' }),
757
+ createMockComment({ content: 'Auto Status Check: REJECTED - third' }),
758
+ createMockComment({ content: 'From: Agent final report' }),
718
759
  ]);
719
760
  mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
720
761
  {
721
- url: 'https://github.com/user/repo/pull/1',
762
+ url: prUrl,
722
763
  isConflicted: false,
723
- isPassedAllCiJob: false,
724
- isCiStateSuccess: false,
764
+ isPassedAllCiJob: true,
765
+ isCiStateSuccess: true,
725
766
  isResolvedAllReviewComments: true,
726
767
  isBranchOutOfDate: false,
727
768
  missingRequiredCheckNames: [],
@@ -739,15 +780,398 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
739
780
  });
740
781
 
741
782
  expect(mockIssueRepository.update).toHaveBeenCalledWith(
742
- expect.objectContaining({
743
- status: 'Awaiting Workspace',
744
- }),
783
+ expect.objectContaining({ status: 'Awaiting Quality Check' }),
745
784
  mockProject,
746
785
  );
747
786
  expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
748
- expect.objectContaining({
749
- url: 'https://github.com/user/repo/issues/1',
750
- }),
787
+ expect.objectContaining({ url: 'https://github.com/user/repo/issues/1' }),
788
+ expect.stringContaining(
789
+ 'Auto Status Check: APPROVED (escalated due to prior failures)',
790
+ ),
791
+ );
792
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
793
+ expect.objectContaining({ url: 'https://github.com/user/repo/issues/1' }),
794
+ expect.stringContaining(
795
+ 'Failed to pass the check automatically for 3 times',
796
+ ),
797
+ );
798
+ const expectedDate = new Date(now);
799
+ expectedDate.setMonth(expectedDate.getMonth() + 1);
800
+ expect(mockIssueRepository.updateNextActionDate).toHaveBeenCalledWith(
801
+ prUrl,
802
+ mockProject,
803
+ expectedDate,
804
+ );
805
+
806
+ jest.useRealTimers();
807
+ });
808
+
809
+ it('should not auto-escalate when rejections are below threshold', async () => {
810
+ const issue = createMockIssue({
811
+ url: 'https://github.com/user/repo/issues/1',
812
+ status: 'Preparation',
813
+ });
814
+
815
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
816
+ mockIssueRepository.get.mockResolvedValue(issue);
817
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
818
+ createMockComment({ content: 'Auto Status Check: REJECTED - first' }),
819
+ createMockComment({ content: 'Auto Status Check: REJECTED - second' }),
820
+ ]);
821
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
822
+ {
823
+ url: 'https://github.com/user/repo/pull/1',
824
+ isConflicted: false,
825
+ isPassedAllCiJob: true,
826
+ isCiStateSuccess: true,
827
+ isResolvedAllReviewComments: true,
828
+ isBranchOutOfDate: false,
829
+ missingRequiredCheckNames: [],
830
+ },
831
+ ]);
832
+
833
+ await useCase.run({
834
+ projectUrl: 'https://github.com/users/user/projects/1',
835
+ issueUrl: 'https://github.com/user/repo/issues/1',
836
+ preparationStatus: 'Preparation',
837
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
838
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
839
+ thresholdForAutoReject: 3,
840
+ workflowBlockerResolvedWebhookUrl: null,
841
+ });
842
+
843
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
844
+ expect.objectContaining({
845
+ status: 'Awaiting Workspace',
846
+ }),
847
+ mockProject,
848
+ );
849
+ });
850
+
851
+ it('should not auto-escalate when failed-to-pass-check comment exists even if threshold met', async () => {
852
+ const issue = createMockIssue({
853
+ url: 'https://github.com/user/repo/issues/1',
854
+ status: 'Preparation',
855
+ });
856
+
857
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
858
+ mockIssueRepository.get.mockResolvedValue(issue);
859
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
860
+ createMockComment({ content: 'Auto Status Check: REJECTED - first' }),
861
+ createMockComment({ content: 'Auto Status Check: REJECTED - second' }),
862
+ createMockComment({ content: 'Auto Status Check: REJECTED - third' }),
863
+ createMockComment({
864
+ content:
865
+ 'Auto Status Check: REJECTED\n\nFailed to pass the check automatically for 3 times',
866
+ }),
867
+ ]);
868
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
869
+ {
870
+ url: 'https://github.com/user/repo/pull/1',
871
+ isConflicted: false,
872
+ isPassedAllCiJob: true,
873
+ isCiStateSuccess: true,
874
+ isResolvedAllReviewComments: true,
875
+ isBranchOutOfDate: false,
876
+ missingRequiredCheckNames: [],
877
+ },
878
+ ]);
879
+
880
+ await useCase.run({
881
+ projectUrl: 'https://github.com/users/user/projects/1',
882
+ issueUrl: 'https://github.com/user/repo/issues/1',
883
+ preparationStatus: 'Preparation',
884
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
885
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
886
+ thresholdForAutoReject: 3,
887
+ workflowBlockerResolvedWebhookUrl: null,
888
+ });
889
+
890
+ expect(mockIssueRepository.update).not.toHaveBeenCalledWith(
891
+ expect.objectContaining({
892
+ status: 'Awaiting Quality Check',
893
+ }),
894
+ mockProject,
895
+ );
896
+ });
897
+
898
+ it('should handle case-insensitive failed-to-pass-check comment', async () => {
899
+ const issue = createMockIssue({
900
+ url: 'https://github.com/user/repo/issues/1',
901
+ status: 'Preparation',
902
+ });
903
+
904
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
905
+ mockIssueRepository.get.mockResolvedValue(issue);
906
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
907
+ createMockComment({ content: 'Auto Status Check: REJECTED - first' }),
908
+ createMockComment({ content: 'Auto Status Check: REJECTED - second' }),
909
+ createMockComment({ content: 'Auto Status Check: REJECTED - third' }),
910
+ createMockComment({ content: 'Auto Status Check: REJECTED - fourth' }),
911
+ createMockComment({ content: 'Auto Status Check: REJECTED - fifth' }),
912
+ createMockComment({
913
+ content:
914
+ 'AUTO STATUS CHECK: APPROVED\n\nFailed to pass the check automatically for 5 times',
915
+ }),
916
+ ]);
917
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
918
+ {
919
+ url: 'https://github.com/user/repo/pull/1',
920
+ isConflicted: false,
921
+ isPassedAllCiJob: true,
922
+ isCiStateSuccess: true,
923
+ isResolvedAllReviewComments: true,
924
+ isBranchOutOfDate: false,
925
+ missingRequiredCheckNames: [],
926
+ },
927
+ ]);
928
+
929
+ await useCase.run({
930
+ projectUrl: 'https://github.com/users/user/projects/1',
931
+ issueUrl: 'https://github.com/user/repo/issues/1',
932
+ preparationStatus: 'Preparation',
933
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
934
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
935
+ thresholdForAutoReject: 3,
936
+ workflowBlockerResolvedWebhookUrl: null,
937
+ });
938
+
939
+ expect(mockIssueRepository.update).not.toHaveBeenCalledWith(
940
+ expect.objectContaining({
941
+ status: 'Awaiting Quality Check',
942
+ }),
943
+ mockProject,
944
+ );
945
+ });
946
+
947
+ it('should not auto-escalate when new-format escalation comment with Auto Status Check prefix exists', async () => {
948
+ const issue = createMockIssue({
949
+ url: 'https://github.com/user/repo/issues/1',
950
+ status: 'Preparation',
951
+ });
952
+
953
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
954
+ mockIssueRepository.get.mockResolvedValue(issue);
955
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
956
+ createMockComment({ content: 'Auto Status Check: REJECTED - first' }),
957
+ createMockComment({ content: 'Auto Status Check: REJECTED - second' }),
958
+ createMockComment({ content: 'Auto Status Check: REJECTED - third' }),
959
+ createMockComment({
960
+ content:
961
+ 'Auto Status Check: APPROVED (escalated due to prior failures)\n\nFailed to pass the check automatically for 3 times',
962
+ }),
963
+ ]);
964
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
965
+ {
966
+ url: 'https://github.com/user/repo/pull/1',
967
+ isConflicted: false,
968
+ isPassedAllCiJob: true,
969
+ isCiStateSuccess: true,
970
+ isResolvedAllReviewComments: true,
971
+ isBranchOutOfDate: false,
972
+ missingRequiredCheckNames: [],
973
+ },
974
+ ]);
975
+
976
+ await useCase.run({
977
+ projectUrl: 'https://github.com/users/user/projects/1',
978
+ issueUrl: 'https://github.com/user/repo/issues/1',
979
+ preparationStatus: 'Preparation',
980
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
981
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
982
+ thresholdForAutoReject: 3,
983
+ workflowBlockerResolvedWebhookUrl: null,
984
+ });
985
+
986
+ expect(mockIssueRepository.update).not.toHaveBeenCalledWith(
987
+ expect.objectContaining({ status: 'Awaiting Quality Check' }),
988
+ mockProject,
989
+ );
990
+ });
991
+
992
+ it('should reject when PR is not found', async () => {
993
+ const issue = createMockIssue({
994
+ url: 'https://github.com/user/repo/issues/1',
995
+ status: 'Preparation',
996
+ });
997
+
998
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
999
+ mockIssueRepository.get.mockResolvedValue(issue);
1000
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
1001
+ createMockComment({ content: 'From: Test report' }),
1002
+ ]);
1003
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
1004
+
1005
+ await useCase.run({
1006
+ projectUrl: 'https://github.com/users/user/projects/1',
1007
+ issueUrl: 'https://github.com/user/repo/issues/1',
1008
+ preparationStatus: 'Preparation',
1009
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
1010
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
1011
+ thresholdForAutoReject: 3,
1012
+ workflowBlockerResolvedWebhookUrl: null,
1013
+ });
1014
+
1015
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
1016
+ expect.objectContaining({
1017
+ status: 'Awaiting Workspace',
1018
+ }),
1019
+ mockProject,
1020
+ );
1021
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
1022
+ expect.objectContaining({
1023
+ url: 'https://github.com/user/repo/issues/1',
1024
+ }),
1025
+ expect.stringContaining('PULL_REQUEST_NOT_FOUND'),
1026
+ );
1027
+ });
1028
+
1029
+ it('should reject when multiple PRs are found', async () => {
1030
+ const issue = createMockIssue({
1031
+ url: 'https://github.com/user/repo/issues/1',
1032
+ status: 'Preparation',
1033
+ });
1034
+
1035
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1036
+ mockIssueRepository.get.mockResolvedValue(issue);
1037
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
1038
+ createMockComment({ content: 'From: Test report' }),
1039
+ ]);
1040
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
1041
+ {
1042
+ url: 'https://github.com/user/repo/pull/1',
1043
+ isConflicted: false,
1044
+ isPassedAllCiJob: true,
1045
+ isCiStateSuccess: true,
1046
+ isResolvedAllReviewComments: true,
1047
+ isBranchOutOfDate: false,
1048
+ missingRequiredCheckNames: [],
1049
+ },
1050
+ {
1051
+ url: 'https://github.com/user/repo/pull/2',
1052
+ isConflicted: false,
1053
+ isPassedAllCiJob: true,
1054
+ isCiStateSuccess: true,
1055
+ isResolvedAllReviewComments: true,
1056
+ isBranchOutOfDate: false,
1057
+ missingRequiredCheckNames: [],
1058
+ },
1059
+ ]);
1060
+
1061
+ await useCase.run({
1062
+ projectUrl: 'https://github.com/users/user/projects/1',
1063
+ issueUrl: 'https://github.com/user/repo/issues/1',
1064
+ preparationStatus: 'Preparation',
1065
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
1066
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
1067
+ thresholdForAutoReject: 3,
1068
+ workflowBlockerResolvedWebhookUrl: null,
1069
+ });
1070
+
1071
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
1072
+ expect.objectContaining({
1073
+ status: 'Awaiting Workspace',
1074
+ }),
1075
+ mockProject,
1076
+ );
1077
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
1078
+ expect.objectContaining({
1079
+ url: 'https://github.com/user/repo/issues/1',
1080
+ }),
1081
+ expect.stringContaining('MULTIPLE_PULL_REQUESTS_FOUND'),
1082
+ );
1083
+ });
1084
+
1085
+ it('should reject when PR is conflicted', async () => {
1086
+ const issue = createMockIssue({
1087
+ url: 'https://github.com/user/repo/issues/1',
1088
+ status: 'Preparation',
1089
+ });
1090
+
1091
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1092
+ mockIssueRepository.get.mockResolvedValue(issue);
1093
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
1094
+ createMockComment({ content: 'From: Test report' }),
1095
+ ]);
1096
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
1097
+ {
1098
+ url: 'https://github.com/user/repo/pull/1',
1099
+ isConflicted: true,
1100
+ isPassedAllCiJob: true,
1101
+ isCiStateSuccess: true,
1102
+ isResolvedAllReviewComments: true,
1103
+ isBranchOutOfDate: false,
1104
+ missingRequiredCheckNames: [],
1105
+ },
1106
+ ]);
1107
+
1108
+ await useCase.run({
1109
+ projectUrl: 'https://github.com/users/user/projects/1',
1110
+ issueUrl: 'https://github.com/user/repo/issues/1',
1111
+ preparationStatus: 'Preparation',
1112
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
1113
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
1114
+ thresholdForAutoReject: 3,
1115
+ workflowBlockerResolvedWebhookUrl: null,
1116
+ });
1117
+
1118
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
1119
+ expect.objectContaining({
1120
+ status: 'Awaiting Workspace',
1121
+ }),
1122
+ mockProject,
1123
+ );
1124
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
1125
+ expect.objectContaining({
1126
+ url: 'https://github.com/user/repo/issues/1',
1127
+ }),
1128
+ expect.stringContaining('PULL_REQUEST_CONFLICTED'),
1129
+ );
1130
+ });
1131
+
1132
+ it('should reject when CI job failed', async () => {
1133
+ const issue = createMockIssue({
1134
+ url: 'https://github.com/user/repo/issues/1',
1135
+ status: 'Preparation',
1136
+ });
1137
+
1138
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1139
+ mockIssueRepository.get.mockResolvedValue(issue);
1140
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
1141
+ createMockComment({ content: 'From: Test report' }),
1142
+ ]);
1143
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
1144
+ {
1145
+ url: 'https://github.com/user/repo/pull/1',
1146
+ isConflicted: false,
1147
+ isPassedAllCiJob: false,
1148
+ isCiStateSuccess: false,
1149
+ isResolvedAllReviewComments: true,
1150
+ isBranchOutOfDate: false,
1151
+ missingRequiredCheckNames: [],
1152
+ },
1153
+ ]);
1154
+
1155
+ await useCase.run({
1156
+ projectUrl: 'https://github.com/users/user/projects/1',
1157
+ issueUrl: 'https://github.com/user/repo/issues/1',
1158
+ preparationStatus: 'Preparation',
1159
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
1160
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
1161
+ thresholdForAutoReject: 3,
1162
+ workflowBlockerResolvedWebhookUrl: null,
1163
+ });
1164
+
1165
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
1166
+ expect.objectContaining({
1167
+ status: 'Awaiting Workspace',
1168
+ }),
1169
+ mockProject,
1170
+ );
1171
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
1172
+ expect.objectContaining({
1173
+ url: 'https://github.com/user/repo/issues/1',
1174
+ }),
751
1175
  expect.stringContaining('ANY_CI_JOB_FAILED_OR_IN_PROGRESS'),
752
1176
  );
753
1177
  });
@@ -1012,20 +1436,120 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
1012
1436
  workflowBlockerResolvedWebhookUrl: null,
1013
1437
  });
1014
1438
 
1015
- expect(mockIssueRepository.findRelatedOpenPRs).toHaveBeenCalled();
1439
+ expect(mockIssueRepository.findRelatedOpenPRs).toHaveBeenCalled();
1440
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
1441
+ expect.objectContaining({
1442
+ status: 'Awaiting Quality Check',
1443
+ }),
1444
+ mockProject,
1445
+ );
1446
+ });
1447
+
1448
+ it('should still check for report comment even when issue has category label', async () => {
1449
+ const issue = createMockIssue({
1450
+ url: 'https://github.com/user/repo/issues/1',
1451
+ status: 'Preparation',
1452
+ labels: ['category:backend'],
1453
+ });
1454
+
1455
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1456
+ mockIssueRepository.get.mockResolvedValue(issue);
1457
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
1458
+ createMockComment({
1459
+ content: 'Auto Status Check: REJECTED\n["NO_REPORT"]',
1460
+ }),
1461
+ ]);
1462
+
1463
+ await useCase.run({
1464
+ projectUrl: 'https://github.com/users/user/projects/1',
1465
+ issueUrl: 'https://github.com/user/repo/issues/1',
1466
+ preparationStatus: 'Preparation',
1467
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
1468
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
1469
+ thresholdForAutoReject: 3,
1470
+ workflowBlockerResolvedWebhookUrl: null,
1471
+ });
1472
+
1473
+ expect(mockIssueRepository.findRelatedOpenPRs).not.toHaveBeenCalled();
1474
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
1475
+ expect.objectContaining({
1476
+ status: 'Awaiting Workspace',
1477
+ }),
1478
+ mockProject,
1479
+ );
1480
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
1481
+ expect.objectContaining({
1482
+ url: 'https://github.com/user/repo/issues/1',
1483
+ }),
1484
+ expect.stringContaining('NO_REPORT_FROM_AGENT_BOT'),
1485
+ );
1486
+ });
1487
+
1488
+ it('should skip PR checks and update to Awaiting Quality Check when issue has llm-agent label', async () => {
1489
+ const issue = createMockIssue({
1490
+ url: 'https://github.com/user/repo/issues/1',
1491
+ status: 'Preparation',
1492
+ labels: ['llm-agent'],
1493
+ });
1494
+
1495
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1496
+ mockIssueRepository.get.mockResolvedValue(issue);
1497
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
1498
+ createMockComment({ content: 'From: Test report' }),
1499
+ ]);
1500
+
1501
+ await useCase.run({
1502
+ projectUrl: 'https://github.com/users/user/projects/1',
1503
+ issueUrl: 'https://github.com/user/repo/issues/1',
1504
+ preparationStatus: 'Preparation',
1505
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
1506
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
1507
+ thresholdForAutoReject: 3,
1508
+ workflowBlockerResolvedWebhookUrl: null,
1509
+ });
1510
+
1511
+ expect(mockIssueRepository.findRelatedOpenPRs).not.toHaveBeenCalled();
1512
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
1513
+ expect.objectContaining({ status: 'Awaiting Quality Check' }),
1514
+ mockProject,
1515
+ );
1516
+ });
1517
+
1518
+ it('should skip PR checks when issue has llm-agent: prefixed label', async () => {
1519
+ const issue = createMockIssue({
1520
+ url: 'https://github.com/user/repo/issues/1',
1521
+ status: 'Preparation',
1522
+ labels: ['llm-agent:claude'],
1523
+ });
1524
+
1525
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1526
+ mockIssueRepository.get.mockResolvedValue(issue);
1527
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
1528
+ createMockComment({ content: 'From: Test report' }),
1529
+ ]);
1530
+
1531
+ await useCase.run({
1532
+ projectUrl: 'https://github.com/users/user/projects/1',
1533
+ issueUrl: 'https://github.com/user/repo/issues/1',
1534
+ preparationStatus: 'Preparation',
1535
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
1536
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
1537
+ thresholdForAutoReject: 3,
1538
+ workflowBlockerResolvedWebhookUrl: null,
1539
+ });
1540
+
1541
+ expect(mockIssueRepository.findRelatedOpenPRs).not.toHaveBeenCalled();
1016
1542
  expect(mockIssueRepository.update).toHaveBeenCalledWith(
1017
- expect.objectContaining({
1018
- status: 'Awaiting Quality Check',
1019
- }),
1543
+ expect.objectContaining({ status: 'Awaiting Quality Check' }),
1020
1544
  mockProject,
1021
1545
  );
1022
1546
  });
1023
1547
 
1024
- it('should still check for report comment even when issue has category label', async () => {
1548
+ it('should still check for report comment even when issue has llm-agent:research label', async () => {
1025
1549
  const issue = createMockIssue({
1026
1550
  url: 'https://github.com/user/repo/issues/1',
1027
1551
  status: 'Preparation',
1028
- labels: ['category:backend'],
1552
+ labels: ['llm-agent:research'],
1029
1553
  });
1030
1554
 
1031
1555
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
@@ -1048,19 +1572,57 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
1048
1572
 
1049
1573
  expect(mockIssueRepository.findRelatedOpenPRs).not.toHaveBeenCalled();
1050
1574
  expect(mockIssueRepository.update).toHaveBeenCalledWith(
1051
- expect.objectContaining({
1052
- status: 'Awaiting Workspace',
1053
- }),
1575
+ expect.objectContaining({ status: 'Awaiting Workspace' }),
1054
1576
  mockProject,
1055
1577
  );
1056
1578
  expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
1057
- expect.objectContaining({
1058
- url: 'https://github.com/user/repo/issues/1',
1059
- }),
1579
+ expect.objectContaining({ url: 'https://github.com/user/repo/issues/1' }),
1060
1580
  expect.stringContaining('NO_REPORT_FROM_AGENT_BOT'),
1061
1581
  );
1062
1582
  });
1063
1583
 
1584
+ it('should use getOpenPullRequest when issue is a PR item', async () => {
1585
+ const prIssue = createMockIssue({
1586
+ url: 'https://github.com/user/repo/pull/10',
1587
+ status: 'Preparation',
1588
+ isPr: true,
1589
+ });
1590
+
1591
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1592
+ mockIssueRepository.get.mockResolvedValue(prIssue);
1593
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
1594
+ createMockComment({ content: 'From: Agent report' }),
1595
+ ]);
1596
+ mockIssueRepository.getOpenPullRequest.mockResolvedValue({
1597
+ url: 'https://github.com/user/repo/pull/10',
1598
+ isConflicted: false,
1599
+ isPassedAllCiJob: true,
1600
+ isCiStateSuccess: true,
1601
+ isResolvedAllReviewComments: true,
1602
+ isBranchOutOfDate: false,
1603
+ missingRequiredCheckNames: [],
1604
+ });
1605
+
1606
+ await useCase.run({
1607
+ projectUrl: 'https://github.com/users/user/projects/1',
1608
+ issueUrl: 'https://github.com/user/repo/pull/10',
1609
+ preparationStatus: 'Preparation',
1610
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
1611
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
1612
+ thresholdForAutoReject: 3,
1613
+ workflowBlockerResolvedWebhookUrl: null,
1614
+ });
1615
+
1616
+ expect(mockIssueRepository.getOpenPullRequest).toHaveBeenCalledWith(
1617
+ 'https://github.com/user/repo/pull/10',
1618
+ );
1619
+ expect(mockIssueRepository.findRelatedOpenPRs).not.toHaveBeenCalled();
1620
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
1621
+ expect.objectContaining({ status: 'Awaiting Quality Check' }),
1622
+ mockProject,
1623
+ );
1624
+ });
1625
+
1064
1626
  describe('workflow blocker webhook notification', () => {
1065
1627
  const createWorkflowBlockerStoryObjectMap = (
1066
1628
  issueUrl: string,
@@ -1161,6 +1723,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
1161
1723
  content: 'Auto Status Check: REJECTED - third',
1162
1724
  }),
1163
1725
  ]);
1726
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
1164
1727
  mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1165
1728
  createWorkflowBlockerStoryObjectMap(
1166
1729
  'https://github.com/user/repo/issues/1',
@@ -1257,7 +1820,6 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
1257
1820
  workflowBlockerResolvedWebhookUrl: null,
1258
1821
  });
1259
1822
 
1260
- expect(mockIssueRepository.getStoryObjectMap).not.toHaveBeenCalled();
1261
1823
  expect(mockWebhookRepository.sendGetRequest).not.toHaveBeenCalled();
1262
1824
  });
1263
1825
 
@@ -1372,4 +1934,257 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
1372
1934
  );
1373
1935
  });
1374
1936
  });
1937
+
1938
+ it('should continue and not enrich dependedIssueUrls when getStoryObjectMap throws', async () => {
1939
+ const issue = createMockIssue({
1940
+ url: 'https://github.com/user/repo/issues/1',
1941
+ status: 'Preparation',
1942
+ dependedIssueUrls: [],
1943
+ });
1944
+
1945
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1946
+ mockIssueRepository.get.mockResolvedValue(issue);
1947
+ mockIssueRepository.getStoryObjectMap.mockRejectedValue(
1948
+ new Error('Story map unavailable'),
1949
+ );
1950
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
1951
+ createMockComment({ content: 'From: Test report' }),
1952
+ ]);
1953
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
1954
+ {
1955
+ url: 'https://github.com/user/repo/pull/1',
1956
+ isConflicted: false,
1957
+ isPassedAllCiJob: true,
1958
+ isCiStateSuccess: true,
1959
+ isResolvedAllReviewComments: true,
1960
+ isBranchOutOfDate: false,
1961
+ missingRequiredCheckNames: [],
1962
+ },
1963
+ ]);
1964
+
1965
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
1966
+
1967
+ await useCase.run({
1968
+ projectUrl: 'https://github.com/users/user/projects/1',
1969
+ issueUrl: 'https://github.com/user/repo/issues/1',
1970
+ preparationStatus: 'Preparation',
1971
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
1972
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
1973
+ thresholdForAutoReject: 3,
1974
+ workflowBlockerResolvedWebhookUrl: null,
1975
+ });
1976
+
1977
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
1978
+ 'Failed to enrich dependedIssueUrls from story object map:',
1979
+ expect.any(Error),
1980
+ );
1981
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
1982
+ expect.objectContaining({ status: 'Awaiting Quality Check' }),
1983
+ mockProject,
1984
+ );
1985
+
1986
+ consoleWarnSpy.mockRestore();
1987
+ });
1988
+
1989
+ it('should return no PRs when getOpenPullRequest returns null for a PR item', async () => {
1990
+ const prIssue = createMockIssue({
1991
+ url: 'https://github.com/user/repo/pull/10',
1992
+ status: 'Preparation',
1993
+ isPr: true,
1994
+ });
1995
+
1996
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1997
+ mockIssueRepository.get.mockResolvedValue(prIssue);
1998
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
1999
+ createMockComment({ content: 'From: Agent report' }),
2000
+ ]);
2001
+ mockIssueRepository.getOpenPullRequest.mockResolvedValue(null);
2002
+
2003
+ await useCase.run({
2004
+ projectUrl: 'https://github.com/users/user/projects/1',
2005
+ issueUrl: 'https://github.com/user/repo/pull/10',
2006
+ preparationStatus: 'Preparation',
2007
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
2008
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
2009
+ thresholdForAutoReject: 3,
2010
+ workflowBlockerResolvedWebhookUrl: null,
2011
+ });
2012
+
2013
+ expect(mockIssueRepository.getOpenPullRequest).toHaveBeenCalledWith(
2014
+ 'https://github.com/user/repo/pull/10',
2015
+ );
2016
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
2017
+ expect.objectContaining({ status: 'Awaiting Workspace' }),
2018
+ mockProject,
2019
+ );
2020
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
2021
+ expect.objectContaining({ url: 'https://github.com/user/repo/pull/10' }),
2022
+ expect.stringContaining('PULL_REQUEST_NOT_FOUND'),
2023
+ );
2024
+ });
2025
+
2026
+ it('should not reject REPORT_HAS_NEXT_STEP when report JSON is invalid', async () => {
2027
+ const issue = createMockIssue({
2028
+ url: 'https://github.com/user/repo/issues/1',
2029
+ status: 'Preparation',
2030
+ });
2031
+
2032
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2033
+ mockIssueRepository.get.mockResolvedValue(issue);
2034
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
2035
+ createMockComment({
2036
+ content: 'From: Agent report\n```json\n{invalid json}\n```',
2037
+ }),
2038
+ ]);
2039
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
2040
+ {
2041
+ url: 'https://github.com/user/repo/pull/1',
2042
+ isConflicted: false,
2043
+ isPassedAllCiJob: true,
2044
+ isCiStateSuccess: true,
2045
+ isResolvedAllReviewComments: true,
2046
+ isBranchOutOfDate: false,
2047
+ missingRequiredCheckNames: [],
2048
+ },
2049
+ ]);
2050
+
2051
+ await useCase.run({
2052
+ projectUrl: 'https://github.com/users/user/projects/1',
2053
+ issueUrl: 'https://github.com/user/repo/issues/1',
2054
+ preparationStatus: 'Preparation',
2055
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
2056
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
2057
+ thresholdForAutoReject: 3,
2058
+ workflowBlockerResolvedWebhookUrl: null,
2059
+ });
2060
+
2061
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
2062
+ expect.objectContaining({ status: 'Awaiting Quality Check' }),
2063
+ mockProject,
2064
+ );
2065
+ });
2066
+
2067
+ it('should not reject REPORT_HAS_NEXT_STEP when report JSON is null', async () => {
2068
+ const issue = createMockIssue({
2069
+ url: 'https://github.com/user/repo/issues/1',
2070
+ status: 'Preparation',
2071
+ });
2072
+
2073
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2074
+ mockIssueRepository.get.mockResolvedValue(issue);
2075
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
2076
+ createMockComment({
2077
+ content: 'From: Agent report\n```json\nnull\n```',
2078
+ }),
2079
+ ]);
2080
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
2081
+ {
2082
+ url: 'https://github.com/user/repo/pull/1',
2083
+ isConflicted: false,
2084
+ isPassedAllCiJob: true,
2085
+ isCiStateSuccess: true,
2086
+ isResolvedAllReviewComments: true,
2087
+ isBranchOutOfDate: false,
2088
+ missingRequiredCheckNames: [],
2089
+ },
2090
+ ]);
2091
+
2092
+ await useCase.run({
2093
+ projectUrl: 'https://github.com/users/user/projects/1',
2094
+ issueUrl: 'https://github.com/user/repo/issues/1',
2095
+ preparationStatus: 'Preparation',
2096
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
2097
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
2098
+ thresholdForAutoReject: 3,
2099
+ workflowBlockerResolvedWebhookUrl: null,
2100
+ });
2101
+
2102
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
2103
+ expect.objectContaining({ status: 'Awaiting Quality Check' }),
2104
+ mockProject,
2105
+ );
2106
+ });
2107
+
2108
+ it('should not reject REPORT_HAS_NEXT_STEP when report JSON has no nextStep property', async () => {
2109
+ const issue = createMockIssue({
2110
+ url: 'https://github.com/user/repo/issues/1',
2111
+ status: 'Preparation',
2112
+ });
2113
+
2114
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2115
+ mockIssueRepository.get.mockResolvedValue(issue);
2116
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
2117
+ createMockComment({
2118
+ content:
2119
+ 'From: Agent report\n```json\n{"status": "done", "result": "success"}\n```',
2120
+ }),
2121
+ ]);
2122
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
2123
+ {
2124
+ url: 'https://github.com/user/repo/pull/1',
2125
+ isConflicted: false,
2126
+ isPassedAllCiJob: true,
2127
+ isCiStateSuccess: true,
2128
+ isResolvedAllReviewComments: true,
2129
+ isBranchOutOfDate: false,
2130
+ missingRequiredCheckNames: [],
2131
+ },
2132
+ ]);
2133
+
2134
+ await useCase.run({
2135
+ projectUrl: 'https://github.com/users/user/projects/1',
2136
+ issueUrl: 'https://github.com/user/repo/issues/1',
2137
+ preparationStatus: 'Preparation',
2138
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
2139
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
2140
+ thresholdForAutoReject: 3,
2141
+ workflowBlockerResolvedWebhookUrl: null,
2142
+ });
2143
+
2144
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
2145
+ expect.objectContaining({ status: 'Awaiting Quality Check' }),
2146
+ mockProject,
2147
+ );
2148
+ });
2149
+
2150
+ it('should not reject REPORT_HAS_NEXT_STEP when report JSON is a non-object value', async () => {
2151
+ const issue = createMockIssue({
2152
+ url: 'https://github.com/user/repo/issues/1',
2153
+ status: 'Preparation',
2154
+ });
2155
+
2156
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2157
+ mockIssueRepository.get.mockResolvedValue(issue);
2158
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
2159
+ createMockComment({
2160
+ content: 'From: Agent report\n```json\n"just a string"\n```',
2161
+ }),
2162
+ ]);
2163
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
2164
+ {
2165
+ url: 'https://github.com/user/repo/pull/1',
2166
+ isConflicted: false,
2167
+ isPassedAllCiJob: true,
2168
+ isCiStateSuccess: true,
2169
+ isResolvedAllReviewComments: true,
2170
+ isBranchOutOfDate: false,
2171
+ missingRequiredCheckNames: [],
2172
+ },
2173
+ ]);
2174
+
2175
+ await useCase.run({
2176
+ projectUrl: 'https://github.com/users/user/projects/1',
2177
+ issueUrl: 'https://github.com/user/repo/issues/1',
2178
+ preparationStatus: 'Preparation',
2179
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
2180
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
2181
+ thresholdForAutoReject: 3,
2182
+ workflowBlockerResolvedWebhookUrl: null,
2183
+ });
2184
+
2185
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
2186
+ expect.objectContaining({ status: 'Awaiting Quality Check' }),
2187
+ mockProject,
2188
+ );
2189
+ });
1375
2190
  });