github-issue-tower-defence-management 1.178.0 → 1.179.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 +16 -0
- package/README.md +1 -0
- package/bin/adapter/entry-points/cli/index.js +2 -0
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +36 -0
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +23 -7
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js +1 -7
- package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js.map +1 -1
- package/bin/domain/usecases/resolveNextStepAgentDispatchRepetition.js +31 -0
- package/bin/domain/usecases/resolveNextStepAgentDispatchRepetition.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.test.ts +2 -0
- package/src/adapter/entry-points/cli/index.ts +6 -0
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +215 -0
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +79 -1
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +128 -0
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +43 -15
- package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +21 -26
- package/src/domain/usecases/console/GenerateConsoleListsUseCase.ts +5 -13
- package/src/domain/usecases/issueReactivationTriggerIsPending.test.ts +2 -0
- package/src/domain/usecases/resolveNextStepAgentDispatchRepetition.test.ts +97 -0
- package/src/domain/usecases/resolveNextStepAgentDispatchRepetition.ts +49 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +3 -1
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +0 -1
- package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts +0 -1
- package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts.map +1 -1
- package/types/domain/usecases/resolveNextStepAgentDispatchRepetition.d.ts +21 -0
- package/types/domain/usecases/resolveNextStepAgentDispatchRepetition.d.ts.map +1 -0
|
@@ -109,6 +109,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
|
|
|
109
109
|
setIssueAgentField: jest.Mock;
|
|
110
110
|
searchIssue: jest.Mock;
|
|
111
111
|
createNewIssue: jest.Mock;
|
|
112
|
+
updateNextActionDate: jest.Mock;
|
|
112
113
|
};
|
|
113
114
|
let mockIssueCommentRepository: {
|
|
114
115
|
getCommentsFromIssue: jest.Mock;
|
|
@@ -155,6 +156,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
|
|
|
155
156
|
setIssueAgentField: jest.fn().mockResolvedValue(undefined),
|
|
156
157
|
searchIssue: jest.fn().mockResolvedValue([]),
|
|
157
158
|
createNewIssue: jest.fn().mockResolvedValue(42),
|
|
159
|
+
updateNextActionDate: jest.fn().mockResolvedValue(undefined),
|
|
158
160
|
};
|
|
159
161
|
|
|
160
162
|
mockIssueCommentRepository = {
|
|
@@ -947,6 +949,85 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
|
|
|
947
949
|
expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
|
|
948
950
|
});
|
|
949
951
|
|
|
952
|
+
it('should comment the repeated dispatch when the declared nextStepAgent is already the agent field value', async () => {
|
|
953
|
+
const issue = createMockIssue({
|
|
954
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
955
|
+
status: 'Preparation',
|
|
956
|
+
agent: 'accounting',
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
960
|
+
mockIssueRepository.get.mockResolvedValue(issue);
|
|
961
|
+
mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
|
|
962
|
+
createMockComment({
|
|
963
|
+
content:
|
|
964
|
+
'From: :robot: triager\n```json\n{"nextStepAgent": "accounting", "nextStep": null}\n```',
|
|
965
|
+
}),
|
|
966
|
+
]);
|
|
967
|
+
|
|
968
|
+
await useCase.run({
|
|
969
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
970
|
+
issueUrl: 'https://github.com/user/repo/issues/1',
|
|
971
|
+
thresholdForAutoReject: 3,
|
|
972
|
+
workflowBlockerResolvedWebhookUrl: null,
|
|
973
|
+
allowedIssueAuthors: null,
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
|
|
977
|
+
mockProject,
|
|
978
|
+
expect.anything(),
|
|
979
|
+
'awaiting-workspace-id',
|
|
980
|
+
);
|
|
981
|
+
expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
|
|
982
|
+
expect.anything(),
|
|
983
|
+
expect.stringContaining('Next step agent dispatch repeated: accounting'),
|
|
984
|
+
);
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
it('should escalate to Failed Preparation when the declared nextStepAgent was already dispatched up to the threshold without a report', 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: 'Next step agent dispatch repeated: accounting',
|
|
1003
|
+
}),
|
|
1004
|
+
createMockComment({
|
|
1005
|
+
content: 'Next step agent dispatch repeated: accounting',
|
|
1006
|
+
}),
|
|
1007
|
+
]);
|
|
1008
|
+
|
|
1009
|
+
await useCase.run({
|
|
1010
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
1011
|
+
issueUrl: 'https://github.com/user/repo/issues/1',
|
|
1012
|
+
thresholdForAutoReject: 3,
|
|
1013
|
+
workflowBlockerResolvedWebhookUrl: null,
|
|
1014
|
+
allowedIssueAuthors: null,
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
|
|
1018
|
+
mockProject,
|
|
1019
|
+
expect.anything(),
|
|
1020
|
+
'failed-preparation-id',
|
|
1021
|
+
);
|
|
1022
|
+
expect(mockIssueRepository.setIssueAgentField).not.toHaveBeenCalled();
|
|
1023
|
+
expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
|
|
1024
|
+
expect.anything(),
|
|
1025
|
+
expect.stringContaining(
|
|
1026
|
+
'Failed to receive a report from the dispatched agent for 3 times',
|
|
1027
|
+
),
|
|
1028
|
+
);
|
|
1029
|
+
});
|
|
1030
|
+
|
|
950
1031
|
it('should not modify labels when nextStepAgent is specified', async () => {
|
|
951
1032
|
const issue = createMockIssue({
|
|
952
1033
|
url: 'https://github.com/user/repo/issues/1',
|
|
@@ -4301,4 +4382,138 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
|
|
|
4301
4382
|
expect(mockIssueRepository.createNewIssue).not.toHaveBeenCalled();
|
|
4302
4383
|
});
|
|
4303
4384
|
});
|
|
4385
|
+
|
|
4386
|
+
describe('when deferPreparation is true', () => {
|
|
4387
|
+
const issueUrl = 'https://github.com/user/repo/issues/1';
|
|
4388
|
+
|
|
4389
|
+
it('sets nextActionDate to start of tomorrow and returns item to Awaiting Workspace without creating any issue', async () => {
|
|
4390
|
+
const now = new Date('2026-08-24T10:00:00Z');
|
|
4391
|
+
jest.useFakeTimers();
|
|
4392
|
+
jest.setSystemTime(now);
|
|
4393
|
+
|
|
4394
|
+
const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
|
|
4395
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
4396
|
+
mockIssueRepository.get.mockResolvedValue(issue);
|
|
4397
|
+
|
|
4398
|
+
await useCase.run({
|
|
4399
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
4400
|
+
issueUrl,
|
|
4401
|
+
thresholdForAutoReject: 3,
|
|
4402
|
+
workflowBlockerResolvedWebhookUrl: null,
|
|
4403
|
+
allowedIssueAuthors: null,
|
|
4404
|
+
deferPreparation: true,
|
|
4405
|
+
});
|
|
4406
|
+
|
|
4407
|
+
expect(mockIssueRepository.updateNextActionDate).toHaveBeenCalledTimes(1);
|
|
4408
|
+
expect(mockIssueRepository.updateNextActionDate).toHaveBeenCalledWith(
|
|
4409
|
+
issueUrl,
|
|
4410
|
+
mockProject,
|
|
4411
|
+
new Date('2026-08-25T00:00:00'),
|
|
4412
|
+
);
|
|
4413
|
+
|
|
4414
|
+
expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
|
|
4415
|
+
mockProject,
|
|
4416
|
+
expect.objectContaining({ status: 'Awaiting Workspace' }),
|
|
4417
|
+
'awaiting-workspace-id',
|
|
4418
|
+
);
|
|
4419
|
+
|
|
4420
|
+
expect(mockIssueCommentRepository.createComment).toHaveBeenCalledTimes(1);
|
|
4421
|
+
expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
|
|
4422
|
+
expect.objectContaining({ url: issueUrl }),
|
|
4423
|
+
expect.stringContaining('2026-08-25'),
|
|
4424
|
+
);
|
|
4425
|
+
|
|
4426
|
+
expect(mockIssueRepository.searchIssue).not.toHaveBeenCalled();
|
|
4427
|
+
expect(mockIssueRepository.createNewIssue).not.toHaveBeenCalled();
|
|
4428
|
+
expect(mockIssueRepository.setDependedIssueUrl).not.toHaveBeenCalled();
|
|
4429
|
+
});
|
|
4430
|
+
|
|
4431
|
+
it('takes precedence over missingAgentName and defers without creating any issue', async () => {
|
|
4432
|
+
const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
|
|
4433
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
4434
|
+
mockIssueRepository.get.mockResolvedValue(issue);
|
|
4435
|
+
|
|
4436
|
+
await useCase.run({
|
|
4437
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
4438
|
+
issueUrl,
|
|
4439
|
+
thresholdForAutoReject: 3,
|
|
4440
|
+
workflowBlockerResolvedWebhookUrl: null,
|
|
4441
|
+
allowedIssueAuthors: null,
|
|
4442
|
+
deferPreparation: true,
|
|
4443
|
+
missingAgentName: 'some-agent',
|
|
4444
|
+
manager: 'alice',
|
|
4445
|
+
});
|
|
4446
|
+
|
|
4447
|
+
expect(mockIssueRepository.updateNextActionDate).toHaveBeenCalledTimes(1);
|
|
4448
|
+
expect(mockIssueRepository.searchIssue).not.toHaveBeenCalled();
|
|
4449
|
+
expect(mockIssueRepository.createNewIssue).not.toHaveBeenCalled();
|
|
4450
|
+
expect(mockIssueRepository.setDependedIssueUrl).not.toHaveBeenCalled();
|
|
4451
|
+
});
|
|
4452
|
+
|
|
4453
|
+
it('does not defer when deferPreparation is false', async () => {
|
|
4454
|
+
const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
|
|
4455
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
4456
|
+
mockIssueRepository.get.mockResolvedValue(issue);
|
|
4457
|
+
mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
|
|
4458
|
+
createMockComment({ content: 'From: :robot: Test report' }),
|
|
4459
|
+
]);
|
|
4460
|
+
mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
|
|
4461
|
+
|
|
4462
|
+
await useCase.run({
|
|
4463
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
4464
|
+
issueUrl,
|
|
4465
|
+
thresholdForAutoReject: 3,
|
|
4466
|
+
workflowBlockerResolvedWebhookUrl: null,
|
|
4467
|
+
allowedIssueAuthors: null,
|
|
4468
|
+
deferPreparation: false,
|
|
4469
|
+
});
|
|
4470
|
+
|
|
4471
|
+
expect(mockIssueRepository.updateNextActionDate).not.toHaveBeenCalled();
|
|
4472
|
+
});
|
|
4473
|
+
|
|
4474
|
+
it('states the session stop reason in the deferral comment', async () => {
|
|
4475
|
+
const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
|
|
4476
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
4477
|
+
mockIssueRepository.get.mockResolvedValue(issue);
|
|
4478
|
+
|
|
4479
|
+
await useCase.run({
|
|
4480
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
4481
|
+
issueUrl,
|
|
4482
|
+
thresholdForAutoReject: 3,
|
|
4483
|
+
workflowBlockerResolvedWebhookUrl: null,
|
|
4484
|
+
allowedIssueAuthors: null,
|
|
4485
|
+
deferPreparation: true,
|
|
4486
|
+
sessionErrorLine:
|
|
4487
|
+
'Task failed 3 consecutive times with terminal_reason=api_error',
|
|
4488
|
+
});
|
|
4489
|
+
|
|
4490
|
+
expect(mockIssueCommentRepository.createComment).toHaveBeenCalledTimes(1);
|
|
4491
|
+
expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
|
|
4492
|
+
expect.objectContaining({ url: issueUrl }),
|
|
4493
|
+
expect.stringContaining(
|
|
4494
|
+
'Task failed 3 consecutive times with terminal_reason=api_error',
|
|
4495
|
+
),
|
|
4496
|
+
);
|
|
4497
|
+
});
|
|
4498
|
+
|
|
4499
|
+
it('states that no stop reason was captured when sessionErrorLine is absent', async () => {
|
|
4500
|
+
const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
|
|
4501
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
4502
|
+
mockIssueRepository.get.mockResolvedValue(issue);
|
|
4503
|
+
|
|
4504
|
+
await useCase.run({
|
|
4505
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
4506
|
+
issueUrl,
|
|
4507
|
+
thresholdForAutoReject: 3,
|
|
4508
|
+
workflowBlockerResolvedWebhookUrl: null,
|
|
4509
|
+
allowedIssueAuthors: null,
|
|
4510
|
+
deferPreparation: true,
|
|
4511
|
+
});
|
|
4512
|
+
|
|
4513
|
+
expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
|
|
4514
|
+
expect.objectContaining({ url: issueUrl }),
|
|
4515
|
+
expect.stringContaining('(not captured)'),
|
|
4516
|
+
);
|
|
4517
|
+
});
|
|
4518
|
+
});
|
|
4304
4519
|
});
|
|
@@ -29,8 +29,12 @@ 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 {
|
|
32
|
+
import {
|
|
33
|
+
issueReactivationTriggerIsPending,
|
|
34
|
+
issueReactivationTriggerStartOfTomorrow,
|
|
35
|
+
} from './issueReactivationTriggerIsPending';
|
|
33
36
|
import { normalizeReportBody } from './normalizeReportBody';
|
|
37
|
+
import { resolveNextStepAgentDispatchRepetition } from './resolveNextStepAgentDispatchRepetition';
|
|
34
38
|
|
|
35
39
|
export class IssueNotFoundError extends Error {
|
|
36
40
|
constructor(issueUrl: string) {
|
|
@@ -79,6 +83,7 @@ export class NotifyFinishedIssuePreparationUseCase {
|
|
|
79
83
|
| 'setIssueAgentField'
|
|
80
84
|
| 'searchIssue'
|
|
81
85
|
| 'createNewIssue'
|
|
86
|
+
| 'updateNextActionDate'
|
|
82
87
|
>,
|
|
83
88
|
private readonly issueCommentRepository: Pick<
|
|
84
89
|
IssueCommentRepository,
|
|
@@ -110,6 +115,7 @@ export class NotifyFinishedIssuePreparationUseCase {
|
|
|
110
115
|
sessionErrorLine?: string | null;
|
|
111
116
|
manager?: string | null;
|
|
112
117
|
developerAgentName?: string | null;
|
|
118
|
+
deferPreparation?: boolean | null;
|
|
113
119
|
}): Promise<void> => {
|
|
114
120
|
const project = await this.projectRepository.getByUrl(params.projectUrl);
|
|
115
121
|
|
|
@@ -153,6 +159,16 @@ export class NotifyFinishedIssuePreparationUseCase {
|
|
|
153
159
|
);
|
|
154
160
|
}
|
|
155
161
|
|
|
162
|
+
if (params.deferPreparation) {
|
|
163
|
+
await this.handleTransientFailureDeferral(
|
|
164
|
+
issue,
|
|
165
|
+
project,
|
|
166
|
+
awaitingWorkspaceStatusOption,
|
|
167
|
+
params.sessionErrorLine ?? null,
|
|
168
|
+
);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
156
172
|
if (params.missingAgentName) {
|
|
157
173
|
await this.handleMissingAgentDefinition(
|
|
158
174
|
issue,
|
|
@@ -229,7 +245,37 @@ export class NotifyFinishedIssuePreparationUseCase {
|
|
|
229
245
|
const nextStepAgent = lastAgentReport
|
|
230
246
|
? extractNextStepAgent(lastAgentReport.content)
|
|
231
247
|
: null;
|
|
248
|
+
const commentsAfterLastAgentReport = lastAgentReport
|
|
249
|
+
? comments.slice(comments.indexOf(lastAgentReport) + 1)
|
|
250
|
+
: [];
|
|
232
251
|
if (nextStepAgent !== null) {
|
|
252
|
+
const repetition = resolveNextStepAgentDispatchRepetition({
|
|
253
|
+
agentFieldValue: issue.agent,
|
|
254
|
+
nextStepAgent,
|
|
255
|
+
commentsAfterLastAgentReport,
|
|
256
|
+
isTrustedAuthor,
|
|
257
|
+
thresholdForAutoReject: params.thresholdForAutoReject,
|
|
258
|
+
});
|
|
259
|
+
if (repetition.type === 'escalateToFailedPreparation') {
|
|
260
|
+
issue.status = FAILED_PREPARATION_STATUS_NAME;
|
|
261
|
+
await this.issueRepository.update(issue, project);
|
|
262
|
+
await this.issueRepository.updateStatus(
|
|
263
|
+
project,
|
|
264
|
+
issue,
|
|
265
|
+
failedPreparationStatusOption.id,
|
|
266
|
+
);
|
|
267
|
+
await this.patchConsoleTab(issue);
|
|
268
|
+
await this.issueCommentRepository.createComment(
|
|
269
|
+
issue,
|
|
270
|
+
repetition.comment,
|
|
271
|
+
);
|
|
272
|
+
await this.sendWorkflowBlockerNotification(
|
|
273
|
+
params.issueUrl,
|
|
274
|
+
params.workflowBlockerResolvedWebhookUrl,
|
|
275
|
+
project,
|
|
276
|
+
);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
233
279
|
const agentOptionId = await this.ensureAgentOptionAndGetId(
|
|
234
280
|
project,
|
|
235
281
|
nextStepAgent,
|
|
@@ -249,6 +295,12 @@ export class NotifyFinishedIssuePreparationUseCase {
|
|
|
249
295
|
awaitingWorkspaceStatusOption.id,
|
|
250
296
|
);
|
|
251
297
|
await this.patchConsoleTab(issue);
|
|
298
|
+
if (repetition.type === 'dispatchAgain') {
|
|
299
|
+
await this.issueCommentRepository.createComment(
|
|
300
|
+
issue,
|
|
301
|
+
repetition.comment,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
252
304
|
return;
|
|
253
305
|
}
|
|
254
306
|
|
|
@@ -382,6 +434,32 @@ export class NotifyFinishedIssuePreparationUseCase {
|
|
|
382
434
|
);
|
|
383
435
|
};
|
|
384
436
|
|
|
437
|
+
private handleTransientFailureDeferral = async (
|
|
438
|
+
issue: Issue,
|
|
439
|
+
project: Project,
|
|
440
|
+
awaitingWorkspaceStatusOption: { id: string },
|
|
441
|
+
sessionErrorLine: string | null,
|
|
442
|
+
): Promise<void> => {
|
|
443
|
+
const tomorrow = issueReactivationTriggerStartOfTomorrow(new Date());
|
|
444
|
+
await this.issueRepository.updateNextActionDate(
|
|
445
|
+
issue.url,
|
|
446
|
+
project,
|
|
447
|
+
tomorrow,
|
|
448
|
+
);
|
|
449
|
+
issue.status = AWAITING_WORKSPACE_STATUS_NAME;
|
|
450
|
+
await this.issueRepository.update(issue, project);
|
|
451
|
+
await this.issueRepository.updateStatus(
|
|
452
|
+
project,
|
|
453
|
+
issue,
|
|
454
|
+
awaitingWorkspaceStatusOption.id,
|
|
455
|
+
);
|
|
456
|
+
await this.patchConsoleTab(issue);
|
|
457
|
+
await this.issueCommentRepository.createComment(
|
|
458
|
+
issue,
|
|
459
|
+
`Preparation deferred due to transient failure; item reactivates from ${tomorrow.toISOString().split('T')[0]}\nSession stop reason: ${sessionErrorLine ?? '(not captured)'}`,
|
|
460
|
+
);
|
|
461
|
+
};
|
|
462
|
+
|
|
385
463
|
private handleMissingAgentDefinition = async (
|
|
386
464
|
issue: Issue,
|
|
387
465
|
project: Project,
|
|
@@ -260,6 +260,134 @@ describe('RevertOrphanedPreparationUseCase', () => {
|
|
|
260
260
|
expect(mockIssueRepository.updateStatus.mock.calls[0][2]).toBe('1');
|
|
261
261
|
});
|
|
262
262
|
|
|
263
|
+
it('should transition to Failed Preparation when the declared next step agent is already assigned and the repetition threshold is met', async () => {
|
|
264
|
+
mockProject.agent = {
|
|
265
|
+
name: 'agent',
|
|
266
|
+
fieldId: 'agent-field-id',
|
|
267
|
+
options: [
|
|
268
|
+
{
|
|
269
|
+
id: 'agent-option-developer',
|
|
270
|
+
name: 'developer',
|
|
271
|
+
color: 'GRAY',
|
|
272
|
+
description: '',
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
};
|
|
276
|
+
const stuckIssue = createMockIssue({
|
|
277
|
+
url: 'https://github.com/user/repo/issues/10',
|
|
278
|
+
status: 'Preparation',
|
|
279
|
+
agent: 'developer',
|
|
280
|
+
});
|
|
281
|
+
mockIssueRepository.getAllIssues.mockResolvedValue({
|
|
282
|
+
project: mockProject,
|
|
283
|
+
issues: [stuckIssue],
|
|
284
|
+
cacheUsed: false,
|
|
285
|
+
});
|
|
286
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
287
|
+
stdout: '',
|
|
288
|
+
stderr: '',
|
|
289
|
+
exitCode: 1,
|
|
290
|
+
});
|
|
291
|
+
mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
|
|
292
|
+
{
|
|
293
|
+
author: 'bot',
|
|
294
|
+
content:
|
|
295
|
+
'From: :robot: triager\n\n```json\n{"nextStep":null,"nextStepAgent":"developer"}\n```',
|
|
296
|
+
createdAt: new Date('2024-01-02T00:00:00Z'),
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
author: 'bot',
|
|
300
|
+
content: 'Next step agent dispatch repeated: developer',
|
|
301
|
+
createdAt: new Date('2024-01-02T01:00:00Z'),
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
author: 'bot',
|
|
305
|
+
content: 'Next step agent dispatch repeated: developer',
|
|
306
|
+
createdAt: new Date('2024-01-02T02:00:00Z'),
|
|
307
|
+
},
|
|
308
|
+
]);
|
|
309
|
+
|
|
310
|
+
await useCase.run({
|
|
311
|
+
projectUrl: 'https://github.com/user/repo',
|
|
312
|
+
preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
|
|
313
|
+
thresholdForAutoReject: 3,
|
|
314
|
+
allowedIssueAuthors: ['bot'],
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
expect(mockIssueRepository.setIssueAgentField.mock.calls).toEqual([]);
|
|
318
|
+
expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
|
|
319
|
+
expect(mockIssueRepository.updateStatus.mock.calls[0][2]).toBe('5');
|
|
320
|
+
expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
|
|
321
|
+
stuckIssue,
|
|
322
|
+
expect.stringContaining(
|
|
323
|
+
'Failed to receive a report from the dispatched agent for 3 times',
|
|
324
|
+
),
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('should record the repeated dispatch even when the project has no Failed Preparation status', async () => {
|
|
329
|
+
mockProject.status.statuses = mockProject.status.statuses.filter(
|
|
330
|
+
(status) => status.name !== 'Failed Preparation',
|
|
331
|
+
);
|
|
332
|
+
mockProject.agent = {
|
|
333
|
+
name: 'agent',
|
|
334
|
+
fieldId: 'agent-field-id',
|
|
335
|
+
options: [
|
|
336
|
+
{
|
|
337
|
+
id: 'agent-option-developer',
|
|
338
|
+
name: 'developer',
|
|
339
|
+
color: 'GRAY',
|
|
340
|
+
description: '',
|
|
341
|
+
},
|
|
342
|
+
],
|
|
343
|
+
};
|
|
344
|
+
const stuckIssue = createMockIssue({
|
|
345
|
+
url: 'https://github.com/user/repo/issues/10',
|
|
346
|
+
status: 'Preparation',
|
|
347
|
+
agent: 'developer',
|
|
348
|
+
});
|
|
349
|
+
mockIssueRepository.getAllIssues.mockResolvedValue({
|
|
350
|
+
project: mockProject,
|
|
351
|
+
issues: [stuckIssue],
|
|
352
|
+
cacheUsed: false,
|
|
353
|
+
});
|
|
354
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
355
|
+
stdout: '',
|
|
356
|
+
stderr: '',
|
|
357
|
+
exitCode: 1,
|
|
358
|
+
});
|
|
359
|
+
mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
|
|
360
|
+
{
|
|
361
|
+
author: 'bot',
|
|
362
|
+
content:
|
|
363
|
+
'From: :robot: triager\n\n```json\n{"nextStep":null,"nextStepAgent":"developer"}\n```',
|
|
364
|
+
createdAt: new Date('2024-01-02T00:00:00Z'),
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
author: 'bot',
|
|
368
|
+
content: 'Next step agent dispatch repeated: developer',
|
|
369
|
+
createdAt: new Date('2024-01-02T01:00:00Z'),
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
author: 'bot',
|
|
373
|
+
content: 'Next step agent dispatch repeated: developer',
|
|
374
|
+
createdAt: new Date('2024-01-02T02:00:00Z'),
|
|
375
|
+
},
|
|
376
|
+
]);
|
|
377
|
+
|
|
378
|
+
await useCase.run({
|
|
379
|
+
projectUrl: 'https://github.com/user/repo',
|
|
380
|
+
preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
|
|
381
|
+
thresholdForAutoReject: 3,
|
|
382
|
+
allowedIssueAuthors: ['bot'],
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
|
|
386
|
+
stuckIssue,
|
|
387
|
+
expect.stringContaining('Next step agent dispatch repeated: developer'),
|
|
388
|
+
);
|
|
389
|
+
});
|
|
390
|
+
|
|
263
391
|
it('should ignore a next step agent designated by an author outside the allowed issue authors', async () => {
|
|
264
392
|
mockProject.agent = {
|
|
265
393
|
name: 'agent',
|
|
@@ -26,6 +26,7 @@ import { extractNextStepAgent } from './extractNextStepAgent';
|
|
|
26
26
|
import { findLastAgentReport } from './findLastAgentReport';
|
|
27
27
|
import { ensureAgentOptionAndGetId } from './ensureAgentOptionAndGetId';
|
|
28
28
|
import { normalizeReportBody } from './normalizeReportBody';
|
|
29
|
+
import { resolveNextStepAgentDispatchRepetition } from './resolveNextStepAgentDispatchRepetition';
|
|
29
30
|
|
|
30
31
|
const ORPHANED_PREPARATION_REJECTION_DETAIL = 'ORPHANED_PREPARATION';
|
|
31
32
|
|
|
@@ -121,11 +122,44 @@ export class RevertOrphanedPreparationUseCase {
|
|
|
121
122
|
if (!isStillInPreparation) {
|
|
122
123
|
continue;
|
|
123
124
|
}
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
const lastAgentReport = findLastAgentReport(comments, (author) =>
|
|
126
|
+
isAuthorAuthorizedForAutoStatusCheck(
|
|
127
|
+
author,
|
|
128
|
+
params.allowedIssueAuthors,
|
|
129
|
+
),
|
|
127
130
|
);
|
|
131
|
+
const nextStepAgent = lastAgentReport
|
|
132
|
+
? extractNextStepAgent(lastAgentReport.content)
|
|
133
|
+
: null;
|
|
128
134
|
if (nextStepAgent !== null) {
|
|
135
|
+
const repetition = resolveNextStepAgentDispatchRepetition({
|
|
136
|
+
agentFieldValue: issue.agent,
|
|
137
|
+
nextStepAgent,
|
|
138
|
+
commentsAfterLastAgentReport: lastAgentReport
|
|
139
|
+
? comments.slice(comments.indexOf(lastAgentReport) + 1)
|
|
140
|
+
: [],
|
|
141
|
+
isTrustedAuthor: (author) =>
|
|
142
|
+
isAuthorAuthorizedForAutoStatusCheck(
|
|
143
|
+
author,
|
|
144
|
+
params.allowedIssueAuthors,
|
|
145
|
+
),
|
|
146
|
+
thresholdForAutoReject: params.thresholdForAutoReject,
|
|
147
|
+
});
|
|
148
|
+
if (
|
|
149
|
+
repetition.type === 'escalateToFailedPreparation' &&
|
|
150
|
+
failedPreparationStatusOption
|
|
151
|
+
) {
|
|
152
|
+
await this.issueRepository.updateStatus(
|
|
153
|
+
project,
|
|
154
|
+
issue,
|
|
155
|
+
failedPreparationStatusOption.id,
|
|
156
|
+
);
|
|
157
|
+
await this.issueCommentRepository.createComment(
|
|
158
|
+
issue,
|
|
159
|
+
repetition.comment,
|
|
160
|
+
);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
129
163
|
const agentOptionId = await ensureAgentOptionAndGetId(
|
|
130
164
|
this.projectRepository,
|
|
131
165
|
project,
|
|
@@ -143,6 +177,12 @@ export class RevertOrphanedPreparationUseCase {
|
|
|
143
177
|
issue,
|
|
144
178
|
awaitingWorkspaceStatusOption.id,
|
|
145
179
|
);
|
|
180
|
+
if (repetition.type !== 'notRepeated') {
|
|
181
|
+
await this.issueCommentRepository.createComment(
|
|
182
|
+
issue,
|
|
183
|
+
repetition.comment,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
146
186
|
continue;
|
|
147
187
|
}
|
|
148
188
|
if (outcome === 'returnToLabelSelectedAgent') {
|
|
@@ -346,18 +386,6 @@ export class RevertOrphanedPreparationUseCase {
|
|
|
346
386
|
return [pr];
|
|
347
387
|
};
|
|
348
388
|
|
|
349
|
-
private resolveNextStepAgent = (
|
|
350
|
-
comments: Comment[],
|
|
351
|
-
allowedIssueAuthors: string[] | null | undefined,
|
|
352
|
-
): string | null => {
|
|
353
|
-
const lastAgentReport = findLastAgentReport(comments, (author) =>
|
|
354
|
-
isAuthorAuthorizedForAutoStatusCheck(author, allowedIssueAuthors),
|
|
355
|
-
);
|
|
356
|
-
return lastAgentReport
|
|
357
|
-
? extractNextStepAgent(lastAgentReport.content)
|
|
358
|
-
: null;
|
|
359
|
-
};
|
|
360
|
-
|
|
361
389
|
private reportBodyHasNextStep = (body: string): boolean => {
|
|
362
390
|
const reportMatch = normalizeReportBody(body).match(
|
|
363
391
|
/```json\n([\s\S]*?)\n```/,
|