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: [
+268 -17
View File
@@ -558,6 +558,44 @@ function executionPrincipalsEqual(
return left.type === "agent" ? left.agentId === right.agentId : left.userId === right.userId;
}
function actorMatchesExecutionParticipant(
actor: { actorType: "user" | "agent"; actorId: string },
participant: ParsedExecutionState["currentParticipant"] | null,
) {
if (!participant) return false;
// Require the actor kind to match the participant kind before comparing ids. Without this
// an agent and a user that happen to share an id value would falsely satisfy participant
// gating on the auto-approval path.
if (participant.type !== actor.actorType) return false;
return participant.type === "agent" ? participant.agentId === actor.actorId : participant.userId === actor.actorId;
}
// Negation/rejection markers that invalidate an otherwise approval-looking heading.
// Match common phrasings ("NOT APPROVED", "Do not approve", "Not approving", "Changes requested",
// "Rejected", "Denied", "Blocked") so a reviewer comment intending to reject cannot auto-complete
// the issue. We rely on the heading being a single line, so testing the heading text alone is safe.
const APPROVAL_NEGATION_REGEX =
/\b(?:NOT|REJECT(?:ED|ING|S)?|DENY|DENIED|DENYING|BLOCK(?:ED|ING|S)?|CHANGES?\s+REQUESTED)\b/i;
function isApprovalReviewComment(body: string) {
const normalized = body.replace(/\r\n?/g, "\n");
const headingMatch = normalized.match(/(?:^|\n)##\s*Review:\s*([^\n]*)/i);
if (headingMatch) {
const headingText = headingMatch[1];
if (/\bAPPROVED\b/i.test(headingText) && !APPROVAL_NEGATION_REGEX.test(headingText)) {
return true;
}
}
// Require the `kind: review` and `decision: approved` lines to appear on truly consecutive
// lines (no blank-line separation) so prose like "the previous sprint decision: approved"
// can't combine with an unrelated `kind: review` line elsewhere in the body to trigger
// auto-approval. Use `[ \t]*` between the lines so `\s*` does not silently swallow a newline.
return (
/^[ \t]*kind[ \t]*:[ \t]*review[ \t]*\n[ \t]*decision[ \t]*:[ \t]*approved[ \t]*$/im.test(normalized)
|| /^[ \t]*decision[ \t]*:[ \t]*approved[ \t]*\n[ \t]*kind[ \t]*:[ \t]*review[ \t]*$/im.test(normalized)
);
}
function buildExecutionStageWakeContext(input: {
state: ParsedExecutionState;
wakeRole: ExecutionStageWakeContext["wakeRole"];
@@ -919,6 +957,13 @@ function buildExecutionStageWakeup(input: {
return null;
}
class AutoApprovalIssueMissingError extends Error {
constructor() {
super("Issue not found during auto-approval transaction");
this.name = "AutoApprovalIssueMissingError";
}
}
export function issueRoutes(
db: Db,
storage: StorageService,
@@ -6567,6 +6612,8 @@ export function issueRoutes(
let reopenFromStatus: string | null = null;
let interruptedRunId: string | null = null;
let currentIssue = issue;
let issueBeforeCommentDecision = issue;
let commentDecisionStageWakeup: ReturnType<typeof buildExecutionStageWakeup> | null = null;
const commentReferenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
let scheduledRetrySupersededByComment = false;
@@ -6654,16 +6701,143 @@ export function issueRoutes(
}
}
const comment = await svc.addComment(id, req.body.body, {
agentId: actor.agentId ?? undefined,
userId: actor.actorType === "user" ? actor.actorId : undefined,
runId: actor.runId,
}, {
authorType: req.body.authorType ?? (actor.actorType === "agent" ? "agent" : "user"),
presentation: req.body.presentation ?? null,
metadata: req.body.metadata ?? null,
sourceTrust: await sourceTrustForActorWrite(currentIssue, actor),
});
const currentExecutionState = parseIssueExecutionState(currentIssue.executionState);
const currentExecutionPolicy = normalizeIssueExecutionPolicy(currentIssue.executionPolicy ?? null);
const shouldAutoApproveReviewComment =
currentIssue.status === "in_review" &&
currentExecutionState?.status === "pending" &&
actorMatchesExecutionParticipant(actor, currentExecutionState.currentParticipant ?? null) &&
isApprovalReviewComment(req.body.body);
// Persist the comment and the auto-approval state transition atomically when both apply.
// Without a single transaction, a 422 (or any error) thrown by the status update after the
// comment is inserted would leave an orphan comment without the corresponding state change.
let comment: Awaited<ReturnType<typeof svc.addComment>>;
if (shouldAutoApproveReviewComment) {
const transition = applyIssueExecutionPolicyTransition({
issue: currentIssue,
policy: currentExecutionPolicy,
requestedStatus: "done",
requestedAssigneePatch: {},
actor: {
agentId: actor.agentId ?? null,
userId: actor.actorType === "user" ? actor.actorId : null,
},
commentBody: req.body.body,
});
const decisionId = transition.decision ? randomUUID() : null;
if (decisionId) {
const nextExecutionState = transition.patch.executionState;
if (!nextExecutionState || typeof nextExecutionState !== "object") {
throw new Error("Execution policy decision patch is missing executionState");
}
transition.patch.executionState = {
...nextExecutionState,
lastDecisionId: decisionId,
};
}
issueBeforeCommentDecision = currentIssue;
const updatePatch = {
...transition.patch,
status: typeof transition.patch.status === "string" ? transition.patch.status : "done",
actorAgentId: actor.agentId ?? null,
actorUserId: actor.actorType === "user" ? actor.actorId : null,
};
const sourceTrust = await sourceTrustForActorWrite(currentIssue, actor);
const commentOptions = {
authorType: req.body.authorType ?? (actor.actorType === "agent" ? "agent" : "user"),
presentation: req.body.presentation ?? null,
metadata: req.body.metadata ?? null,
sourceTrust,
};
let txResult: { comment: Awaited<ReturnType<typeof svc.addComment>>; issue: NonNullable<Awaited<ReturnType<typeof svc.update>>> };
try {
txResult = await db.transaction(async (tx) => {
const insertedComment = await svc.addComment(
id,
req.body.body,
{
agentId: actor.agentId ?? undefined,
userId: actor.actorType === "user" ? actor.actorId : undefined,
runId: actor.runId,
},
commentOptions,
tx,
);
const updated = await svc.update(id, updatePatch, tx);
// Throw (not return null) so drizzle rolls back the inserted comment when the issue
// has been concurrently deleted between the initial fetch and the in-transaction update.
if (!updated) throw new AutoApprovalIssueMissingError();
if (transition.decision && decisionId) {
await tx.insert(issueExecutionDecisions).values({
id: decisionId,
companyId: updated.companyId,
issueId: updated.id,
stageId: transition.decision.stageId,
stageType: transition.decision.stageType,
actorAgentId: actor.agentId ?? null,
actorUserId: actor.actorType === "user" ? actor.actorId : null,
outcome: transition.decision.outcome,
body: transition.decision.body,
createdByRunId: actor.runId ?? null,
});
}
return { comment: insertedComment, issue: updated };
});
} catch (err) {
if (err instanceof AutoApprovalIssueMissingError) {
res.status(404).json({ error: "Issue not found" });
return;
}
throw err;
}
comment = txResult.comment;
currentIssue = txResult.issue;
// Mirror the normal status-change audit trail: every other in_review -> done path
// emits an `issue.updated` activity, so emit one here too for the auto-approval path.
if (issueBeforeCommentDecision.status !== currentIssue.status) {
await logActivity(db, {
companyId: currentIssue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.updated",
entityType: "issue",
entityId: currentIssue.id,
details: {
status: currentIssue.status,
identifier: currentIssue.identifier,
source: "auto_approval_comment",
_previous: { status: issueBeforeCommentDecision.status },
},
});
}
commentDecisionStageWakeup = buildExecutionStageWakeup({
issueId: currentIssue.id,
previousState: currentExecutionState,
nextState: parseIssueExecutionState(currentIssue.executionState),
interruptedRunId,
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
});
} else {
comment = await svc.addComment(id, req.body.body, {
agentId: actor.agentId ?? undefined,
userId: actor.actorType === "user" ? actor.actorId : undefined,
runId: actor.runId,
}, {
authorType: req.body.authorType ?? (actor.actorType === "agent" ? "agent" : "user"),
presentation: req.body.presentation ?? null,
metadata: req.body.metadata ?? null,
sourceTrust: await sourceTrustForActorWrite(currentIssue, actor),
});
}
await issueReferencesSvc.syncComment(comment.id);
const commentReferenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(currentIssue.id);
const commentReferenceDiff = issueReferencesSvc.diffIssueReferenceSummary(
@@ -6735,14 +6909,32 @@ export function issueRoutes(
// Merge all wakeups from this comment into one enqueue per agent to avoid duplicate runs.
void (async () => {
const wakeups = new Map<string, Parameters<typeof heartbeat.wakeup>[1]>();
type WakeupRequest = NonNullable<Parameters<typeof heartbeat.wakeup>[1]>;
const wakeups = new Map<string, { agentId: string; wakeup: WakeupRequest }>();
const addWakeup = (agentId: string, wakeup: WakeupRequest) => {
const wakeIssueId =
wakeup.payload && typeof wakeup.payload === "object" && typeof wakeup.payload.issueId === "string"
? wakeup.payload.issueId
: currentIssue.id;
const key = `${agentId}:${wakeIssueId}`;
if (wakeups.has(key)) return;
wakeups.set(key, { agentId, wakeup });
};
if (commentDecisionStageWakeup) {
addWakeup(commentDecisionStageWakeup.agentId, commentDecisionStageWakeup.wakeup);
}
const assigneeId = currentIssue.assigneeAgentId;
const actorIsAgent = actor.actorType === "agent";
const selfComment = actorIsAgent && actor.actorId === assigneeId;
const skipWake = selfComment || isClosed;
// Re-derive closed-ness from the post-mutation issue so the auto-approval
// transition (in_review -> done) suppresses a stale `issue_commented` wake
// to the returnAssignee for an already-completed issue.
const skipWake = selfComment || isClosedIssueStatus(currentIssue.status);
if (assigneeId && (reopened || !skipWake)) {
if (reopened) {
wakeups.set(assigneeId, {
addWakeup(assigneeId, {
source: "automation",
triggerDetail: "system",
reason: "issue_reopened_via_comment",
@@ -6769,7 +6961,7 @@ export function issueRoutes(
},
});
} else {
wakeups.set(assigneeId, {
addWakeup(assigneeId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
@@ -6804,9 +6996,8 @@ export function issueRoutes(
}
for (const mentionedId of mentionedIds) {
if (wakeups.has(mentionedId)) continue;
if (actorIsAgent && actor.actorId === mentionedId) continue;
wakeups.set(mentionedId, {
addWakeup(mentionedId, {
source: "automation",
triggerDetail: "system",
reason: "issue_comment_mentioned",
@@ -6824,7 +7015,67 @@ export function issueRoutes(
});
}
for (const [agentId, wakeup] of wakeups.entries()) {
const becameDone = issueBeforeCommentDecision.status !== "done" && currentIssue.status === "done";
if (becameDone) {
const dependents = await svc.listWakeableBlockedDependents(currentIssue.id);
for (const dependent of dependents) {
addWakeup(dependent.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_blockers_resolved",
payload: {
issueId: dependent.id,
resolvedBlockerIssueId: currentIssue.id,
blockerIssueIds: dependent.blockerIssueIds,
},
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: dependent.id,
taskId: dependent.id,
wakeReason: "issue_blockers_resolved",
source: "issue.blockers_resolved",
resolvedBlockerIssueId: currentIssue.id,
blockerIssueIds: dependent.blockerIssueIds,
},
});
}
}
const becameTerminal =
!["done", "cancelled"].includes(issueBeforeCommentDecision.status) &&
["done", "cancelled"].includes(currentIssue.status);
if (becameTerminal && currentIssue.parentId) {
const parent = await svc.getWakeableParentAfterChildCompletion(currentIssue.parentId);
if (parent) {
addWakeup(parent.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_children_completed",
payload: {
issueId: parent.id,
completedChildIssueId: currentIssue.id,
childIssueIds: parent.childIssueIds,
childIssueSummaries: parent.childIssueSummaries,
childIssueSummaryTruncated: parent.childIssueSummaryTruncated,
},
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: parent.id,
taskId: parent.id,
wakeReason: "issue_children_completed",
source: "issue.children_completed",
completedChildIssueId: currentIssue.id,
childIssueIds: parent.childIssueIds,
childIssueSummaries: parent.childIssueSummaries,
childIssueSummaryTruncated: parent.childIssueSummaryTruncated,
},
});
}
}
for (const { agentId, wakeup } of wakeups.values()) {
heartbeat
.wakeup(agentId, wakeup)
.catch((err) => logger.warn({ err, issueId: currentIssue.id, agentId }, "failed to wake agent on issue comment"));
+5 -4
View File
@@ -5895,12 +5895,13 @@ export function issueService(db: Db) {
sourceTrust?: typeof issueComments.$inferInsert.sourceTrust;
createdAt?: Date | string | null;
},
dbOrTx: any = db,
) => {
const issue = await db
const issue = await dbOrTx
.select({ companyId: issues.companyId })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
.then((rows: Array<{ companyId: string }>) => rows[0] ?? null);
if (!issue) throw notFound("Issue not found");
@@ -5915,7 +5916,7 @@ export function issueService(db: Db) {
const presentation = issueCommentPresentationSchema.nullable().parse(options?.presentation ?? null);
const metadata = issueCommentMetadataSchema.nullable().parse(options?.metadata ?? null);
const createdAt = options?.createdAt ? new Date(options.createdAt) : null;
const [comment] = await db
const [comment] = await dbOrTx
.insert(issueComments)
.values({
companyId: issue.companyId,
@@ -5933,7 +5934,7 @@ export function issueService(db: Db) {
.returning();
// Update issue's updatedAt so comment activity is reflected in recency sorting
await db
await dbOrTx
.update(issues)
.set({ updatedAt: new Date() })
.where(eq(issues.id, issueId));