github-issue-tower-defence-management 1.180.0 → 1.181.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/bin/adapter/entry-points/console/ui-dist/assets/index-BUFcVUVD.js +86 -0
- package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/bin/adapter/repositories/GoogleSpreadsheetRepository.js +41 -12
- package/bin/adapter/repositories/GoogleSpreadsheetRepository.js.map +1 -1
- package/bin/domain/usecases/CheckIssueReviewReadinessUseCase.js +2 -1
- package/bin/domain/usecases/CheckIssueReviewReadinessUseCase.js.map +1 -1
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +2 -1
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +2 -1
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/TriagerApprovalDispatchUseCase.js +3 -3
- package/bin/domain/usecases/TriagerApprovalDispatchUseCase.js.map +1 -1
- package/bin/domain/usecases/extractFencedJsonBlocks.js +18 -0
- package/bin/domain/usecases/extractFencedJsonBlocks.js.map +1 -0
- package/bin/domain/usecases/extractNextStepAgent.js +14 -23
- package/bin/domain/usecases/extractNextStepAgent.js.map +1 -1
- package/bin/domain/usecases/findLastAgentReport.js +2 -3
- package/bin/domain/usecases/findLastAgentReport.js.map +1 -1
- package/bin/domain/usecases/isAgentReportBody.js +43 -0
- package/bin/domain/usecases/isAgentReportBody.js.map +1 -0
- package/bin/domain/usecases/isPullRequestDeclaredUnnecessary.js +12 -19
- package/bin/domain/usecases/isPullRequestDeclaredUnnecessary.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +1 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.stories.tsx +1 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOkAndAwaitingWorkspaceActions.stories.tsx +25 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOkAndAwaitingWorkspaceActions.test.tsx +50 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOkAndAwaitingWorkspaceActions.tsx +41 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOperationMenu.stories.tsx +1 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOperationMenu.test.tsx +1 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOperationMenu.tsx +5 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.test.ts +70 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.ts +29 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/actionToast.test.ts +14 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/actionToast.ts +5 -1
- package/src/adapter/entry-points/console/ui/src/features/console/logic/operations.ts +1 -0
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +1 -0
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +7 -0
- package/src/adapter/entry-points/console/ui-dist/assets/index-BUFcVUVD.js +86 -0
- package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/src/adapter/repositories/GoogleSpreadsheetRepository.test.ts +47 -0
- package/src/adapter/repositories/GoogleSpreadsheetRepository.ts +76 -36
- package/src/domain/usecases/CheckIssueReviewReadinessUseCase.ts +2 -1
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +50 -0
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +2 -1
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +2 -1
- package/src/domain/usecases/TriagerApprovalDispatchUseCase.ts +6 -5
- package/src/domain/usecases/extractFencedJsonBlocks.test.ts +40 -0
- package/src/domain/usecases/extractFencedJsonBlocks.ts +21 -0
- package/src/domain/usecases/extractNextStepAgent.test.ts +19 -0
- package/src/domain/usecases/extractNextStepAgent.ts +15 -27
- package/src/domain/usecases/findLastAgentReport.test.ts +28 -0
- package/src/domain/usecases/findLastAgentReport.ts +2 -3
- package/src/domain/usecases/isAgentReportBody.test.ts +108 -0
- package/src/domain/usecases/isAgentReportBody.ts +52 -0
- package/src/domain/usecases/isPullRequestDeclaredUnnecessary.test.ts +33 -0
- package/src/domain/usecases/isPullRequestDeclaredUnnecessary.ts +15 -23
- package/types/adapter/repositories/GoogleSpreadsheetRepository.d.ts +3 -1
- package/types/adapter/repositories/GoogleSpreadsheetRepository.d.ts.map +1 -1
- package/types/domain/usecases/CheckIssueReviewReadinessUseCase.d.ts.map +1 -1
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/TriagerApprovalDispatchUseCase.d.ts.map +1 -1
- package/types/domain/usecases/extractFencedJsonBlocks.d.ts +2 -0
- package/types/domain/usecases/extractFencedJsonBlocks.d.ts.map +1 -0
- package/types/domain/usecases/extractNextStepAgent.d.ts.map +1 -1
- package/types/domain/usecases/findLastAgentReport.d.ts.map +1 -1
- package/types/domain/usecases/isAgentReportBody.d.ts +4 -0
- package/types/domain/usecases/isAgentReportBody.d.ts.map +1 -0
- package/types/domain/usecases/isPullRequestDeclaredUnnecessary.d.ts.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-I_2Yu8fM.js +0 -86
- package/src/adapter/entry-points/console/ui-dist/assets/index-I_2Yu8fM.js +0 -86
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>TDPM Console</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-BUFcVUVD.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-DeZcPNCr.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
@@ -215,6 +215,53 @@ describe('GoogleSpreadsheetRepository', () => {
|
|
|
215
215
|
|
|
216
216
|
expect(result).toEqual([['1', 'true', 'text']]);
|
|
217
217
|
});
|
|
218
|
+
|
|
219
|
+
test('retries once after a quota error and returns the value on success', async () => {
|
|
220
|
+
mockSpreadsheetsGet
|
|
221
|
+
.mockRejectedValueOnce(
|
|
222
|
+
new Error('Quota exceeded for quota metric Read requests'),
|
|
223
|
+
)
|
|
224
|
+
.mockResolvedValueOnce({
|
|
225
|
+
status: 200,
|
|
226
|
+
data: {
|
|
227
|
+
sheets: [{ properties: { title: 'RetrySheet' } }],
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
mockSpreadsheetsValuesGet.mockResolvedValue({
|
|
231
|
+
status: 200,
|
|
232
|
+
data: { values: [['retried']] },
|
|
233
|
+
});
|
|
234
|
+
const repositoryWithFastSleep = new GoogleSpreadsheetRepository(
|
|
235
|
+
localStorageRepository,
|
|
236
|
+
'dummy-service-account-key',
|
|
237
|
+
() => mockSheetsClient,
|
|
238
|
+
() => Promise.resolve(),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const result = await repositoryWithFastSleep.getSheet(
|
|
242
|
+
spreadsheetUrl,
|
|
243
|
+
'RetrySheet',
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
expect(result).toEqual([['retried']]);
|
|
247
|
+
expect(mockSpreadsheetsGet).toHaveBeenCalledTimes(2);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('throws after exhausting all retries', async () => {
|
|
251
|
+
mockSpreadsheetsGet.mockRejectedValue(
|
|
252
|
+
new Error('Quota exceeded for quota metric Read requests'),
|
|
253
|
+
);
|
|
254
|
+
const repositoryWithFastSleep = new GoogleSpreadsheetRepository(
|
|
255
|
+
localStorageRepository,
|
|
256
|
+
'dummy-service-account-key',
|
|
257
|
+
() => mockSheetsClient,
|
|
258
|
+
() => Promise.resolve(),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
await expect(
|
|
262
|
+
repositoryWithFastSleep.getSheet(spreadsheetUrl, 'AnySheet'),
|
|
263
|
+
).rejects.toThrow('Quota exceeded');
|
|
264
|
+
});
|
|
218
265
|
});
|
|
219
266
|
|
|
220
267
|
describe('updateCell', () => {
|
|
@@ -44,13 +44,17 @@ interface SheetsApiClient {
|
|
|
44
44
|
export class GoogleSpreadsheetRepository implements SpreadsheetRepository {
|
|
45
45
|
keyFile = './tmp/service-account-key.json';
|
|
46
46
|
private readonly sheetsClient: SheetsApiClient;
|
|
47
|
+
private readonly sleepFn: (ms: number) => Promise<void>;
|
|
47
48
|
|
|
48
49
|
constructor(
|
|
49
50
|
readonly localStorageRepository: LocalStorageRepository,
|
|
50
51
|
serviceAccountKey: string = process.env.GOOGLE_SERVICE_ACCOUNT_KEY ||
|
|
51
52
|
'dummy',
|
|
52
53
|
sheetsClientFactory?: () => SheetsApiClient,
|
|
54
|
+
sleepFn: (ms: number) => Promise<void> = (ms) =>
|
|
55
|
+
new Promise((resolve) => setTimeout(resolve, ms)),
|
|
53
56
|
) {
|
|
57
|
+
this.sleepFn = sleepFn;
|
|
54
58
|
this.localStorageRepository.write(this.keyFile, serviceAccountKey);
|
|
55
59
|
this.sheetsClient = sheetsClientFactory
|
|
56
60
|
? sheetsClientFactory()
|
|
@@ -93,6 +97,30 @@ export class GoogleSpreadsheetRepository implements SpreadsheetRepository {
|
|
|
93
97
|
})();
|
|
94
98
|
}
|
|
95
99
|
|
|
100
|
+
private callWithRetry = async <T>(fn: () => Promise<T>): Promise<T> => {
|
|
101
|
+
const backoffMs = [1000, 2000, 4000];
|
|
102
|
+
let lastError: unknown;
|
|
103
|
+
for (let attempt = 0; attempt <= backoffMs.length; attempt++) {
|
|
104
|
+
try {
|
|
105
|
+
return await fn();
|
|
106
|
+
} catch (error) {
|
|
107
|
+
lastError = error;
|
|
108
|
+
const isQuotaError = (() => {
|
|
109
|
+
if (!(error instanceof Error)) return false;
|
|
110
|
+
if (error.message.includes('Quota exceeded')) return true;
|
|
111
|
+
if ('status' in error && error.status === 429) return true;
|
|
112
|
+
if ('code' in error && error.code === 429) return true;
|
|
113
|
+
return false;
|
|
114
|
+
})();
|
|
115
|
+
if (!isQuotaError || attempt === backoffMs.length) {
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
await this.sleepFn(backoffMs[attempt]);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
throw lastError;
|
|
122
|
+
};
|
|
123
|
+
|
|
96
124
|
getSpreadsheetId = (spreadsheetUrl: string): string => {
|
|
97
125
|
const url = new URL(spreadsheetUrl);
|
|
98
126
|
return url.pathname.split('/')[3];
|
|
@@ -103,9 +131,11 @@ export class GoogleSpreadsheetRepository implements SpreadsheetRepository {
|
|
|
103
131
|
): Promise<string[][] | null> => {
|
|
104
132
|
const sheets = this.sheetsClient;
|
|
105
133
|
const spreadsheetId = this.getSpreadsheetId(spreadsheetUrl);
|
|
106
|
-
const responseSheet = await
|
|
107
|
-
|
|
108
|
-
|
|
134
|
+
const responseSheet = await this.callWithRetry(() =>
|
|
135
|
+
sheets.spreadsheets.get({
|
|
136
|
+
spreadsheetId,
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
109
139
|
if (responseSheet.status !== 200) {
|
|
110
140
|
throw new Error(
|
|
111
141
|
`Failed to get sheet: ${responseSheet.status}. ${JSON.stringify(responseSheet.data)}`,
|
|
@@ -117,10 +147,12 @@ export class GoogleSpreadsheetRepository implements SpreadsheetRepository {
|
|
|
117
147
|
if (!sheet) {
|
|
118
148
|
return null;
|
|
119
149
|
}
|
|
120
|
-
const response = await
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
150
|
+
const response = await this.callWithRetry(() =>
|
|
151
|
+
sheets.spreadsheets.values.get({
|
|
152
|
+
spreadsheetId,
|
|
153
|
+
range: sheetName,
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
124
156
|
if (response.status !== 200) {
|
|
125
157
|
throw new Error(
|
|
126
158
|
`Failed to get sheet: ${response.status}. ${JSON.stringify(response.data)}`,
|
|
@@ -141,14 +173,16 @@ export class GoogleSpreadsheetRepository implements SpreadsheetRepository {
|
|
|
141
173
|
const sheets = this.sheetsClient;
|
|
142
174
|
const spreadsheetId = this.getSpreadsheetId(spreadsheetUrl);
|
|
143
175
|
await this.createNewSheetIfNotExists(spreadsheetUrl, sheetName);
|
|
144
|
-
const response = await
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
176
|
+
const response = await this.callWithRetry(() =>
|
|
177
|
+
sheets.spreadsheets.values.update({
|
|
178
|
+
spreadsheetId,
|
|
179
|
+
range: `${sheetName}!${String.fromCharCode(65 + column)}${row + 1}`,
|
|
180
|
+
valueInputOption: 'RAW',
|
|
181
|
+
requestBody: {
|
|
182
|
+
values: [[value]],
|
|
183
|
+
},
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
152
186
|
if (response.status !== 200) {
|
|
153
187
|
throw new Error(
|
|
154
188
|
`Failed to update cell: ${response.status}. ${JSON.stringify(response.data)}`,
|
|
@@ -161,7 +195,9 @@ export class GoogleSpreadsheetRepository implements SpreadsheetRepository {
|
|
|
161
195
|
): Promise<boolean> => {
|
|
162
196
|
const sheets = this.sheetsClient;
|
|
163
197
|
const spreadsheetId = this.getSpreadsheetId(spreadsheetUrl);
|
|
164
|
-
const response = await
|
|
198
|
+
const response = await this.callWithRetry(() =>
|
|
199
|
+
sheets.spreadsheets.get({ spreadsheetId }),
|
|
200
|
+
);
|
|
165
201
|
if (response.status !== 200) {
|
|
166
202
|
throw new Error(
|
|
167
203
|
`Failed to get sheet: ${response.status}. ${JSON.stringify(response.data)}`,
|
|
@@ -180,20 +216,22 @@ export class GoogleSpreadsheetRepository implements SpreadsheetRepository {
|
|
|
180
216
|
if (await this.sheetExists(spreadsheetUrl, sheetName)) {
|
|
181
217
|
return;
|
|
182
218
|
}
|
|
183
|
-
const response = await
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
219
|
+
const response = await this.callWithRetry(() =>
|
|
220
|
+
sheets.spreadsheets.batchUpdate({
|
|
221
|
+
spreadsheetId,
|
|
222
|
+
requestBody: {
|
|
223
|
+
requests: [
|
|
224
|
+
{
|
|
225
|
+
addSheet: {
|
|
226
|
+
properties: {
|
|
227
|
+
title: sheetName,
|
|
228
|
+
},
|
|
191
229
|
},
|
|
192
230
|
},
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
},
|
|
196
|
-
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
}),
|
|
234
|
+
);
|
|
197
235
|
if (response.status !== 200) {
|
|
198
236
|
throw new Error(
|
|
199
237
|
`Failed to create sheet: ${response.status}. ${JSON.stringify(response.data)}`,
|
|
@@ -211,14 +249,16 @@ export class GoogleSpreadsheetRepository implements SpreadsheetRepository {
|
|
|
211
249
|
await this.createNewSheetIfNotExists(spreadsheetUrl, sheetName);
|
|
212
250
|
const sheet = await this.getSheet(spreadsheetUrl, sheetName);
|
|
213
251
|
const range = `${sheetName}!A${sheet ? sheet.length + 1 : 1}:A`;
|
|
214
|
-
const response = await
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
252
|
+
const response = await this.callWithRetry(() =>
|
|
253
|
+
sheets.spreadsheets.values.append({
|
|
254
|
+
spreadsheetId,
|
|
255
|
+
range: range,
|
|
256
|
+
valueInputOption: 'RAW',
|
|
257
|
+
requestBody: {
|
|
258
|
+
values,
|
|
259
|
+
},
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
222
262
|
if (response.status !== 200) {
|
|
223
263
|
throw new Error(
|
|
224
264
|
`Failed to append values: ${response.status}. ${JSON.stringify(response.data)}`,
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import { resolveLabelsNotRequiringPullRequest } from './resolveLabelsNotRequiringPullRequest';
|
|
8
8
|
import { isPullRequestDeclaredUnnecessary } from './isPullRequestDeclaredUnnecessary';
|
|
9
9
|
import { normalizeReportBody } from './normalizeReportBody';
|
|
10
|
+
import { isAgentReportBody } from './isAgentReportBody';
|
|
10
11
|
|
|
11
12
|
type RejectedReasonType =
|
|
12
13
|
| 'ISSUE_NOT_FOUND'
|
|
@@ -72,7 +73,7 @@ export class CheckIssueReviewReadinessUseCase {
|
|
|
72
73
|
if (
|
|
73
74
|
!lastComment ||
|
|
74
75
|
!isTrustedAuthor(lastComment.author) ||
|
|
75
|
-
!lastComment.content
|
|
76
|
+
!isAgentReportBody(lastComment.content)
|
|
76
77
|
) {
|
|
77
78
|
rejections.push({
|
|
78
79
|
type: 'NO_REPORT_FROM_AGENT_BOT',
|
|
@@ -984,6 +984,56 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
|
|
|
984
984
|
);
|
|
985
985
|
});
|
|
986
986
|
|
|
987
|
+
it('should end the dispatch loop when the dispatched agent reports with the prefix behind a leading fenced json block', async () => {
|
|
988
|
+
const issue = createMockIssue({
|
|
989
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
990
|
+
status: 'Preparation',
|
|
991
|
+
agent: 'accounting',
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
995
|
+
mockIssueRepository.get.mockResolvedValue(issue);
|
|
996
|
+
mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
|
|
997
|
+
createMockComment({
|
|
998
|
+
content:
|
|
999
|
+
'From: :robot: triager\n```json\n{"nextStepAgent": "accounting", "nextStep": null}\n```',
|
|
1000
|
+
}),
|
|
1001
|
+
createMockComment({
|
|
1002
|
+
content:
|
|
1003
|
+
'Auto Status Check: RETURNED_TO_AWAITING_WORKSPACE\nThe report declared that this task needs no pull request.',
|
|
1004
|
+
}),
|
|
1005
|
+
createMockComment({
|
|
1006
|
+
content:
|
|
1007
|
+
'```json\n{ "pullRequestRequired": false, "nextStep": null }\n```\n\nFrom: :robot: accounting (model)\n\n## Result\nDone.',
|
|
1008
|
+
}),
|
|
1009
|
+
]);
|
|
1010
|
+
mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
|
|
1011
|
+
|
|
1012
|
+
await useCase.run({
|
|
1013
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
1014
|
+
issueUrl: 'https://github.com/user/repo/issues/1',
|
|
1015
|
+
thresholdForAutoReject: 3,
|
|
1016
|
+
workflowBlockerResolvedWebhookUrl: null,
|
|
1017
|
+
allowedIssueAuthors: null,
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
expect(mockIssueRepository.update).toHaveBeenCalledWith(
|
|
1021
|
+
expect.objectContaining({
|
|
1022
|
+
status: 'Awaiting Quality Check',
|
|
1023
|
+
}),
|
|
1024
|
+
mockProject,
|
|
1025
|
+
);
|
|
1026
|
+
expect(mockIssueRepository.updateStatus).not.toHaveBeenCalledWith(
|
|
1027
|
+
mockProject,
|
|
1028
|
+
expect.anything(),
|
|
1029
|
+
'awaiting-workspace-id',
|
|
1030
|
+
);
|
|
1031
|
+
expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalledWith(
|
|
1032
|
+
expect.anything(),
|
|
1033
|
+
expect.stringContaining('Next step agent dispatch repeated: accounting'),
|
|
1034
|
+
);
|
|
1035
|
+
});
|
|
1036
|
+
|
|
987
1037
|
it('should escalate to Failed Preparation when the declared nextStepAgent was already dispatched up to the threshold without a report', async () => {
|
|
988
1038
|
const issue = createMockIssue({
|
|
989
1039
|
url: 'https://github.com/user/repo/issues/1',
|
|
@@ -29,6 +29,7 @@ import { Project } from '../entities/Project';
|
|
|
29
29
|
import { ensureAgentOptionAndGetId } from './ensureAgentOptionAndGetId';
|
|
30
30
|
import { extractNextStepAgent } from './extractNextStepAgent';
|
|
31
31
|
import { findLastAgentReport } from './findLastAgentReport';
|
|
32
|
+
import { isAgentReportBody } from './isAgentReportBody';
|
|
32
33
|
import {
|
|
33
34
|
issueReactivationTriggerIsPending,
|
|
34
35
|
issueReactivationTriggerStartOfTomorrow,
|
|
@@ -559,7 +560,7 @@ export class NotifyFinishedIssuePreparationUseCase {
|
|
|
559
560
|
if (
|
|
560
561
|
!lastComment ||
|
|
561
562
|
!isTrustedAuthor(lastComment.author) ||
|
|
562
|
-
!lastComment.content
|
|
563
|
+
!isAgentReportBody(lastComment.content)
|
|
563
564
|
) {
|
|
564
565
|
rejections.push({
|
|
565
566
|
type: 'NO_REPORT_FROM_AGENT_BOT',
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from './returnedToAwaitingWorkspaceMessage';
|
|
25
25
|
import { extractNextStepAgent } from './extractNextStepAgent';
|
|
26
26
|
import { findLastAgentReport } from './findLastAgentReport';
|
|
27
|
+
import { isAgentReportBody } from './isAgentReportBody';
|
|
27
28
|
import { ensureAgentOptionAndGetId } from './ensureAgentOptionAndGetId';
|
|
28
29
|
import { normalizeReportBody } from './normalizeReportBody';
|
|
29
30
|
import { resolveNextStepAgentDispatchRepetition } from './resolveNextStepAgentDispatchRepetition';
|
|
@@ -334,7 +335,7 @@ export class RevertOrphanedPreparationUseCase {
|
|
|
334
335
|
}
|
|
335
336
|
|
|
336
337
|
const lastComment = comments[comments.length - 1];
|
|
337
|
-
if (!lastComment || !lastComment.content
|
|
338
|
+
if (!lastComment || !isAgentReportBody(lastComment.content)) {
|
|
338
339
|
return { outcome: 'reject', comments };
|
|
339
340
|
}
|
|
340
341
|
if (this.reportBodyHasNextStep(lastComment.content)) {
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { IssueCommentRepository } from './adapter-interfaces/IssueCommentRepository';
|
|
2
2
|
import { IssueRepository } from './adapter-interfaces/IssueRepository';
|
|
3
3
|
import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
isAgentReportBody,
|
|
6
|
+
isAgentReportBodyFromAgent,
|
|
7
|
+
} from './isAgentReportBody';
|
|
5
8
|
import { ensureAgentOptionAndGetId } from './ensureAgentOptionAndGetId';
|
|
6
9
|
import { isAuthorAuthorizedForAutoStatusCheck } from './isAuthorAuthorizedForAutoStatusCheck';
|
|
7
10
|
import { isRecord } from './isRecord';
|
|
@@ -23,9 +26,7 @@ type TriagerProposal = {
|
|
|
23
26
|
const parseTriagerProposalBlock = (
|
|
24
27
|
commentContent: string,
|
|
25
28
|
): TriagerProposal | null => {
|
|
26
|
-
if (
|
|
27
|
-
!commentContent.startsWith(`${AGENT_REPORT_PREFIX} ${TRIAGER_AGENT_NAME}`)
|
|
28
|
-
) {
|
|
29
|
+
if (!isAgentReportBodyFromAgent(commentContent, TRIAGER_AGENT_NAME)) {
|
|
29
30
|
return null;
|
|
30
31
|
}
|
|
31
32
|
const jsonBlockMatches = [
|
|
@@ -75,7 +76,7 @@ const isApprovalComment = (
|
|
|
75
76
|
if (!isAuthorAuthorizedForAutoStatusCheck(author, allowedIssueAuthors)) {
|
|
76
77
|
return false;
|
|
77
78
|
}
|
|
78
|
-
if (content
|
|
79
|
+
if (isAgentReportBody(content)) {
|
|
79
80
|
return false;
|
|
80
81
|
}
|
|
81
82
|
return /^(ok|オーケー|はい[\s\S]*)$/i.test(content.trim());
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { extractFencedJsonBlocks } from './extractFencedJsonBlocks';
|
|
2
|
+
|
|
3
|
+
describe('extractFencedJsonBlocks', () => {
|
|
4
|
+
it('returns every fenced json block in the order they appear', () => {
|
|
5
|
+
expect(
|
|
6
|
+
extractFencedJsonBlocks(
|
|
7
|
+
'From: :robot: agent (model)\n\n```json\n{ "pullRequestRequired": false }\n```\n\ntext\n\n```json\n{ "nextStepAgent": "developer" }\n```\n',
|
|
8
|
+
'test',
|
|
9
|
+
),
|
|
10
|
+
).toEqual([{ pullRequestRequired: false }, { nextStepAgent: 'developer' }]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('returns an empty list when the body carries no fenced json block', () => {
|
|
14
|
+
expect(extractFencedJsonBlocks('Please go ahead.', 'test')).toEqual([]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('skips an unparseable block, reports it, and still returns the parseable ones', () => {
|
|
18
|
+
const consoleWarn = jest.spyOn(console, 'warn').mockImplementation();
|
|
19
|
+
expect(
|
|
20
|
+
extractFencedJsonBlocks(
|
|
21
|
+
'From: :robot: agent (model)\n\n```json\n{ "broken": \n```\n\n```json\n{ "ok": true }\n```\n',
|
|
22
|
+
'test',
|
|
23
|
+
),
|
|
24
|
+
).toEqual([{ ok: true }]);
|
|
25
|
+
expect(consoleWarn).toHaveBeenCalledWith(
|
|
26
|
+
'Invalid JSON in report body while checking test:',
|
|
27
|
+
expect.anything(),
|
|
28
|
+
);
|
|
29
|
+
consoleWarn.mockRestore();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('reads blocks whose code fence backticks are backslash escaped', () => {
|
|
33
|
+
expect(
|
|
34
|
+
extractFencedJsonBlocks(
|
|
35
|
+
'From: :robot: agent (model)\n\n\\`\\`\\`json\n{ "nextStepAgent": "developer" }\n\\`\\`\\`\n',
|
|
36
|
+
'test',
|
|
37
|
+
),
|
|
38
|
+
).toEqual([{ nextStepAgent: 'developer' }]);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { normalizeReportBody } from './normalizeReportBody';
|
|
2
|
+
|
|
3
|
+
export const extractFencedJsonBlocks = (
|
|
4
|
+
body: string,
|
|
5
|
+
context: string,
|
|
6
|
+
): unknown[] => {
|
|
7
|
+
const blocks: unknown[] = [];
|
|
8
|
+
for (const match of normalizeReportBody(body).matchAll(
|
|
9
|
+
/```json\n([\s\S]*?)\n```/g,
|
|
10
|
+
)) {
|
|
11
|
+
try {
|
|
12
|
+
blocks.push(JSON.parse(match[1]));
|
|
13
|
+
} catch (error) {
|
|
14
|
+
console.warn(
|
|
15
|
+
`Invalid JSON in report body while checking ${context}:`,
|
|
16
|
+
error,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return blocks;
|
|
21
|
+
};
|
|
@@ -36,4 +36,23 @@ describe('extractNextStepAgent', () => {
|
|
|
36
36
|
it('returns null when the body carries no fenced json block', () => {
|
|
37
37
|
expect(extractNextStepAgent('Please go ahead with that.')).toBeNull();
|
|
38
38
|
});
|
|
39
|
+
|
|
40
|
+
it('returns the declared agent when the routing block is not the first fenced json block', () => {
|
|
41
|
+
expect(
|
|
42
|
+
extractNextStepAgent(
|
|
43
|
+
'From: :robot: agent (model)\n\n```json\n{ "pullRequestRequired": false }\n```\n\n## review result\n\n```json\n{ "pullRequestRequired": false, "nextStepAgent": "systems-analyst" }\n```\n',
|
|
44
|
+
),
|
|
45
|
+
).toBe('systems-analyst');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns the declared agent from a later block and reports the earlier unparseable block', () => {
|
|
49
|
+
const consoleWarn = jest.spyOn(console, 'warn').mockImplementation();
|
|
50
|
+
expect(
|
|
51
|
+
extractNextStepAgent(
|
|
52
|
+
'From: :robot: agent (model)\n\n```json\n{ "pullRequestRequired": false,\n```\n\n```json\n{ "nextStepAgent": "developer" }\n```\n',
|
|
53
|
+
),
|
|
54
|
+
).toBe('developer');
|
|
55
|
+
expect(consoleWarn).toHaveBeenCalled();
|
|
56
|
+
consoleWarn.mockRestore();
|
|
57
|
+
});
|
|
39
58
|
});
|
|
@@ -1,30 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { extractFencedJsonBlocks } from './extractFencedJsonBlocks';
|
|
2
|
+
|
|
2
3
|
export const extractNextStepAgent = (body: string): string | null => {
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
for (const block of extractFencedJsonBlocks(body, 'nextStepAgent')) {
|
|
5
|
+
if (typeof block !== 'object' || block === null) {
|
|
6
|
+
continue;
|
|
7
|
+
}
|
|
8
|
+
if (!('nextStepAgent' in block)) {
|
|
9
|
+
continue;
|
|
10
|
+
}
|
|
11
|
+
const value = Reflect.get(block, 'nextStepAgent');
|
|
12
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
return value.trim();
|
|
8
16
|
}
|
|
9
|
-
|
|
10
|
-
try {
|
|
11
|
-
reportJson = JSON.parse(reportMatch[1]);
|
|
12
|
-
} catch (error) {
|
|
13
|
-
console.warn(
|
|
14
|
-
'Invalid JSON in report body while checking nextStepAgent:',
|
|
15
|
-
error,
|
|
16
|
-
);
|
|
17
|
-
return null;
|
|
18
|
-
}
|
|
19
|
-
if (typeof reportJson !== 'object' || reportJson === null) {
|
|
20
|
-
return null;
|
|
21
|
-
}
|
|
22
|
-
if (!('nextStepAgent' in reportJson)) {
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
25
|
-
const value = Reflect.get(reportJson, 'nextStepAgent');
|
|
26
|
-
if (typeof value !== 'string' || value.trim() === '') {
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
return value.trim();
|
|
17
|
+
return null;
|
|
30
18
|
};
|
|
@@ -55,6 +55,34 @@ describe('findLastAgentReport', () => {
|
|
|
55
55
|
).toBe(withoutReportJson);
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
it('returns the most recent agent report whose prefix follows a leading fenced json block', () => {
|
|
59
|
+
const fencedBeforePrefix =
|
|
60
|
+
'```json\n{ "pullRequestRequired": false }\n```\n\nFrom: :robot: agent (model)\n\n## Result\nDone.';
|
|
61
|
+
expect(
|
|
62
|
+
findLastAgentReport(
|
|
63
|
+
[
|
|
64
|
+
{ author: 'bot', content: report('impl') },
|
|
65
|
+
{ author: 'bot', content: fencedBeforePrefix },
|
|
66
|
+
],
|
|
67
|
+
trustEveryAuthor,
|
|
68
|
+
)?.content,
|
|
69
|
+
).toBe(fencedBeforePrefix);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('skips a later comment whose only agent report prefix sits inside a fenced block', () => {
|
|
73
|
+
const prefixInsideFence =
|
|
74
|
+
'```\nFrom: :robot: agent (model)\n```\n\nQuoted for reference.';
|
|
75
|
+
expect(
|
|
76
|
+
findLastAgentReport(
|
|
77
|
+
[
|
|
78
|
+
{ author: 'bot', content: report('impl') },
|
|
79
|
+
{ author: 'bot', content: prefixInsideFence },
|
|
80
|
+
],
|
|
81
|
+
trustEveryAuthor,
|
|
82
|
+
)?.content,
|
|
83
|
+
).toBe(report('impl'));
|
|
84
|
+
});
|
|
85
|
+
|
|
58
86
|
it('skips a later comment that does not carry the agent report prefix', () => {
|
|
59
87
|
expect(
|
|
60
88
|
findLastAgentReport(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isAgentReportBody } from './isAgentReportBody';
|
|
2
2
|
|
|
3
3
|
export const findLastAgentReport = <
|
|
4
4
|
Comment extends { author: string; content: string },
|
|
@@ -10,6 +10,5 @@ export const findLastAgentReport = <
|
|
|
10
10
|
.reverse()
|
|
11
11
|
.find(
|
|
12
12
|
(comment) =>
|
|
13
|
-
isTrustedAuthor(comment.author) &&
|
|
14
|
-
comment.content.startsWith(AGENT_REPORT_PREFIX),
|
|
13
|
+
isTrustedAuthor(comment.author) && isAgentReportBody(comment.content),
|
|
15
14
|
) ?? null;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isAgentReportBody,
|
|
3
|
+
isAgentReportBodyFromAgent,
|
|
4
|
+
stripLeadingFencedBlocks,
|
|
5
|
+
} from './isAgentReportBody';
|
|
6
|
+
|
|
7
|
+
describe('isAgentReportBody', () => {
|
|
8
|
+
it('accepts a body that opens with the report prefix', () => {
|
|
9
|
+
expect(isAgentReportBody('From: :robot: agent (model)\n\n## Result')).toBe(
|
|
10
|
+
true,
|
|
11
|
+
);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('accepts a body whose report prefix follows a leading fenced json block', () => {
|
|
15
|
+
expect(
|
|
16
|
+
isAgentReportBody(
|
|
17
|
+
'```json\n{ "pullRequestRequired": false }\n```\n\nFrom: :robot: agent (model)\n\n## Result',
|
|
18
|
+
),
|
|
19
|
+
).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('accepts a body whose report prefix follows two leading fenced blocks', () => {
|
|
23
|
+
expect(
|
|
24
|
+
isAgentReportBody(
|
|
25
|
+
'```json\n{ "pullRequestRequired": false }\n```\n~~~\nplain\n~~~\nFrom: :robot: agent (model)',
|
|
26
|
+
),
|
|
27
|
+
).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('accepts a body whose leading fence carries a longer marker than three backticks', () => {
|
|
31
|
+
expect(
|
|
32
|
+
isAgentReportBody(
|
|
33
|
+
'````json\n```\n{ "pullRequestRequired": false }\n```\n````\nFrom: :robot: agent (model)',
|
|
34
|
+
),
|
|
35
|
+
).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('accepts a body whose report prefix follows blank lines', () => {
|
|
39
|
+
expect(isAgentReportBody('\n\nFrom: :robot: agent (model)')).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('accepts a body written with carriage returns and escaped backticks', () => {
|
|
43
|
+
expect(
|
|
44
|
+
isAgentReportBody(
|
|
45
|
+
'\\`\\`\\`json\r\n{ "pullRequestRequired": false }\r\n\\`\\`\\`\r\nFrom: :robot: agent (model)',
|
|
46
|
+
),
|
|
47
|
+
).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('rejects a body whose only report prefix sits inside a fenced block', () => {
|
|
51
|
+
expect(
|
|
52
|
+
isAgentReportBody('```\nFrom: :robot: agent (model)\n```\n\nQuoted.'),
|
|
53
|
+
).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('rejects a body whose report prefix follows prose', () => {
|
|
57
|
+
expect(
|
|
58
|
+
isAgentReportBody('Note about the task.\n\nFrom: :robot: agent (model)'),
|
|
59
|
+
).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('rejects a body whose leading fence is never closed', () => {
|
|
63
|
+
expect(
|
|
64
|
+
isAgentReportBody(
|
|
65
|
+
'```json\n{ "pullRequestRequired": false }\nFrom: :robot: agent (model)',
|
|
66
|
+
),
|
|
67
|
+
).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('rejects a body that carries no report prefix', () => {
|
|
71
|
+
expect(isAgentReportBody('Auto Status Check: RETURNED')).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('rejects an empty body', () => {
|
|
75
|
+
expect(isAgentReportBody('')).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('isAgentReportBodyFromAgent', () => {
|
|
80
|
+
it('accepts a report from the named agent behind a leading fenced block', () => {
|
|
81
|
+
expect(
|
|
82
|
+
isAgentReportBodyFromAgent(
|
|
83
|
+
'```json\n{ "triagerProposal": {} }\n```\nFrom: :robot: triager (model)',
|
|
84
|
+
'triager',
|
|
85
|
+
),
|
|
86
|
+
).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('rejects a report from another agent', () => {
|
|
90
|
+
expect(
|
|
91
|
+
isAgentReportBodyFromAgent('From: :robot: developer (model)', 'triager'),
|
|
92
|
+
).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('stripLeadingFencedBlocks', () => {
|
|
97
|
+
it('keeps a body that opens with prose unchanged', () => {
|
|
98
|
+
expect(stripLeadingFencedBlocks('one\n```\ntwo\n```')).toBe(
|
|
99
|
+
'one\n```\ntwo\n```',
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('removes only the leading fenced blocks', () => {
|
|
104
|
+
expect(stripLeadingFencedBlocks('```\none\n```\nbody\n```\ntwo\n```')).toBe(
|
|
105
|
+
'body\n```\ntwo\n```',
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
});
|