diff --git a/plugins/opencode/commands/adversarial-review.md b/plugins/opencode/commands/adversarial-review.md index ad91e9c..bbd4586 100644 --- a/plugins/opencode/commands/adversarial-review.md +++ b/plugins/opencode/commands/adversarial-review.md @@ -60,7 +60,7 @@ Focus text quoting (REQUIRED): - `/opencode:adversarial-review --background look for race conditions in $RUNTIME` → `node ... adversarial-review --background 'look for race conditions in $RUNTIME'` Foreground flow: -- First, transform `$ARGUMENTS` using the **PR reference extraction** and **Focus text quoting** rules above. Pass through `--wait`, `--background`, `--base`, `--scope`, `--model`, and `--pr` flags as-is; convert any `PR #N` reference in the user's text to `--pr N`; single-quote whatever free-form focus text remains. +- First, transform `$ARGUMENTS` using the **PR reference extraction** and **Focus text quoting** rules above. Pass through `--wait`, `--background`, `--base`, `--scope`, `--model`, `--pr`, `--post`, and `--confidence-threshold` flags as-is; convert any `PR #N` reference in the user's text to `--pr N`; single-quote whatever free-form focus text remains. - Then run the resulting command (illustrative shape — substitute the actual transformed args): ```bash node "${CLAUDE_PLUGIN_ROOT}/scripts/opencode-companion.mjs" adversarial-review [--pr N] [''] @@ -68,6 +68,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/opencode-companion.mjs" adversarial-review < - Return the command stdout verbatim, exactly as-is. - Do not paraphrase, summarize, or add commentary before or after it. - Do not fix any issues mentioned in the review output. +- If `--post` was set, ALSO follow the "Post-review publishing" steps below before your final turn output. Background flow: - Apply the same `$ARGUMENTS` transformation as the foreground flow above (PR ref extraction + focus text single-quoting). @@ -81,3 +82,25 @@ Bash({ ``` - Do not call `BashOutput` or wait for completion in this turn. - After launching the command, tell the user: "OpenCode adversarial review started in the background. Check `/opencode:status` for progress." +- When the user later asks to collect the result and `--post` was part of the original arguments, read the completed `BashOutput` and then follow the "Post-review publishing" steps below on the captured stderr. + +Post-review publishing (only when `--post` is set): +- After the companion script exits, scan its **stderr** (not stdout) for a block shaped like: +``` + +412 +3 +2 +/tmp/opencode-plugin-cc/post-pr-412-…json +gh api -X POST "repos/{owner}/{repo}/pulls/412/reviews" --input '/tmp/…json' +rm -f '/tmp/…json' + +``` +- If the block is present: + 1. Run the exact string inside `` via a single `Bash` tool call. Do not edit the command, do not re-serialize the JSON, do not invent a different endpoint — the companion already quoted the path and picked the correct endpoint. + 2. Parse the response body; GitHub returns a JSON object with an `html_url` field pointing at the newly-created review. + 3. After the verbatim stdout from step "Return the command stdout verbatim" above, append a single-line status like: `Review posted to PR #: `. Include "(N inline comments)" only when `` is greater than zero. + 4. Run the exact string inside `` via a second `Bash` tool call to delete the temp payload file. If this fails, mention it once; do not retry or escalate. + 5. If step 1 fails (non-zero exit, or `gh` returns an error), append `Failed to post review to PR #: ` instead of the success line and still run ``. Do not retry. +- If the block is **not** present (either `--post` wasn't requested or the companion's preparation step failed — in which case stderr will contain a `[opencode-companion] Failed to prepare PR post …` line instead), do nothing extra. +- Never POST, comment, or otherwise mutate the PR unless you found an `` block in stderr on THIS run. Do not "help" by reposting or retrying a previous run's block. diff --git a/plugins/opencode/commands/review.md b/plugins/opencode/commands/review.md index e9179ef..cfaf5a6 100644 --- a/plugins/opencode/commands/review.md +++ b/plugins/opencode/commands/review.md @@ -55,6 +55,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/opencode-companion.mjs" review $ARGUMENTS - Return the command stdout verbatim, exactly as-is. - Do not paraphrase, summarize, or add commentary before or after it. - Do not fix any issues mentioned in the review output. +- If `--post` was set, ALSO follow the "Post-review publishing" steps below before your final turn output. Background flow: - Launch the review with `Bash` in the background: @@ -67,3 +68,25 @@ Bash({ ``` - Do not call `BashOutput` or wait for completion in this turn. - After launching the command, tell the user: "OpenCode review started in the background. Check `/opencode:status` for progress." +- When the user later asks to collect the result and `--post` was part of the original arguments, read the completed `BashOutput` and then follow the "Post-review publishing" steps below on the captured stderr. + +Post-review publishing (only when `--post` is set): +- After the companion script exits, scan its **stderr** (not stdout) for a block shaped like: +``` + +412 +3 +2 +/tmp/opencode-plugin-cc/post-pr-412-…json +gh api -X POST "repos/{owner}/{repo}/pulls/412/reviews" --input '/tmp/…json' +rm -f '/tmp/…json' + +``` +- If the block is present: + 1. Run the exact string inside `` via a single `Bash` tool call. Do not edit the command, do not re-serialize the JSON, do not invent a different endpoint — the companion already quoted the path and picked the correct endpoint. + 2. Parse the response body; GitHub returns a JSON object with an `html_url` field pointing at the newly-created review. + 3. After the verbatim stdout from step "Return the command stdout verbatim" above, append a single-line status like: `Review posted to PR #: `. Include "(N inline comments)" only when `` is greater than zero. + 4. Run the exact string inside `` via a second `Bash` tool call to delete the temp payload file. If this fails, mention it once; do not retry or escalate. + 5. If step 1 fails (non-zero exit, or `gh` returns an error), append `Failed to post review to PR #: ` instead of the success line and still run ``. Do not retry. +- If the block is **not** present (either `--post` wasn't requested or the companion's preparation step failed — in which case stderr will contain a `[opencode-companion] Failed to prepare PR post …` line instead), do nothing extra. +- Never POST, comment, or otherwise mutate the PR unless you found an `` block in stderr on THIS run. Do not "help" by reposting or retrying a previous run's block. diff --git a/plugins/opencode/scripts/lib/pr-comments.mjs b/plugins/opencode/scripts/lib/pr-comments.mjs index 7523cba..c255545 100644 --- a/plugins/opencode/scripts/lib/pr-comments.mjs +++ b/plugins/opencode/scripts/lib/pr-comments.mjs @@ -1,8 +1,8 @@ -// Post an OpenCode review back to a GitHub pull request. +// Prepare a GitHub PR review payload from an OpenCode review. // // Structured review findings from OpenCode have a `file`, `line_start`, -// `line_end`, `confidence`, and a recommendation. We can turn them into: -// - a summary comment on the PR with the full findings table, and +// `line_end`, `confidence`, and a recommendation. We turn them into: +// - a summary comment body for the PR review, and // - inline review comments anchored to specific lines for findings // whose confidence exceeds the user-supplied threshold (default 0.8) // AND whose target line is addressable on GitHub's unified diff for @@ -14,31 +14,46 @@ // high-confidence finding whose line is outside the diff silently // degrades to summary-only; we never drop the finding. // -// We post via `gh api` piping a JSON payload on stdin rather than via -// `gh pr review`, because the CLI wrapper doesn't support inline review -// comments directly and serializing a comments array through repeated -// -f/-F flags is fragile. - +// Execution model: this module does NOT call `gh api` to POST the +// review itself. It constructs a ready-to-POST JSON payload, writes it +// to a temp file, and returns a `gh api … --input ` command. The +// slash-command runner (Claude Code) is responsible for executing that +// command via its Bash tool. This keeps complex gh plumbing out of +// Node, lets Claude show/confirm the payload before it fires, and +// sidesteps the whole class of JSON-stream-reassembly bugs that come +// with `gh api --paginate`. + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import crypto from "node:crypto"; import { spawn } from "node:child_process"; /** - * Post a review to PR `prNumber`. Never throws on posting errors — - * returns a result object the caller can log, because the review text - * was already produced and the local run should not fail because of a - * network or auth glitch on GitHub. + * Prepare a POST-ready GitHub review payload for `prNumber`. Never + * throws — returns `{ prepared: false, error }` on failure. Callers + * should emit the returned command as a structured trailer for Claude + * to execute. * * @param {string} workspace - cwd for the `gh` invocations * @param {object} opts * @param {number} opts.prNumber * @param {object|null} opts.structured - parsed review JSON (or null) - * @param {string} opts.rendered - human-readable review output (fallback - * when `structured` is null) + * @param {string} [opts.rendered] - fallback raw review text (used + * when `structured` is null so the summary comment still has + * *something* to say) * @param {{ providerID: string, modelID: string }|null} opts.model * @param {boolean} opts.adversarial * @param {number} [opts.confidenceThreshold=0.8] - * @returns {Promise<{ posted: boolean, reviewUrl?: string, inlineCount: number, summaryOnlyCount: number, error?: string }>} + * @param {object} [opts.prData] - pre-fetched `{ headSha, files }`, + * primarily for tests; production callers omit this and let the + * module fetch it via `gh`. + * @returns {Promise< + * | { prepared: true, command: string, cleanup: string, payloadPath: string, inlineCount: number, summaryOnlyCount: number, prNumber: number } + * | { prepared: false, error: string } + * >} */ -export async function postReviewToPr(workspace, opts) { +export async function preparePostInstructions(workspace, opts) { const { prNumber, structured, @@ -49,9 +64,11 @@ export async function postReviewToPr(workspace, opts) { } = opts; try { - const prData = await fetchPrData(workspace, prNumber); + const prData = opts.prData ?? (await fetchPrData(workspace, prNumber)); - const findings = Array.isArray(structured?.findings) ? structured.findings : []; + const findings = Array.isArray(structured?.findings) + ? structured.findings + : []; const addableByFile = buildAddableLineMap(prData.files); const { inline, summaryOnly } = splitFindings( findings, @@ -76,30 +93,90 @@ export async function postReviewToPr(workspace, opts) { comments: inline.map(findingToInlineComment), }; - const resp = await ghPostReview(workspace, prNumber, payload); + const payloadPath = writePayloadFile(prNumber, payload); + const quotedPath = shQuote(payloadPath); + const command = `gh api -X POST "repos/{owner}/{repo}/pulls/${prNumber}/reviews" --input ${quotedPath}`; + const cleanup = `rm -f ${quotedPath}`; + return { - posted: true, - reviewUrl: resp.html_url, + prepared: true, + command, + cleanup, + payloadPath, inlineCount: inline.length, summaryOnlyCount: summaryOnly.length, + prNumber, }; } catch (err) { - return { - posted: false, - error: err.message, - inlineCount: 0, - summaryOnlyCount: 0, - }; + return { prepared: false, error: err.message }; } } // --------------------------------------------------------------------- -// gh plumbing +// Payload file + shell quoting +// --------------------------------------------------------------------- + +/** + * Write `payload` to a unique temp file and return its absolute path. + * Exported so tests can call it directly and assert file contents. + */ +export function writePayloadFile(prNumber, payload) { + const dir = path.join(os.tmpdir(), "opencode-plugin-cc"); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + const suffix = crypto.randomBytes(4).toString("hex"); + const filename = `post-pr-${prNumber}-${Date.now()}-${suffix}.json`; + const full = path.join(dir, filename); + fs.writeFileSync(full, JSON.stringify(payload, null, 2), { + encoding: "utf8", + mode: 0o600, + }); + return full; +} + +/** + * POSIX single-quote `s` so bash/zsh pass it through literally. The + * only escape needed inside single quotes is the closing quote itself, + * which is handled by the standard `'\''` trick. + */ +export function shQuote(s) { + return `'${String(s).replace(/'/g, "'\\''")}'`; +} + +// --------------------------------------------------------------------- +// Trailer emission (for Claude Code to act on) +// --------------------------------------------------------------------- + +/** + * Render the stderr trailer the slash command reads to know it should + * POST the review. Kept plain text with tagged XML-ish children so + * Claude can parse it with a single regex and extract the command + * verbatim. + * + * @param {{ prepared: true, command: string, cleanup: string, payloadPath: string, inlineCount: number, summaryOnlyCount: number, prNumber: number }} prepared + * @returns {string} + */ +export function formatPostTrailer(prepared) { + const lines = [ + "", + `${prepared.prNumber}`, + `${prepared.inlineCount}`, + `${prepared.summaryOnlyCount}`, + `${prepared.payloadPath}`, + `${prepared.command}`, + `${prepared.cleanup}`, + "", + "", + ]; + return lines.join("\n"); +} + +// --------------------------------------------------------------------- +// gh plumbing (read-only — no POSTs) // --------------------------------------------------------------------- /** - * Run a `gh` subcommand and return parsed stdout. `input` is piped to - * stdin. Rejects with a useful error on non-zero exit codes. + * Run a `gh` subcommand and return stdout. `input` is piped to stdin. + * Rejects with a useful error on non-zero exit codes. */ function runGh(workspace, args, { input } = {}) { return new Promise((resolve, reject) => { @@ -135,6 +212,17 @@ function runGh(workspace, args, { input } = {}) { }); } +/** + * Fetch the PR head SHA + the file list (with unified-diff patches) so + * we can classify findings into inline vs summary-only before writing + * the payload. Both calls are single-shot — we deliberately do NOT use + * `gh api --paginate`, because its output is a concatenation of per-page + * JSON arrays (`][` at page boundaries) and string-splitting that apart + * corrupts any patch whose content legitimately contains `][` (common + * in JS/Go code). GitHub allows `per_page=100` here, which covers the + * vast majority of real PRs. On a 100+ file PR, findings in the tail + * files simply degrade to summary-only, which is better than crashing. + */ async function fetchPrData(workspace, prNumber) { const headJson = await runGh(workspace, [ "pr", @@ -155,63 +243,20 @@ async function fetchPrData(workspace, prNumber) { ); } - // `gh api` paginates via `--paginate`, but the pulls/files endpoint - // returns at most 30 per page by default — bump per_page to 100 and - // paginate so huge PRs don't lose files past the first page. const filesJson = await runGh(workspace, [ "api", - "--paginate", `repos/{owner}/{repo}/pulls/${prNumber}/files?per_page=100`, ]); - const files = parsePaginatedJson(filesJson); - return { headSha, files }; -} - -/** - * `gh api --paginate` concatenates the per-page JSON arrays as separate - * documents rather than a single merged array, so a naive JSON.parse - * only sees the last page. Split on the `][` page boundary and merge. - */ -function parsePaginatedJson(text) { - const trimmed = text.trim(); - if (!trimmed) return []; - // The simple case: gh emitted one page as one array. - if (trimmed.startsWith("[") && !trimmed.includes("][")) { - return JSON.parse(trimmed); - } - // Multi-page: treat `][` as a page separator and reconstitute. - const chunks = trimmed.split(/\]\s*\[/).map((chunk, i, arr) => { - let c = chunk; - if (i > 0) c = `[${c}`; - if (i < arr.length - 1) c = `${c}]`; - return c; - }); - const out = []; - for (const chunk of chunks) { - const arr = JSON.parse(chunk); - if (Array.isArray(arr)) out.push(...arr); - } - return out; -} - -async function ghPostReview(workspace, prNumber, payload) { - const stdout = await runGh( - workspace, - [ - "api", - "-X", - "POST", - `repos/{owner}/{repo}/pulls/${prNumber}/reviews`, - "--input", - "-", - ], - { input: JSON.stringify(payload) } - ); + let files; try { - return JSON.parse(stdout); + files = JSON.parse(filesJson); } catch (err) { - throw new Error(`gh api POST reviews returned invalid JSON: ${err.message}`); + throw new Error( + `gh api pulls/${prNumber}/files returned invalid JSON: ${err.message}` + ); } + if (!Array.isArray(files)) files = []; + return { headSha, files }; } // --------------------------------------------------------------------- @@ -224,8 +269,7 @@ async function ghPostReview(workspace, prNumber, payload) { * field of a review comment. Those are lines present in the diff as * either additions (`+`) or unchanged context (` `). Deletions (`-`) * only exist on the LEFT side and would need `side: "LEFT"`, which we - * don't bother supporting — our findings target the current state of - * the code, not the old version. + * don't support — our findings target the current state of the code. * * Exported for tests. * diff --git a/plugins/opencode/scripts/opencode-companion.mjs b/plugins/opencode/scripts/opencode-companion.mjs index 816b86a..2be549d 100644 --- a/plugins/opencode/scripts/opencode-companion.mjs +++ b/plugins/opencode/scripts/opencode-companion.mjs @@ -83,7 +83,7 @@ import { } from "./lib/worktree.mjs"; import { readJson, readDenyRules } from "./lib/fs.mjs"; import { resolveReviewAgent } from "./lib/review-agent.mjs"; -import { postReviewToPr } from "./lib/pr-comments.mjs"; +import { preparePostInstructions, formatPostTrailer } from "./lib/pr-comments.mjs"; import { parseModelString, selectFreeModel } from "./lib/model.mjs"; import { applyDefaultModelOptions, @@ -398,7 +398,7 @@ async function handleReview(argv) { console.log(result.rendered); if (postRequested) { - await postReviewAndLog(workspace, { + await emitPostTrailer(workspace, { prNumber: effectivePrNumber, result, adversarial: false, @@ -525,7 +525,7 @@ async function handleAdversarialReview(argv) { console.log(result.rendered); if (postRequested) { - await postReviewAndLog(workspace, { + await emitPostTrailer(workspace, { prNumber: effectivePrNumber, result, adversarial: true, @@ -564,13 +564,24 @@ function parseConfidenceThreshold(raw) { } /** - * Shared post-review publishing path. Never throws — pushes a readable - * log line to stderr on both success and failure because the local - * review output has already been printed to stdout and the user should - * not get a non-zero exit just because GitHub was unhappy. + * Prepare a GitHub review payload and emit a structured trailer on + * stderr describing the `gh api` POST command the slash-command runner + * (Claude Code) should execute to actually publish the review. + * + * The companion deliberately does NOT run the POST itself. Keeping the + * network call in Claude's Bash tool: + * - lets Claude show/confirm the payload before it fires, + * - avoids re-implementing gh plumbing (pagination, JSON stream + * reassembly, auth) in Node, and + * - makes failures debuggable from the chat instead of buried in a + * stderr line the user may never see. + * + * Never throws — on failure the trailer is replaced with a human- + * readable `[opencode-companion] Failed to prepare PR post: …` line so + * the review output (already on stdout) is not disrupted. */ -async function postReviewAndLog(workspace, { prNumber, result, adversarial, confidenceThreshold }) { - const outcome = await postReviewToPr(workspace, { +async function emitPostTrailer(workspace, { prNumber, result, adversarial, confidenceThreshold }) { + const prepared = await preparePostInstructions(workspace, { prNumber, structured: result.structured, rendered: result.rendered, @@ -579,25 +590,14 @@ async function postReviewAndLog(workspace, { prNumber, result, adversarial, conf confidenceThreshold, }); - if (outcome.posted) { - const bits = [`posted review to PR #${prNumber}`]; - if (outcome.inlineCount > 0) { - bits.push( - `${outcome.inlineCount} inline comment${outcome.inlineCount === 1 ? "" : "s"}` - ); - } - if (outcome.summaryOnlyCount > 0) { - bits.push( - `${outcome.summaryOnlyCount} summary-only finding${outcome.summaryOnlyCount === 1 ? "" : "s"}` - ); - } - const url = outcome.reviewUrl ? ` — ${outcome.reviewUrl}` : ""; - process.stderr.write(`[opencode-companion] ${bits.join(", ")}${url}\n`); - } else { + if (!prepared.prepared) { process.stderr.write( - `[opencode-companion] Failed to post review to PR #${prNumber}: ${outcome.error}\n` + `[opencode-companion] Failed to prepare PR post for #${prNumber}: ${prepared.error}\n` ); + return; } + + process.stderr.write(formatPostTrailer(prepared)); } // ------------------------------------------------------------------ diff --git a/tests/pr-comments.test.mjs b/tests/pr-comments.test.mjs index 7ef75b1..736167b 100644 --- a/tests/pr-comments.test.mjs +++ b/tests/pr-comments.test.mjs @@ -1,21 +1,27 @@ -// Unit tests for pr-comments.mjs — the module that posts OpenCode -// review output back to a GitHub PR as a review comment with -// high-confidence findings anchored inline. -// -// We intentionally don't hit the GitHub API from tests. The pure -// functions (diff parsing, finding classification, summary rendering) -// are exported for testing and are exercised here. The thin `gh api` -// wrapper on top is verified by `node --check` and by manual smoke -// test through the companion CLI. - -import { describe, it } from "node:test"; +// Unit tests for pr-comments.mjs — the module that prepares an +// OpenCode review for posting back to a GitHub PR. Since the refactor, +// the module does NOT execute `gh api` to POST anything; it constructs +// the payload, writes it to a temp file, and emits a structured +// stderr trailer for Claude Code to act on. Tests therefore cover: +// - diff parsing (addable-line classification), +// - finding classification (inline vs summary-only), +// - summary body rendering, +// - payload-file + trailer construction via preparePostInstructions +// with an injected `prData` so no real `gh` calls happen. + +import { describe, it, after } from "node:test"; import assert from "node:assert/strict"; +import fs from "node:fs"; import { parseAddableLines, buildAddableLineMap, splitFindings, findingToInlineComment, renderSummaryBody, + writePayloadFile, + shQuote, + formatPostTrailer, + preparePostInstructions, } from "../plugins/opencode/scripts/lib/pr-comments.mjs"; describe("parseAddableLines", () => { @@ -377,3 +383,198 @@ describe("renderSummaryBody", () => { assert.match(body, /Has \\\| pipe and newline/); }); }); + +describe("shQuote", () => { + it("wraps simple paths in single quotes", () => { + assert.equal(shQuote("/tmp/foo.json"), "'/tmp/foo.json'"); + }); + + it("escapes embedded single quotes via the standard '\\'' trick", () => { + assert.equal(shQuote("/tmp/wat's this.json"), "'/tmp/wat'\\''s this.json'"); + }); + + it("treats non-string input as string", () => { + assert.equal(shQuote(42), "'42'"); + }); +}); + +describe("writePayloadFile", () => { + const written = []; + after(() => { + for (const p of written) { + try { + fs.unlinkSync(p); + } catch { + // best-effort cleanup + } + } + }); + + it("writes a JSON file with the exact payload and returns its path", () => { + const payload = { + commit_id: "deadbeef", + event: "COMMENT", + body: "hello", + comments: [{ path: "a.js", line: 1, side: "RIGHT", body: "x" }], + }; + const p = writePayloadFile(42, payload); + written.push(p); + assert.ok(fs.existsSync(p)); + assert.ok(p.includes("post-pr-42-")); + const roundtripped = JSON.parse(fs.readFileSync(p, "utf8")); + assert.deepEqual(roundtripped, payload); + }); + + it("produces a unique path per call", () => { + const a = writePayloadFile(1, {}); + const b = writePayloadFile(1, {}); + written.push(a, b); + assert.notEqual(a, b); + }); +}); + +describe("formatPostTrailer", () => { + it("renders an XML-ish block that Claude can parse with one regex", () => { + const trailer = formatPostTrailer({ + prepared: true, + prNumber: 412, + inlineCount: 3, + summaryOnlyCount: 2, + payloadPath: "/tmp/opencode-plugin-cc/post-pr-412-xyz.json", + command: `gh api -X POST "repos/{owner}/{repo}/pulls/412/reviews" --input '/tmp/opencode-plugin-cc/post-pr-412-xyz.json'`, + cleanup: `rm -f '/tmp/opencode-plugin-cc/post-pr-412-xyz.json'`, + }); + assert.match(trailer, //); + assert.match(trailer, /<\/opencode_post_instructions>/); + assert.match(trailer, /412<\/pr>/); + assert.match(trailer, /3<\/inline_count>/); + assert.match(trailer, /2<\/summary_only_count>/); + assert.match(trailer, /[^<]*post-pr-412-xyz\.json<\/payload_path>/); + assert.match(trailer, /gh api -X POST[^<]*<\/command>/); + assert.match(trailer, /rm -f[^<]*<\/cleanup>/); + }); +}); + +describe("preparePostInstructions (with injected prData)", () => { + const written = []; + after(() => { + for (const p of written) { + try { + fs.unlinkSync(p); + } catch { + // best-effort cleanup + } + } + }); + + const structured = { + verdict: "needs-attention", + summary: "Two issues.", + findings: [ + { + severity: "high", + confidence: 0.92, + title: "Race condition", + file: "src/foo.js", + line_start: 11, + line_end: 11, + body: "Two writers can observe stale state.", + recommendation: "Hold the lock around the read-modify-write.", + }, + { + severity: "medium", + confidence: 0.55, + title: "Missing retries", + file: "src/bar.js", + line_start: 22, + line_end: 22, + body: "Downstream 5xx is not retried.", + recommendation: "Wrap in exponential backoff.", + }, + ], + }; + + const prData = { + headSha: "cafef00d", + files: [ + { + filename: "src/foo.js", + patch: "@@ -10,1 +10,2 @@\n a\n+b", + }, + ], + }; + + it("builds a payload file and command, and bypasses gh entirely", async () => { + const out = await preparePostInstructions("/nowhere", { + prNumber: 412, + structured, + model: { providerID: "openrouter", modelID: "anthropic/claude-opus-4-6" }, + adversarial: true, + confidenceThreshold: 0.8, + prData, + }); + assert.equal(out.prepared, true); + written.push(out.payloadPath); + + // The command must be a `gh api -X POST ...` with the payload path + // quoted. It must also contain the {owner}/{repo} placeholders so + // gh resolves them from the current repo at execution time. + assert.match(out.command, /^gh api -X POST "repos\/\{owner\}\/\{repo\}\/pulls\/412\/reviews" --input '/); + assert.match(out.command, /\.json'$/); + assert.match(out.cleanup, /^rm -f '/); + + // Classification: the high-confidence finding targets line 11 + // which is addressable (it's in the diff), so it becomes inline; + // the medium-confidence finding has no diff coverage and stays + // summary-only. + assert.equal(out.inlineCount, 1); + assert.equal(out.summaryOnlyCount, 1); + + const payload = JSON.parse(fs.readFileSync(out.payloadPath, "utf8")); + assert.equal(payload.commit_id, "cafef00d"); + assert.equal(payload.event, "COMMENT"); + assert.ok(payload.body.includes("OpenCode Adversarial Review")); + assert.ok(payload.body.includes("Needs attention")); + assert.equal(payload.comments.length, 1); + assert.equal(payload.comments[0].path, "src/foo.js"); + assert.equal(payload.comments[0].line, 11); + assert.equal(payload.comments[0].side, "RIGHT"); + assert.match(payload.comments[0].body, /Race condition/); + assert.match(payload.comments[0].body, /92%/); + }); + + it("returns { prepared: false, error } if prData fetch would fail", async () => { + // We don't inject prData, so the module would try to call `gh pr + // view` in `/definitely/not/a/repo` and fail. We only want to + // verify the failure path wraps the error without throwing. + const out = await preparePostInstructions("/definitely/not/a/repo", { + prNumber: 1, + structured, + adversarial: false, + confidenceThreshold: 0.8, + // NOTE: `prData` intentionally omitted + }); + // We can't assert the exact error message because it depends on + // whether `gh` is installed on the host, but we know `prepared` + // must be false. + assert.equal(out.prepared, false); + assert.ok(typeof out.error === "string" && out.error.length > 0); + }); + + it("handles structured=null by falling back to a rendered-text body", async () => { + const out = await preparePostInstructions("/nowhere", { + prNumber: 99, + structured: null, + rendered: "Raw review the model produced.", + adversarial: false, + confidenceThreshold: 0.8, + prData: { headSha: "cafef00d", files: [] }, + }); + assert.equal(out.prepared, true); + written.push(out.payloadPath); + const payload = JSON.parse(fs.readFileSync(out.payloadPath, "utf8")); + assert.ok(payload.body.includes("did not return structured JSON")); + assert.ok(payload.body.includes("Raw review the model produced")); + assert.deepEqual(payload.comments, []); + }); +});