github-issue-tower-defence-management 2.10.0 → 2.10.2

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 (24) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +18 -0
  3. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  4. package/bin/domain/usecases/ConflictedIssueRevertUseCase.js +6 -3
  5. package/bin/domain/usecases/ConflictedIssueRevertUseCase.js.map +1 -1
  6. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +47 -5
  7. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +1 -0
  10. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +24 -0
  11. package/src/domain/usecases/ConflictedIssueRevertUseCase.test.ts +188 -5
  12. package/src/domain/usecases/ConflictedIssueRevertUseCase.ts +12 -4
  13. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +239 -15
  14. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +89 -10
  15. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +1 -0
  16. package/src/domain/usecases/issueReactivationTriggerIsPending.test.ts +6 -0
  17. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +1 -0
  18. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  19. package/types/domain/usecases/ConflictedIssueRevertUseCase.d.ts +1 -1
  20. package/types/domain/usecases/ConflictedIssueRevertUseCase.d.ts.map +1 -1
  21. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +2 -1
  22. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  23. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +1 -0
  24. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
@@ -625,6 +625,7 @@ const createStubIssueRepository = (
625
625
  }
626
626
  : null,
627
627
  setIssueAgentField: async (): Promise<void> => undefined,
628
+ updateBranch: async (): Promise<boolean> => false,
628
629
  updateStoryOptionColor: async (
629
630
  _project,
630
631
  storyOptionId: string,
@@ -2786,6 +2786,30 @@ export class ApiV3CheerioRestIssueRepository
2786
2786
  return `${status} ${reason}`;
2787
2787
  };
2788
2788
 
2789
+ updateBranch = async (prUrl: string): Promise<boolean> => {
2790
+ const { owner, repo, issueNumber: prNumber } = this.parseIssueUrl(prUrl);
2791
+ const response = await this.fetchWithRateLimitRetry(() =>
2792
+ fetch(
2793
+ `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${prNumber}/update-branch`,
2794
+ {
2795
+ method: 'PUT',
2796
+ headers: {
2797
+ Authorization: `Bearer ${this.ghToken}`,
2798
+ Accept: 'application/vnd.github+json',
2799
+ },
2800
+ },
2801
+ ),
2802
+ );
2803
+ if (response.ok) {
2804
+ return true;
2805
+ }
2806
+ if (response.status === 422) {
2807
+ return false;
2808
+ }
2809
+ const reason = await this.formatGitHubErrorWithStatus(response);
2810
+ throw new Error(`Failed to update branch for PR ${prUrl}: ${reason}`);
2811
+ };
2812
+
2789
2813
  deletePullRequestBranch = async (
2790
2814
  prUrl: string,
2791
2815
  branchName: string,
@@ -97,6 +97,7 @@ describe('ConflictedIssueRevertUseCase', () => {
97
97
  getAllIssues: jest.Mock;
98
98
  getOpenPullRequests: jest.Mock;
99
99
  updateStatus: jest.Mock;
100
+ updateBranch: jest.Mock;
100
101
  };
101
102
  let mockIssueCommentRepository: {
102
103
  createComment: jest.Mock;
@@ -122,6 +123,7 @@ describe('ConflictedIssueRevertUseCase', () => {
122
123
  }),
123
124
  getOpenPullRequests: jest.fn().mockResolvedValue(new Map()),
124
125
  updateStatus: jest.fn().mockResolvedValue(undefined),
126
+ updateBranch: jest.fn().mockResolvedValue(false),
125
127
  };
126
128
 
127
129
  mockIssueCommentRepository = {
@@ -144,7 +146,6 @@ describe('ConflictedIssueRevertUseCase', () => {
144
146
  });
145
147
 
146
148
  const excludedStatuses = [
147
- 'Awaiting Workspace',
148
149
  'Done',
149
150
  'Icebox',
150
151
  'Failed Preparation',
@@ -170,6 +171,40 @@ describe('ConflictedIssueRevertUseCase', () => {
170
171
  });
171
172
  });
172
173
 
174
+ it('should process Awaiting Workspace issues when their linked PR is conflicted and update-branch fails', async () => {
175
+ const issue = createMockIssue({ status: 'Awaiting Workspace' });
176
+ const prItem = createMockPrItem({
177
+ url: 'https://github.com/user/repo/pull/1',
178
+ closingIssueReferenceUrls: [issue.url],
179
+ });
180
+ mockIssueRepository.getAllIssues.mockResolvedValue({
181
+ project: mockProject,
182
+ issues: [issue, prItem],
183
+ cacheUsed: false,
184
+ });
185
+ const conflictedPr = createMockRelatedPullRequest({
186
+ url: prItem.url,
187
+ isConflicted: true,
188
+ mergeable: 'CONFLICTING',
189
+ });
190
+ mockIssueRepository.getOpenPullRequests.mockResolvedValue(
191
+ new Map([[conflictedPr.url, conflictedPr]]),
192
+ );
193
+ mockIssueRepository.updateBranch.mockResolvedValue(false);
194
+
195
+ await useCase.run({ projectUrl });
196
+
197
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
198
+ mockProject,
199
+ issue,
200
+ 'awaiting-workspace-id',
201
+ );
202
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
203
+ issue,
204
+ 'conflict',
205
+ );
206
+ });
207
+
173
208
  it('should skip PR items when scanning for target task issues', async () => {
174
209
  const prIssue = createMockIssue({
175
210
  url: 'https://github.com/user/repo/issues/1',
@@ -195,6 +230,7 @@ describe('ConflictedIssueRevertUseCase', () => {
195
230
  mockIssueRepository.getOpenPullRequests.mockResolvedValue(
196
231
  new Map([[conflictedPr.url, conflictedPr]]),
197
232
  );
233
+ mockIssueRepository.updateBranch.mockResolvedValue(false);
198
234
 
199
235
  await useCase.run({ projectUrl });
200
236
 
@@ -255,7 +291,44 @@ describe('ConflictedIssueRevertUseCase', () => {
255
291
  expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
256
292
  });
257
293
 
258
- it('should update status and post conflict comment when linked PR is conflicted', async () => {
294
+ it('should update status and post conflict comment when linked PR is conflicted and update-branch fails', async () => {
295
+ const issue = createMockIssue({
296
+ url: 'https://github.com/user/repo/issues/1',
297
+ status: 'Preparation',
298
+ });
299
+ const prItem = createMockPrItem({
300
+ url: 'https://github.com/user/repo/pull/1',
301
+ closingIssueReferenceUrls: [issue.url],
302
+ });
303
+ mockIssueRepository.getAllIssues.mockResolvedValue({
304
+ project: mockProject,
305
+ issues: [issue, prItem],
306
+ cacheUsed: false,
307
+ });
308
+ const conflictedPr = createMockRelatedPullRequest({
309
+ url: prItem.url,
310
+ isConflicted: true,
311
+ mergeable: 'CONFLICTING',
312
+ });
313
+ mockIssueRepository.getOpenPullRequests.mockResolvedValue(
314
+ new Map([[conflictedPr.url, conflictedPr]]),
315
+ );
316
+ mockIssueRepository.updateBranch.mockResolvedValue(false);
317
+
318
+ await useCase.run({ projectUrl });
319
+
320
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
321
+ mockProject,
322
+ issue,
323
+ 'awaiting-workspace-id',
324
+ );
325
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
326
+ issue,
327
+ 'conflict',
328
+ );
329
+ });
330
+
331
+ it('should not update status or post comment when update-branch succeeds for the conflicted PR', async () => {
259
332
  const issue = createMockIssue({
260
333
  url: 'https://github.com/user/repo/issues/1',
261
334
  status: 'Preparation',
@@ -277,6 +350,105 @@ describe('ConflictedIssueRevertUseCase', () => {
277
350
  mockIssueRepository.getOpenPullRequests.mockResolvedValue(
278
351
  new Map([[conflictedPr.url, conflictedPr]]),
279
352
  );
353
+ mockIssueRepository.updateBranch.mockResolvedValue(true);
354
+
355
+ await useCase.run({ projectUrl });
356
+
357
+ expect(mockIssueRepository.updateBranch).toHaveBeenCalledWith(
358
+ conflictedPr.url,
359
+ );
360
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
361
+ expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
362
+ });
363
+
364
+ it('should call update-branch only for conflicted PRs, not for all linked PRs', async () => {
365
+ const issue = createMockIssue({
366
+ url: 'https://github.com/user/repo/issues/1',
367
+ status: 'Preparation',
368
+ });
369
+ const prItem1 = createMockPrItem({
370
+ url: 'https://github.com/user/repo/pull/1',
371
+ number: 1,
372
+ closingIssueReferenceUrls: [issue.url],
373
+ });
374
+ const prItem2 = createMockPrItem({
375
+ url: 'https://github.com/user/repo/pull/2',
376
+ number: 2,
377
+ closingIssueReferenceUrls: [issue.url],
378
+ });
379
+ mockIssueRepository.getAllIssues.mockResolvedValue({
380
+ project: mockProject,
381
+ issues: [issue, prItem1, prItem2],
382
+ cacheUsed: false,
383
+ });
384
+ const conflictedPr = createMockRelatedPullRequest({
385
+ url: prItem1.url,
386
+ isConflicted: true,
387
+ mergeable: 'CONFLICTING',
388
+ });
389
+ const mergeablePr = createMockRelatedPullRequest({
390
+ url: prItem2.url,
391
+ isConflicted: false,
392
+ mergeable: 'MERGEABLE',
393
+ });
394
+ mockIssueRepository.getOpenPullRequests.mockResolvedValue(
395
+ new Map([
396
+ [conflictedPr.url, conflictedPr],
397
+ [mergeablePr.url, mergeablePr],
398
+ ]),
399
+ );
400
+ mockIssueRepository.updateBranch.mockResolvedValue(true);
401
+
402
+ await useCase.run({ projectUrl });
403
+
404
+ expect(mockIssueRepository.updateBranch).toHaveBeenCalledTimes(1);
405
+ expect(mockIssueRepository.updateBranch).toHaveBeenCalledWith(
406
+ conflictedPr.url,
407
+ );
408
+ expect(mockIssueRepository.updateBranch).not.toHaveBeenCalledWith(
409
+ mergeablePr.url,
410
+ );
411
+ });
412
+
413
+ it('should fall back to status update when any conflicted PR update-branch fails', async () => {
414
+ const issue = createMockIssue({
415
+ url: 'https://github.com/user/repo/issues/1',
416
+ status: 'Preparation',
417
+ });
418
+ const prItem1 = createMockPrItem({
419
+ url: 'https://github.com/user/repo/pull/1',
420
+ number: 1,
421
+ closingIssueReferenceUrls: [issue.url],
422
+ });
423
+ const prItem2 = createMockPrItem({
424
+ url: 'https://github.com/user/repo/pull/2',
425
+ number: 2,
426
+ closingIssueReferenceUrls: [issue.url],
427
+ });
428
+ mockIssueRepository.getAllIssues.mockResolvedValue({
429
+ project: mockProject,
430
+ issues: [issue, prItem1, prItem2],
431
+ cacheUsed: false,
432
+ });
433
+ const conflictedPr1 = createMockRelatedPullRequest({
434
+ url: prItem1.url,
435
+ isConflicted: true,
436
+ mergeable: 'CONFLICTING',
437
+ });
438
+ const conflictedPr2 = createMockRelatedPullRequest({
439
+ url: prItem2.url,
440
+ isConflicted: true,
441
+ mergeable: 'CONFLICTING',
442
+ });
443
+ mockIssueRepository.getOpenPullRequests.mockResolvedValue(
444
+ new Map([
445
+ [conflictedPr1.url, conflictedPr1],
446
+ [conflictedPr2.url, conflictedPr2],
447
+ ]),
448
+ );
449
+ mockIssueRepository.updateBranch
450
+ .mockResolvedValueOnce(true)
451
+ .mockResolvedValueOnce(false);
280
452
 
281
453
  await useCase.run({ projectUrl });
282
454
 
@@ -313,6 +485,7 @@ describe('ConflictedIssueRevertUseCase', () => {
313
485
  mockIssueRepository.getOpenPullRequests.mockResolvedValue(
314
486
  new Map([[conflictedPr.url, conflictedPr]]),
315
487
  );
488
+ mockIssueRepository.updateBranch.mockResolvedValue(false);
316
489
 
317
490
  await useCase.run({ projectUrl });
318
491
 
@@ -430,6 +603,7 @@ describe('ConflictedIssueRevertUseCase', () => {
430
603
  [mergeablePr.url, mergeablePr],
431
604
  ]),
432
605
  );
606
+ mockIssueRepository.updateBranch.mockResolvedValue(false);
433
607
 
434
608
  await useCase.run({ projectUrl });
435
609
 
@@ -491,7 +665,7 @@ describe('ConflictedIssueRevertUseCase', () => {
491
665
  expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
492
666
  });
493
667
 
494
- it('should perform status update before comment posting', async () => {
668
+ it('should call update-branch before status update and comment when update-branch fails', async () => {
495
669
  const callOrder: string[] = [];
496
670
  const issue = createMockIssue({
497
671
  url: 'https://github.com/user/repo/issues/1',
@@ -514,6 +688,10 @@ describe('ConflictedIssueRevertUseCase', () => {
514
688
  mockIssueRepository.getOpenPullRequests.mockResolvedValue(
515
689
  new Map([[conflictedPr.url, conflictedPr]]),
516
690
  );
691
+ mockIssueRepository.updateBranch.mockImplementation(() => {
692
+ callOrder.push('updateBranch');
693
+ return Promise.resolve(false);
694
+ });
517
695
  mockIssueRepository.updateStatus.mockImplementation(() => {
518
696
  callOrder.push('updateStatus');
519
697
  return Promise.resolve();
@@ -525,7 +703,11 @@ describe('ConflictedIssueRevertUseCase', () => {
525
703
 
526
704
  await useCase.run({ projectUrl });
527
705
 
528
- expect(callOrder).toEqual(['updateStatus', 'createComment']);
706
+ expect(callOrder).toEqual([
707
+ 'updateBranch',
708
+ 'updateStatus',
709
+ 'createComment',
710
+ ]);
529
711
  });
530
712
 
531
713
  it('should fetch all PR URLs in a single batched call', async () => {
@@ -567,7 +749,7 @@ describe('ConflictedIssueRevertUseCase', () => {
567
749
  );
568
750
  });
569
751
 
570
- it('should process multiple conflicted issues in one run', async () => {
752
+ it('should process multiple conflicted issues in one run when update-branch fails for both', async () => {
571
753
  const issue1 = createMockIssue({
572
754
  url: 'https://github.com/user/repo/issues/1',
573
755
  number: 1,
@@ -609,6 +791,7 @@ describe('ConflictedIssueRevertUseCase', () => {
609
791
  [conflictedPr2.url, conflictedPr2],
610
792
  ]),
611
793
  );
794
+ mockIssueRepository.updateBranch.mockResolvedValue(false);
612
795
 
613
796
  await useCase.run({ projectUrl });
614
797
 
@@ -11,7 +11,6 @@ import {
11
11
  } from '../entities/WorkflowStatus';
12
12
 
13
13
  const EXCLUDED_STATUSES = new Set([
14
- AWAITING_WORKSPACE_STATUS_NAME,
15
14
  DONE_STATUS_NAME,
16
15
  ICEBOX_STATUS_NAME,
17
16
  FAILED_PREPARATION_STATUS_NAME,
@@ -26,7 +25,7 @@ export class ConflictedIssueRevertUseCase {
26
25
  >,
27
26
  private readonly issueRepository: Pick<
28
27
  IssueRepository,
29
- 'getAllIssues' | 'getOpenPullRequests' | 'updateStatus'
28
+ 'getAllIssues' | 'getOpenPullRequests' | 'updateStatus' | 'updateBranch'
30
29
  >,
31
30
  private readonly issueCommentRepository: Pick<
32
31
  IssueCommentRepository,
@@ -98,8 +97,17 @@ export class ConflictedIssueRevertUseCase {
98
97
  continue;
99
98
  }
100
99
 
101
- const hasConflict = relatedPrs.some((pr) => pr.isConflicted);
102
- if (!hasConflict) {
100
+ const conflictedPrs = relatedPrs.filter((pr) => pr.isConflicted);
101
+ if (conflictedPrs.length === 0) {
102
+ continue;
103
+ }
104
+
105
+ const allBranchesUpdated = (
106
+ await Promise.all(
107
+ conflictedPrs.map((pr) => this.issueRepository.updateBranch(pr.url)),
108
+ )
109
+ ).every(Boolean);
110
+ if (allBranchesUpdated) {
103
111
  continue;
104
112
  }
105
113
 
@@ -117,6 +117,9 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
117
117
  searchIssue: jest.Mock;
118
118
  createNewIssue: jest.Mock;
119
119
  updateNextActionDate: jest.Mock;
120
+ updateStory: jest.Mock;
121
+ addIssueToProject: jest.Mock;
122
+ getIssueByUrl: jest.Mock;
120
123
  };
121
124
  let mockIssueCommentRepository: {
122
125
  getCommentsFromIssue: jest.Mock;
@@ -174,6 +177,9 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
174
177
  searchIssue: jest.fn().mockResolvedValue([]),
175
178
  createNewIssue: jest.fn().mockResolvedValue(42),
176
179
  updateNextActionDate: jest.fn().mockResolvedValue(undefined),
180
+ updateStory: jest.fn().mockResolvedValue(undefined),
181
+ addIssueToProject: jest.fn().mockResolvedValue(undefined),
182
+ getIssueByUrl: jest.fn().mockResolvedValue(null),
177
183
  };
178
184
 
179
185
  mockIssueCommentRepository = {
@@ -4477,11 +4483,10 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
4477
4483
  });
4478
4484
 
4479
4485
  describe('nextStepAgent validation against agents list', () => {
4480
- it('should transition to Failed Preparation when nextStepAgent is not in configured agents list', async () => {
4481
- const issue = createMockIssue({
4482
- url: 'https://github.com/user/repo/issues/1',
4483
- status: 'Preparation',
4484
- });
4486
+ it('creates a workflow blocker issue and returns original task to Awaiting Workspace when nextStepAgent is not in configured agents list', async () => {
4487
+ const issueUrl = 'https://github.com/user/repo/issues/1';
4488
+ const blockerIssueUrl = 'https://github.com/user/repo/issues/42';
4489
+ const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
4485
4490
 
4486
4491
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
4487
4492
  mockIssueRepository.get.mockResolvedValue(issue);
@@ -4491,37 +4496,256 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
4491
4496
  'From: :robot: test-agent (model)\n```json\n{"nextStepAgent": "unknown-agent"}\n```',
4492
4497
  }),
4493
4498
  ]);
4494
- mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
4499
+ mockIssueRepository.searchIssue.mockResolvedValue([]);
4500
+ mockIssueRepository.createNewIssue.mockResolvedValue(42);
4495
4501
 
4496
4502
  await useCase.run({
4497
4503
  projectUrl: 'https://github.com/users/user/projects/1',
4498
- issueUrl: 'https://github.com/user/repo/issues/1',
4504
+ issueUrl,
4499
4505
  thresholdForAutoReject: 3,
4500
4506
  workflowBlockerResolvedWebhookUrl: null,
4501
4507
  allowedIssueAuthors: ['test-user'],
4502
4508
  agents: ['developer', 'triager'],
4503
4509
  });
4504
4510
 
4511
+ expect(mockIssueRepository.createNewIssue).toHaveBeenCalledWith(
4512
+ 'user',
4513
+ 'repo',
4514
+ expect.stringContaining('unknown-agent'),
4515
+ expect.stringContaining(issueUrl),
4516
+ [],
4517
+ [],
4518
+ );
4519
+ expect(mockIssueRepository.setDependedIssueUrl).toHaveBeenCalledWith(
4520
+ issueUrl,
4521
+ mockProject,
4522
+ blockerIssueUrl,
4523
+ );
4505
4524
  expect(mockIssueRepository.update).toHaveBeenCalledWith(
4525
+ expect.objectContaining({ status: 'Awaiting Workspace' }),
4526
+ mockProject,
4527
+ );
4528
+ expect(mockIssueRepository.update).not.toHaveBeenCalledWith(
4506
4529
  expect.objectContaining({ status: 'Failed Preparation' }),
4507
4530
  mockProject,
4508
4531
  );
4509
4532
  expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
4510
4533
  mockProject,
4511
- expect.objectContaining({ status: 'Failed Preparation' }),
4512
- 'failed-preparation-id',
4534
+ expect.objectContaining({ status: 'Awaiting Workspace' }),
4535
+ 'awaiting-workspace-id',
4513
4536
  );
4514
4537
  expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
4515
- expect.objectContaining({
4516
- url: 'https://github.com/user/repo/issues/1',
4538
+ expect.objectContaining({ url: issueUrl }),
4539
+ expect.stringContaining(blockerIssueUrl),
4540
+ );
4541
+ });
4542
+
4543
+ it('sets workflow blocker story on the created blocker issue when project has a matching story', async () => {
4544
+ const issueUrl = 'https://github.com/user/repo/issues/1';
4545
+ const blockerIssueUrl = 'https://github.com/user/repo/issues/42';
4546
+ const blockerStoryId = 'blocker-story-id';
4547
+ const projectWithStory = createMockProject({
4548
+ dependedIssueUrlSeparatedByComma: {
4549
+ name: 'Depended Issue URL',
4550
+ fieldId: 'depended-field-id',
4551
+ },
4552
+ story: {
4553
+ name: 'Story',
4554
+ fieldId: 'story-field-id',
4555
+ databaseId: 1,
4556
+ stories: [
4557
+ {
4558
+ id: blockerStoryId,
4559
+ name: 'workflow blocker',
4560
+ color: 'RED',
4561
+ description: '',
4562
+ },
4563
+ {
4564
+ id: 'regular-story-id',
4565
+ name: 'regular / workflow improvement',
4566
+ color: 'BLUE',
4567
+ description: '',
4568
+ },
4569
+ ],
4570
+ workflowManagementStory: {
4571
+ id: 'wms-id',
4572
+ name: 'workflow management',
4573
+ },
4574
+ },
4575
+ });
4576
+ const blockerIssueObject = createMockIssue({
4577
+ url: blockerIssueUrl,
4578
+ itemId: 'blocker-item-id',
4579
+ });
4580
+ const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
4581
+
4582
+ mockProjectRepository.getByUrl.mockResolvedValue(projectWithStory);
4583
+ mockIssueRepository.get.mockResolvedValue(issue);
4584
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
4585
+ createMockComment({
4586
+ content:
4587
+ 'From: :robot: test-agent (model)\n```json\n{"nextStepAgent": "unknown-agent"}\n```',
4517
4588
  }),
4518
- expect.stringContaining('unknown-agent'),
4589
+ ]);
4590
+ mockIssueRepository.searchIssue.mockResolvedValue([]);
4591
+ mockIssueRepository.createNewIssue.mockResolvedValue(42);
4592
+ mockIssueRepository.getIssueByUrl.mockResolvedValue(blockerIssueObject);
4593
+
4594
+ await useCase.run({
4595
+ projectUrl: 'https://github.com/users/user/projects/1',
4596
+ issueUrl,
4597
+ thresholdForAutoReject: 3,
4598
+ workflowBlockerResolvedWebhookUrl: null,
4599
+ allowedIssueAuthors: ['test-user'],
4600
+ agents: ['developer', 'triager'],
4601
+ });
4602
+
4603
+ expect(mockIssueRepository.addIssueToProject).toHaveBeenCalledWith(
4604
+ projectWithStory,
4605
+ blockerIssueUrl,
4606
+ );
4607
+ expect(mockIssueRepository.updateStory).toHaveBeenCalledWith(
4608
+ expect.objectContaining({ story: projectWithStory.story }),
4609
+ blockerIssueObject,
4610
+ blockerStoryId,
4611
+ );
4612
+ });
4613
+
4614
+ it('reuses an existing open blocker issue instead of creating a new one', async () => {
4615
+ const issueUrl = 'https://github.com/user/repo/issues/1';
4616
+ const existingBlockerUrl = 'https://github.com/user/repo/issues/99';
4617
+ const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
4618
+
4619
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
4620
+ mockIssueRepository.get.mockResolvedValue(issue);
4621
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
4622
+ createMockComment({
4623
+ content:
4624
+ 'From: :robot: test-agent (model)\n```json\n{"nextStepAgent": "unknown-agent"}\n```',
4625
+ }),
4626
+ ]);
4627
+ mockIssueRepository.searchIssue.mockResolvedValue([
4628
+ {
4629
+ url: existingBlockerUrl,
4630
+ title: 'Unregistered agent in workflow configuration: unknown-agent',
4631
+ number: '99',
4632
+ },
4633
+ ]);
4634
+
4635
+ await useCase.run({
4636
+ projectUrl: 'https://github.com/users/user/projects/1',
4637
+ issueUrl,
4638
+ thresholdForAutoReject: 3,
4639
+ workflowBlockerResolvedWebhookUrl: null,
4640
+ allowedIssueAuthors: ['test-user'],
4641
+ agents: ['developer', 'triager'],
4642
+ });
4643
+
4644
+ expect(mockIssueRepository.createNewIssue).not.toHaveBeenCalled();
4645
+ expect(mockIssueRepository.setDependedIssueUrl).toHaveBeenCalledWith(
4646
+ issueUrl,
4647
+ mockProject,
4648
+ existingBlockerUrl,
4519
4649
  );
4520
4650
  expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
4521
- expect.objectContaining({
4522
- url: 'https://github.com/user/repo/issues/1',
4651
+ expect.objectContaining({ url: issueUrl }),
4652
+ expect.stringContaining(existingBlockerUrl),
4653
+ );
4654
+ });
4655
+
4656
+ it('skips story assignment when project has no story field', async () => {
4657
+ const issueUrl = 'https://github.com/user/repo/issues/1';
4658
+ const projectWithoutStory = createMockProject({
4659
+ dependedIssueUrlSeparatedByComma: {
4660
+ name: 'Depended Issue URL',
4661
+ fieldId: 'depended-field-id',
4662
+ },
4663
+ story: null,
4664
+ });
4665
+ const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
4666
+
4667
+ mockProjectRepository.getByUrl.mockResolvedValue(projectWithoutStory);
4668
+ mockIssueRepository.get.mockResolvedValue(issue);
4669
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
4670
+ createMockComment({
4671
+ content:
4672
+ 'From: :robot: test-agent (model)\n```json\n{"nextStepAgent": "unknown-agent"}\n```',
4673
+ }),
4674
+ ]);
4675
+ mockIssueRepository.searchIssue.mockResolvedValue([]);
4676
+ mockIssueRepository.createNewIssue.mockResolvedValue(42);
4677
+
4678
+ await useCase.run({
4679
+ projectUrl: 'https://github.com/users/user/projects/1',
4680
+ issueUrl,
4681
+ thresholdForAutoReject: 3,
4682
+ workflowBlockerResolvedWebhookUrl: null,
4683
+ allowedIssueAuthors: ['test-user'],
4684
+ agents: ['developer', 'triager'],
4685
+ });
4686
+
4687
+ expect(mockIssueRepository.addIssueToProject).not.toHaveBeenCalled();
4688
+ expect(mockIssueRepository.updateStory).not.toHaveBeenCalled();
4689
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
4690
+ expect.objectContaining({ status: 'Awaiting Workspace' }),
4691
+ projectWithoutStory,
4692
+ );
4693
+ });
4694
+
4695
+ it('skips story assignment when no workflow blocker story option exists in project', async () => {
4696
+ const issueUrl = 'https://github.com/user/repo/issues/1';
4697
+ const projectWithNoBlockerStory = createMockProject({
4698
+ dependedIssueUrlSeparatedByComma: {
4699
+ name: 'Depended Issue URL',
4700
+ fieldId: 'depended-field-id',
4701
+ },
4702
+ story: {
4703
+ name: 'Story',
4704
+ fieldId: 'story-field-id',
4705
+ databaseId: 1,
4706
+ stories: [
4707
+ {
4708
+ id: 'regular-story-id',
4709
+ name: 'regular / workflow improvement',
4710
+ color: 'BLUE',
4711
+ description: '',
4712
+ },
4713
+ ],
4714
+ workflowManagementStory: {
4715
+ id: 'wms-id',
4716
+ name: 'workflow management',
4717
+ },
4718
+ },
4719
+ });
4720
+ const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
4721
+
4722
+ mockProjectRepository.getByUrl.mockResolvedValue(
4723
+ projectWithNoBlockerStory,
4724
+ );
4725
+ mockIssueRepository.get.mockResolvedValue(issue);
4726
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
4727
+ createMockComment({
4728
+ content:
4729
+ 'From: :robot: test-agent (model)\n```json\n{"nextStepAgent": "unknown-agent"}\n```',
4523
4730
  }),
4524
- expect.stringContaining('not in the configured agents list'),
4731
+ ]);
4732
+ mockIssueRepository.searchIssue.mockResolvedValue([]);
4733
+ mockIssueRepository.createNewIssue.mockResolvedValue(42);
4734
+
4735
+ await useCase.run({
4736
+ projectUrl: 'https://github.com/users/user/projects/1',
4737
+ issueUrl,
4738
+ thresholdForAutoReject: 3,
4739
+ workflowBlockerResolvedWebhookUrl: null,
4740
+ allowedIssueAuthors: ['test-user'],
4741
+ agents: ['developer', 'triager'],
4742
+ });
4743
+
4744
+ expect(mockIssueRepository.addIssueToProject).not.toHaveBeenCalled();
4745
+ expect(mockIssueRepository.updateStory).not.toHaveBeenCalled();
4746
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
4747
+ expect.objectContaining({ status: 'Awaiting Workspace' }),
4748
+ projectWithNoBlockerStory,
4525
4749
  );
4526
4750
  });
4527
4751