github-issue-tower-defence-management 1.162.5 → 1.164.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 (37) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +1 -1
  3. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +3 -0
  4. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  5. package/bin/adapter/repositories/issue/RestIssueRepository.js +24 -0
  6. package/bin/adapter/repositories/issue/RestIssueRepository.js.map +1 -1
  7. package/bin/domain/entities/RequiredProjectField.js +1 -0
  8. package/bin/domain/entities/RequiredProjectField.js.map +1 -1
  9. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +46 -0
  10. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  11. package/bin/domain/usecases/ProjectRequiredFieldCreateUseCase.js +31 -0
  12. package/bin/domain/usecases/ProjectRequiredFieldCreateUseCase.js.map +1 -1
  13. package/lib/typescript-compat/bin/tsc +2 -0
  14. package/lib/typescript-compat/bin/tsserver +2 -0
  15. package/lib/typescript-compat/lib/typescript.js +1 -0
  16. package/lib/typescript-compat/package.json +13 -0
  17. package/package.json +4 -3
  18. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +1 -0
  19. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +8 -0
  20. package/src/adapter/repositories/issue/RestIssueRepository.ts +30 -0
  21. package/src/domain/entities/RequiredProjectField.ts +1 -0
  22. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +235 -0
  23. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +70 -0
  24. package/src/domain/usecases/ProjectRequiredFieldCreateUseCase.test.ts +464 -28
  25. package/src/domain/usecases/ProjectRequiredFieldCreateUseCase.ts +56 -3
  26. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +5 -0
  27. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +3 -2
  28. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  29. package/types/adapter/repositories/issue/RestIssueRepository.d.ts +1 -0
  30. package/types/adapter/repositories/issue/RestIssueRepository.d.ts.map +1 -1
  31. package/types/domain/entities/RequiredProjectField.d.ts.map +1 -1
  32. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +2 -1
  33. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  34. package/types/domain/usecases/ProjectRequiredFieldCreateUseCase.d.ts +3 -1
  35. package/types/domain/usecases/ProjectRequiredFieldCreateUseCase.d.ts.map +1 -1
  36. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +1 -0
  37. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"ProjectRequiredFieldCreateUseCase.js","sourceRoot":"","sources":["../../../src/domain/usecases/ProjectRequiredFieldCreateUseCase.ts"],"names":[],"mappings":";;;AACA,mEAAyE;AACzE,2EAA2E;AAG3E,MAAa,iCAAiC;IAC5C,YACmB,iBAGhB;QAHgB,sBAAiB,GAAjB,iBAAiB,CAGjC;QAGH,QAAG,GAAG,KAAK,EAAE,MAA8B,EAAiB,EAAE;YAC5D,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,iBAAiB,CAAC,QAAQ,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;YACzE,MAAM,IAAI,CAAC,mBAAmB,CAAC,OAAO,CAAC,CAAC;QAC1C,CAAC,CAAC;QAEM,wBAAmB,GAAG,KAAK,EAAE,OAAgB,EAAiB,EAAE;YACtE,MAAM,kBAAkB,GAAG,CACzB,MAAM,IAAI,CAAC,iBAAiB,CAAC,cAAc,CAAC,OAAO,CAAC,CACrD,CAAC,GAAG,CAAC,4CAAyB,CAAC,CAAC;YACjC,KAAK,MAAM,QAAQ,IAAI,8CAAuB,EAAE,CAAC;gBAC/C,IACE,kBAAkB,CAAC,QAAQ,CAAC,IAAA,4CAAyB,EAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,EACrE,CAAC;oBACD,SAAS;gBACX,CAAC;gBACD,MAAM,IAAI,CAAC,iBAAiB,CAAC,WAAW,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;YAC9D,CAAC;QACH,CAAC,CAAC;IAnBC,CAAC;CAoBL;AA1BD,8EA0BC"}
1
+ {"version":3,"file":"ProjectRequiredFieldCreateUseCase.js","sourceRoot":"","sources":["../../../src/domain/usecases/ProjectRequiredFieldCreateUseCase.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ require('@typescript/typescript6/lib/tsc');
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ require('@typescript/typescript6/lib/tsc');
@@ -0,0 +1 @@
1
+ module.exports = require('@typescript/typescript6');
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "typescript",
3
+ "version": "6.0.3",
4
+ "description": "TypeScript 6 compatibility shim for projects with typescript@7 tooling dependencies",
5
+ "main": "./lib/typescript.js",
6
+ "bin": {
7
+ "tsc": "./bin/tsc",
8
+ "tsserver": "./bin/tsserver"
9
+ },
10
+ "dependencies": {
11
+ "@typescript/typescript6": "6.0.2"
12
+ }
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-issue-tower-defence-management",
3
- "version": "1.162.5",
3
+ "version": "1.164.0",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "scripts": {
@@ -82,6 +82,7 @@
82
82
  "@types/react-dom": "^19.2.3",
83
83
  "@typescript-eslint/eslint-plugin": "^8.65.0",
84
84
  "@typescript-eslint/parser": "^8.65.0",
85
+ "@typescript/typescript6": "6.0.2",
85
86
  "@vitejs/plugin-react": "^6.0.4",
86
87
  "check-node-version": "^4.2.1",
87
88
  "class-variance-authority": "^0.7.1",
@@ -104,12 +105,12 @@
104
105
  "tailwind-merge": "^3.6.0",
105
106
  "tailwindcss": "^4.3.3",
106
107
  "ts-jest": "^29.4.12",
107
- "typescript": "^6.0.3",
108
+ "typescript": "file:./lib/typescript-compat",
108
109
  "typescript-eslint": "^8.65.0",
109
110
  "vite": "^8.1.5"
110
111
  },
111
112
  "dependencies": {
112
- "better-sqlite3": "13.0.2",
113
+ "better-sqlite3": "13.0.3",
113
114
  "commander": "^15.0.0",
114
115
  "dompurify": "3.4.13",
115
116
  "dotenv": "^17.4.2",
@@ -425,6 +425,7 @@ const createStubIssueRepository = (
425
425
  createComment: () => notImplemented('createComment'),
426
426
  updateLabels: () => notImplemented('updateLabels'),
427
427
  removeLabel: () => notImplemented('removeLabel'),
428
+ getOrCreateLabel: () => notImplemented('getOrCreateLabel'),
428
429
  updateAssigneeList: () => notImplemented('updateAssigneeList'),
429
430
  searchIssues: () => notImplemented('searchIssues'),
430
431
  get: async (issueUrl: string): Promise<Issue | null> =>
@@ -527,6 +527,7 @@ export class ApiV3CheerioRestIssueRepository
527
527
  | 'removeLabel'
528
528
  | 'updateAssigneeList'
529
529
  | 'searchIssues'
530
+ | 'getOrCreateLabel'
530
531
  >,
531
532
  readonly graphqlProjectItemRepository: Pick<
532
533
  GraphqlProjectItemRepository,
@@ -1006,6 +1007,13 @@ export class ApiV3CheerioRestIssueRepository
1006
1007
  removeLabel = (issue: Issue, label: string): Promise<void> => {
1007
1008
  return this.restIssueRepository.removeLabel(issue, label);
1008
1009
  };
1010
+ getOrCreateLabel = (
1011
+ org: string,
1012
+ repo: string,
1013
+ labelName: string,
1014
+ ): Promise<void> => {
1015
+ return this.restIssueRepository.getOrCreateLabel(org, repo, labelName);
1016
+ };
1009
1017
  updateAssigneeList = (
1010
1018
  issue: Pick<Issue, 'org' | 'repo' | 'number'>,
1011
1019
  assigneeList: Member['name'][],
@@ -154,6 +154,36 @@ export class RestIssueRepository
154
154
  }
155
155
  };
156
156
 
157
+ getOrCreateLabel = async (
158
+ org: string,
159
+ repo: string,
160
+ labelName: string,
161
+ ): Promise<void> => {
162
+ try {
163
+ await ky.get(
164
+ `https://api.github.com/repos/${org}/${repo}/labels/${encodeURIComponent(labelName)}`,
165
+ {
166
+ headers: {
167
+ Authorization: `token ${this.ghToken}`,
168
+ Accept: 'application/vnd.github.v3+json',
169
+ },
170
+ },
171
+ );
172
+ } catch (e) {
173
+ if (e instanceof HTTPError && e.response.status === 404) {
174
+ await ky.post(`https://api.github.com/repos/${org}/${repo}/labels`, {
175
+ json: { name: labelName, color: 'ededed' },
176
+ headers: {
177
+ Authorization: `token ${this.ghToken}`,
178
+ Accept: 'application/vnd.github.v3+json',
179
+ },
180
+ });
181
+ } else {
182
+ throw e;
183
+ }
184
+ }
185
+ };
186
+
157
187
  updateAssigneeList = async (
158
188
  issue: Pick<Issue, 'org' | 'repo' | 'number'>,
159
189
  assigneeList: Member['name'][],
@@ -16,6 +16,7 @@ const storyOption = (
16
16
  const REQUIRED_STORY_OPTIONS: Omit<FieldOption, 'id'>[] = [
17
17
  storyOption('regular / NO STORY', 'RED'),
18
18
  storyOption('regular / WORKFLOW BLOCKER', 'RED'),
19
+ storyOption('regular / inquiry', 'RED'),
19
20
  storyOption('regular / high priority', 'RED'),
20
21
  storyOption('regular / workflow management', 'YELLOW'),
21
22
  storyOption('regular / routine management', 'YELLOW'),
@@ -92,6 +92,8 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
92
92
  get: jest.Mock;
93
93
  update: jest.Mock;
94
94
  updateStatus: jest.Mock;
95
+ updateLabels: jest.Mock;
96
+ getOrCreateLabel: jest.Mock;
95
97
  findRelatedOpenPRs: jest.Mock;
96
98
  getStoryObjectMap: jest.Mock;
97
99
  getOpenPullRequest: jest.Mock;
@@ -128,6 +130,8 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
128
130
  get: jest.fn(),
129
131
  update: jest.fn(),
130
132
  updateStatus: jest.fn(),
133
+ updateLabels: jest.fn().mockResolvedValue(undefined),
134
+ getOrCreateLabel: jest.fn().mockResolvedValue(undefined),
131
135
  findRelatedOpenPRs: jest.fn(),
132
136
  getOpenPullRequest: jest.fn(),
133
137
  getPullRequestChangedFilePaths: jest.fn().mockResolvedValue([]),
@@ -799,6 +803,237 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
799
803
  );
800
804
  });
801
805
 
806
+ it('should add nextStepAgent label and return to Awaiting Workspace when report has nextStepAgent', async () => {
807
+ const issue = createMockIssue({
808
+ url: 'https://github.com/user/repo/issues/1',
809
+ status: 'Preparation',
810
+ org: 'user',
811
+ repo: 'repo',
812
+ labels: [],
813
+ });
814
+
815
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
816
+ mockIssueRepository.get.mockResolvedValue(issue);
817
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
818
+ createMockComment({
819
+ content:
820
+ 'From: :robot: Agent report\n```json\n{"nextStepAgent": "llm-agent:chore", "nextStep": null}\n```',
821
+ }),
822
+ ]);
823
+
824
+ await useCase.run({
825
+ projectUrl: 'https://github.com/users/user/projects/1',
826
+ issueUrl: 'https://github.com/user/repo/issues/1',
827
+ thresholdForAutoReject: 3,
828
+ workflowBlockerResolvedWebhookUrl: null,
829
+ allowedIssueAuthors: null,
830
+ });
831
+
832
+ expect(mockIssueRepository.getOrCreateLabel).toHaveBeenCalledWith(
833
+ 'user',
834
+ 'repo',
835
+ 'llm-agent:chore',
836
+ );
837
+ expect(mockIssueRepository.updateLabels).toHaveBeenCalledWith(
838
+ expect.objectContaining({ labels: ['llm-agent:chore'] }),
839
+ ['llm-agent:chore'],
840
+ );
841
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
842
+ expect.objectContaining({ status: 'Awaiting Workspace' }),
843
+ mockProject,
844
+ );
845
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
846
+ expect.objectContaining({ url: 'https://github.com/user/repo/issues/1' }),
847
+ expect.stringContaining('llm-agent:chore'),
848
+ );
849
+ });
850
+
851
+ it('should preserve existing labels when adding nextStepAgent label', async () => {
852
+ const issue = createMockIssue({
853
+ url: 'https://github.com/user/repo/issues/1',
854
+ status: 'Preparation',
855
+ org: 'user',
856
+ repo: 'repo',
857
+ labels: ['existing-label'],
858
+ });
859
+
860
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
861
+ mockIssueRepository.get.mockResolvedValue(issue);
862
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
863
+ createMockComment({
864
+ content:
865
+ 'From: :robot: Agent report\n```json\n{"nextStepAgent": "llm-agent:impl", "nextStep": null}\n```',
866
+ }),
867
+ ]);
868
+
869
+ await useCase.run({
870
+ projectUrl: 'https://github.com/users/user/projects/1',
871
+ issueUrl: 'https://github.com/user/repo/issues/1',
872
+ thresholdForAutoReject: 3,
873
+ workflowBlockerResolvedWebhookUrl: null,
874
+ allowedIssueAuthors: null,
875
+ });
876
+
877
+ expect(mockIssueRepository.updateLabels).toHaveBeenCalledWith(
878
+ expect.objectContaining({
879
+ labels: ['existing-label', 'llm-agent:impl'],
880
+ }),
881
+ ['existing-label', 'llm-agent:impl'],
882
+ );
883
+ });
884
+
885
+ it('should not add nextStepAgent label when nextStepAgent is null', async () => {
886
+ const issue = createMockIssue({
887
+ url: 'https://github.com/user/repo/issues/1',
888
+ status: 'Preparation',
889
+ });
890
+
891
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
892
+ mockIssueRepository.get.mockResolvedValue(issue);
893
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
894
+ createMockComment({
895
+ content:
896
+ 'From: :robot: Agent report\n```json\n{"nextStepAgent": null, "nextStep": null}\n```',
897
+ }),
898
+ ]);
899
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
900
+ {
901
+ url: 'https://github.com/user/repo/pull/1',
902
+ isConflicted: false,
903
+ isPassedAllCiJob: true,
904
+ isCiStateSuccess: true,
905
+ isResolvedAllReviewComments: true,
906
+ isBranchOutOfDate: false,
907
+ missingRequiredCheckNames: [],
908
+ },
909
+ ]);
910
+
911
+ await useCase.run({
912
+ projectUrl: 'https://github.com/users/user/projects/1',
913
+ issueUrl: 'https://github.com/user/repo/issues/1',
914
+ thresholdForAutoReject: 3,
915
+ workflowBlockerResolvedWebhookUrl: null,
916
+ allowedIssueAuthors: null,
917
+ });
918
+
919
+ expect(mockIssueRepository.getOrCreateLabel).not.toHaveBeenCalled();
920
+ expect(mockIssueRepository.updateLabels).not.toHaveBeenCalled();
921
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
922
+ expect.objectContaining({ status: 'Awaiting Quality Check' }),
923
+ mockProject,
924
+ );
925
+ });
926
+
927
+ it('should create label when nextStepAgent label does not exist in repo', async () => {
928
+ const issue = createMockIssue({
929
+ url: 'https://github.com/user/repo/issues/1',
930
+ status: 'Preparation',
931
+ org: 'user',
932
+ repo: 'repo',
933
+ labels: [],
934
+ });
935
+
936
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
937
+ mockIssueRepository.get.mockResolvedValue(issue);
938
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
939
+ createMockComment({
940
+ content:
941
+ 'From: :robot: Agent report\n```json\n{"nextStepAgent": "llm-agent:new-agent", "nextStep": null}\n```',
942
+ }),
943
+ ]);
944
+
945
+ await useCase.run({
946
+ projectUrl: 'https://github.com/users/user/projects/1',
947
+ issueUrl: 'https://github.com/user/repo/issues/1',
948
+ thresholdForAutoReject: 3,
949
+ workflowBlockerResolvedWebhookUrl: null,
950
+ allowedIssueAuthors: null,
951
+ });
952
+
953
+ expect(mockIssueRepository.getOrCreateLabel).toHaveBeenCalledWith(
954
+ 'user',
955
+ 'repo',
956
+ 'llm-agent:new-agent',
957
+ );
958
+ });
959
+
960
+ it('should remove existing llm-agent labels and labelsAsLlmAgentName labels when adding nextStepAgent label', async () => {
961
+ const issue = createMockIssue({
962
+ url: 'https://github.com/user/repo/issues/1',
963
+ status: 'Preparation',
964
+ org: 'user',
965
+ repo: 'repo',
966
+ labels: ['llm-agent:chore', 'chore', 'feature-flag'],
967
+ });
968
+
969
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
970
+ mockIssueRepository.get.mockResolvedValue(issue);
971
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
972
+ createMockComment({
973
+ content:
974
+ 'From: :robot: Agent report\n```json\n{"nextStepAgent": "llm-agent:impl", "nextStep": null}\n```',
975
+ }),
976
+ ]);
977
+
978
+ await useCase.run({
979
+ projectUrl: 'https://github.com/users/user/projects/1',
980
+ issueUrl: 'https://github.com/user/repo/issues/1',
981
+ thresholdForAutoReject: 3,
982
+ workflowBlockerResolvedWebhookUrl: null,
983
+ allowedIssueAuthors: null,
984
+ labelsAsLlmAgentName: ['chore', 'accounting'],
985
+ });
986
+
987
+ expect(mockIssueRepository.updateLabels).toHaveBeenCalledWith(
988
+ expect.objectContaining({
989
+ labels: ['feature-flag', 'llm-agent:impl'],
990
+ }),
991
+ ['feature-flag', 'llm-agent:impl'],
992
+ );
993
+ });
994
+
995
+ it('should not add nextStepAgent label when nextStepAgent is an empty string', async () => {
996
+ const issue = createMockIssue({
997
+ url: 'https://github.com/user/repo/issues/1',
998
+ status: 'Preparation',
999
+ });
1000
+
1001
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1002
+ mockIssueRepository.get.mockResolvedValue(issue);
1003
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
1004
+ createMockComment({
1005
+ content:
1006
+ 'From: :robot: Agent report\n```json\n{"nextStepAgent": "", "nextStep": null}\n```',
1007
+ }),
1008
+ ]);
1009
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
1010
+ {
1011
+ url: 'https://github.com/user/repo/pull/1',
1012
+ isConflicted: false,
1013
+ isPassedAllCiJob: true,
1014
+ isCiStateSuccess: true,
1015
+ isResolvedAllReviewComments: true,
1016
+ isBranchOutOfDate: false,
1017
+ missingRequiredCheckNames: [],
1018
+ },
1019
+ ]);
1020
+
1021
+ await useCase.run({
1022
+ projectUrl: 'https://github.com/users/user/projects/1',
1023
+ issueUrl: 'https://github.com/user/repo/issues/1',
1024
+ thresholdForAutoReject: 3,
1025
+ workflowBlockerResolvedWebhookUrl: null,
1026
+ allowedIssueAuthors: null,
1027
+ });
1028
+
1029
+ expect(mockIssueRepository.getOrCreateLabel).not.toHaveBeenCalled();
1030
+ expect(mockIssueRepository.updateLabels).not.toHaveBeenCalled();
1031
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
1032
+ expect.objectContaining({ status: 'Awaiting Quality Check' }),
1033
+ mockProject,
1034
+ );
1035
+ });
1036
+
802
1037
  it('should auto-escalate to Failed Preparation after threshold rejections', async () => {
803
1038
  const issue = createMockIssue({
804
1039
  url: 'https://github.com/user/repo/issues/1',
@@ -58,6 +58,8 @@ export class NotifyFinishedIssuePreparationUseCase {
58
58
  | 'get'
59
59
  | 'update'
60
60
  | 'updateStatus'
61
+ | 'updateLabels'
62
+ | 'getOrCreateLabel'
61
63
  | 'findRelatedOpenPRs'
62
64
  | 'getStoryObjectMap'
63
65
  | 'getOpenPullRequest'
@@ -193,6 +195,43 @@ export class NotifyFinishedIssuePreparationUseCase {
193
195
  const isTrustedAuthor = (author: string): boolean =>
194
196
  this.isAuthorTrusted(author, params.allowedIssueAuthors ?? null);
195
197
 
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(
221
+ project,
222
+ issue,
223
+ awaitingWorkspaceStatusOption.id,
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
+ }
233
+ }
234
+
196
235
  const { rejections, approvedPrUrl } = await this.collectRejections(
197
236
  issue,
198
237
  comments,
@@ -405,6 +444,37 @@ export class NotifyFinishedIssuePreparationUseCase {
405
444
  return nextStepValue !== null && nextStepValue !== undefined;
406
445
  };
407
446
 
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
+
408
478
  private setDependedIssueUrlForAllOpenPRs = async (
409
479
  issue: { url: string; labels: string[]; isPr: boolean },
410
480
  issueUrl: string,