github-issue-tower-defence-management 1.77.3 → 1.79.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 (31) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +5 -2
  3. package/bin/adapter/entry-points/cli/index.js +11 -9
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +3 -3
  6. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  7. package/bin/domain/usecases/CheckIssueReviewReadinessUseCase.js +57 -9
  8. package/bin/domain/usecases/CheckIssueReviewReadinessUseCase.js.map +1 -1
  9. package/bin/domain/usecases/HandleScheduledEventUseCase.js +3 -3
  10. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  11. package/bin/domain/usecases/{RevertNotReadyAwaitingQualityCheckUseCase.js → RevertNotReadyReviewQueueIssueUseCase.js} +20 -4
  12. package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js.map +1 -0
  13. package/package.json +1 -1
  14. package/src/adapter/entry-points/cli/index.test.ts +7 -37
  15. package/src/adapter/entry-points/cli/index.ts +12 -15
  16. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +4 -4
  17. package/src/domain/usecases/CheckIssueReviewReadinessUseCase.test.ts +168 -76
  18. package/src/domain/usecases/CheckIssueReviewReadinessUseCase.ts +89 -15
  19. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +8 -8
  20. package/src/domain/usecases/HandleScheduledEventUseCase.ts +3 -3
  21. package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.test.ts +1127 -0
  22. package/src/domain/usecases/{RevertNotReadyAwaitingQualityCheckUseCase.ts → RevertNotReadyReviewQueueIssueUseCase.ts} +40 -1
  23. package/types/domain/usecases/CheckIssueReviewReadinessUseCase.d.ts +9 -5
  24. package/types/domain/usecases/CheckIssueReviewReadinessUseCase.d.ts.map +1 -1
  25. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +3 -3
  26. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  27. package/types/domain/usecases/{RevertNotReadyAwaitingQualityCheckUseCase.d.ts → RevertNotReadyReviewQueueIssueUseCase.d.ts} +3 -3
  28. package/types/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.d.ts.map +1 -0
  29. package/bin/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.js.map +0 -1
  30. package/src/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.test.ts +0 -728
  31. package/types/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.d.ts.map +0 -1
@@ -40,7 +40,7 @@ import { ProxyClaudeTokenUsageRepository } from '../../repositories/ProxyClaudeT
40
40
  import { ProxyRateLimitCacheRepository } from '../../repositories/ProxyRateLimitCacheRepository';
41
41
  import { UpdateRateLimitCacheUseCase } from '../../../domain/usecases/UpdateRateLimitCacheUseCase';
42
42
  import { RevertOrphanedPreparationUseCase } from '../../../domain/usecases/RevertOrphanedPreparationUseCase';
43
- import { RevertNotReadyAwaitingQualityCheckUseCase } from '../../../domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase';
43
+ import { RevertNotReadyReviewQueueIssueUseCase } from '../../../domain/usecases/RevertNotReadyReviewQueueIssueUseCase';
44
44
  import { GitHubIssueCommentRepository } from '../../repositories/GitHubIssueCommentRepository';
45
45
  import { SetupTowerDefenceProjectUseCase } from '../../../domain/usecases/SetupTowerDefenceProjectUseCase';
46
46
  import { DailySecurityScanUseCase } from '../../../domain/usecases/DailySecurityScanUseCase';
@@ -296,8 +296,8 @@ export class HandleScheduledEventUseCaseHandler {
296
296
  issueCommentRepository,
297
297
  nodeLocalCommandRunner,
298
298
  );
299
- const revertNotReadyAwaitingQualityCheckUseCase =
300
- new RevertNotReadyAwaitingQualityCheckUseCase(
299
+ const revertNotReadyReviewQueueIssueUseCase =
300
+ new RevertNotReadyReviewQueueIssueUseCase(
301
301
  projectRepository,
302
302
  issueRepository,
303
303
  issueCommentRepository,
@@ -329,7 +329,7 @@ export class HandleScheduledEventUseCaseHandler {
329
329
  updateIssueStatusByLabelUseCase,
330
330
  startPreparationUseCase,
331
331
  revertOrphanedPreparationUseCase,
332
- revertNotReadyAwaitingQualityCheckUseCase,
332
+ revertNotReadyReviewQueueIssueUseCase,
333
333
  updateRateLimitCacheUseCase,
334
334
  dailySecurityScanUseCase,
335
335
  systemDateRepository,
@@ -1,34 +1,8 @@
1
1
  import { CheckIssueReviewReadinessUseCase } from './CheckIssueReviewReadinessUseCase';
2
2
  import { Issue } from '../entities/Issue';
3
- import { Project } from '../entities/Project';
3
+ import { Comment } from '../entities/Comment';
4
4
  import { RelatedPullRequest } from './adapter-interfaces/IssueRepository';
5
5
 
6
- const createMockProject = (overrides: Partial<Project> = {}): Project => ({
7
- id: 'project-1',
8
- url: 'https://github.com/users/user/projects/1',
9
- databaseId: 1,
10
- name: 'Test Project',
11
- status: {
12
- name: 'Status',
13
- fieldId: 'field-1',
14
- statuses: [
15
- {
16
- id: 'preparation-id',
17
- name: 'Preparation',
18
- color: 'YELLOW',
19
- description: '',
20
- },
21
- ],
22
- },
23
- nextActionDate: null,
24
- nextActionHour: null,
25
- story: null,
26
- remainingEstimationMinutes: null,
27
- dependedIssueUrlSeparatedByComma: null,
28
- completionDate50PercentConfidence: null,
29
- ...overrides,
30
- });
31
-
32
6
  const createMockIssue = (overrides: Partial<Issue> = {}): Issue => ({
33
7
  nameWithOwner: 'user/repo',
34
8
  number: 1,
@@ -56,6 +30,13 @@ const createMockIssue = (overrides: Partial<Issue> = {}): Issue => ({
56
30
  ...overrides,
57
31
  });
58
32
 
33
+ const createMockComment = (overrides: Partial<Comment> = {}): Comment => ({
34
+ author: 'agent-bot',
35
+ content: 'From: :robot: Agent report',
36
+ createdAt: new Date('2000-01-01T00:00:00Z'),
37
+ ...overrides,
38
+ });
39
+
59
40
  const createReadyPr = (
60
41
  overrides: Partial<RelatedPullRequest> = {},
61
42
  ): RelatedPullRequest => ({
@@ -73,51 +54,151 @@ const createReadyPr = (
73
54
  });
74
55
 
75
56
  describe('CheckIssueReviewReadinessUseCase', () => {
76
- let mockProjectRepository: { getByUrl: jest.Mock };
77
57
  let mockIssueRepository: {
78
- get: jest.Mock;
58
+ getIssueByUrl: jest.Mock;
79
59
  findRelatedOpenPRs: jest.Mock;
80
60
  getOpenPullRequest: jest.Mock;
81
61
  getPullRequestChangedFilePaths: jest.Mock;
82
62
  requestChangesWithInlineComment: jest.Mock;
83
63
  };
64
+ let mockIssueCommentRepository: {
65
+ getCommentsFromIssue: jest.Mock;
66
+ };
84
67
  let useCase: CheckIssueReviewReadinessUseCase;
85
- let mockProject: Project;
86
68
 
87
69
  beforeEach(() => {
88
70
  jest.resetAllMocks();
89
71
 
90
- mockProject = createMockProject();
91
-
92
- mockProjectRepository = {
93
- getByUrl: jest.fn(),
94
- };
95
-
96
72
  mockIssueRepository = {
97
- get: jest.fn(),
73
+ getIssueByUrl: jest.fn(),
98
74
  findRelatedOpenPRs: jest.fn(),
99
75
  getOpenPullRequest: jest.fn(),
100
76
  getPullRequestChangedFilePaths: jest.fn().mockResolvedValue([]),
101
77
  requestChangesWithInlineComment: jest.fn().mockResolvedValue(undefined),
102
78
  };
103
79
 
80
+ mockIssueCommentRepository = {
81
+ getCommentsFromIssue: jest.fn(),
82
+ };
83
+
104
84
  useCase = new CheckIssueReviewReadinessUseCase(
105
- mockProjectRepository,
106
85
  mockIssueRepository,
86
+ mockIssueCommentRepository,
107
87
  );
108
88
  });
109
89
 
110
90
  describe('run', () => {
111
- it('should return reviewReady=true with empty rejections when the linked PR is ready', async () => {
91
+ it('should return reviewReady=false with ISSUE_NOT_FOUND when issue does not exist', async () => {
92
+ mockIssueRepository.getIssueByUrl.mockResolvedValue(null);
93
+
94
+ const result = await useCase.run({
95
+ issueUrl: 'https://github.com/user/repo/issues/999',
96
+ });
97
+
98
+ expect(result.reviewReady).toBe(false);
99
+ expect(result.rejections).toEqual([
100
+ {
101
+ type: 'ISSUE_NOT_FOUND',
102
+ detail: 'Issue not found: https://github.com/user/repo/issues/999',
103
+ },
104
+ ]);
105
+ });
106
+
107
+ it('should return reviewReady=false with NO_REPORT_FROM_AGENT_BOT when no comments exist', async () => {
112
108
  const issue = createMockIssue();
113
- mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
114
- mockIssueRepository.get.mockResolvedValue(issue);
109
+ mockIssueRepository.getIssueByUrl.mockResolvedValue(issue);
110
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([]);
111
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
112
+ createReadyPr(),
113
+ ]);
114
+
115
+ const result = await useCase.run({
116
+ issueUrl: 'https://github.com/user/repo/issues/1',
117
+ });
118
+
119
+ expect(result.reviewReady).toBe(false);
120
+ expect(result.rejections).toContainEqual({
121
+ type: 'NO_REPORT_FROM_AGENT_BOT',
122
+ detail: 'NO_REPORT_FROM_AGENT_BOT',
123
+ });
124
+ });
125
+
126
+ it('should return reviewReady=false with NO_REPORT_FROM_AGENT_BOT when last comment does not start with From: :robot:', async () => {
127
+ const issue = createMockIssue();
128
+ mockIssueRepository.getIssueByUrl.mockResolvedValue(issue);
129
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
130
+ createMockComment({ content: 'Some regular comment' }),
131
+ ]);
132
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
133
+ createReadyPr(),
134
+ ]);
135
+
136
+ const result = await useCase.run({
137
+ issueUrl: 'https://github.com/user/repo/issues/1',
138
+ });
139
+
140
+ expect(result.reviewReady).toBe(false);
141
+ expect(result.rejections).toContainEqual({
142
+ type: 'NO_REPORT_FROM_AGENT_BOT',
143
+ detail: 'NO_REPORT_FROM_AGENT_BOT',
144
+ });
145
+ });
146
+
147
+ it('should return reviewReady=false with REPORT_HAS_NEXT_STEP when last comment has nextStep in JSON', async () => {
148
+ const issue = createMockIssue();
149
+ mockIssueRepository.getIssueByUrl.mockResolvedValue(issue);
150
+ const commentWithNextStep = createMockComment({
151
+ content:
152
+ 'From: :robot: Agent report\n```json\n{"nextStep": "fix the bug"}\n```',
153
+ });
154
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
155
+ commentWithNextStep,
156
+ ]);
157
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
158
+ createReadyPr(),
159
+ ]);
160
+
161
+ const result = await useCase.run({
162
+ issueUrl: 'https://github.com/user/repo/issues/1',
163
+ });
164
+
165
+ expect(result.reviewReady).toBe(false);
166
+ expect(result.rejections).toContainEqual({
167
+ type: 'REPORT_HAS_NEXT_STEP',
168
+ detail: 'REPORT_HAS_NEXT_STEP',
169
+ });
170
+ });
171
+
172
+ it('should return reviewReady=false with PULL_REQUEST_NOT_FOUND when no related PR exists', async () => {
173
+ const issue = createMockIssue();
174
+ mockIssueRepository.getIssueByUrl.mockResolvedValue(issue);
175
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
176
+ createMockComment(),
177
+ ]);
178
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
179
+
180
+ const result = await useCase.run({
181
+ issueUrl: 'https://github.com/user/repo/issues/1',
182
+ });
183
+
184
+ expect(result.reviewReady).toBe(false);
185
+ expect(result.rejections).toContainEqual({
186
+ type: 'PULL_REQUEST_NOT_FOUND',
187
+ detail: 'PULL_REQUEST_NOT_FOUND',
188
+ });
189
+ });
190
+
191
+ it('should return reviewReady=true with empty rejections when all checks pass', async () => {
192
+ const issue = createMockIssue();
193
+ mockIssueRepository.getIssueByUrl.mockResolvedValue(issue);
194
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
195
+ createMockComment(),
196
+ ]);
115
197
  mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
116
198
  createReadyPr(),
117
199
  ]);
118
200
 
119
201
  const result = await useCase.run({
120
- projectUrl: 'https://github.com/users/user/projects/1',
121
202
  issueUrl: 'https://github.com/user/repo/issues/1',
122
203
  });
123
204
 
@@ -125,10 +206,12 @@ describe('CheckIssueReviewReadinessUseCase', () => {
125
206
  expect(result.rejections).toEqual([]);
126
207
  });
127
208
 
128
- it('should return reviewReady=false with rejections when the linked PR has failing CI', async () => {
209
+ it('should return reviewReady=false with ANY_CI_JOB_FAILED_OR_IN_PROGRESS when PR CI is failing', async () => {
129
210
  const issue = createMockIssue();
130
- mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
131
- mockIssueRepository.get.mockResolvedValue(issue);
211
+ mockIssueRepository.getIssueByUrl.mockResolvedValue(issue);
212
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
213
+ createMockComment(),
214
+ ]);
132
215
  mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
133
216
  createReadyPr({
134
217
  isPassedAllCiJob: false,
@@ -137,62 +220,71 @@ describe('CheckIssueReviewReadinessUseCase', () => {
137
220
  ]);
138
221
 
139
222
  const result = await useCase.run({
140
- projectUrl: 'https://github.com/users/user/projects/1',
141
223
  issueUrl: 'https://github.com/user/repo/issues/1',
142
224
  });
143
225
 
144
226
  expect(result.reviewReady).toBe(false);
145
- expect(result.rejections).toHaveLength(1);
146
227
  expect(result.rejections[0].type).toBe(
147
228
  'ANY_CI_JOB_FAILED_OR_IN_PROGRESS',
148
229
  );
149
- expect(result.rejections[0].detail).toContain(
150
- 'https://github.com/user/repo/pull/1',
151
- );
152
230
  });
153
231
 
154
- it('should return reviewReady=false with PULL_REQUEST_NOT_FOUND when no related PR exists', async () => {
232
+ it('should treat all authors as trusted when allowedIssueAuthors is null', async () => {
155
233
  const issue = createMockIssue();
156
- mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
157
- mockIssueRepository.get.mockResolvedValue(issue);
158
- mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
234
+ mockIssueRepository.getIssueByUrl.mockResolvedValue(issue);
235
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
236
+ createMockComment({ author: 'any-unknown-author' }),
237
+ ]);
238
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
239
+ createReadyPr(),
240
+ ]);
159
241
 
160
242
  const result = await useCase.run({
161
- projectUrl: 'https://github.com/users/user/projects/1',
162
243
  issueUrl: 'https://github.com/user/repo/issues/1',
244
+ allowedIssueAuthors: null,
163
245
  });
164
246
 
165
- expect(result.reviewReady).toBe(false);
166
- expect(result.rejections).toEqual([
167
- { type: 'PULL_REQUEST_NOT_FOUND', detail: 'PULL_REQUEST_NOT_FOUND' },
168
- ]);
247
+ expect(result.reviewReady).toBe(true);
248
+ expect(result.rejections).toEqual([]);
169
249
  });
170
250
 
171
- it('should throw IssueNotFoundError when the issue does not exist', async () => {
172
- mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
173
- mockIssueRepository.get.mockResolvedValue(null);
251
+ it('should reject when last comment author is not in allowedIssueAuthors list', async () => {
252
+ const issue = createMockIssue();
253
+ mockIssueRepository.getIssueByUrl.mockResolvedValue(issue);
254
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
255
+ createMockComment({ author: 'untrusted-author' }),
256
+ ]);
257
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
258
+ createReadyPr(),
259
+ ]);
260
+
261
+ const result = await useCase.run({
262
+ issueUrl: 'https://github.com/user/repo/issues/1',
263
+ allowedIssueAuthors: ['trusted-author'],
264
+ });
174
265
 
175
- await expect(
176
- useCase.run({
177
- projectUrl: 'https://github.com/users/user/projects/1',
178
- issueUrl: 'https://github.com/user/repo/issues/999',
179
- }),
180
- ).rejects.toThrow(
181
- 'Issue not found: https://github.com/user/repo/issues/999',
182
- );
266
+ expect(result.reviewReady).toBe(false);
267
+ expect(result.rejections).toContainEqual({
268
+ type: 'NO_REPORT_FROM_AGENT_BOT',
269
+ detail: 'NO_REPORT_FROM_AGENT_BOT',
270
+ });
183
271
  });
184
272
 
185
- it('should not call findRelatedOpenPRs nor mutate state when issue has a category label other than e2e', async () => {
186
- const issue = createMockIssue({ labels: ['category:frontend'] });
187
- mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
188
- mockIssueRepository.get.mockResolvedValue(issue);
273
+ it('should accept when last comment author is in allowedIssueAuthors list', async () => {
274
+ const issue = createMockIssue();
275
+ mockIssueRepository.getIssueByUrl.mockResolvedValue(issue);
276
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
277
+ createMockComment({ author: 'trusted-author' }),
278
+ ]);
279
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
280
+ createReadyPr(),
281
+ ]);
189
282
 
190
283
  const result = await useCase.run({
191
- projectUrl: 'https://github.com/users/user/projects/1',
192
284
  issueUrl: 'https://github.com/user/repo/issues/1',
285
+ allowedIssueAuthors: ['trusted-author'],
193
286
  });
194
287
 
195
- expect(mockIssueRepository.findRelatedOpenPRs).not.toHaveBeenCalled();
196
288
  expect(result.reviewReady).toBe(true);
197
289
  expect(result.rejections).toEqual([]);
198
290
  });
@@ -1,53 +1,127 @@
1
1
  import { IssueRepository } from './adapter-interfaces/IssueRepository';
2
- import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
2
+ import { IssueCommentRepository } from './adapter-interfaces/IssueCommentRepository';
3
3
  import {
4
4
  IssueRejectionEvaluator,
5
5
  PrRejectedReasonType,
6
6
  } from './IssueRejectionEvaluator';
7
- import { IssueNotFoundError } from './NotifyFinishedIssuePreparationUseCase';
7
+
8
+ type RejectedReasonType =
9
+ | 'ISSUE_NOT_FOUND'
10
+ | 'NO_REPORT_FROM_AGENT_BOT'
11
+ | 'REPORT_HAS_NEXT_STEP'
12
+ | PrRejectedReasonType;
8
13
 
9
14
  export type IssueReviewReadinessResult = {
10
15
  reviewReady: boolean;
11
- rejections: { type: PrRejectedReasonType; detail: string }[];
16
+ rejections: { type: RejectedReasonType; detail: string }[];
12
17
  };
13
18
 
14
19
  export class CheckIssueReviewReadinessUseCase {
15
20
  private readonly issueRejectionEvaluator: IssueRejectionEvaluator;
16
21
 
17
22
  constructor(
18
- private readonly projectRepository: Pick<ProjectRepository, 'getByUrl'>,
19
23
  private readonly issueRepository: Pick<
20
24
  IssueRepository,
21
- | 'get'
25
+ | 'getIssueByUrl'
22
26
  | 'findRelatedOpenPRs'
23
27
  | 'getOpenPullRequest'
24
28
  | 'getPullRequestChangedFilePaths'
25
29
  | 'requestChangesWithInlineComment'
26
30
  >,
31
+ private readonly issueCommentRepository: Pick<
32
+ IssueCommentRepository,
33
+ 'getCommentsFromIssue'
34
+ >,
27
35
  ) {
28
36
  this.issueRejectionEvaluator = new IssueRejectionEvaluator(issueRepository);
29
37
  }
30
38
 
31
39
  run = async (params: {
32
- projectUrl: string;
33
40
  issueUrl: string;
41
+ allowedIssueAuthors?: string[] | null;
34
42
  labelsAsLlmAgentName?: string[] | null;
35
43
  }): Promise<IssueReviewReadinessResult> => {
36
- const project = await this.projectRepository.getByUrl(params.projectUrl);
37
- const issue = await this.issueRepository.get(params.issueUrl, project);
44
+ const issue = await this.issueRepository.getIssueByUrl(params.issueUrl);
38
45
 
39
46
  if (!issue) {
40
- throw new IssueNotFoundError(params.issueUrl);
47
+ return {
48
+ reviewReady: false,
49
+ rejections: [
50
+ {
51
+ type: 'ISSUE_NOT_FOUND',
52
+ detail: `Issue not found: ${params.issueUrl}`,
53
+ },
54
+ ],
55
+ };
56
+ }
57
+
58
+ const rejections: { type: RejectedReasonType; detail: string }[] = [];
59
+
60
+ const comments =
61
+ await this.issueCommentRepository.getCommentsFromIssue(issue);
62
+
63
+ const isTrustedAuthor = (author: string): boolean =>
64
+ this.isAuthorTrusted(author, params.allowedIssueAuthors ?? null);
65
+
66
+ const lastComment = comments[comments.length - 1];
67
+ if (
68
+ !lastComment ||
69
+ !isTrustedAuthor(lastComment.author) ||
70
+ !lastComment.content.startsWith('From: :robot:')
71
+ ) {
72
+ rejections.push({
73
+ type: 'NO_REPORT_FROM_AGENT_BOT',
74
+ detail: 'NO_REPORT_FROM_AGENT_BOT',
75
+ });
76
+ } else if (this.reportBodyHasNextStep(lastComment.content)) {
77
+ rejections.push({
78
+ type: 'REPORT_HAS_NEXT_STEP',
79
+ detail: 'REPORT_HAS_NEXT_STEP',
80
+ });
41
81
  }
42
82
 
43
- const { rejections } = await this.issueRejectionEvaluator.evaluate(
44
- issue,
45
- params.labelsAsLlmAgentName ?? [],
46
- );
83
+ const { rejections: prRejections } =
84
+ await this.issueRejectionEvaluator.evaluate(
85
+ issue,
86
+ params.labelsAsLlmAgentName ?? [],
87
+ );
88
+
89
+ const allRejections = [...rejections, ...prRejections];
47
90
 
48
91
  return {
49
- reviewReady: rejections.length === 0,
50
- rejections,
92
+ reviewReady: allRejections.length === 0,
93
+ rejections: allRejections,
51
94
  };
52
95
  };
96
+
97
+ private isAuthorTrusted = (
98
+ author: string,
99
+ allowedIssueAuthors: string[] | null,
100
+ ): boolean =>
101
+ allowedIssueAuthors === null || allowedIssueAuthors.includes(author);
102
+
103
+ private reportBodyHasNextStep = (body: string): boolean => {
104
+ const reportMatch = body.match(/```json\n([\s\S]*?)\n```/);
105
+ if (!reportMatch || reportMatch.length < 2) {
106
+ return false;
107
+ }
108
+ let reportJson: unknown;
109
+ try {
110
+ reportJson = JSON.parse(reportMatch[1]);
111
+ } catch (error) {
112
+ console.warn(
113
+ 'Invalid JSON in report body while checking nextStep:',
114
+ error,
115
+ );
116
+ return false;
117
+ }
118
+ if (typeof reportJson !== 'object' || reportJson === null) {
119
+ return false;
120
+ }
121
+ if (!('nextStep' in reportJson)) {
122
+ return false;
123
+ }
124
+ const nextStepValue = Reflect.get(reportJson, 'nextStep');
125
+ return nextStepValue !== null && nextStepValue !== undefined;
126
+ };
53
127
  }
@@ -22,7 +22,7 @@ import { AssignNoAssigneeIssueToManagerUseCase } from './AssignNoAssigneeIssueTo
22
22
  import { UpdateIssueStatusByLabelUseCase } from './UpdateIssueStatusByLabelUseCase';
23
23
  import { StartPreparationUseCase } from './StartPreparationUseCase';
24
24
  import { RevertOrphanedPreparationUseCase } from './RevertOrphanedPreparationUseCase';
25
- import { RevertNotReadyAwaitingQualityCheckUseCase } from './RevertNotReadyAwaitingQualityCheckUseCase';
25
+ import { RevertNotReadyReviewQueueIssueUseCase } from './RevertNotReadyReviewQueueIssueUseCase';
26
26
  import { SetupTowerDefenceProjectUseCase } from './SetupTowerDefenceProjectUseCase';
27
27
  import { UpdateRateLimitCacheUseCase } from './UpdateRateLimitCacheUseCase';
28
28
  import { DailySecurityScanUseCase } from './DailySecurityScanUseCase';
@@ -115,8 +115,8 @@ describe('HandleScheduledEventUseCase', () => {
115
115
  const mockStartPreparationUseCase = mock<StartPreparationUseCase>();
116
116
  const mockRevertOrphanedPreparationUseCase =
117
117
  mock<RevertOrphanedPreparationUseCase>();
118
- const mockRevertNotReadyAwaitingQualityCheckUseCase =
119
- mock<RevertNotReadyAwaitingQualityCheckUseCase>();
118
+ const mockRevertNotReadyReviewQueueIssueUseCase =
119
+ mock<RevertNotReadyReviewQueueIssueUseCase>();
120
120
  const mockUpdateRateLimitCacheUseCase = mock<UpdateRateLimitCacheUseCase>();
121
121
  const mockDailySecurityScanUseCase = mock<DailySecurityScanUseCase>();
122
122
  const mockDateRepository = mock<DateRepository>();
@@ -142,7 +142,7 @@ describe('HandleScheduledEventUseCase', () => {
142
142
  mockUpdateIssueStatusByLabelUseCase,
143
143
  mockStartPreparationUseCase,
144
144
  mockRevertOrphanedPreparationUseCase,
145
- mockRevertNotReadyAwaitingQualityCheckUseCase,
145
+ mockRevertNotReadyReviewQueueIssueUseCase,
146
146
  mockUpdateRateLimitCacheUseCase,
147
147
  mockDailySecurityScanUseCase,
148
148
  mockDateRepository,
@@ -326,7 +326,7 @@ describe('HandleScheduledEventUseCase', () => {
326
326
  );
327
327
  });
328
328
 
329
- it('should invoke revertNotReadyAwaitingQualityCheckUseCase on every scheduled run', async () => {
329
+ it('should invoke revertNotReadyReviewQueueIssueUseCase on every scheduled run', async () => {
330
330
  const input = {
331
331
  projectName: 'test-project',
332
332
  org: 'test-org',
@@ -346,7 +346,7 @@ describe('HandleScheduledEventUseCase', () => {
346
346
  await useCase.run(input);
347
347
 
348
348
  expect(
349
- mockRevertNotReadyAwaitingQualityCheckUseCase.run,
349
+ mockRevertNotReadyReviewQueueIssueUseCase.run,
350
350
  ).toHaveBeenCalledWith(
351
351
  expect.objectContaining({
352
352
  projectUrl: 'https://github.com/test-org/test-project',
@@ -355,7 +355,7 @@ describe('HandleScheduledEventUseCase', () => {
355
355
  );
356
356
  });
357
357
 
358
- it('should invoke revertNotReadyAwaitingQualityCheckUseCase even when startPreparation is absent', async () => {
358
+ it('should invoke revertNotReadyReviewQueueIssueUseCase even when startPreparation is absent', async () => {
359
359
  const input = {
360
360
  projectName: 'test-project',
361
361
  org: 'test-org',
@@ -375,7 +375,7 @@ describe('HandleScheduledEventUseCase', () => {
375
375
  await useCase.run(input);
376
376
 
377
377
  expect(
378
- mockRevertNotReadyAwaitingQualityCheckUseCase.run,
378
+ mockRevertNotReadyReviewQueueIssueUseCase.run,
379
379
  ).toHaveBeenCalledTimes(1);
380
380
  });
381
381
 
@@ -25,7 +25,7 @@ import {
25
25
  StartPreparationUseCase,
26
26
  } from './StartPreparationUseCase';
27
27
  import { RevertOrphanedPreparationUseCase } from './RevertOrphanedPreparationUseCase';
28
- import { RevertNotReadyAwaitingQualityCheckUseCase } from './RevertNotReadyAwaitingQualityCheckUseCase';
28
+ import { RevertNotReadyReviewQueueIssueUseCase } from './RevertNotReadyReviewQueueIssueUseCase';
29
29
  import { resolveLabelsAsLlmAgentName } from './resolveLabelsAsLlmAgentName';
30
30
  import { SetupTowerDefenceProjectUseCase } from './SetupTowerDefenceProjectUseCase';
31
31
  import { UpdateRateLimitCacheUseCase } from './UpdateRateLimitCacheUseCase';
@@ -62,7 +62,7 @@ export class HandleScheduledEventUseCase {
62
62
  readonly updateIssueStatusByLabelUseCase: UpdateIssueStatusByLabelUseCase,
63
63
  readonly startPreparationUseCase: StartPreparationUseCase,
64
64
  readonly revertOrphanedPreparationUseCase: RevertOrphanedPreparationUseCase,
65
- readonly revertNotReadyAwaitingQualityCheckUseCase: RevertNotReadyAwaitingQualityCheckUseCase,
65
+ readonly revertNotReadyReviewQueueIssueUseCase: RevertNotReadyReviewQueueIssueUseCase,
66
66
  readonly updateRateLimitCacheUseCase: UpdateRateLimitCacheUseCase | null,
67
67
  readonly dailySecurityScanUseCase: DailySecurityScanUseCase | null,
68
68
  readonly dateRepository: DateRepository,
@@ -292,7 +292,7 @@ ${JSON.stringify(e)}
292
292
  topLevel: input.labelsAsLlmAgentName,
293
293
  startPreparation: input.startPreparation?.labelsAsLlmAgentName,
294
294
  });
295
- await this.revertNotReadyAwaitingQualityCheckUseCase.run({
295
+ await this.revertNotReadyReviewQueueIssueUseCase.run({
296
296
  projectUrl: input.projectUrl,
297
297
  allowIssueCacheMinutes: input.allowIssueCacheMinutes,
298
298
  labelsAsLlmAgentName,