github-issue-tower-defence-management 1.164.0 → 1.165.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 (106) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/bin/adapter/entry-points/cli/index.js +1 -0
  3. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  4. package/bin/adapter/entry-points/cli/projectConfig.js +2 -0
  5. package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
  6. package/bin/adapter/entry-points/console/consoleOperationApi.js +1 -0
  7. package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
  8. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js +1 -0
  9. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js.map +1 -1
  10. package/bin/adapter/repositories/GraphqlProjectRepository.js +37 -0
  11. package/bin/adapter/repositories/GraphqlProjectRepository.js.map +1 -1
  12. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +56 -5
  13. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  14. package/bin/adapter/repositories/projectFieldDefinition.js +8 -0
  15. package/bin/adapter/repositories/projectFieldDefinition.js.map +1 -1
  16. package/bin/domain/entities/RequiredProjectField.js +7 -1
  17. package/bin/domain/entities/RequiredProjectField.js.map +1 -1
  18. package/bin/domain/usecases/HandleScheduledEventUseCase.js +1 -0
  19. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  20. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +65 -44
  21. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  22. package/bin/domain/usecases/ProjectRequiredFieldCreateUseCase.js +28 -2
  23. package/bin/domain/usecases/ProjectRequiredFieldCreateUseCase.js.map +1 -1
  24. package/bin/domain/usecases/StartPreparationUseCase.js +5 -4
  25. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/adapter/entry-points/cli/index.test.ts +2 -0
  28. package/src/adapter/entry-points/cli/index.ts +1 -0
  29. package/src/adapter/entry-points/cli/projectConfig.ts +3 -0
  30. package/src/adapter/entry-points/console/consoleOperationApi.ts +1 -0
  31. package/src/adapter/entry-points/console/consoleTabNames.test.ts +1 -0
  32. package/src/adapter/entry-points/console/dashboardEmitterComposerKey.test.ts +1 -0
  33. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +3 -0
  34. package/src/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.test.ts +1 -0
  35. package/src/adapter/entry-points/handlers/dashboardRowWriter.test.ts +1 -0
  36. package/src/adapter/entry-points/handlers/inTmuxByHumanDataWriter.test.ts +2 -0
  37. package/src/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.test.ts +1 -0
  38. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +1 -0
  39. package/src/adapter/entry-points/handlers/ownerCallFileCleaner.test.ts +1 -0
  40. package/src/adapter/entry-points/handlers/situationFileWriter.test.ts +1 -0
  41. package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.test.ts +2 -0
  42. package/src/adapter/entry-points/handlers/tokenStatusWriter.test.ts +1 -0
  43. package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +1 -0
  44. package/src/adapter/repositories/GitHubIssueCommentRepository.test.ts +1 -0
  45. package/src/adapter/repositories/GraphqlProjectRepository.diskCache.test.ts +1 -0
  46. package/src/adapter/repositories/GraphqlProjectRepository.test.ts +2 -0
  47. package/src/adapter/repositories/GraphqlProjectRepository.ts +50 -0
  48. package/src/adapter/repositories/GraphqlProjectRepositoryProjectLocation.test.ts +1 -0
  49. package/src/adapter/repositories/ProjectIssuesCacheRepository.test.ts +1 -0
  50. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +156 -0
  51. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +91 -6
  52. package/src/adapter/repositories/issue/RestIssueRepository.test.ts +1 -0
  53. package/src/adapter/repositories/projectFieldDefinition.test.ts +1 -0
  54. package/src/adapter/repositories/projectFieldDefinition.ts +12 -0
  55. package/src/domain/entities/Issue.ts +1 -0
  56. package/src/domain/entities/Project.ts +5 -0
  57. package/src/domain/entities/RequiredProjectField.ts +6 -0
  58. package/src/domain/usecases/CheckIssueReviewReadinessUseCase.test.ts +1 -0
  59. package/src/domain/usecases/GetStoryObjectMapUseCase.test.ts +2 -0
  60. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +1 -0
  61. package/src/domain/usecases/HandleScheduledEventUseCase.ts +2 -0
  62. package/src/domain/usecases/InTmuxByHumanSessionTokenCountUseCase.test.ts +1 -0
  63. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +146 -0
  64. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +110 -64
  65. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +1 -0
  66. package/src/domain/usecases/ProjectRequiredFieldCreateUseCase.test.ts +75 -1
  67. package/src/domain/usecases/ProjectRequiredFieldCreateUseCase.ts +50 -7
  68. package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.test.ts +2 -0
  69. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +2 -0
  70. package/src/domain/usecases/SetupTowerDefenceProjectUseCase.test.ts +2 -0
  71. package/src/domain/usecases/StaleTmuxSessionKillUseCase.test.ts +2 -0
  72. package/src/domain/usecases/StartPreparationUseCase.test.ts +2 -0
  73. package/src/domain/usecases/StartPreparationUseCase.ts +1 -0
  74. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +5 -0
  75. package/src/domain/usecases/adapter-interfaces/ProjectRepository.ts +6 -0
  76. package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +2 -0
  77. package/src/domain/usecases/dashboard/GenerateDashboardRowUseCase.test.ts +1 -0
  78. package/src/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.test.ts +2 -0
  79. package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +1 -0
  80. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  81. package/types/adapter/entry-points/cli/projectConfig.d.ts +1 -0
  82. package/types/adapter/entry-points/cli/projectConfig.d.ts.map +1 -1
  83. package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
  84. package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -1
  85. package/types/adapter/repositories/GraphqlProjectRepository.d.ts +3 -0
  86. package/types/adapter/repositories/GraphqlProjectRepository.d.ts.map +1 -1
  87. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +2 -0
  88. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  89. package/types/adapter/repositories/projectFieldDefinition.d.ts.map +1 -1
  90. package/types/domain/entities/Issue.d.ts +1 -0
  91. package/types/domain/entities/Issue.d.ts.map +1 -1
  92. package/types/domain/entities/Project.d.ts +5 -0
  93. package/types/domain/entities/Project.d.ts.map +1 -1
  94. package/types/domain/entities/RequiredProjectField.d.ts +1 -0
  95. package/types/domain/entities/RequiredProjectField.d.ts.map +1 -1
  96. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +1 -0
  97. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  98. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +4 -2
  99. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  100. package/types/domain/usecases/ProjectRequiredFieldCreateUseCase.d.ts +5 -1
  101. package/types/domain/usecases/ProjectRequiredFieldCreateUseCase.d.ts.map +1 -1
  102. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  103. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +1 -0
  104. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
  105. package/types/domain/usecases/adapter-interfaces/ProjectRepository.d.ts +3 -0
  106. package/types/domain/usecases/adapter-interfaces/ProjectRepository.d.ts.map +1 -1
@@ -45,6 +45,7 @@ const createMockProject = (overrides: Partial<Project> = {}): Project => ({
45
45
  remainingEstimationMinutes: null,
46
46
  dependedIssueUrlSeparatedByComma: null,
47
47
  completionDate50PercentConfidence: null,
48
+ agent: null,
48
49
  ...overrides,
49
50
  });
50
51
 
@@ -73,6 +74,7 @@ const createMockIssue = (overrides: Partial<Issue> = {}): Issue => ({
73
74
  createdAt: new Date(),
74
75
  author: '',
75
76
  closingIssueReferenceUrls: [],
77
+ agent: null,
76
78
  ...overrides,
77
79
  });
78
80
 
@@ -87,6 +89,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
87
89
  let useCase: NotifyFinishedIssuePreparationUseCase;
88
90
  let mockProjectRepository: {
89
91
  getByUrl: jest.Mock;
92
+ updateAgentList: jest.Mock;
90
93
  };
91
94
  let mockIssueRepository: {
92
95
  get: jest.Mock;
@@ -101,6 +104,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
101
104
  approvePullRequest: jest.Mock;
102
105
  requestChangesWithInlineComment: jest.Mock;
103
106
  setDependedIssueUrl: jest.Mock;
107
+ setIssueAgentField: jest.Mock;
104
108
  };
105
109
  let mockIssueCommentRepository: {
106
110
  getCommentsFromIssue: jest.Mock;
@@ -123,6 +127,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
123
127
 
124
128
  mockProjectRepository = {
125
129
  getByUrl: jest.fn(),
130
+ updateAgentList: jest.fn().mockResolvedValue([]),
126
131
  };
127
132
 
128
133
  mockIssueRepository = {
@@ -138,6 +143,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
138
143
  approvePullRequest: jest.fn().mockResolvedValue(undefined),
139
144
  requestChangesWithInlineComment: jest.fn().mockResolvedValue(undefined),
140
145
  setDependedIssueUrl: jest.fn(),
146
+ setIssueAgentField: jest.fn().mockResolvedValue(undefined),
141
147
  };
142
148
 
143
149
  mockIssueCommentRepository = {
@@ -3599,6 +3605,146 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
3599
3605
  });
3600
3606
  });
3601
3607
 
3608
+ describe('nextStepAgent handling', () => {
3609
+ const makeProjectWithAgent = () =>
3610
+ createMockProject({
3611
+ agent: {
3612
+ name: 'Agent',
3613
+ fieldId: 'agent-field-id',
3614
+ options: [
3615
+ { id: 'opt-impl', name: 'impl', color: 'GRAY', description: '' },
3616
+ ],
3617
+ },
3618
+ });
3619
+
3620
+ it('calls setIssueAgentField with existing option ID when nextStepAgent is in last comment', async () => {
3621
+ const issue = createMockIssue({ status: 'Preparation' });
3622
+ const projectWithAgent = makeProjectWithAgent();
3623
+ mockProjectRepository.getByUrl.mockResolvedValue(projectWithAgent);
3624
+ mockIssueRepository.get.mockResolvedValue(issue);
3625
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
3626
+ createMockComment({
3627
+ content:
3628
+ 'From: :robot: agent (model)\n```json\n{"nextStep": "do something", "nextStepAgent": "impl"}\n```',
3629
+ }),
3630
+ ]);
3631
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
3632
+
3633
+ await useCase.run({
3634
+ projectUrl: 'https://github.com/users/user/projects/1',
3635
+ issueUrl: 'https://github.com/user/repo/issues/1',
3636
+ thresholdForAutoReject: 3,
3637
+ workflowBlockerResolvedWebhookUrl: null,
3638
+ allowedIssueAuthors: null,
3639
+ });
3640
+
3641
+ expect(mockIssueRepository.setIssueAgentField).toHaveBeenCalledWith(
3642
+ 'https://github.com/user/repo/issues/1',
3643
+ projectWithAgent,
3644
+ 'opt-impl',
3645
+ );
3646
+ });
3647
+
3648
+ it('creates a new agent option and calls setIssueAgentField when nextStepAgent is not an existing option', async () => {
3649
+ const issue = createMockIssue({ status: 'Preparation' });
3650
+ const projectWithAgent = makeProjectWithAgent();
3651
+ mockProjectRepository.getByUrl.mockResolvedValue(projectWithAgent);
3652
+ mockIssueRepository.get.mockResolvedValue(issue);
3653
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
3654
+ createMockComment({
3655
+ content:
3656
+ 'From: :robot: agent (model)\n```json\n{"nextStep": "review", "nextStepAgent": "chore"}\n```',
3657
+ }),
3658
+ ]);
3659
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
3660
+ mockProjectRepository.updateAgentList.mockResolvedValue([
3661
+ { id: 'opt-impl', name: 'impl', color: 'GRAY', description: '' },
3662
+ { id: 'opt-chore-new', name: 'chore', color: 'GRAY', description: '' },
3663
+ ]);
3664
+
3665
+ await useCase.run({
3666
+ projectUrl: 'https://github.com/users/user/projects/1',
3667
+ issueUrl: 'https://github.com/user/repo/issues/1',
3668
+ thresholdForAutoReject: 3,
3669
+ workflowBlockerResolvedWebhookUrl: null,
3670
+ allowedIssueAuthors: null,
3671
+ });
3672
+
3673
+ expect(mockProjectRepository.updateAgentList).toHaveBeenCalledWith(
3674
+ projectWithAgent,
3675
+ expect.arrayContaining([
3676
+ expect.objectContaining({ name: 'impl' }),
3677
+ expect.objectContaining({ id: null, name: 'chore', color: 'GRAY' }),
3678
+ ]),
3679
+ );
3680
+ expect(mockIssueRepository.setIssueAgentField).toHaveBeenCalledWith(
3681
+ 'https://github.com/user/repo/issues/1',
3682
+ projectWithAgent,
3683
+ 'opt-chore-new',
3684
+ );
3685
+ });
3686
+
3687
+ it('does not call setIssueAgentField when nextStepAgent is absent from the last comment', async () => {
3688
+ const issue = createMockIssue({ status: 'Preparation' });
3689
+ const projectWithAgent = makeProjectWithAgent();
3690
+ mockProjectRepository.getByUrl.mockResolvedValue(projectWithAgent);
3691
+ mockIssueRepository.get.mockResolvedValue(issue);
3692
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
3693
+ createMockComment({
3694
+ content:
3695
+ 'From: :robot: agent (model)\n```json\n{"nextStep": null}\n```',
3696
+ }),
3697
+ ]);
3698
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
3699
+ {
3700
+ url: 'https://github.com/user/repo/pull/1',
3701
+ isConflicted: false,
3702
+ isPassedAllCiJob: true,
3703
+ isCiStateSuccess: true,
3704
+ isResolvedAllReviewComments: true,
3705
+ isBranchOutOfDate: false,
3706
+ missingRequiredCheckNames: [],
3707
+ },
3708
+ ]);
3709
+
3710
+ await useCase.run({
3711
+ projectUrl: 'https://github.com/users/user/projects/1',
3712
+ issueUrl: 'https://github.com/user/repo/issues/1',
3713
+ thresholdForAutoReject: 3,
3714
+ workflowBlockerResolvedWebhookUrl: null,
3715
+ allowedIssueAuthors: null,
3716
+ });
3717
+
3718
+ expect(mockIssueRepository.setIssueAgentField).not.toHaveBeenCalled();
3719
+ expect(mockProjectRepository.updateAgentList).not.toHaveBeenCalled();
3720
+ });
3721
+
3722
+ it('does not call setIssueAgentField when project has no agent field', async () => {
3723
+ const issue = createMockIssue({ status: 'Preparation' });
3724
+ mockProjectRepository.getByUrl.mockResolvedValue(
3725
+ createMockProject({ agent: null }),
3726
+ );
3727
+ mockIssueRepository.get.mockResolvedValue(issue);
3728
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
3729
+ createMockComment({
3730
+ content:
3731
+ 'From: :robot: agent (model)\n```json\n{"nextStep": "go", "nextStepAgent": "impl"}\n```',
3732
+ }),
3733
+ ]);
3734
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
3735
+
3736
+ await useCase.run({
3737
+ projectUrl: 'https://github.com/users/user/projects/1',
3738
+ issueUrl: 'https://github.com/user/repo/issues/1',
3739
+ thresholdForAutoReject: 3,
3740
+ workflowBlockerResolvedWebhookUrl: null,
3741
+ allowedIssueAuthors: null,
3742
+ });
3743
+
3744
+ expect(mockIssueRepository.setIssueAgentField).not.toHaveBeenCalled();
3745
+ });
3746
+ });
3747
+
3602
3748
  describe('consoleTabsRepository integration', () => {
3603
3749
  let mockConsoleTabsRepository: {
3604
3750
  patchIssueTabTransition: jest.Mock;
@@ -25,6 +25,8 @@ import {
25
25
  ConsoleTabName,
26
26
  } from './console/GenerateConsoleListsUseCase';
27
27
  import { Issue } from '../entities/Issue';
28
+ import { FieldOption, Project } from '../entities/Project';
29
+ import { normalizeProjectFieldName } from '../entities/ProjectFieldName';
28
30
 
29
31
  export class IssueNotFoundError extends Error {
30
32
  constructor(issueUrl: string) {
@@ -52,7 +54,10 @@ export class NotifyFinishedIssuePreparationUseCase {
52
54
  private readonly changeTargetPullRequestApprover: ChangeTargetPullRequestApprover;
53
55
 
54
56
  constructor(
55
- private readonly projectRepository: Pick<ProjectRepository, 'getByUrl'>,
57
+ private readonly projectRepository: Pick<
58
+ ProjectRepository,
59
+ 'getByUrl' | 'updateAgentList'
60
+ >,
56
61
  private readonly issueRepository: Pick<
57
62
  IssueRepository,
58
63
  | 'get'
@@ -67,6 +72,7 @@ export class NotifyFinishedIssuePreparationUseCase {
67
72
  | 'approvePullRequest'
68
73
  | 'requestChangesWithInlineComment'
69
74
  | 'setDependedIssueUrl'
75
+ | 'setIssueAgentField'
70
76
  >,
71
77
  private readonly issueCommentRepository: Pick<
72
78
  IssueCommentRepository,
@@ -93,6 +99,7 @@ export class NotifyFinishedIssuePreparationUseCase {
93
99
  labelsAsLlmAgentName?: string[] | null;
94
100
  labelsNotRequiringPullRequest?: string[] | null;
95
101
  changeTargetPathAliases?: Record<string, string> | null;
102
+ agents?: string[] | null;
96
103
  }): Promise<void> => {
97
104
  const project = await this.projectRepository.getByUrl(params.projectUrl);
98
105
 
@@ -195,41 +202,53 @@ export class NotifyFinishedIssuePreparationUseCase {
195
202
  const isTrustedAuthor = (author: string): boolean =>
196
203
  this.isAuthorTrusted(author, params.allowedIssueAuthors ?? null);
197
204
 
198
- const lastComment = comments[comments.length - 1];
199
- if (
200
- lastComment &&
201
- isTrustedAuthor(lastComment.author) &&
202
- lastComment.content.startsWith('From: :robot:')
203
- ) {
204
- const nextStepAgent = this.extractNextStepAgent(lastComment.content);
205
- if (nextStepAgent !== null) {
206
- await this.issueRepository.getOrCreateLabel(
207
- issue.org,
208
- issue.repo,
209
- nextStepAgent,
210
- );
211
- const agentLabels = params.labelsAsLlmAgentName ?? [];
212
- const filteredLabels = issue.labels.filter(
213
- (label) =>
214
- !label.startsWith('llm-agent:') && !agentLabels.includes(label),
215
- );
216
- const updatedLabels = [...new Set([...filteredLabels, nextStepAgent])];
217
- issue.labels = updatedLabels;
218
- issue.status = AWAITING_WORKSPACE_STATUS_NAME;
219
- await this.issueRepository.update(issue, project);
220
- await this.issueRepository.updateStatus(
205
+ const lastTrustedBotComment = [...comments]
206
+ .reverse()
207
+ .find(
208
+ (c) =>
209
+ isTrustedAuthor(c.author) && c.content.startsWith('From: :robot:'),
210
+ );
211
+ const nextStepAgent = lastTrustedBotComment
212
+ ? this.extractNextStepAgent(lastTrustedBotComment.content)
213
+ : null;
214
+ if (nextStepAgent !== null) {
215
+ const agentOptionId = await this.ensureAgentOptionAndGetId(
216
+ project,
217
+ nextStepAgent,
218
+ );
219
+ if (agentOptionId) {
220
+ await this.issueRepository.setIssueAgentField(
221
+ params.issueUrl,
221
222
  project,
222
- issue,
223
- awaitingWorkspaceStatusOption.id,
223
+ agentOptionId,
224
224
  );
225
- await this.issueRepository.updateLabels(issue, updatedLabels);
226
- await this.patchConsoleTab(issue);
227
- await this.issueCommentRepository.createComment(
228
- issue,
229
- `Next step agent: ${nextStepAgent}`,
230
- );
231
- return;
232
225
  }
226
+ await this.issueRepository.getOrCreateLabel(
227
+ issue.org,
228
+ issue.repo,
229
+ nextStepAgent,
230
+ );
231
+ const agentLabels = params.labelsAsLlmAgentName ?? [];
232
+ const filteredLabels = issue.labels.filter(
233
+ (label) =>
234
+ !label.startsWith('llm-agent:') && !agentLabels.includes(label),
235
+ );
236
+ const updatedLabels = [...new Set([...filteredLabels, nextStepAgent])];
237
+ issue.labels = updatedLabels;
238
+ issue.status = AWAITING_WORKSPACE_STATUS_NAME;
239
+ await this.issueRepository.update(issue, project);
240
+ await this.issueRepository.updateStatus(
241
+ project,
242
+ issue,
243
+ awaitingWorkspaceStatusOption.id,
244
+ );
245
+ await this.issueRepository.updateLabels(issue, updatedLabels);
246
+ await this.patchConsoleTab(issue);
247
+ await this.issueCommentRepository.createComment(
248
+ issue,
249
+ `Next step agent: ${nextStepAgent}`,
250
+ );
251
+ return;
233
252
  }
234
253
 
235
254
  const { rejections, approvedPrUrl } = await this.collectRejections(
@@ -444,37 +463,6 @@ export class NotifyFinishedIssuePreparationUseCase {
444
463
  return nextStepValue !== null && nextStepValue !== undefined;
445
464
  };
446
465
 
447
- private extractNextStepAgent = (body: string): string | null => {
448
- const reportMatch = body.match(/```json\n([\s\S]*?)\n```/);
449
- if (!reportMatch || reportMatch.length < 2) {
450
- return null;
451
- }
452
- let reportJson: unknown;
453
- try {
454
- reportJson = JSON.parse(reportMatch[1]);
455
- } catch (error) {
456
- console.warn(
457
- 'Invalid JSON in report body while checking nextStepAgent:',
458
- error,
459
- );
460
- return null;
461
- }
462
- if (typeof reportJson !== 'object' || reportJson === null) {
463
- return null;
464
- }
465
- if (!('nextStepAgent' in reportJson)) {
466
- return null;
467
- }
468
- const nextStepAgentValue = Reflect.get(reportJson, 'nextStepAgent');
469
- if (
470
- typeof nextStepAgentValue !== 'string' ||
471
- nextStepAgentValue.trim() === ''
472
- ) {
473
- return null;
474
- }
475
- return nextStepAgentValue;
476
- };
477
-
478
466
  private setDependedIssueUrlForAllOpenPRs = async (
479
467
  issue: { url: string; labels: string[]; isPr: boolean },
480
468
  issueUrl: string,
@@ -550,6 +538,64 @@ export class NotifyFinishedIssuePreparationUseCase {
550
538
  return null;
551
539
  };
552
540
 
541
+ private extractNextStepAgent = (body: string): string | null => {
542
+ const reportMatch = body.match(/```json\n([\s\S]*?)\n```/);
543
+ if (!reportMatch || reportMatch.length < 2) {
544
+ return null;
545
+ }
546
+ let reportJson: unknown;
547
+ try {
548
+ reportJson = JSON.parse(reportMatch[1]);
549
+ } catch (error) {
550
+ console.warn(
551
+ 'Invalid JSON in report body while checking nextStepAgent:',
552
+ error,
553
+ );
554
+ return null;
555
+ }
556
+ if (typeof reportJson !== 'object' || reportJson === null) {
557
+ return null;
558
+ }
559
+ if (!('nextStepAgent' in reportJson)) {
560
+ return null;
561
+ }
562
+ const value = Reflect.get(reportJson, 'nextStepAgent');
563
+ if (typeof value !== 'string' || value.trim() === '') {
564
+ return null;
565
+ }
566
+ return value.trim();
567
+ };
568
+
569
+ private ensureAgentOptionAndGetId = async (
570
+ project: Project,
571
+ agentName: string,
572
+ ): Promise<string | null> => {
573
+ if (!project.agent) {
574
+ return null;
575
+ }
576
+ const normalizedTarget = normalizeProjectFieldName(agentName);
577
+ const existing = project.agent.options.find(
578
+ (o) => normalizeProjectFieldName(o.name) === normalizedTarget,
579
+ );
580
+ if (existing) {
581
+ return existing.id;
582
+ }
583
+ const mergedOptions: (Omit<FieldOption, 'id'> & {
584
+ id: FieldOption['id'] | null;
585
+ })[] = [
586
+ ...project.agent.options.map((o) => ({ ...o })),
587
+ { id: null, name: agentName, color: 'GRAY' as const, description: '' },
588
+ ];
589
+ const updatedOptions = await this.projectRepository.updateAgentList(
590
+ project,
591
+ mergedOptions,
592
+ );
593
+ const created = updatedOptions.find(
594
+ (o) => normalizeProjectFieldName(o.name) === normalizedTarget,
595
+ );
596
+ return created?.id ?? null;
597
+ };
598
+
553
599
  private patchConsoleTab = async (issue: Issue): Promise<void> => {
554
600
  if (!this.consoleTabsRepository) return;
555
601
  const targetTabName = this.resolveConsoleTargetTab(issue.status ?? '');
@@ -237,6 +237,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
237
237
  createdAt: now,
238
238
  author: 'HiromiShikata',
239
239
  closingIssueReferenceUrls: [],
240
+ agent: null,
240
241
  ...overrides,
241
242
  });
242
243
 
@@ -1,6 +1,7 @@
1
1
  import { mock } from 'jest-mock-extended';
2
2
  import { FieldOption, Project } from '../entities/Project';
3
3
  import {
4
+ AGENT_FIELD_NAME,
4
5
  DEPENDED_ISSUE_URL_FIELD_NAME,
5
6
  NEXT_ACTION_DATE_FIELD_NAME,
6
7
  NEXT_ACTION_HOUR_FIELD_NAME,
@@ -27,6 +28,7 @@ describe('ProjectRequiredFieldCreateUseCase', () => {
27
28
  remainingEstimationMinutes: null,
28
29
  dependedIssueUrlSeparatedByComma: null,
29
30
  completionDate50PercentConfidence: null,
31
+ agent: null,
30
32
  };
31
33
 
32
34
  const buildProjectWithStory = (stories: FieldOption[]): Project => ({
@@ -50,13 +52,18 @@ describe('ProjectRequiredFieldCreateUseCase', () => {
50
52
  mock<
51
53
  Pick<
52
54
  ProjectRepository,
53
- 'getByUrl' | 'listFieldNames' | 'createField' | 'updateStoryList'
55
+ | 'getByUrl'
56
+ | 'listFieldNames'
57
+ | 'createField'
58
+ | 'updateStoryList'
59
+ | 'updateAgentList'
54
60
  >
55
61
  >();
56
62
  projectRepository.getByUrl.mockResolvedValue(project);
57
63
  projectRepository.listFieldNames.mockResolvedValue(existingFieldNames);
58
64
  projectRepository.createField.mockResolvedValue(undefined);
59
65
  projectRepository.updateStoryList.mockResolvedValue([]);
66
+ projectRepository.updateAgentList.mockResolvedValue([]);
60
67
  return {
61
68
  projectRepository,
62
69
  useCase: new ProjectRequiredFieldCreateUseCase(projectRepository),
@@ -79,6 +86,7 @@ describe('ProjectRequiredFieldCreateUseCase', () => {
79
86
  NEXT_ACTION_DATE_FIELD_NAME,
80
87
  NEXT_ACTION_HOUR_FIELD_NAME,
81
88
  DEPENDED_ISSUE_URL_FIELD_NAME,
89
+ AGENT_FIELD_NAME,
82
90
  ]);
83
91
  });
84
92
 
@@ -91,6 +99,7 @@ describe('ProjectRequiredFieldCreateUseCase', () => {
91
99
  'nextactiondate',
92
100
  'nextactionhour',
93
101
  'Depended Issue URL separated by comma',
102
+ 'Agent',
94
103
  ],
95
104
  projectWithoutStory,
96
105
  );
@@ -114,6 +123,7 @@ describe('ProjectRequiredFieldCreateUseCase', () => {
114
123
  expect(createdFieldNames).toEqual([
115
124
  NEXT_ACTION_HOUR_FIELD_NAME,
116
125
  DEPENDED_ISSUE_URL_FIELD_NAME,
126
+ AGENT_FIELD_NAME,
117
127
  ]);
118
128
  });
119
129
 
@@ -592,4 +602,68 @@ describe('ProjectRequiredFieldCreateUseCase', () => {
592
602
  );
593
603
  });
594
604
  });
605
+
606
+ describe('reconcileAgentOptions', () => {
607
+ const buildProjectWithAgent = (
608
+ existingOptions: { id: string; name: string }[],
609
+ ): Project => ({
610
+ ...projectWithoutStory,
611
+ agent: {
612
+ name: 'Agent',
613
+ fieldId: 'agent-field-id',
614
+ options: existingOptions.map((o) => ({
615
+ ...o,
616
+ color: 'GRAY' as const,
617
+ description: '',
618
+ })),
619
+ },
620
+ });
621
+
622
+ it('adds missing agent names as GRAY options and calls updateAgentList', async () => {
623
+ const project = buildProjectWithAgent([{ id: 'opt-impl', name: 'impl' }]);
624
+ const { projectRepository, useCase } = createUseCase([], project);
625
+
626
+ await useCase.reconcileAgentOptions(project, ['impl', 'chore']);
627
+
628
+ expect(projectRepository.updateAgentList).toHaveBeenCalledWith(
629
+ project,
630
+ expect.arrayContaining([
631
+ expect.objectContaining({ id: 'opt-impl', name: 'impl' }),
632
+ expect.objectContaining({ id: null, name: 'chore', color: 'GRAY' }),
633
+ ]),
634
+ );
635
+ });
636
+
637
+ it('does not call updateAgentList when all agents already exist', async () => {
638
+ const project = buildProjectWithAgent([
639
+ { id: 'opt-impl', name: 'impl' },
640
+ { id: 'opt-chore', name: 'chore' },
641
+ ]);
642
+ const { projectRepository, useCase } = createUseCase([], project);
643
+
644
+ await useCase.reconcileAgentOptions(project, ['impl', 'chore']);
645
+
646
+ expect(projectRepository.updateAgentList).not.toHaveBeenCalled();
647
+ });
648
+
649
+ it('does not call updateAgentList when agentNames is null', async () => {
650
+ const project = buildProjectWithAgent([]);
651
+ const { projectRepository, useCase } = createUseCase([], project);
652
+
653
+ await useCase.reconcileAgentOptions(project, null);
654
+
655
+ expect(projectRepository.updateAgentList).not.toHaveBeenCalled();
656
+ });
657
+
658
+ it('does not call updateAgentList when project has no agent field', async () => {
659
+ const { projectRepository, useCase } = createUseCase(
660
+ [],
661
+ projectWithoutStory,
662
+ );
663
+
664
+ await useCase.reconcileAgentOptions(projectWithoutStory, ['impl']);
665
+
666
+ expect(projectRepository.updateAgentList).not.toHaveBeenCalled();
667
+ });
668
+ });
595
669
  });
@@ -1,12 +1,13 @@
1
1
  import { FieldOption, Project } from '../entities/Project';
2
2
  import { normalizeProjectFieldName } from '../entities/ProjectFieldName';
3
3
  import {
4
+ AGENT_FIELD_NAME,
4
5
  REQUIRED_PROJECT_FIELDS,
5
6
  STORY_FIELD_NAME,
6
7
  } from '../entities/RequiredProjectField';
7
8
  import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
8
9
 
9
- type StoryOptionToSubmit = Omit<FieldOption, 'id'> & {
10
+ type OptionToSubmit = Omit<FieldOption, 'id'> & {
10
11
  id: FieldOption['id'] | null;
11
12
  };
12
13
 
@@ -14,14 +15,25 @@ export class ProjectRequiredFieldCreateUseCase {
14
15
  constructor(
15
16
  private readonly projectRepository: Pick<
16
17
  ProjectRepository,
17
- 'getByUrl' | 'listFieldNames' | 'createField' | 'updateStoryList'
18
+ | 'getByUrl'
19
+ | 'listFieldNames'
20
+ | 'createField'
21
+ | 'updateStoryList'
22
+ | 'updateAgentList'
18
23
  >,
19
24
  ) {}
20
25
 
21
- run = async (params: { projectUrl: string }): Promise<void> => {
26
+ run = async (params: {
27
+ projectUrl: string;
28
+ agents?: string[] | null;
29
+ }): Promise<void> => {
22
30
  const project = await this.projectRepository.getByUrl(params.projectUrl);
23
31
  await this.createMissingFields(project);
24
- await this.reconcileStoryOptions(project);
32
+ const updatedProject = await this.projectRepository.getByUrl(
33
+ params.projectUrl,
34
+ );
35
+ await this.reconcileStoryOptions(updatedProject);
36
+ await this.reconcileAgentOptions(updatedProject, params.agents ?? null);
25
37
  };
26
38
 
27
39
  private createMissingFields = async (project: Project): Promise<void> => {
@@ -51,9 +63,9 @@ export class ProjectRequiredFieldCreateUseCase {
51
63
  return;
52
64
  }
53
65
  const requiredOptions = storyFieldDefinition.options;
54
- const mergedOptions: StoryOptionToSubmit[] = project.story.stories.map(
55
- (o) => ({ ...o }),
56
- );
66
+ const mergedOptions: OptionToSubmit[] = project.story.stories.map((o) => ({
67
+ ...o,
68
+ }));
57
69
  let addedCount = 0;
58
70
  let previousRequiredIndex = -1;
59
71
  for (const required of requiredOptions) {
@@ -75,6 +87,35 @@ export class ProjectRequiredFieldCreateUseCase {
75
87
  await this.projectRepository.updateStoryList(project, mergedOptions);
76
88
  };
77
89
 
90
+ reconcileAgentOptions = async (
91
+ project: Project,
92
+ agentNames: string[] | null,
93
+ ): Promise<void> => {
94
+ if (!project.agent || !agentNames || agentNames.length === 0) {
95
+ return;
96
+ }
97
+ const existingOptions = project.agent.options;
98
+ const existingNames = new Set(
99
+ existingOptions.map((o) => normalizeProjectFieldName(o.name)),
100
+ );
101
+ const toAdd = agentNames.filter(
102
+ (name) => !existingNames.has(normalizeProjectFieldName(name)),
103
+ );
104
+ if (toAdd.length === 0) {
105
+ return;
106
+ }
107
+ const mergedOptions: OptionToSubmit[] = [
108
+ ...existingOptions.map((o) => ({ ...o })),
109
+ ...toAdd.map((name) => ({
110
+ id: null,
111
+ name,
112
+ color: 'GRAY' as const,
113
+ description: '',
114
+ })),
115
+ ];
116
+ await this.projectRepository.updateAgentList(project, mergedOptions);
117
+ };
118
+
78
119
  private optionNameSatisfies = (
79
120
  currentName: string,
80
121
  requiredName: string,
@@ -82,4 +123,6 @@ export class ProjectRequiredFieldCreateUseCase {
82
123
  normalizeProjectFieldName(currentName).startsWith(
83
124
  normalizeProjectFieldName(requiredName),
84
125
  );
126
+
127
+ static readonly AGENT_FIELD_NAME = AGENT_FIELD_NAME;
85
128
  }
@@ -54,6 +54,7 @@ const createMockProject = (overrides: Partial<Project> = {}): Project => ({
54
54
  remainingEstimationMinutes: null,
55
55
  dependedIssueUrlSeparatedByComma: null,
56
56
  completionDate50PercentConfidence: null,
57
+ agent: null,
57
58
  ...overrides,
58
59
  });
59
60
 
@@ -82,6 +83,7 @@ const createMockIssue = (overrides: Partial<Issue> = {}): Issue => ({
82
83
  createdAt: new Date(),
83
84
  author: 'owner',
84
85
  closingIssueReferenceUrls: [],
86
+ agent: null,
85
87
  ...overrides,
86
88
  });
87
89
 
@@ -33,6 +33,7 @@ const createMockIssue = (overrides: Partial<Issue> = {}): Issue => ({
33
33
  createdAt: new Date(),
34
34
  author: '',
35
35
  closingIssueReferenceUrls: [],
36
+ agent: null,
36
37
  ...overrides,
37
38
  });
38
39
 
@@ -84,6 +85,7 @@ const createMockProject = (): Project => ({
84
85
  remainingEstimationMinutes: null,
85
86
  dependedIssueUrlSeparatedByComma: null,
86
87
  completionDate50PercentConfidence: null,
88
+ agent: null,
87
89
  });
88
90
 
89
91
  const createPassingPr = () => ({