github-issue-tower-defence-management 1.32.0 → 1.34.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 (49) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +92 -6
  3. package/bin/adapter/entry-points/cli/index.js +422 -5
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +67 -33
  6. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  7. package/bin/adapter/repositories/FetchWebhookRepository.js +10 -0
  8. package/bin/adapter/repositories/FetchWebhookRepository.js.map +1 -0
  9. package/bin/adapter/repositories/GitHubIssueCommentRepository.js +190 -0
  10. package/bin/adapter/repositories/GitHubIssueCommentRepository.js.map +1 -0
  11. package/bin/adapter/repositories/OauthAPIClaudeRepository.js +225 -0
  12. package/bin/adapter/repositories/OauthAPIClaudeRepository.js.map +1 -0
  13. package/bin/domain/usecases/HandleScheduledEventUseCase.js +17 -1
  14. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  15. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +73 -17
  16. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  17. package/bin/domain/usecases/adapter-interfaces/WebhookRepository.js +3 -0
  18. package/bin/domain/usecases/adapter-interfaces/WebhookRepository.js.map +1 -0
  19. package/package.json +1 -1
  20. package/src/adapter/entry-points/cli/index.test.ts +1315 -15
  21. package/src/adapter/entry-points/cli/index.ts +648 -5
  22. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +14 -0
  23. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +17 -2
  24. package/src/adapter/repositories/FetchWebhookRepository.ts +7 -0
  25. package/src/adapter/repositories/GitHubIssueCommentRepository.ts +291 -0
  26. package/src/adapter/repositories/OauthAPIClaudeRepository.ts +279 -0
  27. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +28 -0
  28. package/src/domain/usecases/HandleScheduledEventUseCase.ts +30 -0
  29. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +722 -16
  30. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +117 -20
  31. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +2 -0
  32. package/src/domain/usecases/adapter-interfaces/WebhookRepository.ts +3 -0
  33. package/types/adapter/entry-points/cli/index.d.ts +19 -0
  34. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  35. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  36. package/types/adapter/repositories/FetchWebhookRepository.d.ts +5 -0
  37. package/types/adapter/repositories/FetchWebhookRepository.d.ts.map +1 -0
  38. package/types/adapter/repositories/GitHubIssueCommentRepository.d.ts +12 -0
  39. package/types/adapter/repositories/GitHubIssueCommentRepository.d.ts.map +1 -0
  40. package/types/adapter/repositories/OauthAPIClaudeRepository.d.ts +13 -0
  41. package/types/adapter/repositories/OauthAPIClaudeRepository.d.ts.map +1 -0
  42. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +10 -1
  43. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  44. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +5 -1
  45. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  46. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +2 -0
  47. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
  48. package/types/domain/usecases/adapter-interfaces/WebhookRepository.d.ts +4 -0
  49. package/types/domain/usecases/adapter-interfaces/WebhookRepository.d.ts.map +1 -0
@@ -1,12 +1,8 @@
1
1
  import { NotifyFinishedIssuePreparationUseCase } from './NotifyFinishedIssuePreparationUseCase';
2
- import { IssueRepository } from './adapter-interfaces/IssueRepository';
3
- import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
4
- import { IssueCommentRepository } from './adapter-interfaces/IssueCommentRepository';
5
2
  import { Issue } from '../entities/Issue';
6
3
  import { Project } from '../entities/Project';
7
4
  import { Comment } from '../entities/Comment';
8
-
9
- type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
5
+ import { StoryObjectMap } from '../entities/StoryObjectMap';
10
6
 
11
7
  const createMockProject = (overrides: Partial<Project> = {}): Project => ({
12
8
  id: 'project-1',
@@ -62,13 +58,23 @@ const createMockComment = (overrides: Partial<Comment> = {}): Comment => ({
62
58
 
63
59
  describe('NotifyFinishedIssuePreparationUseCase', () => {
64
60
  let useCase: NotifyFinishedIssuePreparationUseCase;
65
- let mockProjectRepository: Mocked<Pick<ProjectRepository, 'getByUrl'>>;
66
- let mockIssueRepository: Mocked<
67
- Pick<IssueRepository, 'get' | 'update' | 'findRelatedOpenPRs'>
68
- >;
69
- let mockIssueCommentRepository: Mocked<
70
- Pick<IssueCommentRepository, 'getCommentsFromIssue' | 'createComment'>
71
- >;
61
+ let mockProjectRepository: {
62
+ getByUrl: jest.Mock;
63
+ prepareStatus: jest.Mock;
64
+ };
65
+ let mockIssueRepository: {
66
+ get: jest.Mock;
67
+ update: jest.Mock;
68
+ findRelatedOpenPRs: jest.Mock;
69
+ getStoryObjectMap: jest.Mock;
70
+ };
71
+ let mockIssueCommentRepository: {
72
+ getCommentsFromIssue: jest.Mock;
73
+ createComment: jest.Mock;
74
+ };
75
+ let mockWebhookRepository: {
76
+ sendGetRequest: jest.Mock;
77
+ };
72
78
  let mockProject: Project;
73
79
 
74
80
  beforeEach(() => {
@@ -78,9 +84,15 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
78
84
 
79
85
  mockProjectRepository = {
80
86
  getByUrl: jest.fn(),
87
+ prepareStatus: jest
88
+ .fn()
89
+ .mockImplementation((_name: string, project: Project) =>
90
+ Promise.resolve(project),
91
+ ),
81
92
  };
82
93
 
83
94
  mockIssueRepository = {
95
+ getStoryObjectMap: jest.fn(),
84
96
  get: jest.fn(),
85
97
  update: jest.fn(),
86
98
  findRelatedOpenPRs: jest.fn(),
@@ -91,10 +103,73 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
91
103
  createComment: jest.fn(),
92
104
  };
93
105
 
106
+ mockWebhookRepository = {
107
+ sendGetRequest: jest.fn(),
108
+ };
109
+
94
110
  useCase = new NotifyFinishedIssuePreparationUseCase(
95
111
  mockProjectRepository,
96
112
  mockIssueRepository,
97
113
  mockIssueCommentRepository,
114
+ mockWebhookRepository,
115
+ );
116
+ });
117
+
118
+ it('should call prepareStatus for preparationStatus, awaitingWorkspaceStatus, and awaitingQualityCheckStatus with chained project objects', async () => {
119
+ const projectAfterFirstPrepare = createMockProject();
120
+ const projectAfterSecondPrepare = createMockProject();
121
+ const projectAfterThirdPrepare = createMockProject();
122
+ const issue = createMockIssue({
123
+ url: 'https://github.com/user/repo/issues/1',
124
+ status: 'Preparation',
125
+ });
126
+
127
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
128
+ mockProjectRepository.prepareStatus
129
+ .mockResolvedValueOnce(projectAfterFirstPrepare)
130
+ .mockResolvedValueOnce(projectAfterSecondPrepare)
131
+ .mockResolvedValueOnce(projectAfterThirdPrepare);
132
+ mockIssueRepository.get.mockResolvedValue(issue);
133
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
134
+ createMockComment({ content: 'From: Test report' }),
135
+ ]);
136
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
137
+ {
138
+ url: 'https://github.com/user/repo/pull/1',
139
+ isConflicted: false,
140
+ isPassedAllCiJob: true,
141
+ isCiStateSuccess: true,
142
+ isResolvedAllReviewComments: true,
143
+ isBranchOutOfDate: false,
144
+ missingRequiredCheckNames: [],
145
+ },
146
+ ]);
147
+
148
+ await useCase.run({
149
+ projectUrl: 'https://github.com/users/user/projects/1',
150
+ issueUrl: 'https://github.com/user/repo/issues/1',
151
+ preparationStatus: 'Preparation',
152
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
153
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
154
+ thresholdForAutoReject: 3,
155
+ workflowBlockerResolvedWebhookUrl: null,
156
+ });
157
+
158
+ expect(mockProjectRepository.prepareStatus).toHaveBeenCalledTimes(3);
159
+ expect(mockProjectRepository.prepareStatus).toHaveBeenNthCalledWith(
160
+ 1,
161
+ 'Preparation',
162
+ mockProject,
163
+ );
164
+ expect(mockProjectRepository.prepareStatus).toHaveBeenNthCalledWith(
165
+ 2,
166
+ 'Awaiting Workspace',
167
+ projectAfterFirstPrepare,
168
+ );
169
+ expect(mockProjectRepository.prepareStatus).toHaveBeenNthCalledWith(
170
+ 3,
171
+ 'Awaiting Quality Check',
172
+ projectAfterSecondPrepare,
98
173
  );
99
174
  });
100
175
 
@@ -114,8 +189,10 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
114
189
  url: 'https://github.com/user/repo/pull/1',
115
190
  isConflicted: false,
116
191
  isPassedAllCiJob: true,
192
+ isCiStateSuccess: true,
117
193
  isResolvedAllReviewComments: true,
118
194
  isBranchOutOfDate: false,
195
+ missingRequiredCheckNames: [],
119
196
  },
120
197
  ]);
121
198
 
@@ -126,6 +203,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
126
203
  awaitingWorkspaceStatus: 'Awaiting Workspace',
127
204
  awaitingQualityCheckStatus: 'Awaiting Quality Check',
128
205
  thresholdForAutoReject: 3,
206
+ workflowBlockerResolvedWebhookUrl: null,
129
207
  });
130
208
 
131
209
  expect(mockIssueRepository.update).toHaveBeenCalledTimes(1);
@@ -150,6 +228,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
150
228
  awaitingWorkspaceStatus: 'Awaiting Workspace',
151
229
  awaitingQualityCheckStatus: 'Awaiting Quality Check',
152
230
  thresholdForAutoReject: 3,
231
+ workflowBlockerResolvedWebhookUrl: null,
153
232
  }),
154
233
  ).rejects.toThrow(
155
234
  'Issue not found: https://github.com/user/repo/issues/999',
@@ -173,6 +252,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
173
252
  awaitingWorkspaceStatus: 'Awaiting Workspace',
174
253
  awaitingQualityCheckStatus: 'Awaiting Quality Check',
175
254
  thresholdForAutoReject: 3,
255
+ workflowBlockerResolvedWebhookUrl: null,
176
256
  }),
177
257
  ).rejects.toThrow(
178
258
  'Illegal issue status for https://github.com/user/repo/issues/1: expected Preparation, but got Done',
@@ -197,8 +277,10 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
197
277
  url: 'https://github.com/user/repo/pull/1',
198
278
  isConflicted: false,
199
279
  isPassedAllCiJob: true,
280
+ isCiStateSuccess: true,
200
281
  isResolvedAllReviewComments: true,
201
282
  isBranchOutOfDate: false,
283
+ missingRequiredCheckNames: [],
202
284
  },
203
285
  ]);
204
286
 
@@ -209,6 +291,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
209
291
  awaitingWorkspaceStatus: 'Awaiting Workspace',
210
292
  awaitingQualityCheckStatus: 'Awaiting Quality Check',
211
293
  thresholdForAutoReject: 3,
294
+ workflowBlockerResolvedWebhookUrl: null,
212
295
  });
213
296
 
214
297
  expect(mockIssueRepository.update).toHaveBeenCalledWith(
@@ -225,7 +308,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
225
308
  );
226
309
  });
227
310
 
228
- it('should pass when last comment does not start with Auto Status Check or From:', async () => {
311
+ it('should reject when last comment does not start with From:', async () => {
229
312
  const issue = createMockIssue({
230
313
  url: 'https://github.com/user/repo/issues/1',
231
314
  status: 'Preparation',
@@ -241,8 +324,10 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
241
324
  url: 'https://github.com/user/repo/pull/1',
242
325
  isConflicted: false,
243
326
  isPassedAllCiJob: true,
327
+ isCiStateSuccess: true,
244
328
  isResolvedAllReviewComments: true,
245
329
  isBranchOutOfDate: false,
330
+ missingRequiredCheckNames: [],
246
331
  },
247
332
  ]);
248
333
 
@@ -253,14 +338,21 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
253
338
  awaitingWorkspaceStatus: 'Awaiting Workspace',
254
339
  awaitingQualityCheckStatus: 'Awaiting Quality Check',
255
340
  thresholdForAutoReject: 3,
341
+ workflowBlockerResolvedWebhookUrl: null,
256
342
  });
257
343
 
258
344
  expect(mockIssueRepository.update).toHaveBeenCalledWith(
259
345
  expect.objectContaining({
260
- status: 'Awaiting Quality Check',
346
+ status: 'Awaiting Workspace',
261
347
  }),
262
348
  mockProject,
263
349
  );
350
+ 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'),
355
+ );
264
356
  });
265
357
 
266
358
  it('should reject and set status to Awaiting Workspace when no comments exist', async () => {
@@ -277,8 +369,10 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
277
369
  url: 'https://github.com/user/repo/pull/1',
278
370
  isConflicted: false,
279
371
  isPassedAllCiJob: true,
372
+ isCiStateSuccess: true,
280
373
  isResolvedAllReviewComments: true,
281
374
  isBranchOutOfDate: false,
375
+ missingRequiredCheckNames: [],
282
376
  },
283
377
  ]);
284
378
 
@@ -289,6 +383,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
289
383
  awaitingWorkspaceStatus: 'Awaiting Workspace',
290
384
  awaitingQualityCheckStatus: 'Awaiting Quality Check',
291
385
  thresholdForAutoReject: 3,
386
+ workflowBlockerResolvedWebhookUrl: null,
292
387
  });
293
388
 
294
389
  expect(mockIssueRepository.update).toHaveBeenCalledWith(
@@ -321,6 +416,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
321
416
  awaitingWorkspaceStatus: 'Awaiting Workspace',
322
417
  awaitingQualityCheckStatus: 'Awaiting Quality Check',
323
418
  thresholdForAutoReject: 3,
419
+ workflowBlockerResolvedWebhookUrl: null,
324
420
  });
325
421
 
326
422
  expect(mockIssueRepository.update).toHaveBeenCalledWith(
@@ -356,8 +452,10 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
356
452
  url: 'https://github.com/user/repo/pull/1',
357
453
  isConflicted: false,
358
454
  isPassedAllCiJob: true,
455
+ isCiStateSuccess: true,
359
456
  isResolvedAllReviewComments: true,
360
457
  isBranchOutOfDate: false,
458
+ missingRequiredCheckNames: [],
361
459
  },
362
460
  ]);
363
461
 
@@ -368,6 +466,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
368
466
  awaitingWorkspaceStatus: 'Awaiting Workspace',
369
467
  awaitingQualityCheckStatus: 'Awaiting Quality Check',
370
468
  thresholdForAutoReject: 3,
469
+ workflowBlockerResolvedWebhookUrl: null,
371
470
  });
372
471
 
373
472
  expect(mockIssueRepository.update).toHaveBeenCalledWith(
@@ -378,6 +477,94 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
378
477
  );
379
478
  });
380
479
 
480
+ it('should not auto-escalate when retry comment exists even if threshold met', async () => {
481
+ const issue = createMockIssue({
482
+ url: 'https://github.com/user/repo/issues/1',
483
+ status: 'Preparation',
484
+ });
485
+
486
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
487
+ mockIssueRepository.get.mockResolvedValue(issue);
488
+ 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' }),
493
+ ]);
494
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
495
+ {
496
+ url: 'https://github.com/user/repo/pull/1',
497
+ isConflicted: false,
498
+ isPassedAllCiJob: true,
499
+ isCiStateSuccess: true,
500
+ isResolvedAllReviewComments: true,
501
+ isBranchOutOfDate: false,
502
+ missingRequiredCheckNames: [],
503
+ },
504
+ ]);
505
+
506
+ await useCase.run({
507
+ projectUrl: 'https://github.com/users/user/projects/1',
508
+ issueUrl: 'https://github.com/user/repo/issues/1',
509
+ preparationStatus: 'Preparation',
510
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
511
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
512
+ thresholdForAutoReject: 3,
513
+ workflowBlockerResolvedWebhookUrl: null,
514
+ });
515
+
516
+ expect(mockIssueRepository.update).not.toHaveBeenCalledWith(
517
+ expect.objectContaining({
518
+ status: 'Awaiting Quality Check',
519
+ }),
520
+ mockProject,
521
+ );
522
+ });
523
+
524
+ it('should handle case-insensitive retry comment', async () => {
525
+ const issue = createMockIssue({
526
+ url: 'https://github.com/user/repo/issues/1',
527
+ status: 'Preparation',
528
+ });
529
+
530
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
531
+ 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
+ ]);
538
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
539
+ {
540
+ url: 'https://github.com/user/repo/pull/1',
541
+ isConflicted: false,
542
+ isPassedAllCiJob: true,
543
+ isCiStateSuccess: true,
544
+ isResolvedAllReviewComments: true,
545
+ isBranchOutOfDate: false,
546
+ missingRequiredCheckNames: [],
547
+ },
548
+ ]);
549
+
550
+ await useCase.run({
551
+ projectUrl: 'https://github.com/users/user/projects/1',
552
+ issueUrl: 'https://github.com/user/repo/issues/1',
553
+ preparationStatus: 'Preparation',
554
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
555
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
556
+ thresholdForAutoReject: 3,
557
+ workflowBlockerResolvedWebhookUrl: null,
558
+ });
559
+
560
+ expect(mockIssueRepository.update).not.toHaveBeenCalledWith(
561
+ expect.objectContaining({
562
+ status: 'Awaiting Quality Check',
563
+ }),
564
+ mockProject,
565
+ );
566
+ });
567
+
381
568
  it('should reject when PR is not found', async () => {
382
569
  const issue = createMockIssue({
383
570
  url: 'https://github.com/user/repo/issues/1',
@@ -398,6 +585,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
398
585
  awaitingWorkspaceStatus: 'Awaiting Workspace',
399
586
  awaitingQualityCheckStatus: 'Awaiting Quality Check',
400
587
  thresholdForAutoReject: 3,
588
+ workflowBlockerResolvedWebhookUrl: null,
401
589
  });
402
590
 
403
591
  expect(mockIssueRepository.update).toHaveBeenCalledWith(
@@ -430,15 +618,19 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
430
618
  url: 'https://github.com/user/repo/pull/1',
431
619
  isConflicted: false,
432
620
  isPassedAllCiJob: true,
621
+ isCiStateSuccess: true,
433
622
  isResolvedAllReviewComments: true,
434
623
  isBranchOutOfDate: false,
624
+ missingRequiredCheckNames: [],
435
625
  },
436
626
  {
437
627
  url: 'https://github.com/user/repo/pull/2',
438
628
  isConflicted: false,
439
629
  isPassedAllCiJob: true,
630
+ isCiStateSuccess: true,
440
631
  isResolvedAllReviewComments: true,
441
632
  isBranchOutOfDate: false,
633
+ missingRequiredCheckNames: [],
442
634
  },
443
635
  ]);
444
636
 
@@ -449,6 +641,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
449
641
  awaitingWorkspaceStatus: 'Awaiting Workspace',
450
642
  awaitingQualityCheckStatus: 'Awaiting Quality Check',
451
643
  thresholdForAutoReject: 3,
644
+ workflowBlockerResolvedWebhookUrl: null,
452
645
  });
453
646
 
454
647
  expect(mockIssueRepository.update).toHaveBeenCalledWith(
@@ -481,8 +674,10 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
481
674
  url: 'https://github.com/user/repo/pull/1',
482
675
  isConflicted: true,
483
676
  isPassedAllCiJob: true,
677
+ isCiStateSuccess: true,
484
678
  isResolvedAllReviewComments: true,
485
679
  isBranchOutOfDate: false,
680
+ missingRequiredCheckNames: [],
486
681
  },
487
682
  ]);
488
683
 
@@ -493,6 +688,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
493
688
  awaitingWorkspaceStatus: 'Awaiting Workspace',
494
689
  awaitingQualityCheckStatus: 'Awaiting Quality Check',
495
690
  thresholdForAutoReject: 3,
691
+ workflowBlockerResolvedWebhookUrl: null,
496
692
  });
497
693
 
498
694
  expect(mockIssueRepository.update).toHaveBeenCalledWith(
@@ -525,8 +721,110 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
525
721
  url: 'https://github.com/user/repo/pull/1',
526
722
  isConflicted: false,
527
723
  isPassedAllCiJob: false,
724
+ isCiStateSuccess: false,
725
+ isResolvedAllReviewComments: true,
726
+ isBranchOutOfDate: false,
727
+ missingRequiredCheckNames: [],
728
+ },
729
+ ]);
730
+
731
+ await useCase.run({
732
+ projectUrl: 'https://github.com/users/user/projects/1',
733
+ issueUrl: 'https://github.com/user/repo/issues/1',
734
+ preparationStatus: 'Preparation',
735
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
736
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
737
+ thresholdForAutoReject: 3,
738
+ workflowBlockerResolvedWebhookUrl: null,
739
+ });
740
+
741
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
742
+ expect.objectContaining({
743
+ status: 'Awaiting Workspace',
744
+ }),
745
+ mockProject,
746
+ );
747
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
748
+ expect.objectContaining({
749
+ url: 'https://github.com/user/repo/issues/1',
750
+ }),
751
+ expect.stringContaining('ANY_CI_JOB_FAILED_OR_IN_PROGRESS'),
752
+ );
753
+ });
754
+
755
+ it('should reject with REQUIRED_CI_JOB_NEVER_STARTED when required checks are missing', async () => {
756
+ const issue = createMockIssue({
757
+ url: 'https://github.com/user/repo/issues/1',
758
+ status: 'Preparation',
759
+ });
760
+
761
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
762
+ mockIssueRepository.get.mockResolvedValue(issue);
763
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
764
+ createMockComment({ content: 'From: Test report' }),
765
+ ]);
766
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
767
+ {
768
+ url: 'https://github.com/user/repo/pull/1',
769
+ isConflicted: false,
770
+ isPassedAllCiJob: false,
771
+ isCiStateSuccess: true,
772
+ isResolvedAllReviewComments: true,
773
+ isBranchOutOfDate: false,
774
+ missingRequiredCheckNames: ['E2E Tests', 'deploy-preview'],
775
+ },
776
+ ]);
777
+
778
+ await useCase.run({
779
+ projectUrl: 'https://github.com/users/user/projects/1',
780
+ issueUrl: 'https://github.com/user/repo/issues/1',
781
+ preparationStatus: 'Preparation',
782
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
783
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
784
+ thresholdForAutoReject: 3,
785
+ workflowBlockerResolvedWebhookUrl: null,
786
+ });
787
+
788
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
789
+ expect.objectContaining({
790
+ status: 'Awaiting Workspace',
791
+ }),
792
+ mockProject,
793
+ );
794
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
795
+ expect.objectContaining({
796
+ url: 'https://github.com/user/repo/issues/1',
797
+ }),
798
+ expect.stringContaining('REQUIRED_CI_JOB_NEVER_STARTED'),
799
+ );
800
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
801
+ expect.objectContaining({
802
+ url: 'https://github.com/user/repo/issues/1',
803
+ }),
804
+ expect.stringContaining('E2E Tests'),
805
+ );
806
+ });
807
+
808
+ it('should reject with ANY_CI_JOB_FAILED_OR_IN_PROGRESS when CI has failures and required checks are also missing', async () => {
809
+ const issue = createMockIssue({
810
+ url: 'https://github.com/user/repo/issues/1',
811
+ status: 'Preparation',
812
+ });
813
+
814
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
815
+ mockIssueRepository.get.mockResolvedValue(issue);
816
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
817
+ createMockComment({ content: 'From: Test report' }),
818
+ ]);
819
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
820
+ {
821
+ url: 'https://github.com/user/repo/pull/1',
822
+ isConflicted: false,
823
+ isPassedAllCiJob: false,
824
+ isCiStateSuccess: false,
528
825
  isResolvedAllReviewComments: true,
529
826
  isBranchOutOfDate: false,
827
+ missingRequiredCheckNames: ['deploy-preview'],
530
828
  },
531
829
  ]);
532
830
 
@@ -537,6 +835,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
537
835
  awaitingWorkspaceStatus: 'Awaiting Workspace',
538
836
  awaitingQualityCheckStatus: 'Awaiting Quality Check',
539
837
  thresholdForAutoReject: 3,
838
+ workflowBlockerResolvedWebhookUrl: null,
540
839
  });
541
840
 
542
841
  expect(mockIssueRepository.update).toHaveBeenCalledWith(
@@ -549,7 +848,54 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
549
848
  expect.objectContaining({
550
849
  url: 'https://github.com/user/repo/issues/1',
551
850
  }),
552
- expect.stringContaining('ANY_CI_JOB_FAILED'),
851
+ expect.stringContaining('ANY_CI_JOB_FAILED_OR_IN_PROGRESS'),
852
+ );
853
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
854
+ expect.objectContaining({
855
+ url: 'https://github.com/user/repo/issues/1',
856
+ }),
857
+ expect.stringContaining('deploy-preview'),
858
+ );
859
+ });
860
+
861
+ it('should include PR URL in rejection comment details', async () => {
862
+ const issue = createMockIssue({
863
+ url: 'https://github.com/user/repo/issues/1',
864
+ status: 'Preparation',
865
+ });
866
+
867
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
868
+ mockIssueRepository.get.mockResolvedValue(issue);
869
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
870
+ createMockComment({ content: 'From: Test report' }),
871
+ ]);
872
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
873
+ {
874
+ url: 'https://github.com/user/repo/pull/1',
875
+ isConflicted: false,
876
+ isPassedAllCiJob: false,
877
+ isCiStateSuccess: false,
878
+ isResolvedAllReviewComments: true,
879
+ isBranchOutOfDate: false,
880
+ missingRequiredCheckNames: [],
881
+ },
882
+ ]);
883
+
884
+ await useCase.run({
885
+ projectUrl: 'https://github.com/users/user/projects/1',
886
+ issueUrl: 'https://github.com/user/repo/issues/1',
887
+ preparationStatus: 'Preparation',
888
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
889
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
890
+ thresholdForAutoReject: 3,
891
+ workflowBlockerResolvedWebhookUrl: null,
892
+ });
893
+
894
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
895
+ expect.objectContaining({
896
+ url: 'https://github.com/user/repo/issues/1',
897
+ }),
898
+ expect.stringContaining('https://github.com/user/repo/pull/1'),
553
899
  );
554
900
  });
555
901
 
@@ -569,8 +915,10 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
569
915
  url: 'https://github.com/user/repo/pull/1',
570
916
  isConflicted: false,
571
917
  isPassedAllCiJob: true,
918
+ isCiStateSuccess: true,
572
919
  isResolvedAllReviewComments: false,
573
920
  isBranchOutOfDate: false,
921
+ missingRequiredCheckNames: [],
574
922
  },
575
923
  ]);
576
924
 
@@ -581,6 +929,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
581
929
  awaitingWorkspaceStatus: 'Awaiting Workspace',
582
930
  awaitingQualityCheckStatus: 'Awaiting Quality Check',
583
931
  thresholdForAutoReject: 3,
932
+ workflowBlockerResolvedWebhookUrl: null,
584
933
  });
585
934
 
586
935
  expect(mockIssueRepository.update).toHaveBeenCalledWith(
@@ -617,6 +966,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
617
966
  awaitingWorkspaceStatus: 'Awaiting Workspace',
618
967
  awaitingQualityCheckStatus: 'Awaiting Quality Check',
619
968
  thresholdForAutoReject: 3,
969
+ workflowBlockerResolvedWebhookUrl: null,
620
970
  });
621
971
 
622
972
  expect(mockIssueRepository.findRelatedOpenPRs).not.toHaveBeenCalled();
@@ -628,6 +978,49 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
628
978
  );
629
979
  });
630
980
 
981
+ it('should check PRs when issue has category:e2e label', async () => {
982
+ const issue = createMockIssue({
983
+ url: 'https://github.com/user/repo/issues/1',
984
+ status: 'Preparation',
985
+ labels: ['category:e2e'],
986
+ });
987
+
988
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
989
+ mockIssueRepository.get.mockResolvedValue(issue);
990
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
991
+ createMockComment({ content: 'From: Test report' }),
992
+ ]);
993
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
994
+ {
995
+ url: 'https://github.com/user/repo/pull/1',
996
+ isConflicted: false,
997
+ isPassedAllCiJob: true,
998
+ isCiStateSuccess: true,
999
+ isResolvedAllReviewComments: true,
1000
+ isBranchOutOfDate: false,
1001
+ missingRequiredCheckNames: [],
1002
+ },
1003
+ ]);
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.findRelatedOpenPRs).toHaveBeenCalled();
1016
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
1017
+ expect.objectContaining({
1018
+ status: 'Awaiting Quality Check',
1019
+ }),
1020
+ mockProject,
1021
+ );
1022
+ });
1023
+
631
1024
  it('should still check for report comment even when issue has category label', async () => {
632
1025
  const issue = createMockIssue({
633
1026
  url: 'https://github.com/user/repo/issues/1',
@@ -650,6 +1043,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
650
1043
  awaitingWorkspaceStatus: 'Awaiting Workspace',
651
1044
  awaitingQualityCheckStatus: 'Awaiting Quality Check',
652
1045
  thresholdForAutoReject: 3,
1046
+ workflowBlockerResolvedWebhookUrl: null,
653
1047
  });
654
1048
 
655
1049
  expect(mockIssueRepository.findRelatedOpenPRs).not.toHaveBeenCalled();
@@ -663,7 +1057,319 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
663
1057
  expect.objectContaining({
664
1058
  url: 'https://github.com/user/repo/issues/1',
665
1059
  }),
666
- expect.stringContaining('NO_REPORT'),
1060
+ expect.stringContaining('NO_REPORT_FROM_AGENT_BOT'),
667
1061
  );
668
1062
  });
1063
+
1064
+ describe('workflow blocker webhook notification', () => {
1065
+ const createWorkflowBlockerStoryObjectMap = (
1066
+ issueUrl: string,
1067
+ ): StoryObjectMap => {
1068
+ const map: StoryObjectMap = new Map();
1069
+ map.set('Workflow Blocker Story', {
1070
+ story: {
1071
+ id: 'story-1',
1072
+ name: 'Workflow Blocker Story',
1073
+ color: 'GRAY',
1074
+ description: '',
1075
+ },
1076
+ storyIssue: null,
1077
+ issues: [createMockIssue({ url: issueUrl })],
1078
+ });
1079
+ return map;
1080
+ };
1081
+
1082
+ const createNonBlockerStoryObjectMap = (): StoryObjectMap => {
1083
+ const map: StoryObjectMap = new Map();
1084
+ map.set('Regular Story', {
1085
+ story: {
1086
+ id: 'story-2',
1087
+ name: 'Regular Story',
1088
+ color: 'GRAY',
1089
+ description: '',
1090
+ },
1091
+ storyIssue: null,
1092
+ issues: [
1093
+ createMockIssue({
1094
+ url: 'https://github.com/user/repo/issues/99',
1095
+ }),
1096
+ ],
1097
+ });
1098
+ return map;
1099
+ };
1100
+
1101
+ it('should send webhook when workflow blocker issue status changes to awaitingQualityCheckStatus on checks pass', async () => {
1102
+ const issue = createMockIssue({
1103
+ url: 'https://github.com/user/repo/issues/1',
1104
+ status: 'Preparation',
1105
+ });
1106
+
1107
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1108
+ mockIssueRepository.get.mockResolvedValue(issue);
1109
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
1110
+ createMockComment({ content: 'From: Test report' }),
1111
+ ]);
1112
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
1113
+ {
1114
+ url: 'https://github.com/user/repo/pull/1',
1115
+ isConflicted: false,
1116
+ isPassedAllCiJob: true,
1117
+ isCiStateSuccess: true,
1118
+ isResolvedAllReviewComments: true,
1119
+ isBranchOutOfDate: false,
1120
+ missingRequiredCheckNames: [],
1121
+ },
1122
+ ]);
1123
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1124
+ createWorkflowBlockerStoryObjectMap(
1125
+ 'https://github.com/user/repo/issues/1',
1126
+ ),
1127
+ );
1128
+
1129
+ await useCase.run({
1130
+ projectUrl: 'https://github.com/users/user/projects/1',
1131
+ issueUrl: 'https://github.com/user/repo/issues/1',
1132
+ preparationStatus: 'Preparation',
1133
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
1134
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
1135
+ thresholdForAutoReject: 3,
1136
+ workflowBlockerResolvedWebhookUrl:
1137
+ 'https://example.com/webhook?url={URL}&msg={MESSAGE}',
1138
+ });
1139
+
1140
+ expect(mockWebhookRepository.sendGetRequest).toHaveBeenCalledWith(
1141
+ `https://example.com/webhook?url=${encodeURIComponent('https://github.com/user/repo/issues/1')}&msg=${encodeURIComponent('Workflow blocker resolved: https://github.com/user/repo/issues/1')}`,
1142
+ );
1143
+ });
1144
+
1145
+ it('should send webhook when workflow blocker issue auto-escalates', async () => {
1146
+ const issue = createMockIssue({
1147
+ url: 'https://github.com/user/repo/issues/1',
1148
+ status: 'Preparation',
1149
+ });
1150
+
1151
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1152
+ mockIssueRepository.get.mockResolvedValue(issue);
1153
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
1154
+ createMockComment({
1155
+ content: 'Auto Status Check: REJECTED - first',
1156
+ }),
1157
+ createMockComment({
1158
+ content: 'Auto Status Check: REJECTED - second',
1159
+ }),
1160
+ createMockComment({
1161
+ content: 'Auto Status Check: REJECTED - third',
1162
+ }),
1163
+ ]);
1164
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1165
+ createWorkflowBlockerStoryObjectMap(
1166
+ 'https://github.com/user/repo/issues/1',
1167
+ ),
1168
+ );
1169
+
1170
+ await useCase.run({
1171
+ projectUrl: 'https://github.com/users/user/projects/1',
1172
+ issueUrl: 'https://github.com/user/repo/issues/1',
1173
+ preparationStatus: 'Preparation',
1174
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
1175
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
1176
+ thresholdForAutoReject: 3,
1177
+ workflowBlockerResolvedWebhookUrl:
1178
+ 'https://example.com/notify={MESSAGE}',
1179
+ });
1180
+
1181
+ expect(mockWebhookRepository.sendGetRequest).toHaveBeenCalledTimes(1);
1182
+ expect(mockWebhookRepository.sendGetRequest).toHaveBeenCalledWith(
1183
+ expect.stringContaining('https://example.com/notify='),
1184
+ );
1185
+ });
1186
+
1187
+ it('should not send webhook for non-blocker issues', async () => {
1188
+ const issue = createMockIssue({
1189
+ url: 'https://github.com/user/repo/issues/1',
1190
+ status: 'Preparation',
1191
+ });
1192
+
1193
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1194
+ mockIssueRepository.get.mockResolvedValue(issue);
1195
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
1196
+ createMockComment({ content: 'From: Test report' }),
1197
+ ]);
1198
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
1199
+ {
1200
+ url: 'https://github.com/user/repo/pull/1',
1201
+ isConflicted: false,
1202
+ isPassedAllCiJob: true,
1203
+ isCiStateSuccess: true,
1204
+ isResolvedAllReviewComments: true,
1205
+ isBranchOutOfDate: false,
1206
+ missingRequiredCheckNames: [],
1207
+ },
1208
+ ]);
1209
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1210
+ createNonBlockerStoryObjectMap(),
1211
+ );
1212
+
1213
+ await useCase.run({
1214
+ projectUrl: 'https://github.com/users/user/projects/1',
1215
+ issueUrl: 'https://github.com/user/repo/issues/1',
1216
+ preparationStatus: 'Preparation',
1217
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
1218
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
1219
+ thresholdForAutoReject: 3,
1220
+ workflowBlockerResolvedWebhookUrl:
1221
+ 'https://example.com/webhook?msg={MESSAGE}',
1222
+ });
1223
+
1224
+ expect(mockWebhookRepository.sendGetRequest).not.toHaveBeenCalled();
1225
+ });
1226
+
1227
+ it('should not send webhook when URL is null', async () => {
1228
+ const issue = createMockIssue({
1229
+ url: 'https://github.com/user/repo/issues/1',
1230
+ status: 'Preparation',
1231
+ });
1232
+
1233
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1234
+ mockIssueRepository.get.mockResolvedValue(issue);
1235
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
1236
+ createMockComment({ content: 'From: Test report' }),
1237
+ ]);
1238
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
1239
+ {
1240
+ url: 'https://github.com/user/repo/pull/1',
1241
+ isConflicted: false,
1242
+ isPassedAllCiJob: true,
1243
+ isCiStateSuccess: true,
1244
+ isResolvedAllReviewComments: true,
1245
+ isBranchOutOfDate: false,
1246
+ missingRequiredCheckNames: [],
1247
+ },
1248
+ ]);
1249
+
1250
+ await useCase.run({
1251
+ projectUrl: 'https://github.com/users/user/projects/1',
1252
+ issueUrl: 'https://github.com/user/repo/issues/1',
1253
+ preparationStatus: 'Preparation',
1254
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
1255
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
1256
+ thresholdForAutoReject: 3,
1257
+ workflowBlockerResolvedWebhookUrl: null,
1258
+ });
1259
+
1260
+ expect(mockIssueRepository.getStoryObjectMap).not.toHaveBeenCalled();
1261
+ expect(mockWebhookRepository.sendGetRequest).not.toHaveBeenCalled();
1262
+ });
1263
+
1264
+ it('should log warning and not block workflow when webhook fails', async () => {
1265
+ const issue = createMockIssue({
1266
+ url: 'https://github.com/user/repo/issues/1',
1267
+ status: 'Preparation',
1268
+ });
1269
+
1270
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1271
+ mockIssueRepository.get.mockResolvedValue(issue);
1272
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
1273
+ createMockComment({ content: 'From: Test report' }),
1274
+ ]);
1275
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
1276
+ {
1277
+ url: 'https://github.com/user/repo/pull/1',
1278
+ isConflicted: false,
1279
+ isPassedAllCiJob: true,
1280
+ isCiStateSuccess: true,
1281
+ isResolvedAllReviewComments: true,
1282
+ isBranchOutOfDate: false,
1283
+ missingRequiredCheckNames: [],
1284
+ },
1285
+ ]);
1286
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1287
+ createWorkflowBlockerStoryObjectMap(
1288
+ 'https://github.com/user/repo/issues/1',
1289
+ ),
1290
+ );
1291
+ mockWebhookRepository.sendGetRequest.mockRejectedValue(
1292
+ new Error('Network error'),
1293
+ );
1294
+
1295
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
1296
+
1297
+ await useCase.run({
1298
+ projectUrl: 'https://github.com/users/user/projects/1',
1299
+ issueUrl: 'https://github.com/user/repo/issues/1',
1300
+ preparationStatus: 'Preparation',
1301
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
1302
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
1303
+ thresholdForAutoReject: 3,
1304
+ workflowBlockerResolvedWebhookUrl:
1305
+ 'https://example.com/webhook?msg={MESSAGE}',
1306
+ });
1307
+
1308
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
1309
+ 'Failed to send workflow blocker notification:',
1310
+ expect.any(Error),
1311
+ );
1312
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
1313
+ expect.objectContaining({
1314
+ status: 'Awaiting Quality Check',
1315
+ }),
1316
+ mockProject,
1317
+ );
1318
+
1319
+ consoleWarnSpy.mockRestore();
1320
+ });
1321
+
1322
+ it('should URL-encode placeholders in webhook URL', async () => {
1323
+ const issue = createMockIssue({
1324
+ url: 'https://github.com/user/repo/issues/1',
1325
+ status: 'Preparation',
1326
+ });
1327
+
1328
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1329
+ mockIssueRepository.get.mockResolvedValue(issue);
1330
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
1331
+ createMockComment({ content: 'From: Test report' }),
1332
+ ]);
1333
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
1334
+ {
1335
+ url: 'https://github.com/user/repo/pull/1',
1336
+ isConflicted: false,
1337
+ isPassedAllCiJob: true,
1338
+ isCiStateSuccess: true,
1339
+ isResolvedAllReviewComments: true,
1340
+ isBranchOutOfDate: false,
1341
+ missingRequiredCheckNames: [],
1342
+ },
1343
+ ]);
1344
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1345
+ createWorkflowBlockerStoryObjectMap(
1346
+ 'https://github.com/user/repo/issues/1',
1347
+ ),
1348
+ );
1349
+
1350
+ await useCase.run({
1351
+ projectUrl: 'https://github.com/users/user/projects/1',
1352
+ issueUrl: 'https://github.com/user/repo/issues/1',
1353
+ preparationStatus: 'Preparation',
1354
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
1355
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
1356
+ thresholdForAutoReject: 3,
1357
+ workflowBlockerResolvedWebhookUrl:
1358
+ 'https://example.com/runTasker/notify=:={MESSAGE}',
1359
+ });
1360
+
1361
+ expect(mockWebhookRepository.sendGetRequest).toHaveBeenCalledTimes(1);
1362
+ expect(mockWebhookRepository.sendGetRequest).not.toHaveBeenCalledWith(
1363
+ expect.stringContaining('{MESSAGE}'),
1364
+ );
1365
+ expect(mockWebhookRepository.sendGetRequest).not.toHaveBeenCalledWith(
1366
+ expect.stringContaining('{URL}'),
1367
+ );
1368
+ expect(mockWebhookRepository.sendGetRequest).toHaveBeenCalledWith(
1369
+ expect.stringContaining(
1370
+ encodeURIComponent('Workflow blocker resolved:'),
1371
+ ),
1372
+ );
1373
+ });
1374
+ });
669
1375
  });