// The warm `submit_pr_review` tool — the agent-driven curated-posting surface shared by the two // PR-review doors (`/pr-review-terminal`, `/pr-review-browser`). // // `submit_pr_review` implements the per-door posting contract (contracts §8.4): nothing perk- // driven reaches GitHub before the human triage; ALL perk-side posting flows through this tool // on every door (`gh` mutations and direct `perk pr review-submit` calls are forbidden); the // verdict lands last, atomically with the comments. On the terminal door this tool is the sole // posting path. On the browser door the human platform-posts from the plannotator UI — that // native posting IS the GitHub path; perk composes nothing by default and posts only what the // human explicitly hands it (typically a request-changes verdict, which the UI cannot post). It // delegates to the Python cold door (`perk pr review-submit` — mutations canonical in Python) // via `runColdDoor` (the batch rides the run-scratch stdin channel), then appends `last_review` // to `perk:workflow-state`. The human gate splits: explicit conversational go-ahead ALWAYS // (pinned in the guidelines/skill); formal events (`approve`/`request-changes`) additionally get // the structural gate — headless refuses, interactive raises a blocking `ctx.ui.confirm`. // `dry_run` is the anchor-repair loop: no gates, no record, nothing posted. import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { booleanField, type ColdDoorCtx, type ColdJson, type ExecHost, numberField, runColdDoor, stringField, } from "../substrate/coldDoor.ts"; import { failFor, ok, type Result } from "../substrate/result.ts"; import { arrayParam, booleanParam, numberParam, paramsOf, stringParam, type ToolParams, } from "../substrate/toolParams.ts"; import { appendWorkflowState, type EntrySink } from "../substrate/workflowState.ts"; import type { Severity } from "../surfaces/report.ts"; // ------------------------------------------------------------------------ params export type ReviewEvent = "approve" | "request-changes" | "comment"; /** One curated inline comment (the exact `review-submit --batch` `comments[]` row). */ export interface SubmitComment { path: string; line: number; side?: "LEFT" | "RIGHT"; body: string; } export interface SubmitParams { pr: number; event: ReviewEvent; body: string; comments?: SubmitComment[]; dry_run?: boolean; } /** Decode the optional `comments` array; null = present-but-malformed (whole-batch refusal). */ function decodeSubmitComments(p: ToolParams): SubmitComment[] | undefined | null { const raw = arrayParam(p, "comments"); if (raw === undefined) return undefined; if (raw === null) return null; const comments: SubmitComment[] = []; for (const item of raw) { const row = paramsOf(item); if (row === null) return null; const path = stringParam(row, "path"); const line = numberParam(row, "line"); const side = stringParam(row, "side"); const body = stringParam(row, "body"); if (typeof path !== "string" || path.length === 0) return null; if (typeof line !== "number" || !Number.isInteger(line)) return null; if (side !== undefined && side !== "LEFT" && side !== "RIGHT") return null; if (typeof body !== "string" || body.length === 0) return null; const comment: SubmitComment = { path, line, body }; if (side !== undefined) comment.side = side; comments.push(comment); } return comments; } /** * Strict-decode unknown tool-call params into `SubmitParams` (the tool-boundary seam). Mirrors * `decodePostParams`: submitting a guessed/partial review is a durable GitHub mutation, so ANY * malformed field ⇒ null (whole-batch refusal). `pr` must be an int; `event` exactly one of the * three flag spellings; `body` a string (EMPTY ALLOWED — the cold door owns the event-conditioned * body rule and reports `bad_batch`); each `comments` row strict on * path/line(int)/side(LEFT|RIGHT)/body; `dry_run` a boolean. */ export function decodeSubmitParams(params: unknown): SubmitParams | null { const p = paramsOf(params); if (p === null) return null; const pr = numberParam(p, "pr"); if (typeof pr !== "number" || !Number.isInteger(pr)) return null; const event = stringParam(p, "event"); if (event !== "approve" && event !== "request-changes" && event !== "comment") return null; const body = stringParam(p, "body"); if (typeof body !== "string") return null; const comments = decodeSubmitComments(p); if (comments === null) return null; const dryRun = booleanParam(p, "dry_run"); if (dryRun === null) return null; const result: SubmitParams = { pr, event, body }; if (comments !== undefined) result.comments = comments; if (dryRun !== undefined) result.dry_run = dryRun; return result; } // ------------------------------------------------------------------------ the tool core /** The cold door's ok-arm fields (the `review-submit --json` surface; render-only → lenient). */ export interface SubmitOk { dry_run?: boolean; pr?: number; event?: string; mode?: string; comment_count?: number; } export type SubmitResult = Result; /** Narrow the cold door's `review-submit --json` payload to the fields the tool reports. */ function decodeSubmitResult(payload: ColdJson): SubmitOk { return { dry_run: booleanField(payload, "dry_run"), pr: numberField(payload, "pr"), event: stringField(payload, "event"), mode: stringField(payload, "mode"), comment_count: numberField(payload, "comment_count"), }; } /** One `bad_anchors` `invalid[]` row (the cold door's per-comment repair detail). */ interface InvalidAnchor { index: number; path: string; line: number; side: string; reason: string; } /** * Strict re-narrow of the `bad_anchors` fail payload's `invalid[]` rows. Null on ANY drift — * uncertainty renders as a plain fail, never a half table. */ function decodeInvalidAnchors(payload: ColdJson): InvalidAnchor[] | null { const raw = payload.invalid; if (!Array.isArray(raw)) return null; const rows: InvalidAnchor[] = []; for (const item of raw) { const row = paramsOf(item); if (row === null) return null; const index = row.index; const path = row.path; const line = row.line; const side = row.side; const reason = row.reason; if (typeof index !== "number" || !Number.isInteger(index)) return null; if (typeof path !== "string") return null; if (typeof line !== "number" || !Number.isInteger(line)) return null; if (typeof side !== "string") return null; if (typeof reason !== "string") return null; rows.push({ index, path, line, side, reason }); } return rows; } /** Flag spelling → the REST wire spelling shown in the human confirm. */ const WIRE_EVENT: Record = { approve: "APPROVE", "request-changes": "REQUEST_CHANGES", comment: "COMMENT", }; /** The body's first line, truncated for the confirm-dialog summary. */ function bodyFirstLine(body: string): string { const line = body.split("\n", 1)[0] ?? ""; return line.length > 120 ? `${line.slice(0, 117)}…` : line; } /** * The minimal ctx slice `submitPrReview` needs — `ExtensionContext` satisfies it (compile-checked * in the test); tests fake it. The dialog method is called ONLY behind the `hasUI` guard. */ export interface SubmitCtx extends ColdDoorCtx { hasUI: boolean; ui: { notify(message: string, type?: Severity): void; confirm(title: string, message: string): Promise; }; } /** * Submit the human-curated review batch to the foreign PR (the terminal door's sole posting * surface; the browser door's request-changes / explicit-request path). Delegates to the Python * cold door; returns a soft result (never throws). Gate ladder (skipped when `dry_run`): formal * events refuse headless (`headless_formal_event`) and otherwise require a blocking confirm * (`user_declined` on decline, nothing executed); `comment` posts on the conversational * go-ahead alone. On a real success, records `last_review`. */ export async function submitPrReview( pi: ExecHost & EntrySink, ctx: SubmitCtx, params: SubmitParams, ): Promise { const fail = failFor(ctx, "review", "submit_pr_review"); const dryRun = params.dry_run === true; const commentCount = params.comments?.length ?? 0; if (!dryRun && params.event !== "comment") { if (!ctx.hasUI) { return fail( "headless sessions cannot post formal review verdicts — re-run interactively or use " + "event: comment", "headless_formal_event", ); } const wire = WIRE_EVENT[params.event]; const firstLine = bodyFirstLine(params.body); const summary = `event: ${wire} · ${commentCount} inline comment(s)` + (firstLine.length > 0 ? `\nbody: ${firstLine}` : ""); const yes = await ctx.ui.confirm(`Post ${wire} review to PR #${params.pr}?`, summary); if (!yes) { return fail( `user declined the ${params.event} review — nothing was submitted`, "user_declined", ); } } // The exact `perk pr review-submit --batch` shape ({body, comments?} — the event rides the flag). const batch: Record = { body: params.body }; if (params.comments !== undefined) batch.comments = params.comments; const r = await runColdDoor( pi, ctx, [ "pr", "review-submit", "--pr", String(params.pr), "--event", params.event, ...(dryRun ? ["--dry-run"] : []), "--json", ], { label: "perk pr review-submit", decode: decodeSubmitResult, stdin: { flag: "--batch", content: `${JSON.stringify(batch, null, 2)}\n`, filename: `review-submit-${Date.now()}.json`, }, }, ); if (!r.ok) { // The repair-loop arm: render the per-comment invalid[] detail when it decodes cleanly. if (r.errorType === "bad_anchors" && r.payload !== undefined) { const rows = decodeInvalidAnchors(r.payload); if (rows !== null && rows.length > 0) { const table = rows .map( (row) => ` comment[${row.index}] ${row.path}:${row.line} (${row.side}) — ${row.reason}`, ) .join("\n"); return fail( `${r.message}\n${table}\nrepair these anchors and re-run with dry_run: true`, r.errorType, ); } } return fail(r.message, r.errorType); } const data = r.data; if (dryRun) { const n = data.comment_count ?? commentCount; return ok( `validated — ${n} inline comment(s), event ${params.event}; the batch is submittable`, { ...data }, ); } // Record the outcome (tier-3, best-effort-with-logging, headless-safe). Strict read-back via // rebuild — loud-but-non-fatal, the submission already succeeded. const record = { pr: data.pr ?? params.pr, event: params.event, comment_count: data.comment_count ?? null, mode: data.mode ?? null, at: new Date().toISOString(), }; appendWorkflowState(pi, ctx, { data: { last_review: record }, field: "last_review", expected: record, scope: "review", failure: "last_review read-back failed", }); let text = `submitted ${params.event} review to PR #${record.pr} ` + `(${data.comment_count ?? commentCount} inline comment(s))`; if (data.mode === "review_folded") { text += " — note: inline anchors rejected by GitHub; comments folded into the review body, " + "event preserved"; } else if (data.mode === "comment_fallback") { text += " — note: degraded to a discussion comment"; } return ok(`${text}.`, { ...data }); } const TOOL_GUIDELINES = [ "Call submit_pr_review only after the human triage has settled the batch AND the human has explicitly approved posting — nothing reaches GitHub before triage.", "Validate first with dry_run: true and repair any reported anchors until validation passes; a dry-run never posts, never gates, and records nothing.", "Make ONE real call: comments + body + event land atomically in a single review — the verdict never lands before the comments.", "Formal events (approve / request-changes) additionally raise a blocking in-TUI confirm; headless sessions refuse them (use event: comment or re-run interactively).", "All perk-side GitHub posting flows through this tool on both review doors — never post via gh or bash (direct perk pr review-submit calls are forbidden). On /pr-review-terminal this tool is the sole posting path; on /pr-review-browser the plannotator UI's native platform-posting is the human's own GitHub path, and perk posts only what the human explicitly hands it (typically a request-changes verdict).", ]; // ------------------------------------------------------------------------ registration /** Register the `submit_pr_review` tool (the two review doors register no tools of their own). */ export function registerSubmitPrReview(pi: ExtensionAPI): void { pi.registerTool({ name: "submit_pr_review", label: "Submit PR review", description: "Submit the human-curated review-door outcome to the foreign PR as ONE atomic review " + "(comments + body + event) via the perk cold door. dry_run validates the anchors without " + "posting (the repair loop); a real submission records last_review in workflow-state.", promptSnippet: "Submit the curated review batch to the PR", promptGuidelines: TOOL_GUIDELINES, executionMode: "sequential", parameters: { type: "object", additionalProperties: false, required: ["pr", "event", "body"], properties: { pr: { type: "number", description: "The foreign PR number being reviewed." }, event: { type: "string", enum: ["approve", "request-changes", "comment"], description: "The review event, settled with the human during triage. Formal events " + "(approve/request-changes) additionally raise a blocking confirm dialog.", }, body: { type: "string", description: "The overall review body (markdown). comment/request-changes require a non-empty " + "body; unanchorable findings fold in here.", }, comments: { type: "array", description: "The curated inline comments — human-authored or human-approved only, each anchored " + "to a line in the PR diff (never re-anchor a child's finding).", items: { type: "object", additionalProperties: false, required: ["path", "line", "body"], properties: { path: { type: "string", description: "The changed file path." }, line: { type: "number", description: "A line present in the PR diff." }, side: { type: "string", enum: ["LEFT", "RIGHT"], description: "The diff side the line anchors to (default RIGHT).", }, body: { type: "string", description: "The comment (markdown)." }, }, }, }, dry_run: { type: "boolean", description: "Validate the batch + anchors without posting (the anchor-repair loop). No gates, " + "no last_review record.", }, }, }, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const decoded = decodeSubmitParams(params); if (decoded === null) { return failFor( ctx, "review", "submit_pr_review", )( "submit_pr_review needs { pr: int, event: 'approve'|'request-changes'|'comment', " + "body: string, comments?: [{path, line: int, side?: 'LEFT'|'RIGHT', body}], " + "dry_run?: bool }", "bad_input", ); } return submitPrReview(pi, ctx, decoded); }, }); }