Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion plugins/opencode/commands/adversarial-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,15 @@ 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 <flags> [--pr N] ['<quoted focus text>']
```
- 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).
Expand All @@ -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:
```
<opencode_post_instructions>
<pr>412</pr>
<inline_count>3</inline_count>
<summary_only_count>2</summary_only_count>
<payload_path>/tmp/opencode-plugin-cc/post-pr-412-…json</payload_path>
<command>gh api -X POST "repos/{owner}/{repo}/pulls/412/reviews" --input '/tmp/…json'</command>
<cleanup>rm -f '/tmp/…json'</cleanup>
</opencode_post_instructions>
```
- If the block is present:
1. Run the exact string inside `<command>` 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 #<pr>: <html_url>`. Include "(N inline comments)" only when `<inline_count>` is greater than zero.
4. Run the exact string inside `<cleanup>` 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 #<pr>: <stderr snippet>` instead of the success line and still run `<cleanup>`. 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 `<opencode_post_instructions>` block in stderr on THIS run. Do not "help" by reposting or retrying a previous run's block.
23 changes: 23 additions & 0 deletions plugins/opencode/commands/review.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
```
<opencode_post_instructions>
<pr>412</pr>
<inline_count>3</inline_count>
<summary_only_count>2</summary_only_count>
<payload_path>/tmp/opencode-plugin-cc/post-pr-412-…json</payload_path>
<command>gh api -X POST "repos/{owner}/{repo}/pulls/412/reviews" --input '/tmp/…json'</command>
<cleanup>rm -f '/tmp/…json'</cleanup>
</opencode_post_instructions>
```
- If the block is present:
1. Run the exact string inside `<command>` 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 #<pr>: <html_url>`. Include "(N inline comments)" only when `<inline_count>` is greater than zero.
4. Run the exact string inside `<cleanup>` 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 #<pr>: <stderr snippet>` instead of the success line and still run `<cleanup>`. 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 `<opencode_post_instructions>` block in stderr on THIS run. Do not "help" by reposting or retrying a previous run's block.
208 changes: 126 additions & 82 deletions plugins/opencode/scripts/lib/pr-comments.mjs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 <file>` 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,
Expand All @@ -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,
Expand All @@ -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 = [
"<opencode_post_instructions>",
`<pr>${prepared.prNumber}</pr>`,
`<inline_count>${prepared.inlineCount}</inline_count>`,
`<summary_only_count>${prepared.summaryOnlyCount}</summary_only_count>`,
`<payload_path>${prepared.payloadPath}</payload_path>`,
`<command>${prepared.command}</command>`,
`<cleanup>${prepared.cleanup}</cleanup>`,
"</opencode_post_instructions>",
"",
];
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) => {
Expand Down Expand Up @@ -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",
Expand All @@ -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 };
}

// ---------------------------------------------------------------------
Expand All @@ -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.
*
Expand Down
Loading