github-issue-tower-defence-management 1.163.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 (116) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +1 -1
  3. package/bin/adapter/entry-points/cli/index.js +1 -0
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/cli/projectConfig.js +2 -0
  6. package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
  7. package/bin/adapter/entry-points/console/consoleOperationApi.js +1 -0
  8. package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
  9. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js +1 -0
  10. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js.map +1 -1
  11. package/bin/adapter/repositories/GraphqlProjectRepository.js +37 -0
  12. package/bin/adapter/repositories/GraphqlProjectRepository.js.map +1 -1
  13. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +59 -5
  14. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  15. package/bin/adapter/repositories/issue/RestIssueRepository.js +24 -0
  16. package/bin/adapter/repositories/issue/RestIssueRepository.js.map +1 -1
  17. package/bin/adapter/repositories/projectFieldDefinition.js +8 -0
  18. package/bin/adapter/repositories/projectFieldDefinition.js.map +1 -1
  19. package/bin/domain/entities/RequiredProjectField.js +7 -1
  20. package/bin/domain/entities/RequiredProjectField.js.map +1 -1
  21. package/bin/domain/usecases/HandleScheduledEventUseCase.js +1 -0
  22. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  23. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +67 -0
  24. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  25. package/bin/domain/usecases/ProjectRequiredFieldCreateUseCase.js +28 -2
  26. package/bin/domain/usecases/ProjectRequiredFieldCreateUseCase.js.map +1 -1
  27. package/bin/domain/usecases/StartPreparationUseCase.js +5 -4
  28. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  29. package/lib/typescript-compat/bin/tsc +2 -0
  30. package/lib/typescript-compat/bin/tsserver +2 -0
  31. package/lib/typescript-compat/lib/typescript.js +1 -0
  32. package/lib/typescript-compat/package.json +13 -0
  33. package/package.json +4 -3
  34. package/src/adapter/entry-points/cli/index.test.ts +2 -0
  35. package/src/adapter/entry-points/cli/index.ts +1 -0
  36. package/src/adapter/entry-points/cli/projectConfig.ts +3 -0
  37. package/src/adapter/entry-points/console/consoleOperationApi.ts +1 -0
  38. package/src/adapter/entry-points/console/consoleTabNames.test.ts +1 -0
  39. package/src/adapter/entry-points/console/dashboardEmitterComposerKey.test.ts +1 -0
  40. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +4 -0
  41. package/src/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.test.ts +1 -0
  42. package/src/adapter/entry-points/handlers/dashboardRowWriter.test.ts +1 -0
  43. package/src/adapter/entry-points/handlers/inTmuxByHumanDataWriter.test.ts +2 -0
  44. package/src/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.test.ts +1 -0
  45. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +1 -0
  46. package/src/adapter/entry-points/handlers/ownerCallFileCleaner.test.ts +1 -0
  47. package/src/adapter/entry-points/handlers/situationFileWriter.test.ts +1 -0
  48. package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.test.ts +2 -0
  49. package/src/adapter/entry-points/handlers/tokenStatusWriter.test.ts +1 -0
  50. package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +1 -0
  51. package/src/adapter/repositories/GitHubIssueCommentRepository.test.ts +1 -0
  52. package/src/adapter/repositories/GraphqlProjectRepository.diskCache.test.ts +1 -0
  53. package/src/adapter/repositories/GraphqlProjectRepository.test.ts +2 -0
  54. package/src/adapter/repositories/GraphqlProjectRepository.ts +50 -0
  55. package/src/adapter/repositories/GraphqlProjectRepositoryProjectLocation.test.ts +1 -0
  56. package/src/adapter/repositories/ProjectIssuesCacheRepository.test.ts +1 -0
  57. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +156 -0
  58. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +99 -6
  59. package/src/adapter/repositories/issue/RestIssueRepository.test.ts +1 -0
  60. package/src/adapter/repositories/issue/RestIssueRepository.ts +30 -0
  61. package/src/adapter/repositories/projectFieldDefinition.test.ts +1 -0
  62. package/src/adapter/repositories/projectFieldDefinition.ts +12 -0
  63. package/src/domain/entities/Issue.ts +1 -0
  64. package/src/domain/entities/Project.ts +5 -0
  65. package/src/domain/entities/RequiredProjectField.ts +6 -0
  66. package/src/domain/usecases/CheckIssueReviewReadinessUseCase.test.ts +1 -0
  67. package/src/domain/usecases/GetStoryObjectMapUseCase.test.ts +2 -0
  68. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +1 -0
  69. package/src/domain/usecases/HandleScheduledEventUseCase.ts +2 -0
  70. package/src/domain/usecases/InTmuxByHumanSessionTokenCountUseCase.test.ts +1 -0
  71. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +381 -0
  72. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +117 -1
  73. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +1 -0
  74. package/src/domain/usecases/ProjectRequiredFieldCreateUseCase.test.ts +75 -1
  75. package/src/domain/usecases/ProjectRequiredFieldCreateUseCase.ts +50 -7
  76. package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.test.ts +2 -0
  77. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +2 -0
  78. package/src/domain/usecases/SetupTowerDefenceProjectUseCase.test.ts +2 -0
  79. package/src/domain/usecases/StaleTmuxSessionKillUseCase.test.ts +2 -0
  80. package/src/domain/usecases/StartPreparationUseCase.test.ts +2 -0
  81. package/src/domain/usecases/StartPreparationUseCase.ts +1 -0
  82. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +10 -0
  83. package/src/domain/usecases/adapter-interfaces/ProjectRepository.ts +6 -0
  84. package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +2 -0
  85. package/src/domain/usecases/dashboard/GenerateDashboardRowUseCase.test.ts +1 -0
  86. package/src/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.test.ts +2 -0
  87. package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +1 -0
  88. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  89. package/types/adapter/entry-points/cli/projectConfig.d.ts +1 -0
  90. package/types/adapter/entry-points/cli/projectConfig.d.ts.map +1 -1
  91. package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
  92. package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -1
  93. package/types/adapter/repositories/GraphqlProjectRepository.d.ts +3 -0
  94. package/types/adapter/repositories/GraphqlProjectRepository.d.ts.map +1 -1
  95. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +5 -2
  96. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  97. package/types/adapter/repositories/issue/RestIssueRepository.d.ts +1 -0
  98. package/types/adapter/repositories/issue/RestIssueRepository.d.ts.map +1 -1
  99. package/types/adapter/repositories/projectFieldDefinition.d.ts.map +1 -1
  100. package/types/domain/entities/Issue.d.ts +1 -0
  101. package/types/domain/entities/Issue.d.ts.map +1 -1
  102. package/types/domain/entities/Project.d.ts +5 -0
  103. package/types/domain/entities/Project.d.ts.map +1 -1
  104. package/types/domain/entities/RequiredProjectField.d.ts +1 -0
  105. package/types/domain/entities/RequiredProjectField.d.ts.map +1 -1
  106. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +1 -0
  107. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  108. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +4 -1
  109. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  110. package/types/domain/usecases/ProjectRequiredFieldCreateUseCase.d.ts +5 -1
  111. package/types/domain/usecases/ProjectRequiredFieldCreateUseCase.d.ts.map +1 -1
  112. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  113. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +2 -0
  114. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
  115. package/types/domain/usecases/adapter-interfaces/ProjectRepository.d.ts +3 -0
  116. 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,11 +89,14 @@ 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;
93
96
  update: jest.Mock;
94
97
  updateStatus: jest.Mock;
98
+ updateLabels: jest.Mock;
99
+ getOrCreateLabel: jest.Mock;
95
100
  findRelatedOpenPRs: jest.Mock;
96
101
  getStoryObjectMap: jest.Mock;
97
102
  getOpenPullRequest: jest.Mock;
@@ -99,6 +104,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
99
104
  approvePullRequest: jest.Mock;
100
105
  requestChangesWithInlineComment: jest.Mock;
101
106
  setDependedIssueUrl: jest.Mock;
107
+ setIssueAgentField: jest.Mock;
102
108
  };
103
109
  let mockIssueCommentRepository: {
104
110
  getCommentsFromIssue: jest.Mock;
@@ -121,6 +127,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
121
127
 
122
128
  mockProjectRepository = {
123
129
  getByUrl: jest.fn(),
130
+ updateAgentList: jest.fn().mockResolvedValue([]),
124
131
  };
125
132
 
126
133
  mockIssueRepository = {
@@ -128,12 +135,15 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
128
135
  get: jest.fn(),
129
136
  update: jest.fn(),
130
137
  updateStatus: jest.fn(),
138
+ updateLabels: jest.fn().mockResolvedValue(undefined),
139
+ getOrCreateLabel: jest.fn().mockResolvedValue(undefined),
131
140
  findRelatedOpenPRs: jest.fn(),
132
141
  getOpenPullRequest: jest.fn(),
133
142
  getPullRequestChangedFilePaths: jest.fn().mockResolvedValue([]),
134
143
  approvePullRequest: jest.fn().mockResolvedValue(undefined),
135
144
  requestChangesWithInlineComment: jest.fn().mockResolvedValue(undefined),
136
145
  setDependedIssueUrl: jest.fn(),
146
+ setIssueAgentField: jest.fn().mockResolvedValue(undefined),
137
147
  };
138
148
 
139
149
  mockIssueCommentRepository = {
@@ -799,6 +809,237 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
799
809
  );
800
810
  });
801
811
 
812
+ it('should add nextStepAgent label and return to Awaiting Workspace when report has nextStepAgent', async () => {
813
+ const issue = createMockIssue({
814
+ url: 'https://github.com/user/repo/issues/1',
815
+ status: 'Preparation',
816
+ org: 'user',
817
+ repo: 'repo',
818
+ labels: [],
819
+ });
820
+
821
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
822
+ mockIssueRepository.get.mockResolvedValue(issue);
823
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
824
+ createMockComment({
825
+ content:
826
+ 'From: :robot: Agent report\n```json\n{"nextStepAgent": "llm-agent:chore", "nextStep": null}\n```',
827
+ }),
828
+ ]);
829
+
830
+ await useCase.run({
831
+ projectUrl: 'https://github.com/users/user/projects/1',
832
+ issueUrl: 'https://github.com/user/repo/issues/1',
833
+ thresholdForAutoReject: 3,
834
+ workflowBlockerResolvedWebhookUrl: null,
835
+ allowedIssueAuthors: null,
836
+ });
837
+
838
+ expect(mockIssueRepository.getOrCreateLabel).toHaveBeenCalledWith(
839
+ 'user',
840
+ 'repo',
841
+ 'llm-agent:chore',
842
+ );
843
+ expect(mockIssueRepository.updateLabels).toHaveBeenCalledWith(
844
+ expect.objectContaining({ labels: ['llm-agent:chore'] }),
845
+ ['llm-agent:chore'],
846
+ );
847
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
848
+ expect.objectContaining({ status: 'Awaiting Workspace' }),
849
+ mockProject,
850
+ );
851
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
852
+ expect.objectContaining({ url: 'https://github.com/user/repo/issues/1' }),
853
+ expect.stringContaining('llm-agent:chore'),
854
+ );
855
+ });
856
+
857
+ it('should preserve existing labels when adding nextStepAgent label', async () => {
858
+ const issue = createMockIssue({
859
+ url: 'https://github.com/user/repo/issues/1',
860
+ status: 'Preparation',
861
+ org: 'user',
862
+ repo: 'repo',
863
+ labels: ['existing-label'],
864
+ });
865
+
866
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
867
+ mockIssueRepository.get.mockResolvedValue(issue);
868
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
869
+ createMockComment({
870
+ content:
871
+ 'From: :robot: Agent report\n```json\n{"nextStepAgent": "llm-agent:impl", "nextStep": null}\n```',
872
+ }),
873
+ ]);
874
+
875
+ await useCase.run({
876
+ projectUrl: 'https://github.com/users/user/projects/1',
877
+ issueUrl: 'https://github.com/user/repo/issues/1',
878
+ thresholdForAutoReject: 3,
879
+ workflowBlockerResolvedWebhookUrl: null,
880
+ allowedIssueAuthors: null,
881
+ });
882
+
883
+ expect(mockIssueRepository.updateLabels).toHaveBeenCalledWith(
884
+ expect.objectContaining({
885
+ labels: ['existing-label', 'llm-agent:impl'],
886
+ }),
887
+ ['existing-label', 'llm-agent:impl'],
888
+ );
889
+ });
890
+
891
+ it('should not add nextStepAgent label when nextStepAgent is null', async () => {
892
+ const issue = createMockIssue({
893
+ url: 'https://github.com/user/repo/issues/1',
894
+ status: 'Preparation',
895
+ });
896
+
897
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
898
+ mockIssueRepository.get.mockResolvedValue(issue);
899
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
900
+ createMockComment({
901
+ content:
902
+ 'From: :robot: Agent report\n```json\n{"nextStepAgent": null, "nextStep": null}\n```',
903
+ }),
904
+ ]);
905
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
906
+ {
907
+ url: 'https://github.com/user/repo/pull/1',
908
+ isConflicted: false,
909
+ isPassedAllCiJob: true,
910
+ isCiStateSuccess: true,
911
+ isResolvedAllReviewComments: true,
912
+ isBranchOutOfDate: false,
913
+ missingRequiredCheckNames: [],
914
+ },
915
+ ]);
916
+
917
+ await useCase.run({
918
+ projectUrl: 'https://github.com/users/user/projects/1',
919
+ issueUrl: 'https://github.com/user/repo/issues/1',
920
+ thresholdForAutoReject: 3,
921
+ workflowBlockerResolvedWebhookUrl: null,
922
+ allowedIssueAuthors: null,
923
+ });
924
+
925
+ expect(mockIssueRepository.getOrCreateLabel).not.toHaveBeenCalled();
926
+ expect(mockIssueRepository.updateLabels).not.toHaveBeenCalled();
927
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
928
+ expect.objectContaining({ status: 'Awaiting Quality Check' }),
929
+ mockProject,
930
+ );
931
+ });
932
+
933
+ it('should create label when nextStepAgent label does not exist in repo', async () => {
934
+ const issue = createMockIssue({
935
+ url: 'https://github.com/user/repo/issues/1',
936
+ status: 'Preparation',
937
+ org: 'user',
938
+ repo: 'repo',
939
+ labels: [],
940
+ });
941
+
942
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
943
+ mockIssueRepository.get.mockResolvedValue(issue);
944
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
945
+ createMockComment({
946
+ content:
947
+ 'From: :robot: Agent report\n```json\n{"nextStepAgent": "llm-agent:new-agent", "nextStep": null}\n```',
948
+ }),
949
+ ]);
950
+
951
+ await useCase.run({
952
+ projectUrl: 'https://github.com/users/user/projects/1',
953
+ issueUrl: 'https://github.com/user/repo/issues/1',
954
+ thresholdForAutoReject: 3,
955
+ workflowBlockerResolvedWebhookUrl: null,
956
+ allowedIssueAuthors: null,
957
+ });
958
+
959
+ expect(mockIssueRepository.getOrCreateLabel).toHaveBeenCalledWith(
960
+ 'user',
961
+ 'repo',
962
+ 'llm-agent:new-agent',
963
+ );
964
+ });
965
+
966
+ it('should remove existing llm-agent labels and labelsAsLlmAgentName labels when adding nextStepAgent label', async () => {
967
+ const issue = createMockIssue({
968
+ url: 'https://github.com/user/repo/issues/1',
969
+ status: 'Preparation',
970
+ org: 'user',
971
+ repo: 'repo',
972
+ labels: ['llm-agent:chore', 'chore', 'feature-flag'],
973
+ });
974
+
975
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
976
+ mockIssueRepository.get.mockResolvedValue(issue);
977
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
978
+ createMockComment({
979
+ content:
980
+ 'From: :robot: Agent report\n```json\n{"nextStepAgent": "llm-agent:impl", "nextStep": null}\n```',
981
+ }),
982
+ ]);
983
+
984
+ await useCase.run({
985
+ projectUrl: 'https://github.com/users/user/projects/1',
986
+ issueUrl: 'https://github.com/user/repo/issues/1',
987
+ thresholdForAutoReject: 3,
988
+ workflowBlockerResolvedWebhookUrl: null,
989
+ allowedIssueAuthors: null,
990
+ labelsAsLlmAgentName: ['chore', 'accounting'],
991
+ });
992
+
993
+ expect(mockIssueRepository.updateLabels).toHaveBeenCalledWith(
994
+ expect.objectContaining({
995
+ labels: ['feature-flag', 'llm-agent:impl'],
996
+ }),
997
+ ['feature-flag', 'llm-agent:impl'],
998
+ );
999
+ });
1000
+
1001
+ it('should not add nextStepAgent label when nextStepAgent is an empty string', async () => {
1002
+ const issue = createMockIssue({
1003
+ url: 'https://github.com/user/repo/issues/1',
1004
+ status: 'Preparation',
1005
+ });
1006
+
1007
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1008
+ mockIssueRepository.get.mockResolvedValue(issue);
1009
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
1010
+ createMockComment({
1011
+ content:
1012
+ 'From: :robot: Agent report\n```json\n{"nextStepAgent": "", "nextStep": null}\n```',
1013
+ }),
1014
+ ]);
1015
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
1016
+ {
1017
+ url: 'https://github.com/user/repo/pull/1',
1018
+ isConflicted: false,
1019
+ isPassedAllCiJob: true,
1020
+ isCiStateSuccess: true,
1021
+ isResolvedAllReviewComments: true,
1022
+ isBranchOutOfDate: false,
1023
+ missingRequiredCheckNames: [],
1024
+ },
1025
+ ]);
1026
+
1027
+ await useCase.run({
1028
+ projectUrl: 'https://github.com/users/user/projects/1',
1029
+ issueUrl: 'https://github.com/user/repo/issues/1',
1030
+ thresholdForAutoReject: 3,
1031
+ workflowBlockerResolvedWebhookUrl: null,
1032
+ allowedIssueAuthors: null,
1033
+ });
1034
+
1035
+ expect(mockIssueRepository.getOrCreateLabel).not.toHaveBeenCalled();
1036
+ expect(mockIssueRepository.updateLabels).not.toHaveBeenCalled();
1037
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
1038
+ expect.objectContaining({ status: 'Awaiting Quality Check' }),
1039
+ mockProject,
1040
+ );
1041
+ });
1042
+
802
1043
  it('should auto-escalate to Failed Preparation after threshold rejections', async () => {
803
1044
  const issue = createMockIssue({
804
1045
  url: 'https://github.com/user/repo/issues/1',
@@ -3364,6 +3605,146 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
3364
3605
  });
3365
3606
  });
3366
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
+
3367
3748
  describe('consoleTabsRepository integration', () => {
3368
3749
  let mockConsoleTabsRepository: {
3369
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,12 +54,17 @@ 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'
59
64
  | 'update'
60
65
  | 'updateStatus'
66
+ | 'updateLabels'
67
+ | 'getOrCreateLabel'
61
68
  | 'findRelatedOpenPRs'
62
69
  | 'getStoryObjectMap'
63
70
  | 'getOpenPullRequest'
@@ -65,6 +72,7 @@ export class NotifyFinishedIssuePreparationUseCase {
65
72
  | 'approvePullRequest'
66
73
  | 'requestChangesWithInlineComment'
67
74
  | 'setDependedIssueUrl'
75
+ | 'setIssueAgentField'
68
76
  >,
69
77
  private readonly issueCommentRepository: Pick<
70
78
  IssueCommentRepository,
@@ -91,6 +99,7 @@ export class NotifyFinishedIssuePreparationUseCase {
91
99
  labelsAsLlmAgentName?: string[] | null;
92
100
  labelsNotRequiringPullRequest?: string[] | null;
93
101
  changeTargetPathAliases?: Record<string, string> | null;
102
+ agents?: string[] | null;
94
103
  }): Promise<void> => {
95
104
  const project = await this.projectRepository.getByUrl(params.projectUrl);
96
105
 
@@ -193,6 +202,55 @@ export class NotifyFinishedIssuePreparationUseCase {
193
202
  const isTrustedAuthor = (author: string): boolean =>
194
203
  this.isAuthorTrusted(author, params.allowedIssueAuthors ?? null);
195
204
 
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,
222
+ project,
223
+ agentOptionId,
224
+ );
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;
252
+ }
253
+
196
254
  const { rejections, approvedPrUrl } = await this.collectRejections(
197
255
  issue,
198
256
  comments,
@@ -480,6 +538,64 @@ export class NotifyFinishedIssuePreparationUseCase {
480
538
  return null;
481
539
  };
482
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
+
483
599
  private patchConsoleTab = async (issue: Issue): Promise<void> => {
484
600
  if (!this.consoleTabsRepository) return;
485
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