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