github-issue-tower-defence-management 2.9.0 → 2.10.1

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 (44) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +2 -2
  3. package/bin/adapter/entry-points/cli/index.js +2 -2
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/cli/projectConfig.js +17 -5
  6. package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +1 -1
  8. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  9. package/bin/domain/usecases/CheckIssueReviewReadinessUseCase.js +1 -1
  10. package/bin/domain/usecases/CheckIssueReviewReadinessUseCase.js.map +1 -1
  11. package/bin/domain/usecases/HandleScheduledEventUseCase.js +1 -1
  12. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  13. package/bin/domain/usecases/IssueRejectionEvaluator.js +6 -4
  14. package/bin/domain/usecases/IssueRejectionEvaluator.js.map +1 -1
  15. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +60 -14
  16. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  17. package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js +1 -1
  18. package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js.map +1 -1
  19. package/package.json +1 -1
  20. package/src/adapter/entry-points/cli/index.test.ts +4 -4
  21. package/src/adapter/entry-points/cli/index.ts +2 -2
  22. package/src/adapter/entry-points/cli/projectConfig.test.ts +40 -12
  23. package/src/adapter/entry-points/cli/projectConfig.ts +22 -7
  24. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +2 -2
  25. package/src/domain/usecases/CheckIssueReviewReadinessUseCase.ts +2 -2
  26. package/src/domain/usecases/HandleScheduledEventUseCase.ts +2 -2
  27. package/src/domain/usecases/IssueRejectionEvaluator.test.ts +43 -7
  28. package/src/domain/usecases/IssueRejectionEvaluator.ts +9 -7
  29. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +243 -19
  30. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +103 -21
  31. package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.ts +2 -2
  32. package/src/domain/usecases/issueReactivationTriggerIsPending.test.ts +6 -0
  33. package/types/adapter/entry-points/cli/projectConfig.d.ts +1 -1
  34. package/types/adapter/entry-points/cli/projectConfig.d.ts.map +1 -1
  35. package/types/domain/usecases/CheckIssueReviewReadinessUseCase.d.ts +1 -1
  36. package/types/domain/usecases/CheckIssueReviewReadinessUseCase.d.ts.map +1 -1
  37. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +1 -1
  38. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  39. package/types/domain/usecases/IssueRejectionEvaluator.d.ts +2 -2
  40. package/types/domain/usecases/IssueRejectionEvaluator.d.ts.map +1 -1
  41. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +3 -2
  42. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  43. package/types/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.d.ts +1 -1
  44. package/types/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.d.ts.map +1 -1
@@ -214,7 +214,7 @@ describe('IssueRejectionEvaluator', () => {
214
214
  agent: 'my-developer',
215
215
  },
216
216
  [],
217
- { developerAgentName: 'my-developer' },
217
+ { developerAgentNames: ['my-developer'] },
218
218
  );
219
219
 
220
220
  expect(mockIssueRepository.findRelatedOpenPRs).toHaveBeenCalled();
@@ -222,7 +222,7 @@ describe('IssueRejectionEvaluator', () => {
222
222
  expect(result.rejections[0].type).toBe('PULL_REQUEST_NOT_FOUND');
223
223
  });
224
224
 
225
- it('should not require PR evaluation when issue agent does not match custom developer agent name', async () => {
225
+ it('should not require PR evaluation when issue agent does not match custom developer agent names', async () => {
226
226
  const result = await evaluator.evaluate(
227
227
  {
228
228
  url: 'https://github.com/user/repo/issues/1',
@@ -231,7 +231,7 @@ describe('IssueRejectionEvaluator', () => {
231
231
  agent: 'other-agent',
232
232
  },
233
233
  [],
234
- { developerAgentName: 'my-developer' },
234
+ { developerAgentNames: ['my-developer'] },
235
235
  );
236
236
 
237
237
  expect(mockIssueRepository.findRelatedOpenPRs).not.toHaveBeenCalled();
@@ -239,7 +239,7 @@ describe('IssueRejectionEvaluator', () => {
239
239
  expect(result.approvedPrUrl).toBeNull();
240
240
  });
241
241
 
242
- it('should default to developer when developerAgentName is null', async () => {
242
+ it('should default to developer when developerAgentNames is null', async () => {
243
243
  mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
244
244
 
245
245
  const result = await evaluator.evaluate(
@@ -250,7 +250,7 @@ describe('IssueRejectionEvaluator', () => {
250
250
  agent: 'developer',
251
251
  },
252
252
  [],
253
- { developerAgentName: null },
253
+ { developerAgentNames: null },
254
254
  );
255
255
 
256
256
  expect(mockIssueRepository.findRelatedOpenPRs).toHaveBeenCalled();
@@ -258,6 +258,42 @@ describe('IssueRejectionEvaluator', () => {
258
258
  expect(result.rejections[0].type).toBe('PULL_REQUEST_NOT_FOUND');
259
259
  });
260
260
 
261
+ it('should require PR evaluation when issue agent matches any of multiple developer agent names', async () => {
262
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
263
+
264
+ const result = await evaluator.evaluate(
265
+ {
266
+ url: 'https://github.com/user/repo/issues/1',
267
+ labels: [],
268
+ isPr: false,
269
+ agent: 'second-developer',
270
+ },
271
+ [],
272
+ { developerAgentNames: ['first-developer', 'second-developer'] },
273
+ );
274
+
275
+ expect(mockIssueRepository.findRelatedOpenPRs).toHaveBeenCalled();
276
+ expect(result.rejections).toHaveLength(1);
277
+ expect(result.rejections[0].type).toBe('PULL_REQUEST_NOT_FOUND');
278
+ });
279
+
280
+ it('should not require PR evaluation when issue agent does not match any of multiple developer agent names', async () => {
281
+ const result = await evaluator.evaluate(
282
+ {
283
+ url: 'https://github.com/user/repo/issues/1',
284
+ labels: [],
285
+ isPr: false,
286
+ agent: 'chore',
287
+ },
288
+ [],
289
+ { developerAgentNames: ['first-developer', 'second-developer'] },
290
+ );
291
+
292
+ expect(mockIssueRepository.findRelatedOpenPRs).not.toHaveBeenCalled();
293
+ expect(result.rejections).toHaveLength(0);
294
+ expect(result.approvedPrUrl).toBeNull();
295
+ });
296
+
261
297
  it('should require PR evaluation and reject CI failure when issue agent is pr-reviewer', async () => {
262
298
  mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
263
299
  createReadyPr('https://github.com/user/repo/pull/10', {
@@ -316,7 +352,7 @@ describe('IssueRejectionEvaluator', () => {
316
352
  expect(result.approvedPrUrl).toBe('https://github.com/user/repo/pull/10');
317
353
  });
318
354
 
319
- it('should still require PR evaluation for pr-reviewer when custom developerAgentName is set', async () => {
355
+ it('should still require PR evaluation for pr-reviewer when custom developerAgentNames is set', async () => {
320
356
  mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
321
357
  createReadyPr('https://github.com/user/repo/pull/10', {
322
358
  isPassedAllCiJob: false,
@@ -332,7 +368,7 @@ describe('IssueRejectionEvaluator', () => {
332
368
  agent: 'pr-reviewer',
333
369
  },
334
370
  [],
335
- { developerAgentName: 'my-developer' },
371
+ { developerAgentNames: ['my-developer'] },
336
372
  );
337
373
 
338
374
  expect(mockIssueRepository.findRelatedOpenPRs).toHaveBeenCalled();
@@ -33,9 +33,9 @@ export type EvaluateOptions = {
33
33
  // getOpenPullRequest call, which keeps the transient-failure handling that
34
34
  // distinguishes an unknown state from an absent pull request.
35
35
  resolvedOpenPrByUrl?: ReadonlyMap<string, RelatedPullRequest | null> | null;
36
- // The agent name treated as the developer agent for PR/CI/conflict checks.
37
- // When null or undefined, defaults to 'developer'.
38
- developerAgentName?: string | null;
36
+ // The agent names treated as developer agents for PR/CI/conflict checks.
37
+ // When null, undefined, or empty, defaults to ['developer'].
38
+ developerAgentNames?: string[] | null;
39
39
  detectConflictEvenIfEvaluationSkipped?: boolean;
40
40
  };
41
41
 
@@ -68,7 +68,7 @@ export class IssueRejectionEvaluator {
68
68
  this.requiresPullRequestEvaluation(
69
69
  issue,
70
70
  labelsNotRequiringPullRequest,
71
- options.developerAgentName,
71
+ options.developerAgentNames,
72
72
  )
73
73
  ) {
74
74
  let prsToCheck: RelatedPullRequest[];
@@ -226,15 +226,17 @@ export class IssueRejectionEvaluator {
226
226
  requiresPullRequestEvaluation = (
227
227
  issue: { labels: string[]; body?: string | null; agent?: string | null },
228
228
  labelsNotRequiringPullRequest: string[] = [],
229
- developerAgentName?: string | null,
229
+ developerAgentNames?: string[] | null,
230
230
  ): boolean => {
231
231
  const categoryLabels = issue.labels.filter((label) =>
232
232
  label.startsWith('category:'),
233
233
  );
234
- const effectiveDeveloperAgentName = developerAgentName ?? 'developer';
234
+ const effectiveDeveloperAgentNames = developerAgentNames?.length
235
+ ? developerAgentNames
236
+ : ['developer'];
235
237
  const isNonDeveloperAgent =
236
238
  issue.agent != null &&
237
- issue.agent !== effectiveDeveloperAgentName &&
239
+ !effectiveDeveloperAgentNames.includes(issue.agent) &&
238
240
  issue.agent !== 'pr-reviewer';
239
241
  const hasLabelNotRequiringPullRequest = issue.labels.some((label) =>
240
242
  labelsNotRequiringPullRequest.includes(label),
@@ -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 = {
@@ -1757,7 +1763,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
1757
1763
  );
1758
1764
  });
1759
1765
 
1760
- it('should reject a missing PR when the issue agent matches developerAgentName', async () => {
1766
+ it('should reject a missing PR when the issue agent matches developerAgentNames', async () => {
1761
1767
  const issue = createMockIssue({
1762
1768
  url: 'https://github.com/user/repo/issues/1',
1763
1769
  status: 'Preparation',
@@ -1777,7 +1783,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
1777
1783
  thresholdForAutoReject: 3,
1778
1784
  workflowBlockerResolvedWebhookUrl: null,
1779
1785
  allowedIssueAuthors: ['test-user'],
1780
- developerAgentName: 'my-agent',
1786
+ developerAgentNames: ['my-agent'],
1781
1787
  });
1782
1788
 
1783
1789
  expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
@@ -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
 
@@ -4888,7 +5112,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
4888
5112
  );
4889
5113
  });
4890
5114
 
4891
- it('should reassign to the configured developerAgentName when chore agent has a failing CI PR', async () => {
5115
+ it('should reassign to the first configured developerAgentNames when chore agent has a failing CI PR', async () => {
4892
5116
  const issue = createMockIssue({
4893
5117
  url: 'https://github.com/user/repo/issues/1',
4894
5118
  status: 'Preparation',
@@ -4921,7 +5145,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
4921
5145
  thresholdForAutoReject: 3,
4922
5146
  workflowBlockerResolvedWebhookUrl: null,
4923
5147
  allowedIssueAuthors: null,
4924
- developerAgentName: 'custom-developer',
5148
+ developerAgentNames: ['custom-developer'],
4925
5149
  });
4926
5150
 
4927
5151
  expect(mockIssueRepository.setIssueAgentField).toHaveBeenCalledWith(
@@ -85,6 +85,9 @@ export class NotifyFinishedIssuePreparationUseCase {
85
85
  | 'searchIssue'
86
86
  | 'createNewIssue'
87
87
  | 'updateNextActionDate'
88
+ | 'updateStory'
89
+ | 'addIssueToProject'
90
+ | 'getIssueByUrl'
88
91
  >,
89
92
  private readonly issueCommentRepository: Pick<
90
93
  IssueCommentRepository,
@@ -116,7 +119,7 @@ export class NotifyFinishedIssuePreparationUseCase {
116
119
  missingAgentName?: string | null;
117
120
  sessionErrorLine?: string | null;
118
121
  manager?: string | null;
119
- developerAgentName?: string | null;
122
+ developerAgentNames?: string[] | null;
120
123
  deferPreparation?: boolean | null;
121
124
  }): Promise<void> => {
122
125
  const project = await this.projectRepository.getByUrl(params.projectUrl);
@@ -253,31 +256,26 @@ export class NotifyFinishedIssuePreparationUseCase {
253
256
  params.agents.length > 0 &&
254
257
  !params.agents.includes(nextStepAgent)
255
258
  ) {
256
- issue.status = FAILED_PREPARATION_STATUS_NAME;
257
- await this.issueRepository.update(issue, project);
258
- await this.issueRepository.updateStatus(
259
- project,
260
- issue,
261
- failedPreparationStatusOption.id,
262
- );
263
- await this.patchConsoleTab(issue);
264
- await this.issueCommentRepository.createComment(
259
+ await this.handleUnregisteredNextStepAgent(
265
260
  issue,
266
- `nextStepAgent '${nextStepAgent}' is not in the configured agents list. Update the configuration to include it.`,
261
+ project,
262
+ awaitingWorkspaceStatusOption,
263
+ nextStepAgent,
267
264
  );
268
265
  return;
269
266
  }
270
267
 
271
268
  const ciFailingPrUrl = await this.resolveLinkedPrWithCiFailure(
272
269
  issue,
273
- params.developerAgentName ?? null,
270
+ params.developerAgentNames ?? null,
274
271
  );
275
272
  if (ciFailingPrUrl !== null) {
276
- const effectiveDeveloperAgentName =
277
- params.developerAgentName ?? 'developer';
273
+ const effectiveDeveloperAgentNames = params.developerAgentNames?.length
274
+ ? params.developerAgentNames
275
+ : ['developer'];
278
276
  const agentOptionId = await this.ensureAgentOptionAndGetId(
279
277
  project,
280
- effectiveDeveloperAgentName,
278
+ effectiveDeveloperAgentNames[0],
281
279
  );
282
280
  if (agentOptionId !== null) {
283
281
  await this.issueRepository.setIssueAgentField(
@@ -312,7 +310,7 @@ export class NotifyFinishedIssuePreparationUseCase {
312
310
  isTrustedAuthor,
313
311
  resolveLabelsNotRequiringPullRequest(params),
314
312
  nextStepAgent,
315
- params.developerAgentName,
313
+ params.developerAgentNames,
316
314
  );
317
315
 
318
316
  const rejectionStatusMessage =
@@ -579,6 +577,88 @@ export class NotifyFinishedIssuePreparationUseCase {
579
577
  );
580
578
  };
581
579
 
580
+ private handleUnregisteredNextStepAgent = async (
581
+ issue: Issue,
582
+ project: Project,
583
+ awaitingWorkspaceStatusOption: { id: string },
584
+ nextStepAgent: string,
585
+ ): Promise<void> => {
586
+ const blockerIssueTitle = `Unregistered agent in workflow configuration: ${nextStepAgent}`;
587
+
588
+ const searchResults = await this.issueRepository.searchIssue({
589
+ owner: issue.org,
590
+ repositoryName: issue.repo,
591
+ type: 'issue',
592
+ state: 'open',
593
+ title: blockerIssueTitle,
594
+ });
595
+ const exactMatch = searchResults.find((i) => i.title === blockerIssueTitle);
596
+
597
+ let blockerIssueUrl: string;
598
+ if (exactMatch) {
599
+ blockerIssueUrl = exactMatch.url;
600
+ } else {
601
+ const body = [
602
+ `The last agent report on ${issue.url} designated \`nextStepAgent\` as \`${nextStepAgent}\`, which is absent from the configured agents list.`,
603
+ '',
604
+ `- Missing agent name: \`${nextStepAgent}\``,
605
+ `- Declaring task: ${issue.url}`,
606
+ ].join('\n');
607
+ const issueNumber = await this.issueRepository.createNewIssue(
608
+ issue.org,
609
+ issue.repo,
610
+ blockerIssueTitle,
611
+ body,
612
+ [],
613
+ [],
614
+ );
615
+ blockerIssueUrl = `https://github.com/${issue.org}/${issue.repo}/issues/${issueNumber}`;
616
+ }
617
+
618
+ if (project.story) {
619
+ const workflowBlockerStory = project.story.stories.find((s) =>
620
+ s.name.toLowerCase().includes('workflow blocker'),
621
+ );
622
+ if (workflowBlockerStory) {
623
+ await this.issueRepository.addIssueToProject(project, blockerIssueUrl);
624
+ const blockerIssue =
625
+ await this.issueRepository.getIssueByUrl(blockerIssueUrl);
626
+ if (blockerIssue) {
627
+ await this.issueRepository.updateStory(
628
+ { ...project, story: project.story },
629
+ blockerIssue,
630
+ workflowBlockerStory.id,
631
+ );
632
+ }
633
+ }
634
+ }
635
+
636
+ if (project.dependedIssueUrlSeparatedByComma) {
637
+ await this.issueRepository.setDependedIssueUrl(
638
+ issue.url,
639
+ project,
640
+ blockerIssueUrl,
641
+ );
642
+ } else {
643
+ console.warn(
644
+ `dependedIssueUrlSeparatedByComma not configured; cannot block ${issue.url} via ${blockerIssueUrl}`,
645
+ );
646
+ }
647
+
648
+ issue.status = AWAITING_WORKSPACE_STATUS_NAME;
649
+ await this.issueRepository.update(issue, project);
650
+ await this.issueRepository.updateStatus(
651
+ project,
652
+ issue,
653
+ awaitingWorkspaceStatusOption.id,
654
+ );
655
+ await this.patchConsoleTab(issue);
656
+ await this.issueCommentRepository.createComment(
657
+ issue,
658
+ `nextStepAgent \`${nextStepAgent}\` is not in the configured agents list. Created workflow blocker task:\n${blockerIssueUrl}`,
659
+ );
660
+ };
661
+
582
662
  private collectRejections = async (
583
663
  issue: {
584
664
  url: string;
@@ -590,7 +670,7 @@ export class NotifyFinishedIssuePreparationUseCase {
590
670
  isTrustedAuthor: (author: string) => boolean,
591
671
  labelsNotRequiringPullRequest: string[],
592
672
  nextStepAgent: string | null,
593
- developerAgentName?: string | null,
673
+ developerAgentNames?: string[] | null,
594
674
  ): Promise<{
595
675
  rejections: { type: RejectedReasonType; detail: string }[];
596
676
  approvedPrUrl: string | null;
@@ -618,7 +698,7 @@ export class NotifyFinishedIssuePreparationUseCase {
618
698
  await this.issueRejectionEvaluator.evaluate(
619
699
  issue,
620
700
  labelsNotRequiringPullRequest,
621
- { developerAgentName },
701
+ { developerAgentNames },
622
702
  );
623
703
  const requiredPrRejections = isTriagerAgentName(nextStepAgent)
624
704
  ? prRejections.filter(
@@ -682,12 +762,14 @@ export class NotifyFinishedIssuePreparationUseCase {
682
762
 
683
763
  private resolveLinkedPrWithCiFailure = async (
684
764
  issue: { url: string; agent: string | null; isPr: boolean },
685
- developerAgentName: string | null,
765
+ developerAgentNames: string[] | null,
686
766
  ): Promise<string | null> => {
687
- const effectiveDeveloperName = developerAgentName ?? 'developer';
767
+ const effectiveDeveloperAgentNames = developerAgentNames?.length
768
+ ? developerAgentNames
769
+ : ['developer'];
688
770
  if (
689
771
  issue.agent === null ||
690
- issue.agent === effectiveDeveloperName ||
772
+ effectiveDeveloperAgentNames.includes(issue.agent) ||
691
773
  issue.agent === 'pr-reviewer'
692
774
  ) {
693
775
  return null;