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:
ymmot
2026-06-12 02:54:07 +02:00
committed by GitHub
parent 482f64e343
commit 7058d7b6c3
3 changed files with 1204 additions and 21 deletions
@@ -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: [