- Code Review
+
+ {prMetadata ? 'PR Review' : 'Code Review'}
- {/* Agent badge — unreliable for now across multiple harnesses */}
- {/* {origin && (
-
- {origin === 'claude-code' ? 'Claude Code' : origin === 'pi' ? 'Pi' : 'OpenCode'}
-
- )} */}
- {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