Files
backnotprop__plannotator/apps/opencode-plugin/commands.ts
T
Michael Ramos 216815c12e 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>
2026-03-18 06:01:40 -07:00

265 lines
7.7 KiB
TypeScript

/**
* Command Handlers for OpenCode Plugin
*
* Handles /plannotator-review, /plannotator-annotate, and /plannotator-last
* slash commands. Extracted from the event hook for modularity.
*/
import {
startReviewServer,
handleReviewServerReady,
} from "@plannotator/server/review";
import {
startAnnotateServer,
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 */
export interface CommandDeps {
client: any;
htmlContent: string;
reviewHtmlContent: string;
getSharingEnabled: () => Promise<boolean>;
getShareBaseUrl: () => string | undefined;
directory?: string;
}
export async function handleReviewCommand(
event: any,
deps: CommandDeps
) {
const { client, reviewHtmlContent, getSharingEnabled, getShareBaseUrl, directory } = deps;
// @ts-ignore - Event properties contain arguments
const urlArg: string = event.properties?.arguments || "";
const isPRMode = urlArg?.startsWith("http://") || urlArg?.startsWith("https://");
let rawPatch: string;
let gitRef: string;
let diffError: string | undefined;
let gitContext: Awaited<ReturnType<typeof getGitContext>> | undefined;
let prMetadata: Awaited<ReturnType<typeof fetchPR>>["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: isPRMode ? undefined : "uncommitted",
gitContext,
prMetadata,
sharingEnabled: await getSharingEnabled(),
shareBaseUrl: getShareBaseUrl(),
htmlContent: reviewHtmlContent,
opencodeClient: client,
onReady: handleReviewServerReady,
});
const result = await server.waitForDecision();
await Bun.sleep(1500);
server.stop();
if (result.feedback) {
// @ts-ignore - Event properties contain sessionID
const sessionId = event.properties?.sessionID;
if (sessionId) {
const shouldSwitchAgent = result.agentSwitch && result.agentSwitch !== "disabled";
const targetAgent = result.agentSwitch || "build";
const message = result.approved
? "# Code Review\n\nCode review completed — no changes requested."
: isPRMode
? result.feedback
: `# Code Review Feedback\n\n${result.feedback}\n\nPlease address this feedback.`;
try {
await client.session.prompt({
path: { id: sessionId },
body: {
...(shouldSwitchAgent && { agent: targetAgent }),
parts: [{ type: "text", text: message }],
},
});
} catch {
// Session may not be available
}
}
}
}
export async function handleAnnotateCommand(
event: any,
deps: CommandDeps
) {
const { client, htmlContent, getSharingEnabled, getShareBaseUrl } = deps;
// @ts-ignore - Event properties contain arguments
const filePath = event.properties?.arguments || event.arguments || "";
if (!filePath) {
client.app.log({ level: "error", message: "Usage: /plannotator-annotate <file.md>" });
return;
}
client.app.log({ level: "info", message: `Opening annotation UI for ${filePath}...` });
const projectRoot = process.cwd();
const resolved = await resolveMarkdownFile(filePath, projectRoot);
if (resolved.kind === "ambiguous") {
client.app.log({
level: "error",
message: `Ambiguous filename "${resolved.input}" — found ${resolved.matches.length} matches:\n${resolved.matches.map((m) => ` ${m}`).join("\n")}`,
});
return;
}
if (resolved.kind === "not_found") {
client.app.log({ level: "error", message: `File not found: ${resolved.input}` });
return;
}
const absolutePath = resolved.path;
client.app.log({ level: "info", message: `Resolved: ${absolutePath}` });
const markdown = await Bun.file(absolutePath).text();
const server = await startAnnotateServer({
markdown,
filePath: absolutePath,
origin: "opencode",
sharingEnabled: await getSharingEnabled(),
shareBaseUrl: getShareBaseUrl(),
htmlContent,
onReady: handleAnnotateServerReady,
});
const result = await server.waitForDecision();
await Bun.sleep(1500);
server.stop();
if (result.feedback) {
// @ts-ignore - Event properties contain sessionID
const sessionId = event.properties?.sessionID;
if (sessionId) {
try {
await client.session.prompt({
path: { id: sessionId },
body: {
parts: [{
type: "text",
text: `# Markdown Annotations\n\nFile: ${absolutePath}\n\n${result.feedback}\n\nPlease address the annotation feedback above.`,
}],
},
});
} catch {
// Session may not be available
}
}
}
}
/**
* Handle /plannotator-last command.
* Called from command.execute.before — returns the feedback string
* so the caller can set it as output.parts for the agent to see.
*/
export async function handleAnnotateLastCommand(
event: any,
deps: CommandDeps
): Promise<string | null> {
const { client, htmlContent, getSharingEnabled, getShareBaseUrl } = deps;
// @ts-ignore - Event properties contain sessionID
const sessionId = event.properties?.sessionID;
if (!sessionId) {
client.app.log({ level: "error", message: "No active session." });
return null;
}
// Fetch messages from session
const messagesResponse = await client.session.messages({
path: { id: sessionId },
});
const messages = messagesResponse.data;
// Walk backward, find last assistant message with text
let lastText: string | null = null;
if (messages) {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.info.role === "assistant") {
const textParts = msg.parts
.filter((p: any) => p.type === "text" && p.text?.trim())
.map((p: any) => p.text);
if (textParts.length > 0) {
lastText = textParts.join("\n");
break;
}
}
}
}
if (!lastText) {
client.app.log({ level: "error", message: "No assistant message found in session." });
return null;
}
client.app.log({ level: "info", message: "Opening annotation UI for last message..." });
const server = await startAnnotateServer({
markdown: lastText,
filePath: "last-message",
origin: "opencode",
mode: "annotate-last",
sharingEnabled: await getSharingEnabled(),
shareBaseUrl: getShareBaseUrl(),
htmlContent,
onReady: handleAnnotateServerReady,
});
const result = await server.waitForDecision();
await Bun.sleep(1500);
server.stop();
return result.feedback || null;
}