From 216815c12e93e41682c8f4bfef4fe5e22291653c Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 18 Mar 2026 06:01:40 -0700 Subject: [PATCH] feat: PR review support via GitHub URL (#324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(review): add runtime-agnostic PR provider Introduces `packages/shared/pr-provider.ts` with a `PRRuntime` interface (same pattern as ReviewGitRuntime in review-core.ts) and GitHub PR operations: URL parsing, auth check, diff/metadata fetching, and file content retrieval via `gh` CLI. `packages/server/pr.ts` is the Bun wrapper that pre-binds the runtime, matching the git.ts pattern. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(review): add PR mode to review server When `prMetadata` is provided to `startReviewServer`, the server enters PR mode: `/api/diff` includes PR metadata and omits gitContext, `/api/diff/switch` and `/api/git-add` return 400 (not applicable), and `/api/file-content` fetches from GitHub API using base/head SHAs instead of local git. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(review): PR review flow for Claude Code and OpenCode Detects URL argument in `/plannotator-review` command. When a GitHub PR URL is provided, fetches diff and metadata via `gh` CLI and starts the review server in PR mode. Local review mode is unchanged when no URL is passed. Updates slash command definitions to pass $ARGUMENTS through. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(review): PR metadata display in review UI Shows "PR Review" badge, PR title with link, and owner/repo in the header when reviewing a pull request. Diff switcher and staging controls auto-hide since gitContext is omitted in PR mode. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(review): contextualized PR feedback for agent In PR mode, the feedback markdown now includes PR metadata (repo, number, title, branches, URL) so the agent has full context about the remote PR being reviewed. Removes the aggressive "address all of them" instruction in PR mode since the content is self-explanatory. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(review): extract exportReviewFeedback + add tests Moves the pure feedback construction function from App.tsx to utils/exportFeedback.ts so it can be unit tested. Drops the unused `files` parameter. 11 tests covering local/PR headers, annotation grouping, sorting, file-scope ordering, and suggested code rendering. Co-Authored-By: Claude Opus 4.6 (1M context) * test(review): strengthen PR/local boundary tests Replaces shallow header checks with comprehensive boundary assertions: local mode must never contain PR-specific content (repo, URL, branches), PR mode must include all context fields and exclude the generic header. Covers null/undefined prMetadata edge cases. Co-Authored-By: Claude Opus 4.6 (1M context) * test(review): remove redundant empty-annotations test Covered by the "no annotations: returns generic empty regardless of prMetadata" test which checks all three cases (no arg, null, PR mode). Co-Authored-By: Claude Opus 4.6 (1M context) * fix(review): guard fetchPR, fix UTF-8 decoding, correct useCallback deps - Wrap fetchPR() in try/catch in both hook and OpenCode entry points so network/auth errors show a clean message instead of a stack trace - Replace atob() with Buffer.from() for UTF-8 correct base64 decoding of PR file content from GitHub API - Fix stale useCallback deps in handleCopyFeedback (files → prMetadata) Co-Authored-By: Claude Opus 4.6 (1M context) * fix(install): add $ARGUMENTS to review command for PR URL support Install scripts were writing the review slash command without $ARGUMENTS, so PR URLs passed to /plannotator-review were silently dropped. Also switches PS1 heredoc to single-quoted to prevent $ARGUMENTS from being expanded as a PowerShell variable. Co-Authored-By: Claude Opus 4.6 (1M context) * docs: add PR review support to docs and READMEs Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- README.md | 3 +- apps/hook/README.md | 2 +- apps/hook/commands/plannotator-review.md | 4 +- apps/hook/server/index.ts | 61 +++++-- .../src/content/docs/commands/code-review.md | 33 +++- .../docs/getting-started/quickstart.md | 2 +- .../src/content/docs/guides/claude-code.md | 8 +- .../src/content/docs/guides/opencode.md | 8 +- apps/opencode-plugin/commands.ts | 57 +++++- .../commands/plannotator-review.md | 2 +- packages/review-editor/App.tsx | 118 ++++-------- .../utils/exportFeedback.test.ts | 138 ++++++++++++++ .../review-editor/utils/exportFeedback.ts | 82 +++++++++ packages/server/package.json | 3 +- packages/server/pr.ts | 55 ++++++ packages/server/review.ts | 44 ++++- packages/shared/package.json | 3 +- packages/shared/pr-provider.ts | 171 ++++++++++++++++++ scripts/install.cmd | 4 +- scripts/install.ps1 | 8 +- scripts/install.sh | 4 +- 21 files changed, 676 insertions(+), 134 deletions(-) create mode 100644 packages/review-editor/utils/exportFeedback.test.ts create mode 100644 packages/review-editor/utils/exportFeedback.ts create mode 100644 packages/server/pr.ts create mode 100644 packages/shared/pr-provider.ts diff --git a/README.md b/README.md index cc6cfe22..e4fcac1c 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Interactive Plan & Code Review for AI Coding Agents. Mark up and refine your pla - +
Visual Plan ReviewBuilt-in hookApprove or deny agent plans with inline annotations
Plan DiffAutomaticSee what changed when the agent revises a plan
Code Review/plannotator-reviewReview git diffs with line-level annotations
Code Review/plannotator-reviewReview git diffs or GitHub PRs with line-level annotations
Annotate Any File/plannotator-annotateAnnotate any markdown file and send feedback to your agent
Annotate Last Message/plannotator-lastAnnotate the agent's last response and send structured feedback
@@ -143,6 +143,7 @@ irm https://plannotator.ai/install.ps1 | iex ``` !plannotator review # Code review for current changes +!plannotator review # Review a GitHub pull request !plannotator annotate file.md # Annotate a markdown file !plannotator last # Annotate the last agent message ``` diff --git a/apps/hook/README.md b/apps/hook/README.md index 94a43788..d8c68a54 100644 --- a/apps/hook/README.md +++ b/apps/hook/README.md @@ -108,7 +108,7 @@ The plugin registers three slash commands: | Command | Description | |---------|-------------| -| `/plannotator-review` | Open code review UI for uncommitted changes | +| `/plannotator-review` | Open code review UI for uncommitted changes or a GitHub PR | | `/plannotator-annotate ` | Annotate any markdown file | | `/plannotator-last` | Annotate the agent's last message | diff --git a/apps/hook/commands/plannotator-review.md b/apps/hook/commands/plannotator-review.md index 433fa132..d5c57633 100644 --- a/apps/hook/commands/plannotator-review.md +++ b/apps/hook/commands/plannotator-review.md @@ -1,11 +1,11 @@ --- -description: Open interactive code review for current changes +description: Open interactive code review for current changes or a PR URL allowed-tools: Bash(plannotator:*) --- ## Code Review Feedback -!`plannotator review` +!`plannotator review $ARGUMENTS` ## Your task diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 4d763a06..ab5390cf 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -44,6 +44,7 @@ import { handleAnnotateServerReady, } from "@plannotator/server/annotate"; import { getGitContext, runGitDiff } from "@plannotator/server/git"; +import { parsePRUrl, checkGhAuth, fetchPR } from "@plannotator/server/pr"; import { writeRemoteShareLink } from "@plannotator/server/share-url"; import { resolveMarkdownFile } from "@plannotator/server/resolve-file"; import { registerSession, unregisterSession, listSessions } from "@plannotator/server/sessions"; @@ -135,25 +136,61 @@ if (args[0] === "sessions") { // CODE REVIEW MODE // ============================================ - // Get git context (branches, available diff options) - const gitContext = await getGitContext(); + const urlArg = args[1]; + const isPRMode = urlArg?.startsWith("http://") || urlArg?.startsWith("https://"); - // Run git diff HEAD (uncommitted changes - default) - const { patch: rawPatch, label: gitRef, error: diffError } = await runGitDiff( - "uncommitted", - gitContext.defaultBranch - ); + let rawPatch: string; + let gitRef: string; + let diffError: string | undefined; + let gitContext: Awaited> | undefined; + let prMetadata: Awaited>["metadata"] | undefined; + + if (isPRMode) { + // --- PR Review Mode --- + const prRef = parsePRUrl(urlArg); + if (!prRef) { + console.error(`Invalid PR URL: ${urlArg}`); + console.error("Supported formats: https://github.com/owner/repo/pull/123"); + process.exit(1); + } + + try { + await checkGhAuth(); + } catch (err) { + console.error(err instanceof Error ? err.message : "GitHub CLI auth check failed"); + process.exit(1); + } + + console.error(`Fetching PR #${prRef.number} from ${prRef.owner}/${prRef.repo}...`); + try { + const pr = await fetchPR(prRef); + rawPatch = pr.rawPatch; + gitRef = `PR #${prRef.number}`; + prMetadata = pr.metadata; + } catch (err) { + console.error(err instanceof Error ? err.message : "Failed to fetch PR"); + process.exit(1); + } + } else { + // --- Local Review Mode --- + gitContext = await getGitContext(); + const diffResult = await runGitDiff("uncommitted", gitContext.defaultBranch); + rawPatch = diffResult.patch; + gitRef = diffResult.label; + diffError = diffResult.error; + } const reviewProject = (await detectProjectName()) ?? "_unknown"; - // Start review server (even if empty - user can switch diff types) + // Start review server (even if empty - user can switch diff types in local mode) const server = await startReviewServer({ rawPatch, gitRef, error: diffError, origin: "claude-code", - diffType: "uncommitted", + diffType: isPRMode ? undefined : "uncommitted", gitContext, + prMetadata, sharingEnabled, shareBaseUrl, htmlContent: reviewHtmlContent, @@ -173,7 +210,7 @@ if (args[0] === "sessions") { mode: "review", project: reviewProject, startedAt: new Date().toISOString(), - label: `review-${reviewProject}`, + label: isPRMode ? `pr-review-${prMetadata!.owner}/${prMetadata!.repo}#${prMetadata!.number}` : `review-${reviewProject}`, }); // Wait for user feedback @@ -190,7 +227,9 @@ if (args[0] === "sessions") { console.log("Code review completed — no changes requested."); } else { console.log(result.feedback); - console.log("\nThe reviewer has identified issues above. You must address all of them."); + if (!isPRMode) { + console.log("\nThe reviewer has identified issues above. You must address all of them."); + } } process.exit(0); diff --git a/apps/marketing/src/content/docs/commands/code-review.md b/apps/marketing/src/content/docs/commands/code-review.md index fa13ec10..4a6b6c0d 100644 --- a/apps/marketing/src/content/docs/commands/code-review.md +++ b/apps/marketing/src/content/docs/commands/code-review.md @@ -1,25 +1,33 @@ --- title: "Code Review" -description: "The /plannotator-review slash command for reviewing uncommitted code changes." +description: "The /plannotator-review slash command for reviewing uncommitted code changes or GitHub pull requests." sidebar: order: 11 section: "Commands" --- -The `/plannotator-review` command opens an interactive code review UI for your uncommitted changes. +The `/plannotator-review` command opens an interactive code review UI for your uncommitted changes or a GitHub pull request. ## Usage -In your Claude Code or OpenCode session, type: +**Review uncommitted changes:** ``` /plannotator-review ``` -The command runs `git diff` to capture uncommitted changes, then opens a diff viewer in your browser. +**Review a GitHub pull request:** + +``` +/plannotator-review https://github.com/owner/repo/pull/123 +``` + +PR review uses the `gh` CLI for authentication, so private repos work automatically if you're authenticated with `gh auth login`. ## How it works +**Local review:** + ``` User runs /plannotator-review ↓ @@ -35,6 +43,23 @@ Send Feedback → feedback sent to agent Approve → "LGTM" sent to agent ``` +**PR review:** + +``` +User runs /plannotator-review + ↓ +Agent runs: plannotator review + ↓ +gh CLI fetches PR diff and metadata + ↓ +Review server starts, opens browser with diff viewer + ↓ +User annotates code, provides feedback + ↓ +Send Feedback → PR context included in feedback +Approve → "LGTM" sent to agent +``` + ## The diff viewer The review UI shows your changes in a familiar diff format: diff --git a/apps/marketing/src/content/docs/getting-started/quickstart.md b/apps/marketing/src/content/docs/getting-started/quickstart.md index 4a7e07b0..ce46f9d8 100644 --- a/apps/marketing/src/content/docs/getting-started/quickstart.md +++ b/apps/marketing/src/content/docs/getting-started/quickstart.md @@ -60,6 +60,6 @@ After approval, Claude implements the plan. After feedback, Claude revises the p Beyond plan review, Plannotator provides slash commands you can use anytime during a session: -- **`/plannotator-review`** — Review uncommitted code changes. See [Code Review](/docs/commands/code-review/). +- **`/plannotator-review`** — Review uncommitted code changes, or pass a GitHub PR URL to review a pull request. See [Code Review](/docs/commands/code-review/). - **`/plannotator-annotate `** — Annotate any markdown file. See [Annotate](/docs/commands/annotate/). - **`/plannotator-last`** — Annotate the agent's last message. See [Annotate Last](/docs/commands/annotate-last/). diff --git a/apps/marketing/src/content/docs/guides/claude-code.md b/apps/marketing/src/content/docs/guides/claude-code.md index 124325a6..e0125559 100644 --- a/apps/marketing/src/content/docs/guides/claude-code.md +++ b/apps/marketing/src/content/docs/guides/claude-code.md @@ -73,7 +73,13 @@ The plugin registers three slash commands that work inside your Claude Code sess ### `/plannotator-review` -Opens a code review UI for your uncommitted `git diff`. See the [code review docs](/docs/commands/code-review/) for details. +Opens a code review UI for your uncommitted `git diff`. Also supports reviewing GitHub pull requests: + +``` +/plannotator-review https://github.com/owner/repo/pull/123 +``` + +See the [code review docs](/docs/commands/code-review/) for details. ### `/plannotator-annotate ` diff --git a/apps/marketing/src/content/docs/guides/opencode.md b/apps/marketing/src/content/docs/guides/opencode.md index f46373f2..94f4fb21 100644 --- a/apps/marketing/src/content/docs/guides/opencode.md +++ b/apps/marketing/src/content/docs/guides/opencode.md @@ -44,7 +44,13 @@ The plugin registers three slash commands: ### `/plannotator-review` -Opens a code review UI for uncommitted changes. Requires the CLI to be installed (the slash command runs `plannotator review` under the hood). +Opens a code review UI for uncommitted changes. Also supports reviewing GitHub pull requests: + +``` +/plannotator-review https://github.com/owner/repo/pull/123 +``` + +Requires the CLI to be installed (the slash command runs `plannotator review` under the hood). ### `/plannotator-annotate ` diff --git a/apps/opencode-plugin/commands.ts b/apps/opencode-plugin/commands.ts index 94fed581..964feff0 100644 --- a/apps/opencode-plugin/commands.ts +++ b/apps/opencode-plugin/commands.ts @@ -14,6 +14,7 @@ import { handleAnnotateServerReady, } from "@plannotator/server/annotate"; import { getGitContext, runGitDiffWithContext } from "@plannotator/server/git"; +import { parsePRUrl, checkGhAuth, fetchPR } from "@plannotator/server/pr"; import { resolveMarkdownFile } from "@plannotator/server/resolve-file"; /** Shared dependencies injected by the plugin */ @@ -32,21 +33,59 @@ export async function handleReviewCommand( ) { const { client, reviewHtmlContent, getSharingEnabled, getShareBaseUrl, directory } = deps; - client.app.log({ level: "info", message: "Opening code review UI..." }); + // @ts-ignore - Event properties contain arguments + const urlArg: string = event.properties?.arguments || ""; + const isPRMode = urlArg?.startsWith("http://") || urlArg?.startsWith("https://"); - const gitContext = await getGitContext(directory); - const { patch: rawPatch, label: gitRef, error: diffError } = await runGitDiffWithContext( - "uncommitted", - gitContext - ); + let rawPatch: string; + let gitRef: string; + let diffError: string | undefined; + let gitContext: Awaited> | undefined; + let prMetadata: Awaited>["metadata"] | undefined; + + if (isPRMode) { + client.app.log({ level: "info", message: "Fetching PR for review..." }); + + const prRef = parsePRUrl(urlArg); + if (!prRef) { + client.app.log({ level: "error", message: `Invalid PR URL: ${urlArg}` }); + return; + } + + try { + await checkGhAuth(); + } catch (err) { + client.app.log({ level: "error", message: err instanceof Error ? err.message : "GitHub CLI auth check failed" }); + return; + } + + try { + const pr = await fetchPR(prRef); + rawPatch = pr.rawPatch; + gitRef = `PR #${prRef.number}`; + prMetadata = pr.metadata; + } catch (err) { + client.app.log({ level: "error", message: err instanceof Error ? err.message : "Failed to fetch PR" }); + return; + } + } else { + client.app.log({ level: "info", message: "Opening code review UI..." }); + + gitContext = await getGitContext(directory); + const diffResult = await runGitDiffWithContext("uncommitted", gitContext); + rawPatch = diffResult.patch; + gitRef = diffResult.label; + diffError = diffResult.error; + } const server = await startReviewServer({ rawPatch, gitRef, error: diffError, origin: "opencode", - diffType: "uncommitted", + diffType: isPRMode ? undefined : "uncommitted", gitContext, + prMetadata, sharingEnabled: await getSharingEnabled(), shareBaseUrl: getShareBaseUrl(), htmlContent: reviewHtmlContent, @@ -68,7 +107,9 @@ export async function handleReviewCommand( const message = result.approved ? "# Code Review\n\nCode review completed — no changes requested." - : `# Code Review Feedback\n\n${result.feedback}\n\nPlease address this feedback.`; + : isPRMode + ? result.feedback + : `# Code Review Feedback\n\n${result.feedback}\n\nPlease address this feedback.`; try { await client.session.prompt({ diff --git a/apps/opencode-plugin/commands/plannotator-review.md b/apps/opencode-plugin/commands/plannotator-review.md index 4e040efd..bcbb55fc 100644 --- a/apps/opencode-plugin/commands/plannotator-review.md +++ b/apps/opencode-plugin/commands/plannotator-review.md @@ -1,5 +1,5 @@ --- -description: Open interactive code review for current changes +description: Open interactive code review for current changes or a PR URL --- The Plannotator Code Review has been triggered. Opening the review UI... diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index 0d774fdc..ef6c6964 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -19,7 +19,9 @@ import { DiffViewer } from './components/DiffViewer'; import { ReviewPanel } from './components/ReviewPanel'; import { FileTree } from './components/FileTree'; import { DEMO_DIFF } from './demoData'; +import { exportReviewFeedback } from './utils/exportFeedback'; import type { DiffOption, WorktreeInfo, GitContext } from '@plannotator/shared/types'; +import type { PRMetadata } from '@plannotator/shared/pr-provider'; declare const __APP_VERSION__: string; @@ -79,73 +81,6 @@ function generateId(): string { return Math.random().toString(36).substring(2, 9); } -// Export annotations as markdown feedback -function exportReviewFeedback(annotations: CodeAnnotation[], files: DiffFile[]): string { - if (annotations.length === 0) { - return '# Code Review\n\nNo feedback provided.'; - } - - const grouped = new Map(); - for (const ann of annotations) { - const existing = grouped.get(ann.filePath) || []; - existing.push(ann); - grouped.set(ann.filePath, existing); - } - - let output = '# Code Review Feedback\n\n'; - - for (const [filePath, fileAnnotations] of grouped) { - output += `## ${filePath}\n\n`; - - const sorted = [...fileAnnotations].sort((a, b) => { - const aScope = a.scope ?? 'line'; - const bScope = b.scope ?? 'line'; - if (aScope !== bScope) { - return aScope === 'file' ? -1 : 1; - } - return a.lineStart - b.lineStart; - }); - - for (let i = 0; i < sorted.length; i++) { - const ann = sorted[i]; - const scope = ann.scope ?? 'line'; - - if (scope === 'file') { - output += `### File Comment\n`; - - if (ann.text) { - output += `${ann.text}\n`; - } - - if (ann.suggestedCode) { - output += `\n**Suggested code:**\n\`\`\`\n${ann.suggestedCode}\n\`\`\`\n`; - } - - output += '\n'; - continue; - } - - const lineRange = ann.lineStart === ann.lineEnd - ? `Line ${ann.lineStart}` - : `Lines ${ann.lineStart}-${ann.lineEnd}`; - - output += `### ${lineRange} (${ann.side})\n`; - - if (ann.text) { - output += `${ann.text}\n`; - } - - if (ann.suggestedCode) { - output += `\n**Suggested code:**\n\`\`\`\n${ann.suggestedCode}\n\`\`\`\n`; - } - - output += '\n'; - } - } - - return output; -} - const ReviewApp: React.FC = () => { const [diffData, setDiffData] = useState(null); const [files, setFiles] = useState([]); @@ -174,6 +109,7 @@ const ReviewApp: React.FC = () => { const [showApproveWarning, setShowApproveWarning] = useState(false); const [sharingEnabled, setSharingEnabled] = useState(true); const [repoInfo, setRepoInfo] = useState<{ display: string; branch?: string } | null>(null); + const [prMetadata, setPrMetadata] = useState(null); const identity = useMemo(() => getIdentity(), []); @@ -243,6 +179,7 @@ const ReviewApp: React.FC = () => { gitContext?: GitContext; sharingEnabled?: boolean; repoInfo?: { display: string; branch?: string }; + prMetadata?: PRMetadata; error?: string; }) => { const apiFiles = parseDiffToFiles(data.rawPatch); @@ -261,6 +198,7 @@ const ReviewApp: React.FC = () => { if (data.gitContext) setGitContext(data.gitContext); if (data.sharingEnabled !== undefined) setSharingEnabled(data.sharingEnabled); if (data.repoInfo) setRepoInfo(data.repoInfo); + if (data.prMetadata) setPrMetadata(data.prMetadata); if (data.error) setDiffError(data.error); }) .catch(() => { @@ -513,7 +451,7 @@ const ReviewApp: React.FC = () => { return; } try { - const feedback = exportReviewFeedback(annotations, files); + const feedback = exportReviewFeedback(annotations, prMetadata); await navigator.clipboard.writeText(feedback); setCopyFeedback('Feedback copied!'); setTimeout(() => setCopyFeedback(null), 2000); @@ -522,16 +460,16 @@ const ReviewApp: React.FC = () => { setCopyFeedback('Failed to copy'); setTimeout(() => setCopyFeedback(null), 2000); } - }, [annotations, files]); + }, [annotations, prMetadata]); const activeFile = files[activeFileIndex]; const feedbackMarkdown = useMemo(() => { - let output = exportReviewFeedback(annotations, files); + let output = exportReviewFeedback(annotations, prMetadata); if (editorAnnotations.length > 0) { output += exportEditorAnnotations(editorAnnotations); } return output; - }, [annotations, files, editorAnnotations]); + }, [annotations, prMetadata, editorAnnotations]); const totalAnnotationCount = annotations.length + editorAnnotations.length; @@ -656,29 +594,35 @@ const ReviewApp: React.FC = () => { > v{typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'} - - Code Review + - {/* Agent badge — unreliable for now across multiple harnesses */} - {/* {origin && ( - - )} */} - {repoInfo && ( + {prMetadata ? ( + <> + | + + {prMetadata.owner}/{prMetadata.repo} + + + #{prMetadata.number} {prMetadata.title} + + + ) : repoInfo ? ( <> | {repoInfo.display} - )} + ) : null}
diff --git a/packages/review-editor/utils/exportFeedback.test.ts b/packages/review-editor/utils/exportFeedback.test.ts new file mode 100644 index 00000000..25690bed --- /dev/null +++ b/packages/review-editor/utils/exportFeedback.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from "bun:test"; +import { exportReviewFeedback } from "./exportFeedback"; +import type { CodeAnnotation } from "@plannotator/ui/types"; +import type { PRMetadata } from "@plannotator/shared/pr-provider"; + +const ann = (overrides: Partial = {}): CodeAnnotation => ({ + id: "1", + type: "comment", + filePath: "src/index.ts", + lineStart: 10, + lineEnd: 10, + side: "new", + text: "This looks wrong", + createdAt: Date.now(), + ...overrides, +}); + +const prMeta: PRMetadata = { + platform: "github", + owner: "acme", + repo: "widgets", + number: 42, + title: "fix: broken widget", + author: "alice", + baseBranch: "main", + headBranch: "fix/widget", + baseSha: "abc123", + headSha: "def456", + url: "https://github.com/acme/widgets/pull/42", +}; + +describe("exportReviewFeedback", () => { + it("local mode: uses generic header, no PR content", () => { + const result = exportReviewFeedback([ann()]); + expect(result).toStartWith("# Code Review Feedback\n\n"); + // Must not leak any PR-specific content + expect(result).not.toContain("PR Review"); + expect(result).not.toContain("github.com"); + expect(result).not.toContain("Branch:"); + expect(result).not.toContain("acme"); + }); + + it("local mode with null prMetadata: same as no prMetadata", () => { + const result = exportReviewFeedback([ann()], null); + expect(result).toStartWith("# Code Review Feedback\n\n"); + expect(result).not.toContain("PR Review"); + }); + + it("local mode with undefined prMetadata: same as no prMetadata", () => { + const result = exportReviewFeedback([ann()], undefined); + expect(result).toStartWith("# Code Review Feedback\n\n"); + expect(result).not.toContain("PR Review"); + }); + + it("PR mode: includes all PR context fields", () => { + const result = exportReviewFeedback([ann()], prMeta); + expect(result).toStartWith("# PR Review: acme/widgets#42\n\n"); + expect(result).toContain("**fix: broken widget**"); + expect(result).toContain("Branch: `fix/widget` → `main`"); + expect(result).toContain("https://github.com/acme/widgets/pull/42"); + // Must not contain the generic local header + expect(result).not.toContain("# Code Review Feedback"); + }); + + it("PR mode: annotations still render after PR header", () => { + const result = exportReviewFeedback([ann({ text: "needs fix" })], prMeta); + // PR header comes first, then file/line annotations + const headerIdx = result.indexOf("PR Review:"); + const annotationIdx = result.indexOf("needs fix"); + expect(headerIdx).toBeLessThan(annotationIdx); + expect(result).toContain("## src/index.ts"); + expect(result).toContain("### Line 10 (new)"); + }); + + it("no annotations: returns generic empty regardless of prMetadata", () => { + expect(exportReviewFeedback([], prMeta)).toBe("# Code Review\n\nNo feedback provided."); + expect(exportReviewFeedback([], null)).toBe("# Code Review\n\nNo feedback provided."); + expect(exportReviewFeedback([])).toBe("# Code Review\n\nNo feedback provided."); + }); + + it("groups annotations by file", () => { + const result = exportReviewFeedback([ + ann({ filePath: "a.ts", lineStart: 5, lineEnd: 5, text: "first" }), + ann({ filePath: "b.ts", lineStart: 1, lineEnd: 1, text: "second" }), + ]); + expect(result).toContain("## a.ts"); + expect(result).toContain("## b.ts"); + }); + + it("sorts annotations by line number within a file", () => { + const result = exportReviewFeedback([ + ann({ lineStart: 20, lineEnd: 20, text: "later" }), + ann({ lineStart: 5, lineEnd: 5, text: "earlier" }), + ]); + const earlierIdx = result.indexOf("earlier"); + const laterIdx = result.indexOf("later"); + expect(earlierIdx).toBeLessThan(laterIdx); + }); + + it("puts file-scoped annotations before line annotations", () => { + const result = exportReviewFeedback([ + ann({ lineStart: 1, lineEnd: 1, text: "line comment" }), + ann({ scope: "file", text: "file comment" }), + ]); + const fileIdx = result.indexOf("File Comment"); + const lineIdx = result.indexOf("Line 1"); + expect(fileIdx).toBeLessThan(lineIdx); + }); + + it("renders line ranges", () => { + const result = exportReviewFeedback([ + ann({ lineStart: 10, lineEnd: 15 }), + ]); + expect(result).toContain("### Lines 10-15 (new)"); + }); + + it("renders single lines", () => { + const result = exportReviewFeedback([ + ann({ lineStart: 7, lineEnd: 7 }), + ]); + expect(result).toContain("### Line 7 (new)"); + }); + + it("renders suggested code", () => { + const result = exportReviewFeedback([ + ann({ suggestedCode: "const x = 1;" }), + ]); + expect(result).toContain("**Suggested code:**"); + expect(result).toContain("const x = 1;"); + }); + + it("includes side indicator", () => { + const result = exportReviewFeedback([ + ann({ side: "old", lineStart: 3, lineEnd: 3 }), + ]); + expect(result).toContain("### Line 3 (old)"); + }); +}); diff --git a/packages/review-editor/utils/exportFeedback.ts b/packages/review-editor/utils/exportFeedback.ts new file mode 100644 index 00000000..fe372bb6 --- /dev/null +++ b/packages/review-editor/utils/exportFeedback.ts @@ -0,0 +1,82 @@ +import type { CodeAnnotation } from '@plannotator/ui/types'; +import type { PRMetadata } from '@plannotator/shared/pr-provider'; + +/** + * Build markdown feedback from code review annotations. + * + * In PR mode (prMeta provided), the header includes repo, PR number, + * title, branches, and URL so the receiving agent has full context. + */ +export function exportReviewFeedback( + annotations: CodeAnnotation[], + prMeta?: PRMetadata | null, +): string { + if (annotations.length === 0) { + return '# Code Review\n\nNo feedback provided.'; + } + + const grouped = new Map(); + for (const ann of annotations) { + const existing = grouped.get(ann.filePath) || []; + existing.push(ann); + grouped.set(ann.filePath, existing); + } + + let output = prMeta + ? `# PR Review: ${prMeta.owner}/${prMeta.repo}#${prMeta.number}\n\n` + + `**${prMeta.title}**\n` + + `Branch: \`${prMeta.headBranch}\` → \`${prMeta.baseBranch}\`\n` + + `${prMeta.url}\n\n` + : '# Code Review Feedback\n\n'; + + for (const [filePath, fileAnnotations] of grouped) { + output += `## ${filePath}\n\n`; + + const sorted = [...fileAnnotations].sort((a, b) => { + const aScope = a.scope ?? 'line'; + const bScope = b.scope ?? 'line'; + if (aScope !== bScope) { + return aScope === 'file' ? -1 : 1; + } + return a.lineStart - b.lineStart; + }); + + for (let i = 0; i < sorted.length; i++) { + const ann = sorted[i]; + const scope = ann.scope ?? 'line'; + + if (scope === 'file') { + output += `### File Comment\n`; + + if (ann.text) { + output += `${ann.text}\n`; + } + + if (ann.suggestedCode) { + output += `\n**Suggested code:**\n\`\`\`\n${ann.suggestedCode}\n\`\`\`\n`; + } + + output += '\n'; + continue; + } + + const lineRange = ann.lineStart === ann.lineEnd + ? `Line ${ann.lineStart}` + : `Lines ${ann.lineStart}-${ann.lineEnd}`; + + output += `### ${lineRange} (${ann.side})\n`; + + if (ann.text) { + output += `${ann.text}\n`; + } + + if (ann.suggestedCode) { + output += `\n**Suggested code:**\n\`\`\`\n${ann.suggestedCode}\n\`\`\`\n`; + } + + output += '\n'; + } + } + + return output; +} diff --git a/packages/server/package.json b/packages/server/package.json index f09f463f..8e762dcc 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -17,7 +17,8 @@ "./share-url": "./share-url.ts", "./resolve-file": "./resolve-file.ts", "./sessions": "./sessions.ts", - "./project": "./project.ts" + "./project": "./project.ts", + "./pr": "./pr.ts" }, "files": [ "*.ts" diff --git a/packages/server/pr.ts b/packages/server/pr.ts new file mode 100644 index 00000000..cfaadb5d --- /dev/null +++ b/packages/server/pr.ts @@ -0,0 +1,55 @@ +/** + * PR provider for Bun runtimes + * + * Thin wrapper around shared pr-provider.ts, same pattern as git.ts. + * Pre-binds a Bun-based runtime so consumers get a clean API. + */ + +import { + type PRRef, + type PRMetadata, + type PRRuntime, + parsePRUrl as parsePRUrlCore, + checkGhAuth as checkGhAuthCore, + fetchPR as fetchPRCore, + fetchPRFileContent as fetchPRFileContentCore, +} from "@plannotator/shared/pr-provider"; + +export type { PRRef, PRMetadata } from "@plannotator/shared/pr-provider"; + +const runtime: PRRuntime = { + async runCommand(cmd, args) { + const proc = Bun.spawn([cmd, ...args], { + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + return { stdout, stderr, exitCode }; + }, +}; + +export const parsePRUrl = parsePRUrlCore; + +export function checkGhAuth(): Promise { + return checkGhAuthCore(runtime); +} + +export function fetchPR( + ref: PRRef, +): Promise<{ metadata: PRMetadata; rawPatch: string }> { + return fetchPRCore(runtime, ref); +} + +export function fetchPRFileContent( + ref: PRRef, + sha: string, + filePath: string, +): Promise { + return fetchPRFileContentCore(runtime, ref, sha, filePath); +} diff --git a/packages/server/review.ts b/packages/server/review.ts index 4a0fea37..6cbdee2f 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -15,11 +15,13 @@ import { getRepoInfo } from "./repo"; import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleFavicon, type OpencodeClient } from "./shared-handlers"; import { contentHash, deleteDraft } from "./draft"; import { createEditorAnnotationHandler } from "./editor-annotations"; +import { type PRMetadata, fetchPRFileContent } from "./pr"; // Re-export utilities export { isRemoteSession, getServerPort } from "./remote"; export { openBrowser } from "./browser"; export { type DiffType, type DiffOption, type GitContext, type WorktreeInfo } from "./git"; +export { type PRMetadata } from "./pr"; export { handleServerReady as handleReviewServerReady } from "./shared-handlers"; // --- Types --- @@ -47,6 +49,8 @@ export interface ReviewServerOptions { onReady?: (url: string, isRemote: boolean, port: number) => void; /** OpenCode client for querying available agents (OpenCode only) */ opencodeClient?: OpencodeClient; + /** PR metadata when reviewing a pull request (PR mode) */ + prMetadata?: PRMetadata; } export interface ReviewServerResult { @@ -83,8 +87,9 @@ const RETRY_DELAY_MS = 500; export async function startReviewServer( options: ReviewServerOptions ): Promise { - const { htmlContent, origin, gitContext, sharingEnabled = true, shareBaseUrl, onReady } = options; + const { htmlContent, origin, gitContext, sharingEnabled = true, shareBaseUrl, onReady, prMetadata } = options; + const isPRMode = !!prMetadata; const draftKey = contentHash(options.rawPatch); const editorAnnotations = createEditorAnnotationHandler(); @@ -98,7 +103,10 @@ export async function startReviewServer( const configuredPort = getServerPort(); // Detect repo info (cached for this session) - const repoInfo = await getRepoInfo(); + // In PR mode, derive from metadata instead of local git + const repoInfo = isPRMode + ? { display: `${prMetadata.owner}/${prMetadata.repo}`, branch: `PR #${prMetadata.number}` } + : await getRepoInfo(); // Decision promise let resolveDecision: (result: { @@ -133,17 +141,24 @@ export async function startReviewServer( rawPatch: currentPatch, gitRef: currentGitRef, origin, - diffType: currentDiffType, - gitContext, + diffType: isPRMode ? undefined : currentDiffType, + gitContext: isPRMode ? undefined : gitContext, sharingEnabled, shareBaseUrl, repoInfo, + ...(isPRMode && { prMetadata }), ...(currentError && { error: currentError }), }); } - // API: Switch diff type + // API: Switch diff type (disabled in PR mode) if (url.pathname === "/api/diff/switch" && req.method === "POST") { + if (isPRMode) { + return Response.json( + { error: "Not available for PR reviews" }, + { status: 400 }, + ); + } try { const body = (await req.json()) as { diffType: DiffType }; let newDiffType = body.diffType; @@ -195,6 +210,17 @@ export async function startReviewServer( return Response.json({ error: "Invalid path" }, { status: 400 }); } } + + if (isPRMode) { + // Fetch file content from GitHub API using base/head SHAs + const prRef = { platform: prMetadata.platform, owner: prMetadata.owner, repo: prMetadata.repo, number: prMetadata.number } as const; + const [oldContent, newContent] = await Promise.all([ + fetchPRFileContent(prRef, prMetadata.baseSha, oldPath || filePath), + fetchPRFileContent(prRef, prMetadata.headSha, filePath), + ]); + return Response.json({ oldContent, newContent }); + } + const defaultBranch = gitContext?.defaultBranch || "main"; const defaultCwd = gitContext?.cwd; const result = await getFileContentsForDiff( @@ -207,8 +233,14 @@ export async function startReviewServer( return Response.json(result); } - // API: Git add / reset (stage / unstage) a file + // API: Git add / reset (stage / unstage) a file (disabled in PR mode) if (url.pathname === "/api/git-add" && req.method === "POST") { + if (isPRMode) { + return Response.json( + { error: "Not available for PR reviews" }, + { status: 400 }, + ); + } try { const body = (await req.json()) as { filePath: string; undo?: boolean }; if (!body.filePath) { diff --git a/packages/shared/package.json b/packages/shared/package.json index 243b075d..75972987 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -8,6 +8,7 @@ "./feedback-templates": "./feedback-templates.ts", "./review-core": "./review-core.ts", "./checklist": "./checklist.ts", - "./types": "./types.ts" + "./types": "./types.ts", + "./pr-provider": "./pr-provider.ts" } } diff --git a/packages/shared/pr-provider.ts b/packages/shared/pr-provider.ts new file mode 100644 index 00000000..d7ba2df4 --- /dev/null +++ b/packages/shared/pr-provider.ts @@ -0,0 +1,171 @@ +/** + * Runtime-agnostic PR provider shared by Bun runtimes and Pi. + * + * Same pattern as review-core.ts: a runtime interface abstracts subprocess + * execution so the logic is reusable across Bun and Node/jiti. + */ + +// --- Types --- + +export interface CommandResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export interface PRRuntime { + runCommand: ( + cmd: string, + args: string[], + ) => Promise; +} + +export interface PRRef { + platform: "github"; + owner: string; + repo: string; + number: number; +} + +export interface PRMetadata { + platform: "github"; + owner: string; + repo: string; + number: number; + title: string; + author: string; + baseBranch: string; + headBranch: string; + baseSha: string; + headSha: string; + url: string; +} + +// --- URL Parsing --- + +/** + * Parse a PR URL into its components. + * + * Handles: + * - https://github.com/owner/repo/pull/123 + * - https://github.com/owner/repo/pull/123/files + * - https://github.com/owner/repo/pull/123/commits + */ +export function parsePRUrl(url: string): PRRef | null { + if (!url) return null; + + // GitHub: https://github.com/{owner}/{repo}/pull/{number}[/...] + const ghMatch = url.match( + /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/, + ); + if (ghMatch) { + return { + platform: "github", + owner: ghMatch[1], + repo: ghMatch[2], + number: parseInt(ghMatch[3], 10), + }; + } + + return null; +} + +// --- Auth --- + +export async function checkGhAuth(runtime: PRRuntime): Promise { + const result = await runtime.runCommand("gh", ["auth", "status"]); + if (result.exitCode !== 0) { + const stderr = result.stderr.trim(); + throw new Error( + `GitHub CLI not authenticated. Run \`gh auth login\` first.\n${stderr}`, + ); + } +} + +// --- Fetch PR --- + +export async function fetchPR( + runtime: PRRuntime, + ref: PRRef, +): Promise<{ metadata: PRMetadata; rawPatch: string }> { + const repo = `${ref.owner}/${ref.repo}`; + + // Fetch diff and metadata in parallel + const [diffResult, viewResult] = await Promise.all([ + runtime.runCommand("gh", [ + "pr", "diff", String(ref.number), + "--repo", repo, + ]), + runtime.runCommand("gh", [ + "pr", "view", String(ref.number), + "--repo", repo, + "--json", "title,author,baseRefName,headRefName,baseRefOid,headRefOid,url", + ]), + ]); + + if (diffResult.exitCode !== 0) { + throw new Error( + `Failed to fetch PR diff: ${diffResult.stderr.trim() || `exit code ${diffResult.exitCode}`}`, + ); + } + + if (viewResult.exitCode !== 0) { + throw new Error( + `Failed to fetch PR metadata: ${viewResult.stderr.trim() || `exit code ${viewResult.exitCode}`}`, + ); + } + + const raw = JSON.parse(viewResult.stdout) as { + title: string; + author: { login: string }; + baseRefName: string; + headRefName: string; + baseRefOid: string; + headRefOid: string; + url: string; + }; + + const metadata: PRMetadata = { + platform: "github", + owner: ref.owner, + repo: ref.repo, + number: ref.number, + title: raw.title, + author: raw.author.login, + baseBranch: raw.baseRefName, + headBranch: raw.headRefName, + baseSha: raw.baseRefOid, + headSha: raw.headRefOid, + url: raw.url, + }; + + return { metadata, rawPatch: diffResult.stdout }; +} + +// --- File Content --- + +export async function fetchPRFileContent( + runtime: PRRuntime, + ref: PRRef, + sha: string, + filePath: string, +): Promise { + const result = await runtime.runCommand("gh", [ + "api", + `repos/${ref.owner}/${ref.repo}/contents/${filePath}?ref=${sha}`, + "--jq", ".content", + ]); + + if (result.exitCode !== 0) return null; + + const base64Content = result.stdout.trim(); + if (!base64Content) return null; + + // GitHub returns base64-encoded content with newlines + const cleaned = base64Content.replace(/\n/g, ""); + try { + return Buffer.from(cleaned, "base64").toString("utf-8"); + } catch { + return null; + } +} diff --git a/scripts/install.cmd b/scripts/install.cmd index 8d485dbc..9e09894e 100644 --- a/scripts/install.cmd +++ b/scripts/install.cmd @@ -175,13 +175,13 @@ if not exist "!CLAUDE_COMMANDS_DIR!" mkdir "!CLAUDE_COMMANDS_DIR!" ( echo --- -echo description: Open interactive code review for current changes +echo description: Open interactive code review for current changes or a PR URL echo allowed-tools: Bash^(plannotator:*^) echo --- echo. echo ## Code Review Feedback echo. -echo !`plannotator review` +echo !`plannotator review $ARGUMENTS` echo. echo ## Your task echo. diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 24715bcb..4231c48c 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -128,20 +128,20 @@ if (Get-Command pi -ErrorAction SilentlyContinue) { $claudeCommandsDir = if ($env:CLAUDE_CONFIG_DIR) { "$env:CLAUDE_CONFIG_DIR\commands" } else { "$env:USERPROFILE\.claude\commands" } New-Item -ItemType Directory -Force -Path $claudeCommandsDir | Out-Null -@" +@' --- -description: Open interactive code review for current changes +description: Open interactive code review for current changes or a PR URL allowed-tools: Bash(plannotator:*) --- ## Code Review Feedback -!`plannotator review` +!`plannotator review $ARGUMENTS` ## Your task If the review above contains feedback or annotations, address them. If no changes were requested, acknowledge and continue. -"@ | Set-Content -Path "$claudeCommandsDir\plannotator-review.md" +'@ | Set-Content -Path "$claudeCommandsDir\plannotator-review.md" Write-Host "Installed /plannotator-review command to $claudeCommandsDir\plannotator-review.md" diff --git a/scripts/install.sh b/scripts/install.sh index d0140fe0..000f69e2 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -126,13 +126,13 @@ mkdir -p "$CLAUDE_COMMANDS_DIR" cat > "$CLAUDE_COMMANDS_DIR/plannotator-review.md" << 'COMMAND_EOF' --- -description: Open interactive code review for current changes +description: Open interactive code review for current changes or a PR URL allowed-tools: Bash(plannotator:*) --- ## Code Review Feedback -!`plannotator review` +!`plannotator review $ARGUMENTS` ## Your task