fix(server): let board users cancel issues with an active review stage (#10655)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The server enforces an issue execution policy. It gates status changes while a review or approval stage is active. > - A board user could not cancel a task while an agent reviewer held the active stage. The API returned "Only the active reviewer or approver can advance the current execution stage". > - Board users own the board. They must always be able to edit and cancel any task. > - This pull request adds a board override to the execution stage transition. A board cancel clears the pending stage state and proceeds instead of raising an error. > - The benefit is that board users can always stop work, even while a review is pending or the stored stage state has drifted. ## Linked Issues or Issue Description No public GitHub issue exists for this bug. Description follows `bug_report.yml`: **What happened?** A board user set a task to `cancelled` while the task had an active reviewer stage held by an agent. The PATCH failed with "Task Update Failed... only the assigned approver or reviewer...". The same failure occurred when the stored stage state had drifted: the server silently forced the task back to `in_review` instead of honoring the cancel. **Expected behavior** A board user can always edit and cancel any task. A board cancel must clear the pending review stage and apply the requested status. **Steps to reproduce** 1. Create a task assigned to agent A with agent B configured as reviewer in the execution policy. 2. Let agent A hand the task off so the review stage becomes active. 3. As the board user, set the task status to Cancelled. 4. The update fails with the reviewer-only error. Related work: #5487 touches the execution-policy approver UI. It does not address the board cancel path. ## What Changed - `server/src/routes/issues.ts`: the issue PATCH route now passes `allowBoardOverride` when the actor is a board user. - `server/src/services/issue-execution-policy.ts`: when `allowBoardOverride` is set and the requested status is not `in_review` or `in_progress`, the transition clears `executionState` and proceeds. This applies both while a stage decision is pending and when the stage state has drifted, so a board cancel is no longer rejected or silently flipped back to `in_review`. - Reviewer gating is unchanged for everyone else: a board user who is the active participant still uses the normal approve / request-changes flow, and non-participant agents still receive the 422 guard. - Assignee-only board updates on an `in_review` task keep the stage state coherent: reassigning to an eligible stage participant re-pends the stage with them as the current participant, while reassigning to a non-participant (or unassigning) dissolves the review back to `in_progress` instead of persisting an `in_review` issue with no execution state or an ineligible participant. - New unit tests and route tests cover board cancellation of an active review stage and of a drifted pending review, plus reviewer swap, non-participant reassignment, and unassignment during an active review. ## Verification - In `server/`: `pnpm exec vitest run src/__tests__/issue-execution-policy.test.ts src/__tests__/issue-execution-policy-routes.test.ts` — 2 files, 73/73 tests pass on top of current `master`. - In `server/`: `pnpm run typecheck` passes. ## Risks - Low risk. The override branch runs only for board actors and only for target statuses other than `in_review` and `in_progress`. Cancelling clears `executionState`, so a later reopen starts from a fresh stage state. Agent-facing flows and reviewer gating are unchanged. ## Model Used - Claude Fable 5 (Anthropic), model ID `claude-fable-5`, running in Claude Code (Claude Agent SDK) with extended thinking and agentic tool use. ## Checklist - [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 checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
parent
ada47be764
commit
0f12721ee9
|
|
@ -433,6 +433,234 @@ describe("issue execution policy routes", () => {
|
|||
expect(mockIssueApprovalService.listApprovalsForIssue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a board user to cancel an active agent review task", async () => {
|
||||
const policy = normalizeIssueExecutionPolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
status: "in_review",
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-1008",
|
||||
title: "Active review",
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: "11111111-1111-4111-8111-111111111111",
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: "33333333-3333-4333-8333-333333333333" },
|
||||
returnAssignee: { type: "agent", agentId: "44444444-4444-4444-8444-444444444444" },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const res = await request(await createApp())
|
||||
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||
.send({ status: "cancelled" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
expect.objectContaining({
|
||||
status: "cancelled",
|
||||
executionState: null,
|
||||
actorAgentId: null,
|
||||
actorUserId: "local-board",
|
||||
}),
|
||||
);
|
||||
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a board user to cancel a drifted pending agent review task", async () => {
|
||||
const policy = normalizeIssueExecutionPolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
status: "blocked",
|
||||
assigneeAgentId: "44444444-4444-4444-8444-444444444444",
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-1009",
|
||||
title: "Drifted active review",
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: "11111111-1111-4111-8111-111111111111",
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: "33333333-3333-4333-8333-333333333333" },
|
||||
returnAssignee: { type: "agent", agentId: "44444444-4444-4444-8444-444444444444" },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const res = await request(await createApp())
|
||||
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||
.send({ status: "cancelled" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
expect.objectContaining({
|
||||
status: "cancelled",
|
||||
executionState: null,
|
||||
actorAgentId: null,
|
||||
actorUserId: "local-board",
|
||||
}),
|
||||
);
|
||||
const updatePatch = mockIssueService.update.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(updatePatch.status).toBe("cancelled");
|
||||
expect(updatePatch.assigneeAgentId).toBeUndefined();
|
||||
expect(updatePatch.assigneeUserId).toBeUndefined();
|
||||
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the review stage pending when a board user reassigns to an eligible participant", async () => {
|
||||
const policy = normalizeIssueExecutionPolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
type: "review",
|
||||
participants: [
|
||||
{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" },
|
||||
{ type: "agent", agentId: "55555555-5555-4555-8555-555555555555" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
status: "in_review",
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-1010",
|
||||
title: "Reassigned review",
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: "11111111-1111-4111-8111-111111111111",
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: "33333333-3333-4333-8333-333333333333" },
|
||||
returnAssignee: { type: "agent", agentId: "44444444-4444-4444-8444-444444444444" },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const res = await request(await createApp())
|
||||
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||
.send({ assigneeAgentId: "55555555-5555-4555-8555-555555555555" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const updatePatch = mockIssueService.update.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(updatePatch.status).toBe("in_review");
|
||||
expect(updatePatch.assigneeAgentId).toBe("55555555-5555-4555-8555-555555555555");
|
||||
expect(updatePatch.assigneeUserId).toBeNull();
|
||||
expect(updatePatch.executionState).toMatchObject({
|
||||
status: "pending",
|
||||
currentStageId: "11111111-1111-4111-8111-111111111111",
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: "55555555-5555-4555-8555-555555555555" },
|
||||
returnAssignee: { type: "agent", agentId: "44444444-4444-4444-8444-444444444444" },
|
||||
});
|
||||
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dissolves the review when a board user reassigns an in_review task to a non-participant", async () => {
|
||||
const policy = normalizeIssueExecutionPolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
status: "in_review",
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-1011",
|
||||
title: "Reassigned away from review",
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: "11111111-1111-4111-8111-111111111111",
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: "33333333-3333-4333-8333-333333333333" },
|
||||
returnAssignee: { type: "agent", agentId: "44444444-4444-4444-8444-444444444444" },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const res = await request(await createApp())
|
||||
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||
.send({ assigneeAgentId: "55555555-5555-4555-8555-555555555555" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const updatePatch = mockIssueService.update.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(updatePatch.status).toBe("in_progress");
|
||||
expect(updatePatch.executionState).toBeNull();
|
||||
expect(updatePatch.assigneeAgentId).toBe("55555555-5555-4555-8555-555555555555");
|
||||
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not auto-start execution review when reviewers are added to an already in_review issue", async () => {
|
||||
const policy = normalizeIssueExecutionPolicy({
|
||||
stages: [
|
||||
|
|
|
|||
|
|
@ -569,6 +569,181 @@ describe("issue execution policy transitions", () => {
|
|||
).toThrow("Only the active reviewer or approver can advance");
|
||||
});
|
||||
|
||||
it("board override can cancel an active review without recording an approval decision", () => {
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "cancelled",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { userId: boardUserId },
|
||||
allowBoardOverride: true,
|
||||
commentBody: "Cancelling this task",
|
||||
});
|
||||
|
||||
expect(result.patch).toEqual({ executionState: null });
|
||||
expect(result.decision).toBeUndefined();
|
||||
expect(result.workflowControlledAssignment).toBeUndefined();
|
||||
});
|
||||
|
||||
it("board override can cancel a drifted pending review without rebuilding the pending stage", () => {
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "blocked",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "cancelled",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { userId: boardUserId },
|
||||
allowBoardOverride: true,
|
||||
commentBody: "Cancelling this drifted task",
|
||||
});
|
||||
|
||||
expect(result.patch).toEqual({ executionState: null });
|
||||
expect(result.decision).toBeUndefined();
|
||||
expect(result.workflowControlledAssignment).toBeUndefined();
|
||||
});
|
||||
|
||||
it("board override reassignment to an eligible participant re-pends the stage", () => {
|
||||
const multiReviewerPolicy = makePolicy([
|
||||
{
|
||||
type: "review",
|
||||
participants: [
|
||||
{ type: "agent", agentId: qaAgentId },
|
||||
{ type: "agent", agentId: ctoAgentId },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const multiReviewerStageId = multiReviewerPolicy.stages[0].id;
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: multiReviewerPolicy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: multiReviewerStageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy: multiReviewerPolicy,
|
||||
requestedAssigneePatch: { assigneeAgentId: ctoAgentId },
|
||||
actor: { userId: boardUserId },
|
||||
allowBoardOverride: true,
|
||||
commentBody: "Swapping the reviewer",
|
||||
});
|
||||
|
||||
expect(result.patch.status).toBe("in_review");
|
||||
expect(result.patch.assigneeAgentId).toBe(ctoAgentId);
|
||||
expect(result.patch.assigneeUserId).toBeNull();
|
||||
expect(result.patch.executionState).toMatchObject({
|
||||
status: "pending",
|
||||
currentStageId: multiReviewerStageId,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: ctoAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
});
|
||||
expect(result.decision).toBeUndefined();
|
||||
});
|
||||
|
||||
it("board override reassignment to a non-participant dissolves the review", () => {
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy,
|
||||
requestedAssigneePatch: { assigneeAgentId: coderAgentId },
|
||||
actor: { userId: boardUserId },
|
||||
allowBoardOverride: true,
|
||||
commentBody: "Handing the task back",
|
||||
});
|
||||
|
||||
expect(result.patch).toEqual({ executionState: null, status: "in_progress" });
|
||||
expect(result.decision).toBeUndefined();
|
||||
expect(result.workflowControlledAssignment).toBeUndefined();
|
||||
});
|
||||
|
||||
it("board override unassignment dissolves the review instead of stranding in_review", () => {
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy,
|
||||
requestedAssigneePatch: { assigneeAgentId: null, assigneeUserId: null },
|
||||
actor: { userId: boardUserId },
|
||||
allowBoardOverride: true,
|
||||
commentBody: "Unassigning the reviewer",
|
||||
});
|
||||
|
||||
expect(result.patch).toEqual({ executionState: null, status: "in_progress" });
|
||||
expect(result.decision).toBeUndefined();
|
||||
expect(result.workflowControlledAssignment).toBeUndefined();
|
||||
});
|
||||
|
||||
it("non-participant can still post non-advancing updates", () => {
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
|
|
|
|||
|
|
@ -8005,6 +8005,7 @@ export function issueRoutes(
|
|||
agentId: actor.agentId ?? null,
|
||||
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||
},
|
||||
allowBoardOverride: req.actor.type === "board",
|
||||
commentBody,
|
||||
reviewRequest: reviewRequest === undefined ? undefined : reviewRequest,
|
||||
monitorExplicitlyUpdated: req.body.executionPolicy !== undefined && monitorChanged,
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ type TransitionInput = {
|
|||
requestedStatus?: string;
|
||||
requestedAssigneePatch: RequestedAssigneePatch;
|
||||
actor: ActorLike;
|
||||
allowBoardOverride?: boolean;
|
||||
commentBody?: string | null;
|
||||
reviewRequest?: IssueExecutionState["reviewRequest"] | null;
|
||||
monitorExplicitlyUpdated?: boolean;
|
||||
|
|
@ -837,6 +838,16 @@ function applyIssueExecutionStageTransition(input: TransitionInput): TransitionR
|
|||
};
|
||||
}
|
||||
|
||||
if (
|
||||
input.allowBoardOverride &&
|
||||
requestedStatus &&
|
||||
requestedStatus !== "in_review" &&
|
||||
requestedStatus !== "in_progress"
|
||||
) {
|
||||
patch.executionState = null;
|
||||
return { patch };
|
||||
}
|
||||
|
||||
if (requestedStatus && requestedStatus !== "in_review") {
|
||||
if (!input.commentBody?.trim()) {
|
||||
throw unprocessable(`Requesting changes requires a comment. ${STAGE_DECISION_COMMENT_HINT}`);
|
||||
|
|
@ -898,6 +909,37 @@ function applyIssueExecutionStageTransition(input: TransitionInput): TransitionR
|
|||
!principalsEqual(currentAssignee, currentParticipant) ||
|
||||
!principalsEqual(existingState?.currentParticipant ?? null, currentParticipant);
|
||||
|
||||
if (input.allowBoardOverride && attemptedStageAdvance) {
|
||||
if (requestedStatus !== undefined && requestedStatus !== "in_review") {
|
||||
patch.executionState = null;
|
||||
return { patch };
|
||||
}
|
||||
// Assignee-only override: the issue stays in_review, so clearing the
|
||||
// execution state would strand it with no participant or return
|
||||
// assignment. Re-pend the stage when the board's chosen assignee is an
|
||||
// eligible stage participant; otherwise (unassign or a non-participant)
|
||||
// dissolve the review — storing an ineligible participant would be
|
||||
// silently replaced by the stage-membership repair on the next
|
||||
// transition.
|
||||
if (explicitAssignee && stageHasParticipant(activeStage, explicitAssignee)) {
|
||||
buildPendingStagePatch({
|
||||
patch,
|
||||
previous: existingState,
|
||||
policy: input.policy,
|
||||
stage: activeStage,
|
||||
participant: explicitAssignee,
|
||||
returnAssignee: existingState?.returnAssignee ?? currentAssignee ?? actor,
|
||||
reviewRequest: effectiveReviewRequest,
|
||||
});
|
||||
return { patch };
|
||||
}
|
||||
patch.executionState = null;
|
||||
if (input.issue.status === "in_review") {
|
||||
patch.status = "in_progress";
|
||||
}
|
||||
return { patch };
|
||||
}
|
||||
|
||||
if (attemptedStageAdvance && !stageStateDrifted) {
|
||||
throw unprocessable("Only the active reviewer or approver can advance the current execution stage");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue