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.
- package/CHANGELOG.md +20 -0
- package/README.md +5 -2
- package/bin/adapter/entry-points/cli/index.js +11 -9
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +3 -3
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/domain/usecases/CheckIssueReviewReadinessUseCase.js +57 -9
- package/bin/domain/usecases/CheckIssueReviewReadinessUseCase.js.map +1 -1
- package/bin/domain/usecases/HandleScheduledEventUseCase.js +3 -3
- package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
- package/bin/domain/usecases/{RevertNotReadyAwaitingQualityCheckUseCase.js → RevertNotReadyReviewQueueIssueUseCase.js} +20 -4
- package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.test.ts +7 -37
- package/src/adapter/entry-points/cli/index.ts +12 -15
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +4 -4
- package/src/domain/usecases/CheckIssueReviewReadinessUseCase.test.ts +168 -76
- package/src/domain/usecases/CheckIssueReviewReadinessUseCase.ts +89 -15
- package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +8 -8
- package/src/domain/usecases/HandleScheduledEventUseCase.ts +3 -3
- package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.test.ts +1127 -0
- package/src/domain/usecases/{RevertNotReadyAwaitingQualityCheckUseCase.ts → RevertNotReadyReviewQueueIssueUseCase.ts} +40 -1
- package/types/domain/usecases/CheckIssueReviewReadinessUseCase.d.ts +9 -5
- package/types/domain/usecases/CheckIssueReviewReadinessUseCase.d.ts.map +1 -1
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +3 -3
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
- package/types/domain/usecases/{RevertNotReadyAwaitingQualityCheckUseCase.d.ts → RevertNotReadyReviewQueueIssueUseCase.d.ts} +3 -3
- package/types/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.d.ts.map +1 -0
- package/bin/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.js.map +0 -1
- package/src/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.test.ts +0 -728
- 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 {
|
|
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
|
|
300
|
-
new
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
114
|
-
|
|
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
|
|
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
|
-
|
|
131
|
-
|
|
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
|
|
232
|
+
it('should treat all authors as trusted when allowedIssueAuthors is null', async () => {
|
|
155
233
|
const issue = createMockIssue();
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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(
|
|
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
|
|
172
|
-
|
|
173
|
-
mockIssueRepository.
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
186
|
-
const issue = createMockIssue(
|
|
187
|
-
|
|
188
|
-
|
|
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 {
|
|
2
|
+
import { IssueCommentRepository } from './adapter-interfaces/IssueCommentRepository';
|
|
3
3
|
import {
|
|
4
4
|
IssueRejectionEvaluator,
|
|
5
5
|
PrRejectedReasonType,
|
|
6
6
|
} from './IssueRejectionEvaluator';
|
|
7
|
-
|
|
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:
|
|
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
|
-
| '
|
|
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
|
|
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
|
-
|
|
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 } =
|
|
44
|
-
|
|
45
|
-
|
|
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:
|
|
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 {
|
|
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
|
|
119
|
-
mock<
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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.
|
|
295
|
+
await this.revertNotReadyReviewQueueIssueUseCase.run({
|
|
296
296
|
projectUrl: input.projectUrl,
|
|
297
297
|
allowIssueCacheMinutes: input.allowIssueCacheMinutes,
|
|
298
298
|
labelsAsLlmAgentName,
|