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.
Files changed (73) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/bin/adapter/entry-points/console/ui-dist/assets/index-BUFcVUVD.js +86 -0
  3. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  4. package/bin/adapter/repositories/GoogleSpreadsheetRepository.js +41 -12
  5. package/bin/adapter/repositories/GoogleSpreadsheetRepository.js.map +1 -1
  6. package/bin/domain/usecases/CheckIssueReviewReadinessUseCase.js +2 -1
  7. package/bin/domain/usecases/CheckIssueReviewReadinessUseCase.js.map +1 -1
  8. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +2 -1
  9. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  10. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +2 -1
  11. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  12. package/bin/domain/usecases/TriagerApprovalDispatchUseCase.js +3 -3
  13. package/bin/domain/usecases/TriagerApprovalDispatchUseCase.js.map +1 -1
  14. package/bin/domain/usecases/extractFencedJsonBlocks.js +18 -0
  15. package/bin/domain/usecases/extractFencedJsonBlocks.js.map +1 -0
  16. package/bin/domain/usecases/extractNextStepAgent.js +14 -23
  17. package/bin/domain/usecases/extractNextStepAgent.js.map +1 -1
  18. package/bin/domain/usecases/findLastAgentReport.js +2 -3
  19. package/bin/domain/usecases/findLastAgentReport.js.map +1 -1
  20. package/bin/domain/usecases/isAgentReportBody.js +43 -0
  21. package/bin/domain/usecases/isAgentReportBody.js.map +1 -0
  22. package/bin/domain/usecases/isPullRequestDeclaredUnnecessary.js +12 -19
  23. package/bin/domain/usecases/isPullRequestDeclaredUnnecessary.js.map +1 -1
  24. package/package.json +1 -1
  25. package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +1 -1
  26. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.stories.tsx +1 -0
  27. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOkAndAwaitingWorkspaceActions.stories.tsx +25 -0
  28. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOkAndAwaitingWorkspaceActions.test.tsx +50 -0
  29. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOkAndAwaitingWorkspaceActions.tsx +41 -0
  30. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOperationMenu.stories.tsx +1 -0
  31. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOperationMenu.test.tsx +1 -0
  32. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOperationMenu.tsx +5 -0
  33. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.test.ts +70 -0
  34. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.ts +29 -0
  35. package/src/adapter/entry-points/console/ui/src/features/console/logic/actionToast.test.ts +14 -0
  36. package/src/adapter/entry-points/console/ui/src/features/console/logic/actionToast.ts +5 -1
  37. package/src/adapter/entry-points/console/ui/src/features/console/logic/operations.ts +1 -0
  38. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +1 -0
  39. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +7 -0
  40. package/src/adapter/entry-points/console/ui-dist/assets/index-BUFcVUVD.js +86 -0
  41. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  42. package/src/adapter/repositories/GoogleSpreadsheetRepository.test.ts +47 -0
  43. package/src/adapter/repositories/GoogleSpreadsheetRepository.ts +76 -36
  44. package/src/domain/usecases/CheckIssueReviewReadinessUseCase.ts +2 -1
  45. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +50 -0
  46. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +2 -1
  47. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +2 -1
  48. package/src/domain/usecases/TriagerApprovalDispatchUseCase.ts +6 -5
  49. package/src/domain/usecases/extractFencedJsonBlocks.test.ts +40 -0
  50. package/src/domain/usecases/extractFencedJsonBlocks.ts +21 -0
  51. package/src/domain/usecases/extractNextStepAgent.test.ts +19 -0
  52. package/src/domain/usecases/extractNextStepAgent.ts +15 -27
  53. package/src/domain/usecases/findLastAgentReport.test.ts +28 -0
  54. package/src/domain/usecases/findLastAgentReport.ts +2 -3
  55. package/src/domain/usecases/isAgentReportBody.test.ts +108 -0
  56. package/src/domain/usecases/isAgentReportBody.ts +52 -0
  57. package/src/domain/usecases/isPullRequestDeclaredUnnecessary.test.ts +33 -0
  58. package/src/domain/usecases/isPullRequestDeclaredUnnecessary.ts +15 -23
  59. package/types/adapter/repositories/GoogleSpreadsheetRepository.d.ts +3 -1
  60. package/types/adapter/repositories/GoogleSpreadsheetRepository.d.ts.map +1 -1
  61. package/types/domain/usecases/CheckIssueReviewReadinessUseCase.d.ts.map +1 -1
  62. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  63. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
  64. package/types/domain/usecases/TriagerApprovalDispatchUseCase.d.ts.map +1 -1
  65. package/types/domain/usecases/extractFencedJsonBlocks.d.ts +2 -0
  66. package/types/domain/usecases/extractFencedJsonBlocks.d.ts.map +1 -0
  67. package/types/domain/usecases/extractNextStepAgent.d.ts.map +1 -1
  68. package/types/domain/usecases/findLastAgentReport.d.ts.map +1 -1
  69. package/types/domain/usecases/isAgentReportBody.d.ts +4 -0
  70. package/types/domain/usecases/isAgentReportBody.d.ts.map +1 -0
  71. package/types/domain/usecases/isPullRequestDeclaredUnnecessary.d.ts.map +1 -1
  72. package/bin/adapter/entry-points/console/ui-dist/assets/index-I_2Yu8fM.js +0 -86
  73. 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-I_2Yu8fM.js"></script>
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 sheets.spreadsheets.get({
107
- spreadsheetId,
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 sheets.spreadsheets.values.get({
121
- spreadsheetId,
122
- range: sheetName,
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 sheets.spreadsheets.values.update({
145
- spreadsheetId,
146
- range: `${sheetName}!${String.fromCharCode(65 + column)}${row + 1}`,
147
- valueInputOption: 'RAW',
148
- requestBody: {
149
- values: [[value]],
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 sheets.spreadsheets.get({ spreadsheetId });
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 sheets.spreadsheets.batchUpdate({
184
- spreadsheetId,
185
- requestBody: {
186
- requests: [
187
- {
188
- addSheet: {
189
- properties: {
190
- title: sheetName,
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 sheets.spreadsheets.values.append({
215
- spreadsheetId,
216
- range: range,
217
- valueInputOption: 'RAW',
218
- requestBody: {
219
- values,
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.startsWith('From: :robot:')
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.startsWith('From: :robot:')
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.startsWith('From: :robot:')) {
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 { AGENT_REPORT_PREFIX } from './agentReportPrefix';
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.startsWith(AGENT_REPORT_PREFIX)) {
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 { normalizeReportBody } from './normalizeReportBody';
1
+ import { extractFencedJsonBlocks } from './extractFencedJsonBlocks';
2
+
2
3
  export const extractNextStepAgent = (body: string): string | null => {
3
- const reportMatch = normalizeReportBody(body).match(
4
- /```json\n([\s\S]*?)\n```/,
5
- );
6
- if (!reportMatch || reportMatch.length < 2) {
7
- return null;
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
- let reportJson: unknown;
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 { AGENT_REPORT_PREFIX } from './agentReportPrefix';
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
+ });