Add request changes review outcome

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-10 11:56:36 +00:00
1 parent 0ee0b2891c
commit b4c1ed61fe
5 files changed
+260 -10

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerPipelineCommands } from "../commands/pipelines.js";
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
const CASE_ID = "11111111-1111-4111-8111-111111111111";
function createProgram(): Command {
const program = new Command();
program.exitOverride();
program.configureOutput({
writeOut: () => {},
writeErr: () => {},
});
registerPipelineCommands(program);
return program;
}
async function run(args: string[]): Promise<void> {
await createProgram().parseAsync([
...args,
"--api-base",
"http://localhost:3100",
"--api-key",
"board-token",
"--company-id",
COMPANY_ID,
], { from: "user" });
}
describe("pipeline CLI commands", () => {
beforeEach(() => {
vi.restoreAllMocks();
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_API_URL;
vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("sends request-changes review decisions", async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ case: { id: CASE_ID } }));
vi.stubGlobal("fetch", fetchMock);
await run([
"pipelines",
"case",
"review",
CASE_ID,
"--request-changes",
"--reason",
"Needs edits",
"--expected-version",
"2",
]);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[0]).toBe(`http://localhost:3100/api/cases/${CASE_ID}/review`);
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({
decision: "request_changes",
reason: "Needs edits",
expectedVersion: 2,
});
});
it("passes request_changes rows through review-bulk", async () => {
const dir = await mkdtemp(join(tmpdir(), "paperclip-pipeline-cli-"));
const file = join(dir, "review-bulk.json");
await writeFile(file, JSON.stringify([
{ caseId: CASE_ID, decision: "request_changes", reason: "Needs edits", expectedVersion: 2 },
]));
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ results: [] }));
vi.stubGlobal("fetch", fetchMock);
try {
await run(["pipelines", "review-bulk", "--file", file]);
} finally {
await rm(dir, { recursive: true, force: true });
}
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[0]).toBe(`http://localhost:3100/api/companies/${COMPANY_ID}/review-cases/bulk`);
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({
items: [{ caseId: CASE_ID, decision: "request_changes", reason: "Needs edits", expectedVersion: 2 }],
});
});
});
function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response {
return new Response(JSON.stringify(body), init);
}
+17 -3
View File
@@ -168,6 +168,7 @@ interface ResolveSuggestionOptions extends PipelineOptions {
interface ReviewOptions extends PipelineOptions {
approve?: boolean;
reject?: boolean;
requestChanges?: boolean;
reason?: string;
expectedVersion: string;
editsJson?: string;
@@ -404,7 +405,7 @@ export function registerPipelineCommands(program: Command): void {
addPipelineOptions(
pipelines
.command("review-bulk")
.description("Apply bulk review decisions")
.description("Apply bulk review decisions: approve, reject, or request_changes")
.requiredOption("--file <path>", "JSON file containing an array or { items }")
.action((opts: ReviewBulkOptions) => withPipelineErrors(async () => {
const ctx = resolvePipelineContext(opts);
@@ -575,10 +576,11 @@ function registerCaseCommands(caseCommand: Command): void {
addPipelineOptions(
caseCommand
.command("review")
.description("Approve or reject a case in a review stage")
.description("Approve, reject, or request changes for a case in a review stage")
.argument("<caseId>", "Case ID")
.option("--approve", "Approve the case")
.option("--reject", "Reject the case")
.option("--request-changes", "Request changes for the case")
.option("--reason <text>", "Decision reason")
.requiredOption("--expected-version <n>", "Expected case version")
.option("--edits-json <json>", "Review edits JSON")
@@ -590,7 +592,7 @@ function registerCaseCommands(caseCommand: Command): void {
.option("--lease-token <token>", "Lease token")
.action((caseId: string, opts: ReviewOptions) => withPipelineErrors(async () => {
const ctx = resolvePipelineContext(opts);
const decision = exactlyOneFlag(opts.approve, opts.reject, "--approve", "--reject") === "--approve" ? "approve" : "reject";
const decision = reviewDecisionFromOptions(opts);
const edits = await buildReviewEdits(opts);
printOutput(await ctx.api.post(apiPath`/api/cases/${caseId}/review`, {
decision,
@@ -726,6 +728,18 @@ function exactlyOneFlag(first: boolean | undefined, second: boolean | undefined,
return first ? firstName : secondName;
}
function reviewDecisionFromOptions(opts: ReviewOptions): "approve" | "reject" | "request_changes" {
const selected = [
opts.approve ? { flag: "--approve", decision: "approve" as const } : null,
opts.reject ? { flag: "--reject", decision: "reject" as const } : null,
opts.requestChanges ? { flag: "--request-changes", decision: "request_changes" as const } : null,
].filter((item): item is NonNullable<typeof item> => item !== null);
if (selected.length !== 1) {
throw new Error("Pass exactly one of --approve, --reject, or --request-changes.");
}
return selected[0]!.decision;
}
function printPipeline(row: PipelineDetail | PipelineSummary | null, ctx: ResolvedClientContext): void {
if (!row) return printOutput(null, { json: ctx.json });
if (ctx.json) return printOutput(row, { json: true });