mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
216815c12e
* 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>
265 lines
7.7 KiB
TypeScript
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;
|
|
}
|