From 2e31fb7c91c8d721827b148378ea4ecb6af670cd Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 6 Apr 2026 09:05:39 -0500 Subject: [PATCH] Add comprehensive e2e tests for signoff execution policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expands the execution policy test suite from 3 to 34 tests covering: - Full happy path (executor → review → approval → done) - Changes requested flow with re-submission - Review-only and approval-only policy variants - Access control (non-participant cannot advance stages) - Comment requirements (empty, whitespace-only, null) - Policy removal mid-flow with state cleanup - Reopening done/cancelled issues clears execution state - Multi-participant stage selection and exclusion - User-type reviewer participants - No-op transitions and edge cases Co-Authored-By: Paperclip --- .../__tests__/issue-execution-policy.test.ts | 979 ++++++++++++++++-- 1 file changed, 866 insertions(+), 113 deletions(-) diff --git a/server/src/__tests__/issue-execution-policy.test.ts b/server/src/__tests__/issue-execution-policy.test.ts index 1d66840f..cbaf5798 100644 --- a/server/src/__tests__/issue-execution-policy.test.ts +++ b/server/src/__tests__/issue-execution-policy.test.ts @@ -1,131 +1,884 @@ import { describe, expect, it } from "vitest"; -import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts"; +import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy, parseIssueExecutionState } from "../services/issue-execution-policy.ts"; +import type { IssueExecutionPolicy, IssueExecutionState } from "@paperclipai/shared"; + +const coderAgentId = "11111111-1111-4111-8111-111111111111"; +const qaAgentId = "22222222-2222-4222-8222-222222222222"; +const ctoAgentId = "33333333-3333-4333-8333-333333333333"; +const ctoUserId = "cto-user"; +const boardUserId = "board-user"; + +function makePolicy( + stages: Array<{ type: "review" | "approval"; participants: Array<{ type: "agent" | "user"; agentId?: string; userId?: string }> }>, +) { + return normalizeIssueExecutionPolicy({ stages })!; +} + +function twoStagePolicy() { + return makePolicy([ + { type: "review", participants: [{ type: "agent", agentId: qaAgentId }] }, + { type: "approval", participants: [{ type: "user", userId: ctoUserId }] }, + ]); +} + +function reviewOnlyPolicy() { + return makePolicy([ + { type: "review", participants: [{ type: "agent", agentId: qaAgentId }] }, + ]); +} + +function approvalOnlyPolicy() { + return makePolicy([ + { type: "approval", participants: [{ type: "user", userId: ctoUserId }] }, + ]); +} + +describe("normalizeIssueExecutionPolicy", () => { + it("returns null for null/undefined input", () => { + expect(normalizeIssueExecutionPolicy(null)).toBeNull(); + expect(normalizeIssueExecutionPolicy(undefined)).toBeNull(); + }); + + it("returns null when stages are empty", () => { + expect(normalizeIssueExecutionPolicy({ stages: [] })).toBeNull(); + }); + + it("throws when all participants are invalid (missing agentId)", () => { + expect(() => + normalizeIssueExecutionPolicy({ + stages: [{ type: "review", participants: [{ type: "agent" }] }], + }), + ).toThrow("Invalid execution policy"); + }); + + it("deduplicates participants within a stage", () => { + const result = normalizeIssueExecutionPolicy({ + stages: [ + { + type: "review", + participants: [ + { type: "agent", agentId: qaAgentId }, + { type: "agent", agentId: qaAgentId }, + ], + }, + ], + }); + expect(result!.stages[0].participants).toHaveLength(1); + }); + + it("assigns UUIDs to stages and participants", () => { + const result = normalizeIssueExecutionPolicy({ + stages: [ + { type: "review", participants: [{ type: "agent", agentId: qaAgentId }] }, + ], + }); + expect(result!.stages[0].id).toBeDefined(); + expect(result!.stages[0].participants[0].id).toBeDefined(); + }); + + it("always sets commentRequired to true", () => { + const result = normalizeIssueExecutionPolicy({ + commentRequired: false, + stages: [ + { type: "review", participants: [{ type: "agent", agentId: qaAgentId }] }, + ], + }); + expect(result!.commentRequired).toBe(true); + }); + + it("defaults mode to normal", () => { + const result = normalizeIssueExecutionPolicy({ + stages: [ + { type: "review", participants: [{ type: "agent", agentId: qaAgentId }] }, + ], + }); + expect(result!.mode).toBe("normal"); + }); + + it("throws for invalid input", () => { + expect(() => normalizeIssueExecutionPolicy({ stages: [{ type: "invalid_type" }] })).toThrow(); + }); +}); + +describe("parseIssueExecutionState", () => { + it("returns null for null/undefined", () => { + expect(parseIssueExecutionState(null)).toBeNull(); + expect(parseIssueExecutionState(undefined)).toBeNull(); + }); + + it("returns null for invalid shape", () => { + expect(parseIssueExecutionState({ status: "bogus" })).toBeNull(); + }); + + it("parses a valid state", () => { + const state = parseIssueExecutionState({ + status: "pending", + currentStageId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + currentStageIndex: 0, + currentStageType: "review", + currentParticipant: { type: "agent", agentId: qaAgentId }, + returnAssignee: { type: "agent", agentId: coderAgentId }, + completedStageIds: [], + lastDecisionId: null, + lastDecisionOutcome: null, + }); + expect(state).not.toBeNull(); + expect(state!.status).toBe("pending"); + }); +}); describe("issue execution policy transitions", () => { - const coderAgentId = "11111111-1111-4111-8111-111111111111"; - const qaAgentId = "22222222-2222-4222-8222-222222222222"; - const ctoUserId = "cto-user"; - const policy = normalizeIssueExecutionPolicy({ - stages: [ - { - type: "review", - participants: [{ type: "agent", agentId: qaAgentId }], - }, - { - type: "approval", - participants: [{ type: "user", userId: ctoUserId }], - }, - ], - }); + describe("happy path: executor → review → approval → done", () => { + const policy = twoStagePolicy(); - it("routes executor completion into review", () => { - const result = applyIssueExecutionPolicyTransition({ - issue: { - status: "in_progress", - assigneeAgentId: coderAgentId, - assigneeUserId: null, - executionPolicy: policy, - executionState: null, - }, - policy, - requestedStatus: "done", - requestedAssigneePatch: {}, - actor: { agentId: coderAgentId }, - commentBody: "Implemented the feature", - }); - - expect(result.patch.status).toBe("in_review"); - expect(result.patch.assigneeAgentId).toBe(qaAgentId); - expect(result.patch.executionState).toMatchObject({ - status: "pending", - currentStageType: "review", - returnAssignee: { type: "agent", agentId: coderAgentId }, - }); - expect(result.decision).toBeUndefined(); - }); - - it("returns review changes to the prior executor", () => { - const reviewStageId = policy?.stages[0]?.id ?? "review-stage"; - 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, + it("routes executor completion into review", () => { + const result = applyIssueExecutionPolicyTransition({ + issue: { + status: "in_progress", + assigneeAgentId: coderAgentId, + assigneeUserId: null, + executionPolicy: policy, + executionState: null, }, - }, - policy, - requestedStatus: "in_progress", - requestedAssigneePatch: {}, - actor: { agentId: qaAgentId }, - commentBody: "Needs another pass on edge cases", + policy, + requestedStatus: "done", + requestedAssigneePatch: {}, + actor: { agentId: coderAgentId }, + commentBody: "Implemented the feature", + }); + + expect(result.patch.status).toBe("in_review"); + expect(result.patch.assigneeAgentId).toBe(qaAgentId); + expect(result.patch.executionState).toMatchObject({ + status: "pending", + currentStageType: "review", + returnAssignee: { type: "agent", agentId: coderAgentId }, + }); + expect(result.decision).toBeUndefined(); }); - expect(result.patch.status).toBe("in_progress"); - expect(result.patch.assigneeAgentId).toBe(coderAgentId); - expect(result.patch.executionState).toMatchObject({ - status: "changes_requested", - currentStageType: "review", - returnAssignee: { type: "agent", agentId: coderAgentId }, - lastDecisionOutcome: "changes_requested", + it("reviewer approves → advances to approval stage", () => { + const reviewStageId = policy.stages[0].id; + 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: "done", + requestedAssigneePatch: {}, + actor: { agentId: qaAgentId }, + commentBody: "QA signoff complete", + }); + + expect(result.patch.status).toBe("in_review"); + expect(result.patch.assigneeAgentId).toBeNull(); + expect(result.patch.assigneeUserId).toBe(ctoUserId); + expect(result.patch.executionState).toMatchObject({ + status: "pending", + currentStageType: "approval", + completedStageIds: [reviewStageId], + currentParticipant: { type: "user", userId: ctoUserId }, + }); + expect(result.decision).toMatchObject({ + stageId: reviewStageId, + stageType: "review", + outcome: "approved", + }); }); - expect(result.decision).toMatchObject({ - stageId: reviewStageId, - stageType: "review", - outcome: "changes_requested", + + it("approver approves → marks completed (allows done)", () => { + const reviewStageId = policy.stages[0].id; + const approvalStageId = policy.stages[1].id; + const result = applyIssueExecutionPolicyTransition({ + issue: { + status: "in_review", + assigneeAgentId: null, + assigneeUserId: ctoUserId, + executionPolicy: policy, + executionState: { + status: "pending", + currentStageId: approvalStageId, + currentStageIndex: 1, + currentStageType: "approval", + currentParticipant: { type: "user", userId: ctoUserId }, + returnAssignee: { type: "agent", agentId: coderAgentId }, + completedStageIds: [reviewStageId], + lastDecisionId: null, + lastDecisionOutcome: null, + }, + }, + policy, + requestedStatus: "done", + requestedAssigneePatch: {}, + actor: { userId: ctoUserId }, + commentBody: "Approved, ship it", + }); + + expect(result.patch.executionState).toMatchObject({ + status: "completed", + completedStageIds: expect.arrayContaining([reviewStageId, approvalStageId]), + lastDecisionOutcome: "approved", + }); + expect(result.decision).toMatchObject({ + stageId: approvalStageId, + stageType: "approval", + outcome: "approved", + }); + // status should NOT be overridden — caller can set done + expect(result.patch.status).toBeUndefined(); }); }); - it("advances approved review work into approval", () => { - const reviewStageId = policy?.stages[0]?.id ?? "review-stage"; - 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, + describe("changes requested flow", () => { + const policy = twoStagePolicy(); + const reviewStageId = policy.stages[0].id; + + it("reviewer requests changes → returns to executor", () => { + 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: "done", - requestedAssigneePatch: {}, - actor: { agentId: qaAgentId }, - commentBody: "QA signoff complete", + policy, + requestedStatus: "in_progress", + requestedAssigneePatch: {}, + actor: { agentId: qaAgentId }, + commentBody: "Needs another pass on edge cases", + }); + + expect(result.patch.status).toBe("in_progress"); + expect(result.patch.assigneeAgentId).toBe(coderAgentId); + expect(result.patch.executionState).toMatchObject({ + status: "changes_requested", + currentStageType: "review", + returnAssignee: { type: "agent", agentId: coderAgentId }, + lastDecisionOutcome: "changes_requested", + }); + expect(result.decision).toMatchObject({ + stageId: reviewStageId, + stageType: "review", + outcome: "changes_requested", + }); }); - expect(result.patch.status).toBe("in_review"); - expect(result.patch.assigneeAgentId).toBeNull(); - expect(result.patch.assigneeUserId).toBe(ctoUserId); - expect(result.patch.executionState).toMatchObject({ - status: "pending", - currentStageType: "approval", - completedStageIds: [reviewStageId], - currentParticipant: { type: "user", userId: ctoUserId }, + it("executor re-submits after changes → returns to same review stage", () => { + const result = applyIssueExecutionPolicyTransition({ + issue: { + status: "in_progress", + assigneeAgentId: coderAgentId, + assigneeUserId: null, + executionPolicy: policy, + executionState: { + status: "changes_requested", + currentStageId: reviewStageId, + currentStageIndex: 0, + currentStageType: "review", + currentParticipant: { type: "agent", agentId: qaAgentId }, + returnAssignee: { type: "agent", agentId: coderAgentId }, + completedStageIds: [], + lastDecisionId: null, + lastDecisionOutcome: "changes_requested", + }, + }, + policy, + requestedStatus: "done", + requestedAssigneePatch: {}, + actor: { agentId: coderAgentId }, + commentBody: "Fixed edge cases", + }); + + expect(result.patch.status).toBe("in_review"); + expect(result.patch.assigneeAgentId).toBe(qaAgentId); + expect(result.patch.executionState).toMatchObject({ + status: "pending", + currentStageId: reviewStageId, + currentStageType: "review", + currentParticipant: { type: "agent", agentId: qaAgentId }, + }); }); - expect(result.decision).toMatchObject({ - stageId: reviewStageId, - stageType: "review", - outcome: "approved", + }); + + describe("review-only policy (no approval stage)", () => { + const policy = reviewOnlyPolicy(); + const reviewStageId = policy.stages[0].id; + + it("reviewer approval completes the policy", () => { + 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: "done", + requestedAssigneePatch: {}, + actor: { agentId: qaAgentId }, + commentBody: "LGTM", + }); + + expect(result.patch.executionState).toMatchObject({ + status: "completed", + completedStageIds: [reviewStageId], + lastDecisionOutcome: "approved", + }); + expect(result.decision).toMatchObject({ + stageType: "review", + outcome: "approved", + }); + }); + }); + + describe("approval-only policy (no review stage)", () => { + const policy = approvalOnlyPolicy(); + + it("executor completion routes directly to approval", () => { + const result = applyIssueExecutionPolicyTransition({ + issue: { + status: "in_progress", + assigneeAgentId: coderAgentId, + assigneeUserId: null, + executionPolicy: policy, + executionState: null, + }, + policy, + requestedStatus: "done", + requestedAssigneePatch: {}, + actor: { agentId: coderAgentId }, + commentBody: "Done", + }); + + expect(result.patch.status).toBe("in_review"); + expect(result.patch.assigneeUserId).toBe(ctoUserId); + expect(result.patch.executionState).toMatchObject({ + status: "pending", + currentStageType: "approval", + }); + }); + }); + + describe("access control", () => { + const policy = twoStagePolicy(); + const reviewStageId = policy.stages[0].id; + + it("non-participant cannot advance stage via status change", () => { + expect(() => + 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: "done", + requestedAssigneePatch: {}, + actor: { agentId: coderAgentId }, + commentBody: "Trying to bypass review", + }), + ).toThrow("Only the active reviewer or approver can advance"); + }); + + it("non-participant can still post non-advancing updates", () => { + 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: undefined, + requestedAssigneePatch: {}, + actor: { agentId: coderAgentId }, + commentBody: "Just a note", + }); + + // No error — just no patch modifications + expect(result.patch).toEqual({}); + }); + }); + + describe("comment requirements", () => { + const policy = twoStagePolicy(); + const reviewStageId = policy.stages[0].id; + + it("approval without comment throws", () => { + expect(() => + 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: "done", + requestedAssigneePatch: {}, + actor: { agentId: qaAgentId }, + commentBody: "", + }), + ).toThrow("requires a comment"); + }); + + it("changes requested without comment throws", () => { + expect(() => + 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: "in_progress", + requestedAssigneePatch: {}, + actor: { agentId: qaAgentId }, + commentBody: null, + }), + ).toThrow("requires a comment"); + }); + + it("whitespace-only comment is treated as empty", () => { + expect(() => + 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: "done", + requestedAssigneePatch: {}, + actor: { agentId: qaAgentId }, + commentBody: " ", + }), + ).toThrow("requires a comment"); + }); + }); + + describe("policy removal mid-flow", () => { + it("clears execution state when policy removed and returns to executor", () => { + // Use a real UUID for currentStageId so parseIssueExecutionState succeeds + const stageId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const result = applyIssueExecutionPolicyTransition({ + issue: { + status: "in_review", + assigneeAgentId: qaAgentId, + assigneeUserId: null, + executionPolicy: null, + executionState: { + status: "pending", + currentStageId: stageId, + currentStageIndex: 0, + currentStageType: "review", + currentParticipant: { type: "agent", agentId: qaAgentId }, + returnAssignee: { type: "agent", agentId: coderAgentId }, + completedStageIds: [], + lastDecisionId: null, + lastDecisionOutcome: null, + }, + }, + policy: null, + requestedStatus: undefined, + requestedAssigneePatch: {}, + actor: { agentId: qaAgentId }, + }); + + expect(result.patch.executionState).toBeNull(); + expect(result.patch.status).toBe("in_progress"); + expect(result.patch.assigneeAgentId).toBe(coderAgentId); + }); + + it("clears execution state without assignee change when not in_review", () => { + const stageId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const result = applyIssueExecutionPolicyTransition({ + issue: { + status: "in_progress", + assigneeAgentId: coderAgentId, + assigneeUserId: null, + executionPolicy: null, + executionState: { + status: "changes_requested", + currentStageId: stageId, + currentStageIndex: 0, + currentStageType: "review", + currentParticipant: { type: "agent", agentId: qaAgentId }, + returnAssignee: { type: "agent", agentId: coderAgentId }, + completedStageIds: [], + lastDecisionId: null, + lastDecisionOutcome: "changes_requested", + }, + }, + policy: null, + requestedStatus: undefined, + requestedAssigneePatch: {}, + actor: { agentId: coderAgentId }, + }); + + expect(result.patch.executionState).toBeNull(); + // Not in_review, so no status/assignee change + expect(result.patch.status).toBeUndefined(); + }); + }); + + describe("reopening from done/cancelled clears state", () => { + it("reopening a done issue clears execution state", () => { + const policy = twoStagePolicy(); + const result = applyIssueExecutionPolicyTransition({ + issue: { + status: "done", + assigneeAgentId: coderAgentId, + assigneeUserId: null, + executionPolicy: policy, + executionState: { + status: "completed", + currentStageId: null, + currentStageIndex: null, + currentStageType: null, + currentParticipant: null, + returnAssignee: { type: "agent", agentId: coderAgentId }, + completedStageIds: [policy.stages[0].id, policy.stages[1].id], + lastDecisionId: null, + lastDecisionOutcome: "approved", + }, + }, + policy, + requestedStatus: "todo", + requestedAssigneePatch: {}, + actor: { userId: boardUserId }, + }); + + expect(result.patch.executionState).toBeNull(); + }); + }); + + describe("no-op transitions", () => { + const policy = twoStagePolicy(); + + it("non-done status change without review context is a no-op", () => { + const result = applyIssueExecutionPolicyTransition({ + issue: { + status: "in_progress", + assigneeAgentId: coderAgentId, + assigneeUserId: null, + executionPolicy: policy, + executionState: null, + }, + policy, + requestedStatus: "blocked", + requestedAssigneePatch: {}, + actor: { agentId: coderAgentId }, + }); + + expect(result.patch).toEqual({}); + }); + + it("no policy and no state is a no-op", () => { + const result = applyIssueExecutionPolicyTransition({ + issue: { + status: "in_progress", + assigneeAgentId: coderAgentId, + assigneeUserId: null, + executionPolicy: null, + executionState: null, + }, + policy: null, + requestedStatus: "done", + requestedAssigneePatch: {}, + actor: { agentId: coderAgentId }, + }); + + expect(result.patch).toEqual({}); + }); + }); + + describe("multi-participant stages", () => { + it("selects the preferred participant when explicitly requested", () => { + const policy = makePolicy([ + { + type: "review", + participants: [ + { type: "agent", agentId: qaAgentId }, + { type: "agent", agentId: ctoAgentId }, + ], + }, + ]); + + const result = applyIssueExecutionPolicyTransition({ + issue: { + status: "in_progress", + assigneeAgentId: coderAgentId, + assigneeUserId: null, + executionPolicy: policy, + executionState: null, + }, + policy, + requestedStatus: "done", + requestedAssigneePatch: { assigneeAgentId: ctoAgentId }, + actor: { agentId: coderAgentId }, + commentBody: "Ready for review", + }); + + expect(result.patch.assigneeAgentId).toBe(ctoAgentId); + }); + + it("falls back to first participant when no preference given", () => { + const policy = makePolicy([ + { + type: "review", + participants: [ + { type: "agent", agentId: qaAgentId }, + { type: "agent", agentId: ctoAgentId }, + ], + }, + ]); + + const result = applyIssueExecutionPolicyTransition({ + issue: { + status: "in_progress", + assigneeAgentId: coderAgentId, + assigneeUserId: null, + executionPolicy: policy, + executionState: null, + }, + policy, + requestedStatus: "done", + requestedAssigneePatch: {}, + actor: { agentId: coderAgentId }, + commentBody: "Ready for review", + }); + + expect(result.patch.assigneeAgentId).toBe(qaAgentId); + }); + + it("excludes the return assignee from participant selection", () => { + const policy = makePolicy([ + { + type: "review", + participants: [ + { type: "agent", agentId: coderAgentId }, + { type: "agent", agentId: qaAgentId }, + ], + }, + ]); + + const result = applyIssueExecutionPolicyTransition({ + issue: { + status: "in_progress", + assigneeAgentId: coderAgentId, + assigneeUserId: null, + executionPolicy: policy, + executionState: null, + }, + policy, + requestedStatus: "done", + requestedAssigneePatch: {}, + actor: { agentId: coderAgentId }, + commentBody: "Done", + }); + + // coderAgentId is the returnAssignee, so QA should be selected + expect(result.patch.assigneeAgentId).toBe(qaAgentId); + }); + }); + + describe("changes requested with no return assignee", () => { + it("throws when requesting changes with no return assignee", () => { + const policy = twoStagePolicy(); + const reviewStageId = policy.stages[0].id; + expect(() => + 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: null, + completedStageIds: [], + lastDecisionId: null, + lastDecisionOutcome: null, + }, + }, + policy, + requestedStatus: "in_progress", + requestedAssigneePatch: {}, + actor: { agentId: qaAgentId }, + commentBody: "Changes needed", + }), + ).toThrow("no return assignee"); + }); + }); + + describe("approval stage changes requested → bounces back to executor", () => { + it("approver requests changes during approval stage", () => { + const policy = twoStagePolicy(); + const reviewStageId = policy.stages[0].id; + const approvalStageId = policy.stages[1].id; + const result = applyIssueExecutionPolicyTransition({ + issue: { + status: "in_review", + assigneeAgentId: null, + assigneeUserId: ctoUserId, + executionPolicy: policy, + executionState: { + status: "pending", + currentStageId: approvalStageId, + currentStageIndex: 1, + currentStageType: "approval", + currentParticipant: { type: "user", userId: ctoUserId }, + returnAssignee: { type: "agent", agentId: coderAgentId }, + completedStageIds: [reviewStageId], + lastDecisionId: null, + lastDecisionOutcome: null, + }, + }, + policy, + requestedStatus: "in_progress", + requestedAssigneePatch: {}, + actor: { userId: ctoUserId }, + commentBody: "Not happy with the approach, needs rework", + }); + + expect(result.patch.status).toBe("in_progress"); + expect(result.patch.assigneeAgentId).toBe(coderAgentId); + expect(result.patch.executionState).toMatchObject({ + status: "changes_requested", + currentStageType: "approval", + lastDecisionOutcome: "changes_requested", + }); + expect(result.decision).toMatchObject({ + stageId: approvalStageId, + stageType: "approval", + outcome: "changes_requested", + }); + }); + }); + + describe("user participants", () => { + it("handles user-type reviewer participant correctly", () => { + const policy = makePolicy([ + { type: "review", participants: [{ type: "user", userId: boardUserId }] }, + ]); + + const result = applyIssueExecutionPolicyTransition({ + issue: { + status: "in_progress", + assigneeAgentId: coderAgentId, + assigneeUserId: null, + executionPolicy: policy, + executionState: null, + }, + policy, + requestedStatus: "done", + requestedAssigneePatch: {}, + actor: { agentId: coderAgentId }, + commentBody: "Done", + }); + + expect(result.patch.status).toBe("in_review"); + expect(result.patch.assigneeAgentId).toBeNull(); + expect(result.patch.assigneeUserId).toBe(boardUserId); }); }); });