fix: auto-complete approved review comments (#5839)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Issue lifecycle and review handoff rely on a comment-driven "approve" gesture from the active reviewer to transition `in_review` → `done` > - The original auto-completion path matched approval markers loosely and split the comment insert from the status transition, which let `NOT APPROVED` close issues and let a 422-on-status-change leave an orphan comment behind > - That broke the safety expectation that a rejection comment can never auto-complete an issue, and that observable state (comment+status) cannot diverge from intended state > - This pull request tightens the approval regex against negated phrasings and wraps the comment insert + status transition + execution decision in one transaction so a failed transition rolls the comment back > - The benefit is that reviewers can post negated phrasings safely, and any failure in the auto-approval transition leaves the thread unchanged instead of in a half-applied state ## Linked Issues or Issue Description ### What happened? The comment-driven auto-approval path in `routes/issues.ts` had two latent safety bugs surfaced during review: 1. The approval-detection regex matched negated phrasings such as `NOT APPROVED`, `NOT APPROVED.`, `Do not approve`, `Not approving this`, so a reviewer comment intended as a rejection could auto-complete the issue. 2. The auto-approval insert + status transition + execution decision were not atomic. If the post-comment status update returned 422 (`unprocessable`), the persisted approval comment was left behind without the corresponding state change, leaving the thread half-applied. ### Expected behavior - Negated approval phrasings (`NOT APPROVED`, `not approved.`, `I do not approve`, `not approving this`, etc.) must never trigger auto-completion. Positive controls (`Approved`, `LGTM, approved`) must continue to trigger it. - A failed status transition must roll back the corresponding approval comment so observable state and intended state never diverge. ### Steps to reproduce 1. Open an `in_review` issue assigned to a reviewer. 2. As the reviewer, post `NOT APPROVED` as a comment. 3. Prior to this fix: the issue auto-transitions to `done`. After this fix: the issue stays `in_review`, the comment lands, and no transition fires. 4. Separately, induce a 422 on the post-approval status update (e.g. concurrent delete). Prior to this fix: the approval comment is persisted but the issue stays `in_review`. After this fix: the comment is rolled back along with the failed transition. ### Paperclip version or commit Branch tip `ca60f00276` at the time of this submission. Targets `master`. ### Deployment mode Affects both hosted and self-hosted deployments. Behavior is server-side only. ## What Changed - Tightened the review-marker approval regex in `server/src/routes/issues.ts` so it rejects negated phrasings while preserving positive controls. - Required structured `kind: review` / `decision: approved` metadata adjacent to the markdown approval marker (rejects blank-separated structured approval and mismatched actor kinds). - Wrapped the auto-approval comment insert, status transition, and execution decision in a single drizzle transaction in `server/src/routes/issues.ts`, threading the transaction handle through `addComment` in `server/src/services/issues.ts` so a concurrent delete or 422 transition rolls back the comment. - Added a dedicated activity log entry for the post-approval status transition and skipped stale `issue_commented` wakes that arrive after the auto-approval transition. - Added 61 regression tests in `server/src/__tests__/issue-comment-reopen-routes.test.ts` covering negated phrasings, positive controls, structured-metadata adjacency, actor-kind matching, atomic rollback on transition failure, and stale-wake suppression. ## Verification - `cd server && npx vitest run src/__tests__/issue-comment-reopen-routes.test.ts` → 61/61 passing locally. - Typecheck on changed files passes locally. - All required CI checks expected to be green on this branch tip. ## Risks - Low risk. Changes are localized to the comment-driven auto-approval path; existing `addComment` callers are unaffected (the new transaction handle parameter is optional and defaults to the top-level `db`). - The transaction wrapper changes observable timing very slightly (single tx vs. two-step), but the only externally visible effect is atomicity — failures now leave no orphan state. - Regex tightening is opt-out safe (positive controls still match) but if a reviewer in the wild used a creative phrasing not covered by the regression set, they may need to repost as `Approved`. ## Model Used - Provider: Anthropic - Model: Claude Opus 4.7 (`claude-opus-4-7`) - Capabilities: extended thinking, tool use, code execution ## Checklist - [x] I searched for similar open/closed PRs and confirmed this is not a duplicate - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have either linked existing issues or described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have considered and documented any risks above --------- Co-authored-by: Tommy <tommy@Mac-mini-Anton.local> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Devin Foley <devin@paperclip.ing>
This commit is contained in:
@@ -1447,6 +1447,937 @@ describe.sequential("issue comment reopen routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-approves a reviewer comment with the APPROVED review marker", async () => {
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
assigneeAgentId: reviewerAgentId,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||
returnAssignee: { type: "agent", agentId: "22222222-2222-4222-8222-222222222222" },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
const reviewBody = "## Review: PAP-580 - APPROVED\n\nLooks good.";
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-review-1",
|
||||
issueId: issue.id,
|
||||
companyId: issue.companyId,
|
||||
body: reviewBody,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
authorAgentId: reviewerAgentId,
|
||||
authorUserId: null,
|
||||
});
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>, tx?: unknown) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
executionState: patch.executionState,
|
||||
status: "done",
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
_tx: tx,
|
||||
}));
|
||||
|
||||
const res = await request(
|
||||
await installActor(createApp(), {
|
||||
type: "agent",
|
||||
agentId: reviewerAgentId,
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
runId: "run-review-1",
|
||||
}),
|
||||
)
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
.send({ body: reviewBody });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toMatchObject({
|
||||
id: "comment-review-1",
|
||||
issueId: issue.id,
|
||||
body: reviewBody,
|
||||
});
|
||||
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
expect.objectContaining({
|
||||
status: "done",
|
||||
actorAgentId: reviewerAgentId,
|
||||
actorUserId: null,
|
||||
executionState: expect.objectContaining({
|
||||
status: "completed",
|
||||
lastDecisionId: expect.any(String),
|
||||
lastDecisionOutcome: "approved",
|
||||
}),
|
||||
}),
|
||||
mockTx,
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-approves a reviewer comment with structured review metadata", async () => {
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
assigneeAgentId: reviewerAgentId,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||
returnAssignee: { type: "agent", agentId: "22222222-2222-4222-8222-222222222222" },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
const reviewBody = "kind: review\ndecision: approved\nsummary: ship it";
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-review-2",
|
||||
issueId: issue.id,
|
||||
companyId: issue.companyId,
|
||||
body: reviewBody,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
authorAgentId: reviewerAgentId,
|
||||
authorUserId: null,
|
||||
});
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>, tx?: unknown) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
executionState: patch.executionState,
|
||||
status: "done",
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
_tx: tx,
|
||||
}));
|
||||
|
||||
const res = await request(
|
||||
await installActor(createApp(), {
|
||||
type: "agent",
|
||||
agentId: reviewerAgentId,
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
runId: "run-review-2",
|
||||
}),
|
||||
)
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
.send({ body: reviewBody });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toMatchObject({
|
||||
id: "comment-review-2",
|
||||
issueId: issue.id,
|
||||
body: reviewBody,
|
||||
});
|
||||
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
expect.objectContaining({
|
||||
status: "done",
|
||||
actorAgentId: reviewerAgentId,
|
||||
actorUserId: null,
|
||||
executionState: expect.objectContaining({
|
||||
status: "completed",
|
||||
lastDecisionId: expect.any(String),
|
||||
lastDecisionOutcome: "approved",
|
||||
}),
|
||||
}),
|
||||
mockTx,
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-approves a reviewer comment and wakes dependents when the final blocker resolves", async () => {
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const dependentAgentId = "44444444-4444-4444-8444-444444444444";
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
assigneeAgentId: reviewerAgentId,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||
returnAssignee: { type: "agent", agentId: "22222222-2222-4222-8222-222222222222" },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
parentId: null,
|
||||
};
|
||||
const reviewBody = "## Review: PAP-580 - APPROVED\n\nLooks good.";
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-review-3",
|
||||
issueId: issue.id,
|
||||
companyId: issue.companyId,
|
||||
body: reviewBody,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
authorAgentId: reviewerAgentId,
|
||||
authorUserId: null,
|
||||
});
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>, tx?: unknown) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
executionState: patch.executionState,
|
||||
status: "done",
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
_tx: tx,
|
||||
}));
|
||||
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([
|
||||
{
|
||||
id: "dependent-1",
|
||||
assigneeAgentId: dependentAgentId,
|
||||
blockerIssueIds: [issue.id],
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await request(
|
||||
await installActor(createApp(), {
|
||||
type: "agent",
|
||||
agentId: reviewerAgentId,
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
runId: "run-review-3",
|
||||
}),
|
||||
)
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
.send({ body: reviewBody });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockIssueService.listWakeableBlockedDependents).toHaveBeenCalledWith(issue.id);
|
||||
await waitForWakeup(() => {
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||
dependentAgentId,
|
||||
expect.objectContaining({
|
||||
reason: "issue_blockers_resolved",
|
||||
payload: expect.objectContaining({
|
||||
issueId: "dependent-1",
|
||||
resolvedBlockerIssueId: issue.id,
|
||||
blockerIssueIds: [issue.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not wake the returnAssignee with issue_commented when auto-approval reassigns the issue", async () => {
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const returnAssigneeAgentId = "22222222-2222-4222-8222-222222222222";
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
assigneeAgentId: reviewerAgentId,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||
returnAssignee: { type: "agent", agentId: returnAssigneeAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
const reviewBody = "## Review: APPROVED";
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-review-5",
|
||||
issueId: issue.id,
|
||||
companyId: issue.companyId,
|
||||
body: reviewBody,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
authorAgentId: reviewerAgentId,
|
||||
authorUserId: null,
|
||||
});
|
||||
// Simulate the policy transition reassigning the now-done issue back to the
|
||||
// returnAssignee so the post-mutation assignee differs from the reviewer.
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>, tx?: unknown) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
executionState: patch.executionState,
|
||||
assigneeAgentId: returnAssigneeAgentId,
|
||||
status: "done",
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
_tx: tx,
|
||||
}));
|
||||
|
||||
const res = await request(
|
||||
await installActor(createApp(), {
|
||||
type: "agent",
|
||||
agentId: reviewerAgentId,
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
runId: "run-review-stale-isclosed",
|
||||
}),
|
||||
)
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
.send({ body: reviewBody });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
// Allow any deferred wakeup task to flush before asserting it never fired.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const issueCommentedWakeCalls = mockHeartbeatService.wakeup.mock.calls.filter(
|
||||
([, wakeup]: [string, { reason?: string }]) => wakeup?.reason === "issue_commented",
|
||||
);
|
||||
expect(issueCommentedWakeCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not auto-approve APPROVED comments from a non-review participant", async () => {
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
assigneeAgentId: reviewerAgentId,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||
returnAssignee: { type: "agent", agentId: "22222222-2222-4222-8222-222222222222" },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
const reviewBody = "## Review: PAP-580 - APPROVED\n\nLooks good.";
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-review-4",
|
||||
issueId: issue.id,
|
||||
companyId: issue.companyId,
|
||||
body: reviewBody,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
authorAgentId: null,
|
||||
authorUserId: "local-board",
|
||||
});
|
||||
|
||||
const res = await request(await installActor(createApp()))
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
.send({ body: reviewBody });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockDb.transaction).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ reason: "issue_blockers_resolved" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not auto-approve when actor kind disagrees with the participant kind", async () => {
|
||||
// The reviewer participant is a USER, but the request actor is an AGENT whose id collides
|
||||
// with that user's id. The auto-approval gate must dispatch on actor kind, not just id, so
|
||||
// this comment must follow the normal non-approval insert path.
|
||||
const sharedId = "33333333-3333-4333-8333-333333333333";
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "user", userId: sharedId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
assigneeAgentId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "user", userId: sharedId },
|
||||
returnAssignee: { type: "agent", agentId: "22222222-2222-4222-8222-222222222222" },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
const reviewBody = "## Review: PAP-580 - APPROVED\n\nShipping.";
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-mismatched-kind",
|
||||
issueId: issue.id,
|
||||
companyId: issue.companyId,
|
||||
body: reviewBody,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
authorAgentId: sharedId,
|
||||
authorUserId: null,
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
await installActor(createApp(), {
|
||||
type: "agent",
|
||||
agentId: sharedId,
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
runId: "run-kind-mismatch",
|
||||
}),
|
||||
)
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
.send({ body: reviewBody });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockDb.transaction).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not auto-approve structured metadata separated by a blank line", async () => {
|
||||
// Even though both `kind: review` and `decision: approved` appear, a blank line between
|
||||
// them means they are not on truly consecutive lines, so the strict regex must reject it.
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
assigneeAgentId: reviewerAgentId,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||
returnAssignee: { type: "agent", agentId: "22222222-2222-4222-8222-222222222222" },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
const reviewBody = "kind: review\n\ndecision: approved";
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-blank-line-metadata",
|
||||
issueId: issue.id,
|
||||
companyId: issue.companyId,
|
||||
body: reviewBody,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
authorAgentId: reviewerAgentId,
|
||||
authorUserId: null,
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
await installActor(createApp(), {
|
||||
type: "agent",
|
||||
agentId: reviewerAgentId,
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
runId: "run-blank-line-metadata",
|
||||
}),
|
||||
)
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
.send({ body: reviewBody });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockDb.transaction).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not auto-approve reviewer comments without an approval marker", async () => {
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
assigneeAgentId: reviewerAgentId,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||
returnAssignee: { type: "agent", agentId: "22222222-2222-4222-8222-222222222222" },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
const reviewBody = "Looks good.";
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-review-5",
|
||||
issueId: issue.id,
|
||||
companyId: issue.companyId,
|
||||
body: reviewBody,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
authorAgentId: reviewerAgentId,
|
||||
authorUserId: null,
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
await installActor(createApp(), {
|
||||
type: "agent",
|
||||
agentId: reviewerAgentId,
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
runId: "run-review-5",
|
||||
}),
|
||||
)
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
.send({ body: reviewBody });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockDb.transaction).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not auto-approve approval comments outside the in_review status", async () => {
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_progress",
|
||||
assigneeAgentId: reviewerAgentId,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||
returnAssignee: { type: "agent", agentId: "22222222-2222-4222-8222-222222222222" },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
const reviewBody = "## Review: PAP-580 - APPROVED\n\nLooks good.";
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-review-6",
|
||||
issueId: issue.id,
|
||||
companyId: issue.companyId,
|
||||
body: reviewBody,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
authorAgentId: reviewerAgentId,
|
||||
authorUserId: null,
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
await installActor(createApp(), {
|
||||
type: "agent",
|
||||
agentId: reviewerAgentId,
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
runId: "run-review-6",
|
||||
}),
|
||||
)
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
.send({ body: reviewBody });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockDb.transaction).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ name: "uppercase negation", body: "## Review: NOT APPROVED" },
|
||||
{ name: "uppercase negation with trailing period", body: "## Review: NOT APPROVED." },
|
||||
{ name: "mixed-case negation", body: "## Review: Not approved." },
|
||||
{ name: "do-not phrasing", body: "## Review: Do not approve" },
|
||||
{ name: "present-progressive negation", body: "## Review: Not approving" },
|
||||
{ name: "structured rejection", body: "kind: review\ndecision: rejected\nsummary: ship it" },
|
||||
{ name: "structured changes_requested", body: "kind: review\ndecision: changes_requested\nsummary: ship it" },
|
||||
{
|
||||
name: "disjoint structured metadata across prose",
|
||||
body: "kind: review\n\nThe previous sprint decision: approved by stakeholders, but this round still needs work.",
|
||||
},
|
||||
{
|
||||
name: "disjoint structured metadata with summary line between",
|
||||
body: "kind: review\nsummary: needs more work\ndecision: approved",
|
||||
},
|
||||
])("does not auto-approve negated approval phrasings ($name)", ({ body }) => {
|
||||
it("rejects the auto-approval transition and keeps the comment as a regular comment", async () => {
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
assigneeAgentId: reviewerAgentId,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||
returnAssignee: { type: "agent", agentId: "22222222-2222-4222-8222-222222222222" },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-review-negated",
|
||||
issueId: issue.id,
|
||||
companyId: issue.companyId,
|
||||
body,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
authorAgentId: reviewerAgentId,
|
||||
authorUserId: null,
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
await installActor(createApp(), {
|
||||
type: "agent",
|
||||
agentId: reviewerAgentId,
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
runId: "run-review-negated",
|
||||
}),
|
||||
)
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
.send({ body });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockDb.transaction).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ name: "uppercase approval", body: "## Review: APPROVED" },
|
||||
{ name: "trailing punctuation", body: "## Review: APPROVED!" },
|
||||
{ name: "ticketed approval", body: "## Review: PAP-580 - APPROVED" },
|
||||
{ name: "lowercase approval", body: "## Review: LGTM, approved" },
|
||||
{ name: "approval with body context", body: "## Review: APPROVED\n\nReady to ship." },
|
||||
])("auto-approves positive approval phrasings ($name)", ({ body }) => {
|
||||
it("triggers the auto-approval transition", async () => {
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
assigneeAgentId: reviewerAgentId,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||
returnAssignee: { type: "agent", agentId: "22222222-2222-4222-8222-222222222222" },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-review-positive",
|
||||
issueId: issue.id,
|
||||
companyId: issue.companyId,
|
||||
body,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
authorAgentId: reviewerAgentId,
|
||||
authorUserId: null,
|
||||
});
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>, tx?: unknown) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
executionState: patch.executionState,
|
||||
status: "done",
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
_tx: tx,
|
||||
}));
|
||||
|
||||
const res = await request(
|
||||
await installActor(createApp(), {
|
||||
type: "agent",
|
||||
agentId: reviewerAgentId,
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
runId: "run-review-positive",
|
||||
}),
|
||||
)
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
.send({ body });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
expect.objectContaining({ status: "done" }),
|
||||
mockTx,
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.updated",
|
||||
details: expect.objectContaining({
|
||||
status: "done",
|
||||
source: "auto_approval_comment",
|
||||
_previous: { status: "in_review" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls back the comment when the auto-approval status transition fails", async () => {
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
assigneeAgentId: reviewerAgentId,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||
returnAssignee: { type: "agent", agentId: "22222222-2222-4222-8222-222222222222" },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
const reviewBody = "## Review: PAP-580 - APPROVED\n\nLooks good.";
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-review-atomic",
|
||||
issueId: issue.id,
|
||||
companyId: issue.companyId,
|
||||
body: reviewBody,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
authorAgentId: reviewerAgentId,
|
||||
authorUserId: null,
|
||||
});
|
||||
const { unprocessable } = await import("../errors.js");
|
||||
mockIssueService.update.mockRejectedValue(unprocessable("Issue can only have one assignee"));
|
||||
|
||||
const res = await request(
|
||||
await installActor(createApp(), {
|
||||
type: "agent",
|
||||
agentId: reviewerAgentId,
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
runId: "run-review-atomic",
|
||||
}),
|
||||
)
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
.send({ body: reviewBody });
|
||||
|
||||
// The route must propagate the 422 (no successful 201) and must insert the
|
||||
// comment inside the same transaction as the status update so the comment
|
||||
// rolls back when the status update fails.
|
||||
expect(res.status).toBe(422);
|
||||
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(mockIssueService.addComment).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
reviewBody,
|
||||
expect.objectContaining({ agentId: reviewerAgentId }),
|
||||
expect.any(Object),
|
||||
mockTx,
|
||||
);
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rolls back the auto-approval comment when the issue is concurrently deleted", async () => {
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
assigneeAgentId: reviewerAgentId,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||
returnAssignee: { type: "agent", agentId: "22222222-2222-4222-8222-222222222222" },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
const reviewBody = "## Review: PAP-580 - APPROVED\n\nLooks good.";
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-review-missing",
|
||||
issueId: issue.id,
|
||||
companyId: issue.companyId,
|
||||
body: reviewBody,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
authorAgentId: reviewerAgentId,
|
||||
authorUserId: null,
|
||||
});
|
||||
// Simulate the concurrent-delete race: svc.update resolves to null instead of throwing.
|
||||
mockIssueService.update.mockResolvedValue(null);
|
||||
|
||||
const res = await request(
|
||||
await installActor(createApp(), {
|
||||
type: "agent",
|
||||
agentId: reviewerAgentId,
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
runId: "run-review-missing",
|
||||
}),
|
||||
)
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
.send({ body: reviewBody });
|
||||
|
||||
// The route must surface a 404 AND keep the transaction rollback path intact:
|
||||
// the addComment INSERT must run inside the same transaction that the throw aborts,
|
||||
// so the comment cannot survive when the status update finds no issue.
|
||||
expect(res.status).toBe(404);
|
||||
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(mockIssueService.addComment).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
reviewBody,
|
||||
expect.objectContaining({ agentId: reviewerAgentId }),
|
||||
expect.any(Object),
|
||||
mockTx,
|
||||
);
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("coerces executor handoff patches into workflow-controlled review wakes", async () => {
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
|
||||
Reference in New Issue
Block a user