diff --git a/README.md b/README.md index 38dffd65d..dd6461ebc 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ Need a realistic document to try? Copy the [product requirements document templa /plannotator-review # Review a GitHub pull request /plannotator-review # 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). diff --git a/apps/hook/server/cli.test.ts b/apps/hook/server/cli.test.ts index 97c1f399c..41081cf28 100644 --- a/apps/hook/server/cli.test.ts +++ b/apps/hook/server/cli.test.ts @@ -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 ]"); - expect(output).toContain("plannotator review [--git | --gitbutler] [--tailscale] [PR_URL]"); + expect(output).toContain("plannotator review [--git | --gitbutler] [--patch-file ] [--tailscale] [PR_URL]"); expect(output).toContain("plannotator annotate "); expect(output).toContain("[--markdown] [--no-jina]"); expect(output).toContain("plannotator annotate-last [--stdin]"); @@ -108,6 +108,7 @@ describe("CLI subcommand help", () => { "plannotator review [--git | --gitbutler]", ); expect(formatSubcommandHelp("review")).toContain("--gitbutler"); + expect(formatSubcommandHelp("review")).toContain("--patch-file "); expect(formatSubcommandHelp("review")).toContain("PR_URL"); expect(formatSubcommandHelp("annotate")).toContain("--no-jina"); expect(formatSubcommandHelp("annotate")).toContain("--require-approval"); diff --git a/apps/hook/server/cli.ts b/apps/hook/server/cli.ts index 7bbb728b4..87e4e8a56 100644 --- a/apps/hook/server/cli.ts +++ b/apps/hook/server/cli.ts @@ -141,7 +141,7 @@ export function formatTopLevelHelp(): string { " plannotator --help", " plannotator --version, -v", " plannotator [--browser ]", - " plannotator review [--git | --gitbutler] [--tailscale] [PR_URL]", + " plannotator review [--git | --gitbutler] [--patch-file ] [--tailscale] [PR_URL]", " plannotator annotate [--markdown] [--no-jina] [--tailscale] [--gate] [--json] [--hook] [--require-approval] [--result-file ]", " plannotator annotate-last [--stdin] [--tailscale] [--gate] [--json] [--hook]", " plannotator copilot-last [--gate] [--json] [--hook]", @@ -175,7 +175,7 @@ export function formatTopLevelHelp(): string { export const SUBCOMMAND_HELP: Record = { review: [ "Usage:", - " plannotator review [--git | --gitbutler] [--local | --no-local] [--tailscale] [PR_URL]", + " plannotator review [--git | --gitbutler] [--local | --no-local] [--patch-file ] [--tailscale] [PR_URL]", "", "Review local VCS changes or a GitHub/GitLab pull request in the browser.", "", @@ -184,13 +184,17 @@ export const SUBCOMMAND_HELP: Record = { " --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: [ diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 52f466861..224ed90cf 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -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; @@ -751,7 +755,18 @@ if (args[0] === "sessions") { let worktreeCleanup: (() => void | Promise) | undefined; let workspace: Awaited> | 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) { @@ -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, @@ -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; diff --git a/apps/opencode-plugin/commands.ts b/apps/opencode-plugin/commands.ts index 28c37a432..f0c6b407a 100644 --- a/apps/opencode-plugin/commands.ts +++ b/apps/opencode-plugin/commands.ts @@ -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; diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts index 6b36649e7..a38cbb286 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -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, diff --git a/packages/server/agent-review-message.test.ts b/packages/server/agent-review-message.test.ts index b30e91589..33374f82b 100644 --- a/packages/server/agent-review-message.test.ts +++ b/packages/server/agent-review-message.test.ts @@ -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, diff --git a/packages/shared/review-args.test.ts b/packages/shared/review-args.test.ts index deb73258e..47a17b2b0 100644 --- a/packages/shared/review-args.test.ts +++ b/packages/shared/review-args.test.ts @@ -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"); + }); }); diff --git a/packages/shared/review-args.ts b/packages/shared/review-args.ts index 7d80f0899..ec5926a45 100644 --- a/packages/shared/review-args.ts +++ b/packages/shared/review-args.ts @@ -3,6 +3,7 @@ import { stripWrappingQuotes } from "./resolve-file"; export interface ParsedReviewArgs { prUrl?: string; + patchFile?: string; vcsType?: VcsSelection; useLocal: boolean; } @@ -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": @@ -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, }; diff --git a/packages/shared/review-core.ts b/packages/shared/review-core.ts index 7825a39e9..0d69a33ca 100644 --- a/packages/shared/review-core.ts +++ b/packages/shared/review-core.ts @@ -42,6 +42,7 @@ export type DiffType = | `commit:${string}` | `worktree:${string}` | `gitbutler:${string}` + | "static-patch" | "p4-default" | `p4-changelist:${string}`;