mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
Add loose review request handoffs
This commit is contained in:
1 parent
5e2d7bca4a
commit
681554cb45
12 files changed
+167
-2
No files matched your search
@@ -795,6 +795,9 @@ describe("issue comment reopen routes", () => {
|
||||
status: "in_review",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: "local-board",
|
||||
reviewRequest: {
|
||||
instructions: "Please verify the fix against the reproduction steps and note any residual risk.",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
@@ -811,6 +814,9 @@ describe("issue comment reopen routes", () => {
|
||||
type: "agent",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
},
|
||||
reviewRequest: {
|
||||
instructions: "Please verify the fix against the reproduction steps and note any residual risk.",
|
||||
},
|
||||
});
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||
"33333333-3333-4333-8333-333333333333",
|
||||
@@ -821,6 +827,9 @@ describe("issue comment reopen routes", () => {
|
||||
executionStage: expect.objectContaining({
|
||||
wakeRole: "reviewer",
|
||||
stageType: "review",
|
||||
reviewRequest: {
|
||||
instructions: "Please verify the fix against the reproduction steps and note any residual risk.",
|
||||
},
|
||||
allowedActions: ["approve", "request_changes"],
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -171,6 +171,38 @@ describe("issue execution policy transitions", () => {
|
||||
expect(result.decision).toBeUndefined();
|
||||
});
|
||||
|
||||
it("carries loose review instructions on the pending handoff", () => {
|
||||
const reviewInstructions = [
|
||||
"Please focus on whether the migration path is reversible.",
|
||||
"",
|
||||
"- Check failure handling",
|
||||
"- Call out any unclear operator instructions",
|
||||
].join("\n");
|
||||
|
||||
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 migration",
|
||||
reviewRequest: { instructions: reviewInstructions },
|
||||
});
|
||||
|
||||
expect(result.patch.executionState).toMatchObject({
|
||||
status: "pending",
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
reviewRequest: { instructions: reviewInstructions },
|
||||
});
|
||||
});
|
||||
|
||||
it("reviewer approves → advances to approval stage", () => {
|
||||
const reviewStageId = policy.stages[0].id;
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
@@ -214,6 +246,44 @@ describe("issue execution policy transitions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("lets a reviewer provide loose instructions for the next approval stage", () => {
|
||||
const reviewStageId = policy.stages[0].id;
|
||||
const approvalInstructions = "Please decide whether this is ready to ship, with any launch caveats.";
|
||||
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 },
|
||||
reviewRequest: { instructions: "Review the implementation details." },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "done",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: qaAgentId },
|
||||
commentBody: "QA signoff complete",
|
||||
reviewRequest: { instructions: approvalInstructions },
|
||||
});
|
||||
|
||||
expect(result.patch.executionState).toMatchObject({
|
||||
status: "pending",
|
||||
currentStageType: "approval",
|
||||
currentParticipant: { type: "user", userId: ctoUserId },
|
||||
reviewRequest: { instructions: approvalInstructions },
|
||||
});
|
||||
});
|
||||
|
||||
it("approver approves → marks completed (allows done)", () => {
|
||||
const reviewStageId = policy.stages[0].id;
|
||||
const approvalStageId = policy.stages[1].id;
|
||||
|
||||
@@ -101,6 +101,7 @@ type ExecutionStageWakeContext = {
|
||||
stageType: ParsedExecutionState["currentStageType"];
|
||||
currentParticipant: ParsedExecutionState["currentParticipant"];
|
||||
returnAssignee: ParsedExecutionState["returnAssignee"];
|
||||
reviewRequest: ParsedExecutionState["reviewRequest"];
|
||||
lastDecisionOutcome: ParsedExecutionState["lastDecisionOutcome"];
|
||||
allowedActions: string[];
|
||||
};
|
||||
@@ -124,6 +125,7 @@ function buildExecutionStageWakeContext(input: {
|
||||
stageType: input.state.currentStageType,
|
||||
currentParticipant: input.state.currentParticipant,
|
||||
returnAssignee: input.state.returnAssignee,
|
||||
reviewRequest: input.state.reviewRequest ?? null,
|
||||
lastDecisionOutcome: input.state.lastDecisionOutcome,
|
||||
allowedActions: input.allowedActions,
|
||||
};
|
||||
@@ -1788,6 +1790,7 @@ export function issueRoutes(
|
||||
: null;
|
||||
const {
|
||||
comment: commentBody,
|
||||
reviewRequest,
|
||||
reopen: reopenRequested,
|
||||
interrupt: interruptRequested,
|
||||
hiddenAt: hiddenAtRaw,
|
||||
@@ -1814,7 +1817,8 @@ export function issueRoutes(
|
||||
: false;
|
||||
let interruptedRunId: string | null = null;
|
||||
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(existing);
|
||||
const isAgentWorkUpdate = req.actor.type === "agent" && Object.keys(updateFields).length > 0;
|
||||
const isAgentWorkUpdate =
|
||||
req.actor.type === "agent" && (Object.keys(updateFields).length > 0 || reviewRequest !== undefined);
|
||||
|
||||
if (closedExecutionWorkspace && (commentBody || isAgentWorkUpdate)) {
|
||||
respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace);
|
||||
@@ -1888,6 +1892,7 @@ export function issueRoutes(
|
||||
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||
},
|
||||
commentBody,
|
||||
reviewRequest: reviewRequest ?? undefined,
|
||||
});
|
||||
const decisionId = transition.decision ? randomUUID() : null;
|
||||
if (decisionId) {
|
||||
@@ -1901,6 +1906,17 @@ export function issueRoutes(
|
||||
};
|
||||
}
|
||||
Object.assign(updateFields, transition.patch);
|
||||
if (reviewRequest !== undefined && transition.patch.executionState === undefined) {
|
||||
const existingExecutionState = parseIssueExecutionState(existing.executionState);
|
||||
if (!existingExecutionState || existingExecutionState.status !== "pending") {
|
||||
res.status(422).json({ error: "reviewRequest requires an active review or approval stage" });
|
||||
return;
|
||||
}
|
||||
updateFields.executionState = {
|
||||
...existingExecutionState,
|
||||
reviewRequest,
|
||||
};
|
||||
}
|
||||
|
||||
const nextAssigneeAgentId =
|
||||
updateFields.assigneeAgentId === undefined ? existing.assigneeAgentId : (updateFields.assigneeAgentId as string | null);
|
||||
|
||||
@@ -31,6 +31,7 @@ type TransitionInput = {
|
||||
requestedAssigneePatch: RequestedAssigneePatch;
|
||||
actor: ActorLike;
|
||||
commentBody?: string | null;
|
||||
reviewRequest?: IssueExecutionState["reviewRequest"] | null;
|
||||
};
|
||||
|
||||
type TransitionResult = {
|
||||
@@ -168,6 +169,7 @@ function buildCompletedState(previous: IssueExecutionState | null, currentStage:
|
||||
currentStageType: null,
|
||||
currentParticipant: null,
|
||||
returnAssignee: previous?.returnAssignee ?? null,
|
||||
reviewRequest: null,
|
||||
completedStageIds,
|
||||
lastDecisionId: previous?.lastDecisionId ?? null,
|
||||
lastDecisionOutcome: "approved",
|
||||
@@ -186,6 +188,7 @@ function buildStateWithCompletedStages(input: {
|
||||
currentStageType: input.previous?.currentStageType ?? null,
|
||||
currentParticipant: input.previous?.currentParticipant ?? null,
|
||||
returnAssignee: input.previous?.returnAssignee ?? input.returnAssignee,
|
||||
reviewRequest: input.previous?.reviewRequest ?? null,
|
||||
completedStageIds: input.completedStageIds,
|
||||
lastDecisionId: input.previous?.lastDecisionId ?? null,
|
||||
lastDecisionOutcome: input.previous?.lastDecisionOutcome ?? null,
|
||||
@@ -204,6 +207,7 @@ function buildSkippedStageCompletedState(input: {
|
||||
currentStageType: null,
|
||||
currentParticipant: null,
|
||||
returnAssignee: input.previous?.returnAssignee ?? input.returnAssignee,
|
||||
reviewRequest: null,
|
||||
completedStageIds: input.completedStageIds,
|
||||
lastDecisionId: input.previous?.lastDecisionId ?? null,
|
||||
lastDecisionOutcome: input.previous?.lastDecisionOutcome ?? null,
|
||||
@@ -216,6 +220,7 @@ function buildPendingState(input: {
|
||||
stageIndex: number;
|
||||
participant: IssueExecutionStagePrincipal;
|
||||
returnAssignee: IssueExecutionStagePrincipal | null;
|
||||
reviewRequest?: IssueExecutionState["reviewRequest"] | null;
|
||||
}): IssueExecutionState {
|
||||
return {
|
||||
status: PENDING_STATUS,
|
||||
@@ -224,6 +229,7 @@ function buildPendingState(input: {
|
||||
currentStageType: input.stage.type,
|
||||
currentParticipant: input.participant,
|
||||
returnAssignee: input.returnAssignee,
|
||||
reviewRequest: input.reviewRequest ?? null,
|
||||
completedStageIds: input.previous?.completedStageIds ?? [],
|
||||
lastDecisionId: input.previous?.lastDecisionId ?? null,
|
||||
lastDecisionOutcome: input.previous?.lastDecisionOutcome ?? null,
|
||||
@@ -236,6 +242,7 @@ function buildChangesRequestedState(previous: IssueExecutionState, currentStage:
|
||||
status: CHANGES_REQUESTED_STATUS,
|
||||
currentStageId: currentStage.id,
|
||||
currentStageType: currentStage.type,
|
||||
reviewRequest: null,
|
||||
lastDecisionOutcome: "changes_requested",
|
||||
};
|
||||
}
|
||||
@@ -247,6 +254,7 @@ function buildPendingStagePatch(input: {
|
||||
stage: IssueExecutionStage;
|
||||
participant: IssueExecutionStagePrincipal;
|
||||
returnAssignee: IssueExecutionStagePrincipal | null;
|
||||
reviewRequest?: IssueExecutionState["reviewRequest"] | null;
|
||||
}) {
|
||||
input.patch.status = "in_review";
|
||||
Object.assign(input.patch, patchForPrincipal(input.participant));
|
||||
@@ -256,6 +264,7 @@ function buildPendingStagePatch(input: {
|
||||
stageIndex: input.policy.stages.findIndex((candidate) => candidate.id === input.stage.id),
|
||||
participant: input.participant,
|
||||
returnAssignee: input.returnAssignee,
|
||||
reviewRequest: input.reviewRequest,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -359,6 +368,7 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
|
||||
stage: activeStage,
|
||||
participant,
|
||||
returnAssignee: existingState?.returnAssignee ?? currentAssignee ?? actor,
|
||||
reviewRequest: input.reviewRequest ?? existingState?.reviewRequest ?? null,
|
||||
});
|
||||
return {
|
||||
patch,
|
||||
@@ -405,6 +415,7 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
|
||||
stage: nextStage,
|
||||
participant,
|
||||
returnAssignee: existingState?.returnAssignee ?? currentAssignee ?? actor,
|
||||
reviewRequest: input.reviewRequest ?? null,
|
||||
});
|
||||
return {
|
||||
patch,
|
||||
@@ -461,6 +472,7 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
|
||||
stage: activeStage,
|
||||
participant: currentParticipant,
|
||||
returnAssignee: existingState?.returnAssignee ?? currentAssignee ?? actor,
|
||||
reviewRequest: input.reviewRequest ?? existingState?.reviewRequest ?? null,
|
||||
});
|
||||
return {
|
||||
patch,
|
||||
@@ -538,6 +550,7 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
|
||||
stage: pendingStage,
|
||||
participant,
|
||||
returnAssignee,
|
||||
reviewRequest: input.reviewRequest ?? null,
|
||||
});
|
||||
return {
|
||||
patch,
|
||||
|
||||
Reference in new issue
Block a user