github-issue-tower-defence-management 1.164.0 → 1.165.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 (106) hide show
  1. package/CHANGELOG.md +19 -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 +40 -3
  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 +2 -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 +73 -44
  21. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  22. package/bin/domain/usecases/ProjectRequiredFieldCreateUseCase.js +40 -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 +53 -3
  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 +1 -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 +156 -0
  64. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +120 -64
  65. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +1 -0
  66. package/src/domain/usecases/ProjectRequiredFieldCreateUseCase.test.ts +86 -1
  67. package/src/domain/usecases/ProjectRequiredFieldCreateUseCase.ts +62 -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
@@ -3,6 +3,7 @@ import { Issue } from '../entities/Issue';
3
3
  import { Project } from '../entities/Project';
4
4
  import { Comment } from '../entities/Comment';
5
5
  import { StoryObjectMap } from '../entities/StoryObjectMap';
6
+ import { AGENT_FIELD_NAME } from '../entities/RequiredProjectField';
6
7
 
7
8
  const createMockProject = (overrides: Partial<Project> = {}): Project => ({
8
9
  id: 'project-1',
@@ -45,6 +46,7 @@ const createMockProject = (overrides: Partial<Project> = {}): Project => ({
45
46
  remainingEstimationMinutes: null,
46
47
  dependedIssueUrlSeparatedByComma: null,
47
48
  completionDate50PercentConfidence: null,
49
+ agent: null,
48
50
  ...overrides,
49
51
  });
50
52
 
@@ -73,6 +75,7 @@ const createMockIssue = (overrides: Partial<Issue> = {}): Issue => ({
73
75
  createdAt: new Date(),
74
76
  author: '',
75
77
  closingIssueReferenceUrls: [],
78
+ agent: null,
76
79
  ...overrides,
77
80
  });
78
81
 
@@ -87,6 +90,8 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
87
90
  let useCase: NotifyFinishedIssuePreparationUseCase;
88
91
  let mockProjectRepository: {
89
92
  getByUrl: jest.Mock;
93
+ updateAgentList: jest.Mock;
94
+ createField: jest.Mock;
90
95
  };
91
96
  let mockIssueRepository: {
92
97
  get: jest.Mock;
@@ -101,6 +106,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
101
106
  approvePullRequest: jest.Mock;
102
107
  requestChangesWithInlineComment: jest.Mock;
103
108
  setDependedIssueUrl: jest.Mock;
109
+ setIssueAgentField: jest.Mock;
104
110
  };
105
111
  let mockIssueCommentRepository: {
106
112
  getCommentsFromIssue: jest.Mock;
@@ -123,6 +129,8 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
123
129
 
124
130
  mockProjectRepository = {
125
131
  getByUrl: jest.fn(),
132
+ updateAgentList: jest.fn().mockResolvedValue([]),
133
+ createField: jest.fn().mockResolvedValue(undefined),
126
134
  };
127
135
 
128
136
  mockIssueRepository = {
@@ -138,6 +146,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
138
146
  approvePullRequest: jest.fn().mockResolvedValue(undefined),
139
147
  requestChangesWithInlineComment: jest.fn().mockResolvedValue(undefined),
140
148
  setDependedIssueUrl: jest.fn(),
149
+ setIssueAgentField: jest.fn().mockResolvedValue(undefined),
141
150
  };
142
151
 
143
152
  mockIssueCommentRepository = {
@@ -3599,6 +3608,153 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
3599
3608
  });
3600
3609
  });
3601
3610
 
3611
+ describe('nextStepAgent handling', () => {
3612
+ const makeProjectWithAgent = () =>
3613
+ createMockProject({
3614
+ agent: {
3615
+ name: 'Agent',
3616
+ fieldId: 'agent-field-id',
3617
+ options: [
3618
+ { id: 'opt-impl', name: 'impl', color: 'GRAY', description: '' },
3619
+ ],
3620
+ },
3621
+ });
3622
+
3623
+ it('calls setIssueAgentField with existing option ID when nextStepAgent is in last comment', async () => {
3624
+ const issue = createMockIssue({ status: 'Preparation' });
3625
+ const projectWithAgent = makeProjectWithAgent();
3626
+ mockProjectRepository.getByUrl.mockResolvedValue(projectWithAgent);
3627
+ mockIssueRepository.get.mockResolvedValue(issue);
3628
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
3629
+ createMockComment({
3630
+ content:
3631
+ 'From: :robot: agent (model)\n```json\n{"nextStep": "do something", "nextStepAgent": "impl"}\n```',
3632
+ }),
3633
+ ]);
3634
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
3635
+
3636
+ await useCase.run({
3637
+ projectUrl: 'https://github.com/users/user/projects/1',
3638
+ issueUrl: 'https://github.com/user/repo/issues/1',
3639
+ thresholdForAutoReject: 3,
3640
+ workflowBlockerResolvedWebhookUrl: null,
3641
+ allowedIssueAuthors: null,
3642
+ });
3643
+
3644
+ expect(mockIssueRepository.setIssueAgentField).toHaveBeenCalledWith(
3645
+ 'https://github.com/user/repo/issues/1',
3646
+ projectWithAgent,
3647
+ 'opt-impl',
3648
+ );
3649
+ });
3650
+
3651
+ it('creates a new agent option and calls setIssueAgentField when nextStepAgent is not an existing option', async () => {
3652
+ const issue = createMockIssue({ status: 'Preparation' });
3653
+ const projectWithAgent = makeProjectWithAgent();
3654
+ mockProjectRepository.getByUrl.mockResolvedValue(projectWithAgent);
3655
+ mockIssueRepository.get.mockResolvedValue(issue);
3656
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
3657
+ createMockComment({
3658
+ content:
3659
+ 'From: :robot: agent (model)\n```json\n{"nextStep": "review", "nextStepAgent": "chore"}\n```',
3660
+ }),
3661
+ ]);
3662
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
3663
+ mockProjectRepository.updateAgentList.mockResolvedValue([
3664
+ { id: 'opt-impl', name: 'impl', color: 'GRAY', description: '' },
3665
+ { id: 'opt-chore-new', name: 'chore', color: 'GRAY', description: '' },
3666
+ ]);
3667
+
3668
+ await useCase.run({
3669
+ projectUrl: 'https://github.com/users/user/projects/1',
3670
+ issueUrl: 'https://github.com/user/repo/issues/1',
3671
+ thresholdForAutoReject: 3,
3672
+ workflowBlockerResolvedWebhookUrl: null,
3673
+ allowedIssueAuthors: null,
3674
+ });
3675
+
3676
+ expect(mockProjectRepository.updateAgentList).toHaveBeenCalledWith(
3677
+ projectWithAgent,
3678
+ expect.arrayContaining([
3679
+ expect.objectContaining({ name: 'impl' }),
3680
+ expect.objectContaining({ id: null, name: 'chore', color: 'GRAY' }),
3681
+ ]),
3682
+ );
3683
+ expect(mockIssueRepository.setIssueAgentField).toHaveBeenCalledWith(
3684
+ 'https://github.com/user/repo/issues/1',
3685
+ projectWithAgent,
3686
+ 'opt-chore-new',
3687
+ );
3688
+ });
3689
+
3690
+ it('does not call setIssueAgentField when nextStepAgent is absent from the last comment', async () => {
3691
+ const issue = createMockIssue({ status: 'Preparation' });
3692
+ const projectWithAgent = makeProjectWithAgent();
3693
+ mockProjectRepository.getByUrl.mockResolvedValue(projectWithAgent);
3694
+ mockIssueRepository.get.mockResolvedValue(issue);
3695
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
3696
+ createMockComment({
3697
+ content:
3698
+ 'From: :robot: agent (model)\n```json\n{"nextStep": null}\n```',
3699
+ }),
3700
+ ]);
3701
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
3702
+ {
3703
+ url: 'https://github.com/user/repo/pull/1',
3704
+ isConflicted: false,
3705
+ isPassedAllCiJob: true,
3706
+ isCiStateSuccess: true,
3707
+ isResolvedAllReviewComments: true,
3708
+ isBranchOutOfDate: false,
3709
+ missingRequiredCheckNames: [],
3710
+ },
3711
+ ]);
3712
+
3713
+ await useCase.run({
3714
+ projectUrl: 'https://github.com/users/user/projects/1',
3715
+ issueUrl: 'https://github.com/user/repo/issues/1',
3716
+ thresholdForAutoReject: 3,
3717
+ workflowBlockerResolvedWebhookUrl: null,
3718
+ allowedIssueAuthors: null,
3719
+ });
3720
+
3721
+ expect(mockIssueRepository.setIssueAgentField).not.toHaveBeenCalled();
3722
+ expect(mockProjectRepository.updateAgentList).not.toHaveBeenCalled();
3723
+ });
3724
+
3725
+ it('creates agent field via createField and does not call setIssueAgentField when project has no agent field and re-fetch also returns no agent', async () => {
3726
+ const issue = createMockIssue({ status: 'Preparation' });
3727
+ const projectWithoutAgent = createMockProject({ agent: null });
3728
+ mockProjectRepository.getByUrl.mockResolvedValue(projectWithoutAgent);
3729
+ mockIssueRepository.get.mockResolvedValue(issue);
3730
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
3731
+ createMockComment({
3732
+ content:
3733
+ 'From: :robot: agent (model)\n```json\n{"nextStep": "go", "nextStepAgent": "impl"}\n```',
3734
+ }),
3735
+ ]);
3736
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
3737
+
3738
+ await useCase.run({
3739
+ projectUrl: 'https://github.com/users/user/projects/1',
3740
+ issueUrl: 'https://github.com/user/repo/issues/1',
3741
+ thresholdForAutoReject: 3,
3742
+ workflowBlockerResolvedWebhookUrl: null,
3743
+ allowedIssueAuthors: null,
3744
+ });
3745
+
3746
+ expect(mockProjectRepository.createField).toHaveBeenCalledWith(
3747
+ projectWithoutAgent,
3748
+ {
3749
+ name: AGENT_FIELD_NAME,
3750
+ dataType: 'SINGLE_SELECT',
3751
+ options: [{ name: 'impl', color: 'GRAY', description: '' }],
3752
+ },
3753
+ );
3754
+ expect(mockIssueRepository.setIssueAgentField).not.toHaveBeenCalled();
3755
+ });
3756
+ });
3757
+
3602
3758
  describe('consoleTabsRepository integration', () => {
3603
3759
  let mockConsoleTabsRepository: {
3604
3760
  patchIssueTabTransition: jest.Mock;
@@ -25,6 +25,9 @@ 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';
30
+ import { AGENT_FIELD_NAME } from '../entities/RequiredProjectField';
28
31
 
29
32
  export class IssueNotFoundError extends Error {
30
33
  constructor(issueUrl: string) {
@@ -52,7 +55,10 @@ export class NotifyFinishedIssuePreparationUseCase {
52
55
  private readonly changeTargetPullRequestApprover: ChangeTargetPullRequestApprover;
53
56
 
54
57
  constructor(
55
- private readonly projectRepository: Pick<ProjectRepository, 'getByUrl'>,
58
+ private readonly projectRepository: Pick<
59
+ ProjectRepository,
60
+ 'getByUrl' | 'updateAgentList' | 'createField'
61
+ >,
56
62
  private readonly issueRepository: Pick<
57
63
  IssueRepository,
58
64
  | 'get'
@@ -67,6 +73,7 @@ export class NotifyFinishedIssuePreparationUseCase {
67
73
  | 'approvePullRequest'
68
74
  | 'requestChangesWithInlineComment'
69
75
  | 'setDependedIssueUrl'
76
+ | 'setIssueAgentField'
70
77
  >,
71
78
  private readonly issueCommentRepository: Pick<
72
79
  IssueCommentRepository,
@@ -93,6 +100,7 @@ export class NotifyFinishedIssuePreparationUseCase {
93
100
  labelsAsLlmAgentName?: string[] | null;
94
101
  labelsNotRequiringPullRequest?: string[] | null;
95
102
  changeTargetPathAliases?: Record<string, string> | null;
103
+ agents?: string[] | null;
96
104
  }): Promise<void> => {
97
105
  const project = await this.projectRepository.getByUrl(params.projectUrl);
98
106
 
@@ -195,41 +203,53 @@ export class NotifyFinishedIssuePreparationUseCase {
195
203
  const isTrustedAuthor = (author: string): boolean =>
196
204
  this.isAuthorTrusted(author, params.allowedIssueAuthors ?? null);
197
205
 
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(
206
+ const lastTrustedBotComment = [...comments]
207
+ .reverse()
208
+ .find(
209
+ (c) =>
210
+ isTrustedAuthor(c.author) && c.content.startsWith('From: :robot:'),
211
+ );
212
+ const nextStepAgent = lastTrustedBotComment
213
+ ? this.extractNextStepAgent(lastTrustedBotComment.content)
214
+ : null;
215
+ if (nextStepAgent !== null) {
216
+ const agentOptionId = await this.ensureAgentOptionAndGetId(
217
+ project,
218
+ nextStepAgent,
219
+ );
220
+ if (agentOptionId) {
221
+ await this.issueRepository.setIssueAgentField(
222
+ params.issueUrl,
221
223
  project,
222
- issue,
223
- awaitingWorkspaceStatusOption.id,
224
+ agentOptionId,
224
225
  );
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
226
  }
227
+ await this.issueRepository.getOrCreateLabel(
228
+ issue.org,
229
+ issue.repo,
230
+ nextStepAgent,
231
+ );
232
+ const agentLabels = params.labelsAsLlmAgentName ?? [];
233
+ const filteredLabels = issue.labels.filter(
234
+ (label) =>
235
+ !label.startsWith('llm-agent:') && !agentLabels.includes(label),
236
+ );
237
+ const updatedLabels = [...new Set([...filteredLabels, nextStepAgent])];
238
+ issue.labels = updatedLabels;
239
+ issue.status = AWAITING_WORKSPACE_STATUS_NAME;
240
+ await this.issueRepository.update(issue, project);
241
+ await this.issueRepository.updateStatus(
242
+ project,
243
+ issue,
244
+ awaitingWorkspaceStatusOption.id,
245
+ );
246
+ await this.issueRepository.updateLabels(issue, updatedLabels);
247
+ await this.patchConsoleTab(issue);
248
+ await this.issueCommentRepository.createComment(
249
+ issue,
250
+ `Next step agent: ${nextStepAgent}`,
251
+ );
252
+ return;
233
253
  }
234
254
 
235
255
  const { rejections, approvedPrUrl } = await this.collectRejections(
@@ -444,37 +464,6 @@ export class NotifyFinishedIssuePreparationUseCase {
444
464
  return nextStepValue !== null && nextStepValue !== undefined;
445
465
  };
446
466
 
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
467
  private setDependedIssueUrlForAllOpenPRs = async (
479
468
  issue: { url: string; labels: string[]; isPr: boolean },
480
469
  issueUrl: string,
@@ -550,6 +539,73 @@ export class NotifyFinishedIssuePreparationUseCase {
550
539
  return null;
551
540
  };
552
541
 
542
+ private extractNextStepAgent = (body: string): string | null => {
543
+ const reportMatch = body.match(/```json\n([\s\S]*?)\n```/);
544
+ if (!reportMatch || reportMatch.length < 2) {
545
+ return null;
546
+ }
547
+ let reportJson: unknown;
548
+ try {
549
+ reportJson = JSON.parse(reportMatch[1]);
550
+ } catch (error) {
551
+ console.warn(
552
+ 'Invalid JSON in report body while checking nextStepAgent:',
553
+ error,
554
+ );
555
+ return null;
556
+ }
557
+ if (typeof reportJson !== 'object' || reportJson === null) {
558
+ return null;
559
+ }
560
+ if (!('nextStepAgent' in reportJson)) {
561
+ return null;
562
+ }
563
+ const value = Reflect.get(reportJson, 'nextStepAgent');
564
+ if (typeof value !== 'string' || value.trim() === '') {
565
+ return null;
566
+ }
567
+ return value.trim();
568
+ };
569
+
570
+ private ensureAgentOptionAndGetId = async (
571
+ project: Project,
572
+ agentName: string,
573
+ ): Promise<string | null> => {
574
+ const normalizedTarget = normalizeProjectFieldName(agentName);
575
+ if (!project.agent) {
576
+ await this.projectRepository.createField(project, {
577
+ name: AGENT_FIELD_NAME,
578
+ dataType: 'SINGLE_SELECT',
579
+ options: [{ name: agentName, color: 'GRAY' as const, description: '' }],
580
+ });
581
+ const refreshed = await this.projectRepository.getByUrl(project.url);
582
+ const created = refreshed.agent?.options.find(
583
+ (o) => normalizeProjectFieldName(o.name) === normalizedTarget,
584
+ );
585
+ return created?.id ?? null;
586
+ }
587
+ const existing = project.agent.options.find(
588
+ (o) => normalizeProjectFieldName(o.name) === normalizedTarget,
589
+ );
590
+ if (existing) {
591
+ return existing.id;
592
+ }
593
+ const mergedOptions: (Omit<FieldOption, 'id'> & {
594
+ id: FieldOption['id'] | null;
595
+ })[] = [
596
+ ...project.agent.options.map((o) => ({ ...o })),
597
+ { id: null, name: agentName, color: 'GRAY' as const, description: '' },
598
+ ];
599
+ const updatedOptions = await this.projectRepository.updateAgentList(
600
+ project,
601
+ mergedOptions,
602
+ );
603
+ const added = updatedOptions.find(
604
+ (o) => normalizeProjectFieldName(o.name) === normalizedTarget,
605
+ );
606
+ return added?.id ?? null;
607
+ };
608
+
553
609
  private patchConsoleTab = async (issue: Issue): Promise<void> => {
554
610
  if (!this.consoleTabsRepository) return;
555
611
  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),
@@ -592,4 +599,82 @@ describe('ProjectRequiredFieldCreateUseCase', () => {
592
599
  );
593
600
  });
594
601
  });
602
+
603
+ describe('reconcileAgentOptions', () => {
604
+ const buildProjectWithAgent = (
605
+ existingOptions: { id: string; name: string }[],
606
+ ): Project => ({
607
+ ...projectWithoutStory,
608
+ agent: {
609
+ name: 'Agent',
610
+ fieldId: 'agent-field-id',
611
+ options: existingOptions.map((o) => ({
612
+ ...o,
613
+ color: 'GRAY' as const,
614
+ description: '',
615
+ })),
616
+ },
617
+ });
618
+
619
+ it('adds missing agent names as GRAY options and calls updateAgentList', async () => {
620
+ const project = buildProjectWithAgent([{ id: 'opt-impl', name: 'impl' }]);
621
+ const { projectRepository, useCase } = createUseCase([], project);
622
+
623
+ await useCase.reconcileAgentOptions(project, ['impl', 'chore']);
624
+
625
+ expect(projectRepository.updateAgentList).toHaveBeenCalledWith(
626
+ project,
627
+ expect.arrayContaining([
628
+ expect.objectContaining({ id: 'opt-impl', name: 'impl' }),
629
+ expect.objectContaining({ id: null, name: 'chore', color: 'GRAY' }),
630
+ ]),
631
+ );
632
+ });
633
+
634
+ it('does not call updateAgentList when all agents already exist', async () => {
635
+ const project = buildProjectWithAgent([
636
+ { id: 'opt-impl', name: 'impl' },
637
+ { id: 'opt-chore', name: 'chore' },
638
+ ]);
639
+ const { projectRepository, useCase } = createUseCase([], project);
640
+
641
+ await useCase.reconcileAgentOptions(project, ['impl', 'chore']);
642
+
643
+ expect(projectRepository.updateAgentList).not.toHaveBeenCalled();
644
+ });
645
+
646
+ it('does not call updateAgentList when agentNames is null', async () => {
647
+ const project = buildProjectWithAgent([]);
648
+ const { projectRepository, useCase } = createUseCase([], project);
649
+
650
+ await useCase.reconcileAgentOptions(project, null);
651
+
652
+ expect(projectRepository.updateAgentList).not.toHaveBeenCalled();
653
+ });
654
+
655
+ it('calls createField with AGENT_FIELD_NAME and initial agents when project has no agent field', async () => {
656
+ const { projectRepository, useCase } = createUseCase(
657
+ [],
658
+ projectWithoutStory,
659
+ );
660
+
661
+ await useCase.reconcileAgentOptions(projectWithoutStory, [
662
+ 'impl',
663
+ 'chore',
664
+ ]);
665
+
666
+ expect(projectRepository.updateAgentList).not.toHaveBeenCalled();
667
+ expect(projectRepository.createField).toHaveBeenCalledWith(
668
+ projectWithoutStory,
669
+ {
670
+ name: AGENT_FIELD_NAME,
671
+ dataType: 'SINGLE_SELECT',
672
+ options: [
673
+ { name: 'impl', color: 'GRAY', description: '' },
674
+ { name: 'chore', color: 'GRAY', description: '' },
675
+ ],
676
+ },
677
+ );
678
+ });
679
+ });
595
680
  });
@@ -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,47 @@ 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 (!agentNames || agentNames.length === 0) {
95
+ return;
96
+ }
97
+ if (!project.agent) {
98
+ await this.projectRepository.createField(project, {
99
+ name: AGENT_FIELD_NAME,
100
+ dataType: 'SINGLE_SELECT',
101
+ options: agentNames.map((name) => ({
102
+ name,
103
+ color: 'GRAY' as const,
104
+ description: '',
105
+ })),
106
+ });
107
+ return;
108
+ }
109
+ const existingOptions = project.agent.options;
110
+ const existingNames = new Set(
111
+ existingOptions.map((o) => normalizeProjectFieldName(o.name)),
112
+ );
113
+ const toAdd = agentNames.filter(
114
+ (name) => !existingNames.has(normalizeProjectFieldName(name)),
115
+ );
116
+ if (toAdd.length === 0) {
117
+ return;
118
+ }
119
+ const mergedOptions: OptionToSubmit[] = [
120
+ ...existingOptions.map((o) => ({ ...o })),
121
+ ...toAdd.map((name) => ({
122
+ id: null,
123
+ name,
124
+ color: 'GRAY' as const,
125
+ description: '',
126
+ })),
127
+ ];
128
+ await this.projectRepository.updateAgentList(project, mergedOptions);
129
+ };
130
+
78
131
  private optionNameSatisfies = (
79
132
  currentName: string,
80
133
  requiredName: string,
@@ -82,4 +135,6 @@ export class ProjectRequiredFieldCreateUseCase {
82
135
  normalizeProjectFieldName(currentName).startsWith(
83
136
  normalizeProjectFieldName(requiredName),
84
137
  );
138
+
139
+ static readonly AGENT_FIELD_NAME = AGENT_FIELD_NAME;
85
140
  }