feat: PR review support via GitHub URL (#324)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* docs: add PR review support to docs and READMEs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Ramos
2026-03-18 06:01:40 -07:00
committed by GitHub
parent afdc6f214e
commit 216815c12e
21 changed files with 676 additions and 134 deletions
+38 -6
View File
@@ -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<ReviewServerResult> {
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) {