Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ Need a realistic document to try? Copy the [product requirements document templa
/plannotator-review <github-pr-url> # Review a GitHub pull request
/plannotator-review <gitlab-mr-url> # Review a GitLab merge request
plannotator review --gitbutler # Review an active GitButler workspace
plannotator review --patch-file reading.diff # Review a static caller-supplied unified diff
```

GitButler users can review the whole workspace, one stack, or one branch layer. See the [GitButler workflow guide](https://docs.plannotator.ai/open-source/workflows/gitbutler).
Expand Down
3 changes: 2 additions & 1 deletion apps/hook/server/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe("CLI top-level help", () => {
expect(output).toContain("plannotator [--browser <name>]");
// Deliberate literal: the review usage line is API surface for agents
// probing --help (and for the knowledge-skill freshness guard).
expect(output).toContain("plannotator review [--git | --gitbutler] [--base <ref>] [--diff-type <type>] [--tailscale] [PR_URL]");
expect(output).toContain("plannotator review [--git | --gitbutler] [--base <ref>] [--diff-type <type>] [--patch-file <path | ->] [--tailscale] [PR_URL]");
expect(output).toContain("plannotator annotate <file.md | file.txt | file.html | https://... | folder/>");
expect(output).toContain("[--markdown] [--no-jina]");
expect(output).toContain("plannotator annotate-last [--stdin]");
Expand Down Expand Up @@ -113,6 +113,7 @@ describe("CLI subcommand help", () => {
// Deliberate literals: the open-state flag tokens are API surface.
expect(formatSubcommandHelp("review")).toContain("--base <ref>");
expect(formatSubcommandHelp("review")).toContain("--diff-type <type>");
expect(formatSubcommandHelp("review")).toContain("--patch-file <path | ->");
expect(formatSubcommandHelp("review")).toContain("PR_URL");
expect(formatSubcommandHelp("annotate")).toContain("--no-jina");
expect(formatSubcommandHelp("annotate")).toContain("--require-approval");
Expand Down
8 changes: 6 additions & 2 deletions apps/hook/server/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export function formatTopLevelHelp(): string {
" plannotator --help",
" plannotator --version, -v",
" plannotator [--browser <name>]",
" plannotator review [--git | --gitbutler] [--base <ref>] [--diff-type <type>] [--tailscale] [PR_URL]",
" plannotator review [--git | --gitbutler] [--base <ref>] [--diff-type <type>] [--patch-file <path | ->] [--tailscale] [PR_URL]",
" plannotator annotate <file.md | file.txt | file.html | https://... | folder/> [--markdown] [--no-jina] [--tailscale] [--gate] [--json] [--hook] [--require-approval] [--result-file <path>]",
" plannotator annotate-last [--stdin] [--tailscale] [--gate] [--json] [--hook]",
" plannotator copilot-last [--gate] [--json] [--hook]",
Expand Down Expand Up @@ -175,7 +175,7 @@ export function formatTopLevelHelp(): string {
export const SUBCOMMAND_HELP: Record<string, string> = {
review: [
"Usage:",
" plannotator review [--git | --gitbutler] [--base <ref>] [--diff-type <type>] [--local | --no-local] [--tailscale] [--json] [PR_URL]",
" plannotator review [--git | --gitbutler] [--base <ref>] [--diff-type <type>] [--local | --no-local] [--patch-file <path | ->] [--tailscale] [--json] [PR_URL]",
"",
"Review local VCS changes or a GitHub/GitLab pull request in the browser.",
"",
Expand All @@ -190,10 +190,13 @@ export const SUBCOMMAND_HELP: Record<string, string> = {
" Session-only; never changes your saved defaults. Git only.",
" --local For PR review, prepare a local checkout for full file access (default)",
" --no-local For PR review, skip the local checkout (diff only)",
" --patch-file Display a static unified diff from a file, or use - for stdin",
" --tailscale Publish the loopback session over your tailnet via tailscale serve (HTTPS)",
" --json Emit one decision/message JSON record instead of plaintext",
" PR_URL GitHub PR or GitLab MR URL to review",
"",
" --patch-file cannot be combined with PR_URL.",
"",
"JSON output:",
' { "decision": "approved" | "annotated" | "dismissed", "message": string }',
" message is the rendered plaintext output without its final console newline:",
Expand All @@ -206,6 +209,7 @@ export const SUBCOMMAND_HELP: Record<string, string> = {
" plannotator review --git",
" plannotator review --gitbutler",
" plannotator review --base feature/part-1 # review one layer of a stacked branch",
" plannotator review --patch-file reading.diff",
" plannotator review https://github.com/owner/repo/pull/123",
].join("\n"),
annotate: [
Expand Down
41 changes: 38 additions & 3 deletions apps/hook/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,25 @@ if (helpSubcommand) {

exitOnUnknownSubcommand(args);

// Read a caller-supplied unified diff for static patch mode (`--patch-file`).
// "-" means stdin; file paths resolve against the given cwd. A read failure
// is a startup failure: exit 1 like every other review startup failure.
async function readStaticPatch(patchFile: string, cwd: string): Promise<{ rawPatch: string; gitRef: string }> {
try {
const rawPatch = patchFile === "-"
? await Bun.stdin.text()
: await Bun.file(path.resolve(cwd, patchFile)).text();
if (!rawPatch.trim()) {
console.error("Static patch review requires non-empty unified-diff content.");
process.exit(1);
}
return { rawPatch, gitRef: patchFile === "-" ? "stdin patch" : patchFile };
} catch (err) {
console.error(`Failed to read patch file: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}

if (args[0] === "uninstall") {
let options: ReturnType<typeof parseUninstallOptions>;
try {
Expand Down Expand Up @@ -836,7 +855,12 @@ if (args[0] === "sessions") {
let worktreeCleanup: (() => void | Promise<void>) | undefined;
let workspace: Awaited<ReturnType<typeof buildLocalWorkspaceReview>> | undefined;

if (isPRMode) {
if (reviewArgs.patchFile) {
const patch = await readStaticPatch(reviewArgs.patchFile, process.env.PLANNOTATOR_CWD || process.cwd());
rawPatch = patch.rawPatch;
gitRef = patch.gitRef;
initialDiffType = "static-patch";
} else if (isPRMode) {
// --- PR Review Mode ---
// The base comes from the pull request — the open-state flags always
// error here (validated before any auth check or platform fetch).
Expand Down Expand Up @@ -1154,7 +1178,7 @@ if (args[0] === "sessions") {
error: diffError,
origin: detectedOrigin,
project: reviewProject,
diffType: workspace ? (initialDiffType ?? workspace.diffType) : gitContext ? (initialDiffType ?? "unstaged") : undefined,
diffType: workspace ? (initialDiffType ?? workspace.diffType) : gitContext ? (initialDiffType ?? "unstaged") : initialDiffType,
gitContext,
initialBase: initialBaseFromFlags,
initialBaseExplicit: initialBaseFromFlags !== undefined,
Expand Down Expand Up @@ -1863,7 +1887,18 @@ if (args[0] === "sessions") {
let workspace: Awaited<ReturnType<typeof buildLocalWorkspaceReview>> | undefined;
let agentCwd: string | undefined;

if (isPRMode) {
if (reviewArgs.patchFile) {
if (reviewArgs.patchFile === "-") {
// The bridge's stdin carries the input JSON; a stdin patch has no
// channel. Direct `plannotator review --patch-file -` remains the way.
console.error("--patch-file - (stdin) is not available through the OpenCode bridge; pass a file path");
process.exit(1);
}
const patch = await readStaticPatch(reviewArgs.patchFile, process.env.PLANNOTATOR_CWD || process.cwd());
rawPatch = patch.rawPatch;
gitRef = patch.gitRef;
userDiffType = "static-patch";
} else if (isPRMode) {
await resolveCliReviewOpenState(reviewArgs, {
isPRMode: true,
isWorkspace: false,
Expand Down
22 changes: 22 additions & 0 deletions apps/marketing/src/content/docs/commands/code-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,28 @@ PR review uses the `gh` CLI for authentication, so private repos work automatica

GitLab merge request URLs are also supported when the `glab` CLI is installed and authenticated.

**Review a patch file, with no repository:**

```
plannotator review --patch-file reading.diff
curl -s https://example.com/change.diff | plannotator review --patch-file -
```

`--patch-file` opens the review UI against a caller-supplied unified diff — a
patch from an email, a paste, a CI artifact, or a remote agent — with no Git
repo, no worktree and no VCS detection. Use `-` to read the patch from stdin.

The patch is the whole session, so everything that would read a working tree is
switched off: no staging, no hunk-context expansion, no "Open in editor" or code
navigation, no diff-type or base switching, no Git status or commit panels, and
no diff-staleness refresh. Annotating, Ask AI, Guided Review and submitting
feedback all work as usual, and the header names the patch instead of a branch.

Because it replaces VCS detection entirely, `--patch-file` cannot be combined
with a PR/MR URL, `--base`, `--diff-type`, `--git`/`--gitbutler`, or
`--local`/`--no-local`; each combination is a startup error naming the conflict,
as is an empty or unreadable patch.

## How it works

**Local review:**
Expand Down
9 changes: 7 additions & 2 deletions apps/pi-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ Use these inside `instructions` strings. They render once, when the phase is ent

### Code review

Run `/plannotator-review` to open your current VCS changes in the code review UI. Annotate specific lines, switch between the modes supported by the detected Git, GitButler, or JJ provider, and submit feedback that gets sent to the agent. Pass `--git` or `--gitbutler` to force that provider; GitButler requires `but` 0.21.0 or newer on `PATH`.
Run `/plannotator-review` to open your current VCS changes in the code review UI. Annotate specific lines, switch between the modes supported by the detected Git, GitButler, or JJ provider, and submit feedback that gets sent to the agent. Pass `--git` or `--gitbutler` to force that provider; GitButler requires `but` 0.21.0 or newer on `PATH`. Pass `--patch-file <path>` to review a static caller-supplied unified diff without a repository.

### Shared Plannotator event API

Expand All @@ -213,7 +213,12 @@ Supported actions and payloads:

- `plan-review`: `{ planContent, planFilePath? }`
- `review-status`: `{ reviewId }`
- `code-review`: `{ cwd?, defaultBranch?, diffType? }`
- `code-review`: `{ cwd?, defaultBranch?, diffType?, vcsType?, useLocal?, prUrl?, patchFile? }`

Pass `patchFile` (path read at request time, resolved against `cwd`) to
review a caller-supplied patch without a local repository — the review opens
in static-patch mode with no file-system affordances that would need the
worktree. `patchFile` is mutually exclusive with `prUrl`.
- `annotate`: `{ filePath, markdown?, mode?, folderPath? }`
- `annotate-last`: `{ markdown? }`
- `archive`: `{ customPlanPath? }`
Expand Down
1 change: 1 addition & 0 deletions apps/pi-extension/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,7 @@ export default function plannotator(pi: ExtensionAPI): void {
}
const session = await startCodeReviewBrowserSession(ctx, {
prUrl: reviewArgs.prUrl,
patchFile: reviewArgs.patchFile,
vcsType: reviewArgs.vcsType,
useLocal: reviewArgs.useLocal,
// --base / --diff-type: session-only open state from user flags.
Expand Down
28 changes: 28 additions & 0 deletions apps/pi-extension/plannotator-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ type CodeReviewOptions = {
prUrl?: string;
vcsType?: VcsSelection;
useLocal?: boolean;
/**
* Path to a unified-diff file to review without a repo — the file is read
* once at call time (resolved relative to the caller's cwd, not ctx.cwd).
* Mutually exclusive with `prUrl` and local/VCS modes. The path doubles as
* the display label in the review header.
*/
patchFile?: string;
/**
* `defaultBranch` / `diffType` came from user CLI flags (`--base` /
* `--diff-type` on /plannotator-review): validate strictly (provider
Expand Down Expand Up @@ -583,6 +590,27 @@ async function createCodeReviewBrowserSession(
worktreeCleanup = undefined;
}
}
} else if (options.patchFile !== undefined) {
// --- Static Patch Mode ---
// Caller-supplied unified diff, reviewed without any repository: the
// server serves rawPatch as-is, workspace undefined, gitContext undefined,
// diffType "static-patch" — identical to the direct CLI's --patch-file
// path. No refresh: there is no live tree to recompute against; the
// initial patch is the session's whole content.
// `-` means stdin, which only the direct CLI has: this host reaches the
// review through an extension event with no stdin of its own, so refuse
// rather than reading a file literally named "-".
if (options.patchFile === "-") {
throw new Error(
"--patch-file - (stdin) is not available here; pass a file path instead",
);
}
rawPatch = readFileSync(resolve(options.cwd ?? ctx.cwd, options.patchFile), "utf-8");
if (!rawPatch.trim()) {
throw new Error("Static patch review requires non-empty unified-diff content.");
}
gitRef = options.patchFile;
diffType = "static-patch";
} else {
// --- Local Review Mode ---
const cwd = options.cwd ?? ctx.cwd;
Expand Down
5 changes: 5 additions & 0 deletions apps/pi-extension/plannotator-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ export interface PlannotatorCodeReviewPayload {
useLocal?: boolean;
cwd?: string;
prUrl?: string;
/** Path to a unified-diff file — static patch mode (no repo required).
* Mutually exclusive with `prUrl`. Read by the host at request time,
* resolved against payload.cwd (or the session cwd). */
patchFile?: string;
}

export interface PlannotatorCodeReviewResult {
Expand Down Expand Up @@ -376,6 +380,7 @@ export function registerPlannotatorEventListeners(
vcsType: request.payload?.vcsType,
useLocal: request.payload?.useLocal,
prUrl: request.payload?.prUrl,
patchFile: request.payload?.patchFile,
});
request.respond({ status: "handled", result });
return;
Expand Down
Loading