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 @@ -109,6 +109,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 @@ -30,7 +30,7 @@ describe("CLI top-level help", () => {
expect(output).toContain("plannotator --help");
expect(output).toContain("plannotator --version, -v");
expect(output).toContain("plannotator [--browser <name>]");
expect(output).toContain("plannotator review [--git | --gitbutler] [--tailscale] [PR_URL]");
expect(output).toContain("plannotator review [--git | --gitbutler] [--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 @@ -108,6 +108,7 @@ describe("CLI subcommand help", () => {
"plannotator review [--git | --gitbutler]",
);
expect(formatSubcommandHelp("review")).toContain("--gitbutler");
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] [--tailscale] [PR_URL]",
" plannotator review [--git | --gitbutler] [--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] [--local | --no-local] [--tailscale] [PR_URL]",
" plannotator review [--git | --gitbutler] [--local | --no-local] [--patch-file <path | ->] [--tailscale] [PR_URL]",
"",
"Review local VCS changes or a GitHub/GitLab pull request in the browser.",
"",
Expand All @@ -184,13 +184,17 @@ export const SUBCOMMAND_HELP: Record<string, string> = {
" --gitbutler Force GitButler as the VCS (requires but 0.21.0+)",
" --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)",
" PR_URL GitHub PR or GitLab MR URL to review",
"",
" --patch-file cannot be combined with PR_URL.",
"",
"Examples:",
" plannotator review",
" plannotator review --git",
" plannotator review --gitbutler",
" plannotator review --patch-file reading.diff",
" plannotator review https://github.com/owner/repo/pull/123",
].join("\n"),
annotate: [
Expand Down
23 changes: 21 additions & 2 deletions apps/hook/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,10 @@ if (args[0] === "sessions") {

const reviewArgs = parseReviewArgs(args.slice(1));
const urlArg = reviewArgs.prUrl;
if (reviewArgs.patchFile && urlArg) {
console.error("--patch-file cannot be combined with a PR/MR URL");
process.exit(1);
}
const isPRMode = urlArg !== undefined;
const useLocal = isPRMode && reviewArgs.useLocal;

Expand All @@ -751,7 +755,18 @@ if (args[0] === "sessions") {
let worktreeCleanup: (() => void | Promise<void>) | undefined;
let workspace: Awaited<ReturnType<typeof buildLocalWorkspaceReview>> | undefined;

if (isPRMode) {
if (reviewArgs.patchFile) {
try {
rawPatch = reviewArgs.patchFile === "-"
? await Bun.stdin.text()
: await Bun.file(reviewArgs.patchFile).text();
gitRef = reviewArgs.patchFile === "-" ? "stdin patch" : reviewArgs.patchFile;
initialDiffType = "static-patch";
} catch (err) {
console.error(`Failed to read patch file: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
} else if (isPRMode) {
// --- PR Review Mode ---
const prRef = parsePRUrl(urlArg);
if (!prRef) {
Expand Down Expand Up @@ -1036,7 +1051,7 @@ if (args[0] === "sessions") {
gitRef,
error: diffError,
origin: detectedOrigin,
diffType: workspace ? (initialDiffType ?? workspace.diffType) : gitContext ? (initialDiffType ?? "unstaged") : undefined,
diffType: workspace ? (initialDiffType ?? workspace.diffType) : gitContext ? (initialDiffType ?? "unstaged") : initialDiffType,
gitContext,
initialFingerprint,
prMetadata,
Expand Down Expand Up @@ -1720,6 +1735,10 @@ if (args[0] === "sessions") {
inputJson,
);
const reviewArgs = parseReviewArgs(typeof input.arguments === "string" ? input.arguments : "");
if (reviewArgs.patchFile) {
console.error("--patch-file is only supported by the direct plannotator review CLI");
process.exit(1);
}
const urlArg = reviewArgs.prUrl;
const isPRMode = urlArg !== undefined;

Expand Down
4 changes: 4 additions & 0 deletions apps/opencode-plugin/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ export async function handleReviewCommand(

// @ts-ignore - Event properties contain arguments
const reviewArgs = parseReviewArgs(event.properties?.arguments || "");
if (reviewArgs.patchFile) {
client.app.log({ level: "error", message: "--patch-file is only supported by the direct plannotator review CLI" });
return;
}
const urlArg = reviewArgs.prUrl;
const isPRMode = urlArg !== undefined;

Expand Down
4 changes: 4 additions & 0 deletions apps/pi-extension/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,10 @@ export default function plannotator(pi: ExtensionAPI): void {
try {
const { parseReviewArgs } = await import("./generated/review-args.ts");
const reviewArgs = parseReviewArgs(args ?? "");
if (reviewArgs.patchFile) {
ctx.ui.notify("--patch-file is only supported by the direct plannotator review CLI", "error");
return;
}
const session = await startCodeReviewBrowserSession(ctx, {
prUrl: reviewArgs.prUrl,
vcsType: reviewArgs.vcsType,
Expand Down
12 changes: 12 additions & 0 deletions packages/server/agent-review-message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ describe("buildAgentReviewUserMessage", () => {
expect(message).toContain(patch);
});

test("uses the inline patch as Ask AI context for static patch reviews", () => {
// given
const diffType = "static-patch";

// when
const message = buildAgentReviewUserMessage(patch, diffType, undefined, undefined, true);

// then
expect(message).toContain(patch);
expect(message).not.toContain("working tree");
});

test("treats the inline GitButler patch as authoritative", () => {
const message = buildAgentReviewUserMessage(
patch,
Expand Down
26 changes: 26 additions & 0 deletions packages/shared/review-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,30 @@ describe("parseReviewArgs", () => {
useLocal: true,
});
});

test("parses one external patch file", () => {
// given
const input = ["--patch-file", "reading.diff"];

// when
const result = parseReviewArgs(input);

// then
expect(result.patchFile).toBe("reading.diff");
expect(result.prUrl).toBeUndefined();
});

test("rejects a missing or duplicate patch file", () => {
// given
const missingPath = ["--patch-file"];
const duplicatePath = ["--patch-file", "one.diff", "--patch-file", "two.diff"];

// when
const parseMissingPath = () => parseReviewArgs(missingPath);
const parseDuplicatePath = () => parseReviewArgs(duplicatePath);

// then
expect(parseMissingPath).toThrow("--patch-file requires a path or -");
expect(parseDuplicatePath).toThrow("--patch-file may only be specified once");
});
});
14 changes: 14 additions & 0 deletions packages/shared/review-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { stripWrappingQuotes } from "./resolve-file";

export interface ParsedReviewArgs {
prUrl?: string;
patchFile?: string;
vcsType?: VcsSelection;
useLocal: boolean;
}
Expand All @@ -16,6 +17,18 @@ export function parseReviewArgs(input: string | string[]): ParsedReviewArgs {
let useLocal = true;
const positional: string[] = [];

const patchFileIndex = tokens.indexOf("--patch-file");
const patchFile = patchFileIndex === -1 ? undefined : tokens[patchFileIndex + 1];
if (patchFileIndex !== -1) {
if (!patchFile || patchFile.startsWith("--")) {
throw new Error("--patch-file requires a path or -");
}
if (tokens.lastIndexOf("--patch-file") !== patchFileIndex) {
throw new Error("--patch-file may only be specified once");
}
tokens.splice(patchFileIndex, 2);
}

for (const token of tokens) {
switch (token) {
case "--git":
Expand All @@ -39,6 +52,7 @@ export function parseReviewArgs(input: string | string[]): ParsedReviewArgs {
const target = positional[0];
return {
prUrl: target && isReviewUrl(target) ? target : undefined,
patchFile,
vcsType,
useLocal,
};
Expand Down
1 change: 1 addition & 0 deletions packages/shared/review-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export type DiffType =
| `commit:${string}`
| `worktree:${string}`
| `gitbutler:${string}`
| "static-patch"
| "p4-default"
| `p4-changelist:${string}`;

Expand Down