Files
Michael Ramos 9130d2d6a3 feat(review): open a review on a specific base and diff type (#1484)
Adds two session-only flags to plannotator review, parsed in the shared
parser so every host inherits them together:

- --base <ref> opens the session against a caller-chosen compare target
  (branch, origin/<branch>, tag, SHA, HEAD~N), probed with git rev-parse
  --verify --end-of-options before the server starts so a typo'd ref is a
  startup error with near-match suggestions instead of a silently
  mislabelled merge-base->HEAD diff.
- --diff-type <id> opens the session in one of the nine flat git diff
  modes (REVIEW_OPEN_DIFF_TYPES, pinned against GIT_DIFF_TYPES).

The flags are a seed, never a setting: nothing writes config.json or any
review cookie, and the UI stays fully mutable. Validation is pure in
packages/shared/review-open-state.ts (provider matrix errors on
jj/GitButler/P4/workspace/PR mode, promote-with-notice when the saved
default is base-irrelevant, fatal explicit contradiction).

A flagged base rides explicitBase semantics: the new initialBaseExplicit
server option (both runtimes) seeds baseExplicitlyChosen, suppressing the
startup origin/* upgrade and canonicalization, and openStatePinned rides
/api/diff so the client neither offers the first-run setup dialog (its
one-time cookie is NOT consumed) nor runs the panel-pair self-heal for a
pinned session. The since-base dropdown label now renders from the live
active base, matching the adjacent base picker.

Coverage: Bun CLI, opencode-review bridge, OpenCode embedded plugin, and
the Pi extension (re-vendored; strict validation on the slash-command
path only, programmatic callers unchanged). Skills, command stubs, help
text, and docs updated across every host surface.
2026-09-09 21:35:05 -07:00

614 lines
23 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 { type DiffType, prepareLocalReviewDiff, detectManagedVcs, gitRuntime } from "@plannotator/server/vcs";
import { resolveReviewOpenState } from "@plannotator/shared/review-open-state";
import { detectProjectName } from "@plannotator/server/project";
import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr";
import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config";
import {
composeReviewApprovedMessage,
getAnnotateApprovedWithNotesPrompt,
getReviewDeniedSuffix,
getAnnotateFileFeedbackPrompt,
} from "@plannotator/shared/prompts";
import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles, getAnnotatableDocRegex, MAX_ANNOTATABLE_FILE_BYTES } from "@plannotator/shared/resolve-file";
import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common";
import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown";
import { parseAnnotateArgs } from "@plannotator/shared/annotate-args";
import {
annotateInputNamesExistingTarget,
buildAmbiguousAnnotateArgsMessage,
buildUnresolvedAnnotateArgsMessage,
probeAnnotateToken,
selectAnnotateTokenTarget,
} from "@plannotator/shared/annotate-target";
import { parseReviewArgs } from "@plannotator/shared/review-args";
import { urlToMarkdown, isConvertedSource } from "@plannotator/shared/url-to-markdown";
import { buildLocalWorkspaceReview, type WorkspaceDiffType } from "@plannotator/server/review-workspace";
import { statSync } from "fs";
import path from "path";
import { resolveValidatedTargetAgent } from "./agent-switch";
import { deliverOpenCodePrompt } from "./prompt-delivery-error";
/** Shared dependencies injected by the plugin */
export interface CommandDeps {
client: any;
htmlContent: string;
reviewHtmlContent: string;
getSharingEnabled: () => Promise<boolean>;
getShareBaseUrl: () => string | undefined;
getPasteApiUrl: () => string | undefined;
directory?: string;
/**
* Annotate server starter. Injectable so tests can supply a stub without a
* global `mock.module` (which Bun cannot scope per-file or unset, and which
* would leak into other suites). Defaults to the real annotate server.
*/
startAnnotateServer?: typeof startAnnotateServer;
/** Review server starter — injectable for the same reason. */
startReviewServer?: typeof startReviewServer;
}
export async function handleReviewCommand(
event: any,
deps: CommandDeps
) {
const { client, reviewHtmlContent, getSharingEnabled, getShareBaseUrl, directory } = deps;
// @ts-ignore - Event properties contain arguments
const reviewArgs = parseReviewArgs(event.properties?.arguments || "");
// Argument-shape failures refuse to start a session (same contract as the
// CLI's exit 1) — surfaced through the plugin's existing log path.
if (reviewArgs.errors.length > 0) {
for (const parseError of reviewArgs.errors) {
client.app.log({ level: "error", message: `[Plannotator] ${parseError}` });
}
return;
}
const urlArg = reviewArgs.prUrl;
const isPRMode = urlArg !== undefined;
// Caller-pinned open state (--base/--diff-type): session-only seed, same
// contract as the CLI. Fatal validation failures surface through the
// plugin's existing log path and refuse to start a session.
const openStatePinned = reviewArgs.base !== undefined || reviewArgs.diffType !== undefined;
let initialBaseFromFlags: string | undefined;
let rawPatch: string;
let gitRef: string;
let diffError: string | undefined;
let initialFingerprint: string | undefined;
let userDiffType: DiffType | WorkspaceDiffType | undefined;
let gitContext: Awaited<ReturnType<typeof prepareLocalReviewDiff>>["gitContext"] | undefined;
let prMetadata: Awaited<ReturnType<typeof fetchPR>>["metadata"] | undefined;
let workspace: Awaited<ReturnType<typeof buildLocalWorkspaceReview>> | undefined;
let agentCwd: string | undefined;
if (isPRMode) {
if (openStatePinned) {
const openState = resolveReviewOpenState({
parsed: reviewArgs,
isPRMode: true,
isWorkspace: false,
resolvedDefaultDiffType: resolveDefaultDiffType(loadConfig()),
});
if (openState.error) {
client.app.log({ level: "error", message: `[Plannotator] ${openState.error}` });
return;
}
}
const prRef = parsePRUrl(urlArg);
if (!prRef) {
client.app.log({ level: "error", message: `Invalid PR/MR URL: ${urlArg}` });
return;
}
client.app.log({ level: "info", message: `Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...` });
try {
await checkPRAuth(prRef);
} catch (err) {
const cliName = getCliName(prRef);
client.app.log({ level: "error", message: err instanceof Error ? err.message : `${cliName} auth check failed` });
return;
}
try {
const pr = await fetchPR(prRef);
rawPatch = pr.rawPatch;
gitRef = `${getMRLabel(prRef)} ${getMRNumberLabel(prRef)}`;
prMetadata = pr.metadata;
} catch (err) {
client.app.log({ level: "error", message: err instanceof Error ? err.message : `Failed to fetch ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)}` });
return;
}
} else {
client.app.log({ level: "info", message: "Opening code review UI..." });
const config = loadConfig();
const cwd = directory ?? process.cwd();
const managedVcs = await detectManagedVcs(cwd, reviewArgs.vcsType);
const forcedVcs = !!reviewArgs.vcsType && reviewArgs.vcsType !== "auto";
if (managedVcs || forcedVcs) {
const providerId = (managedVcs?.id ?? reviewArgs.vcsType) as
| "git"
| "gitbutler"
| "jj"
| "p4"
| undefined;
let baseResolves: boolean | undefined;
if (openStatePinned && reviewArgs.base !== undefined && providerId === "git") {
// --end-of-options blocks flag injection; the probe keeps a typo'd
// base from producing a mislabelled merge-base→HEAD diff.
const probe = await gitRuntime.runGit(
["rev-parse", "--verify", "--quiet", "--end-of-options", `${reviewArgs.base}^{commit}`],
{ cwd },
);
baseResolves = probe.exitCode === 0;
}
const openState = resolveReviewOpenState({
parsed: reviewArgs,
isPRMode: false,
isWorkspace: false,
providerId,
resolvedDefaultDiffType: resolveDefaultDiffType(config),
baseResolves,
});
if (openState.error) {
client.app.log({ level: "error", message: `[Plannotator] ${openState.error}` });
return;
}
for (const notice of openState.notices) {
client.app.log({ level: "info", message: `[Plannotator] ${notice}` });
}
try {
const diffResult = await prepareLocalReviewDiff({
cwd,
vcsType: reviewArgs.vcsType,
requestedDiffType: openState.requestedDiffType,
requestedBase: openState.requestedBase,
configuredDiffType: resolveDefaultDiffType(config),
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
});
gitContext = diffResult.gitContext;
userDiffType = diffResult.diffType;
rawPatch = diffResult.rawPatch;
gitRef = diffResult.gitRef;
diffError = diffResult.error;
initialFingerprint = diffResult.fingerprint;
// Forward the base the patch was computed against — without it the
// server serves this patch under the detected default: a mixed-base
// review.
if (openState.requestedBase !== undefined) initialBaseFromFlags = diffResult.base;
} catch (err) {
client.app.log({ level: "error", message: err instanceof Error ? err.message : "Failed to prepare local review diff" });
return;
}
} else {
if (openStatePinned) {
const openState = resolveReviewOpenState({
parsed: reviewArgs,
isPRMode: false,
isWorkspace: true,
resolvedDefaultDiffType: resolveDefaultDiffType(config),
});
if (openState.error) {
client.app.log({ level: "error", message: `[Plannotator] ${openState.error}` });
return;
}
}
workspace = await buildLocalWorkspaceReview(cwd, {
configuredDiffType: resolveDefaultDiffType(config),
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
});
if (workspace.repos.length === 0) {
client.app.log({ level: "error", message: "Not in a VCS repo and no nested Git/JJ/GitButler repositories were found." });
return;
}
rawPatch = workspace.rawPatch;
gitRef = workspace.gitRef;
diffError = workspace.error;
userDiffType = workspace.diffType;
agentCwd = workspace.root;
}
}
// @ts-ignore - Event properties contain sessionID
const sessionId = event.properties?.sessionID;
const startServer = deps.startReviewServer ?? startReviewServer;
const server = await startServer({
rawPatch,
gitRef,
error: diffError,
origin: "opencode",
project: (await detectProjectName()) ?? undefined,
diffType: isPRMode ? undefined : userDiffType,
gitContext,
initialBase: initialBaseFromFlags,
initialBaseExplicit: initialBaseFromFlags !== undefined,
openStatePinned,
initialFingerprint,
prMetadata,
workspace,
agentCwd,
sharingEnabled: await getSharingEnabled(),
shareBaseUrl: getShareBaseUrl(),
// Approve-time notes are delivered below only when there is a session to
// prompt into — same per-session gating as the annotate command's advert.
approvalNotesSupported: Boolean(sessionId),
htmlContent: reviewHtmlContent,
opencodeClient: client,
onReady: (url, isRemote, port) => {
handleReviewServerReady(url, isRemote, port);
client.app.log({ level: "info", message: `[Plannotator] Open code review: ${url}` });
},
});
const result = await server.waitForDecision();
await Bun.sleep(1500);
server.stop();
if (result.exit) {
return;
}
// An approval must be delivered even with an empty feedback string: the
// old feedback-only gate rode on the removed LGTM placeholder making
// `feedback` truthy on every approval — with the placeholder gone (PR5,
// spec §6.4), gating on feedback alone would silently drop bare approvals.
if (result.feedback || result.approved) {
if (sessionId) {
const targetAgent = await resolveValidatedTargetAgent({
client,
targetAgent: result.agentSwitch,
directory,
});
// Append the verification-only suffix when the reviewer sent annotations to
// act on (PR mode included). Platform PR actions post a status message
// with no annotations — those go through verbatim, no suffix.
// Approvals carry the reviewer's approve-time notes after the prompt.
const message = result.approved
? composeReviewApprovedMessage("opencode", result.feedback)
: result.annotations.length > 0
? `${result.feedback}${getReviewDeniedSuffix("opencode")}`
: result.feedback;
try {
await client.session.prompt({
path: { id: sessionId },
body: {
...(targetAgent && { 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, getPasteApiUrl, directory } = deps;
const startServer = deps.startAnnotateServer ?? startAnnotateServer;
// @ts-ignore - Event properties contain arguments
const rawArgs = event.properties?.arguments || event.arguments || "";
// Split known annotate flags out of the args; rest is the file path.
// --json is accepted silently (OpenCode writes to session, not stdout).
// parseAnnotateArgs strips leading @ on filePath (reference-mode convention).
// `rawFilePath` preserves it for the scoped-package markdown fallback.
let { filePath, rawFilePath, gate, renderHtml: renderHtmlFlag, renderMarkdown: renderMarkdownFlag, noJina } = parseAnnotateArgs(rawArgs);
// @ts-ignore - Event properties contain sessionID
const sessionId = event.properties?.sessionID;
if (!filePath) {
client.app.log({ level: "error", message: "Usage: /plannotator-annotate <file.md | file.txt | file.html | https://... | folder/> [--markdown] [--no-jina] [--gate] [--json]" });
return;
}
// Tolerant fallback (#1182): when the whole argument string names nothing,
// probe each token; exactly one existing target proceeds, several is an
// error, several unresolvable words get an actionable message instead of
// "File not found: the". Bare directory names only count in the sole-arg
// pre-pass, and unrecognized dash-prefixed tokens disable tolerance so a
// typo'd flag errors the way it always did.
const tolerantRoot = directory || process.cwd();
if (!annotateInputNamesExistingTarget(rawFilePath, tolerantRoot)) {
const selection = selectAnnotateTokenTarget(rawFilePath, (token) =>
probeAnnotateToken(token, tolerantRoot, { bareDirectories: false }),
);
if (selection.kind === "single") {
filePath = selection.candidate.value;
rawFilePath = selection.candidate.value;
} else if (selection.kind === "multiple") {
client.app.log({ level: "error", message: buildAmbiguousAnnotateArgsMessage(selection.candidates) });
return;
} else if (selection.kind === "none" && selection.words.length > 1) {
// Content flags only; --gate is transport for this invocation, not a
// property of the target.
const flags = [
...(renderMarkdownFlag ? ["--markdown"] : []),
...(noJina ? ["--no-jina"] : []),
...(renderHtmlFlag ? ["--render-html"] : []),
];
client.app.log({ level: "error", message: buildUnresolvedAnnotateArgsMessage({ words: selection.words, flags }) });
return;
}
// "flagged" (unrecognized dash tokens) or a single unresolvable word
// falls through to the existing pipeline so its specific errors
// ("File not found", unsupported type) stay verbatim.
}
let markdown: string;
let rawHtml: string | undefined;
let absolutePath: string;
let folderPath: string | undefined;
let annotateMode: "annotate" | "annotate-folder" = "annotate";
let isFolder = false;
let sourceInfo: string | undefined;
let sourceConverted = false;
const agentCwd = directory || process.cwd();
// --- URL annotation ---
const isUrl = /^https?:\/\//i.test(filePath);
if (isUrl) {
const useJina = resolveUseJina(noJina, loadConfig());
client.app.log({ level: "info", message: `Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}...` });
try {
const result = await urlToMarkdown(filePath, { useJina });
markdown = result.markdown;
sourceConverted = isConvertedSource(result.source);
} catch (err) {
client.app.log({ level: "error", message: `Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}` });
return;
}
absolutePath = filePath;
sourceInfo = filePath;
} else {
const projectRoot = agentCwd;
const resolvedArg = resolveUserPath(filePath, projectRoot);
try {
isFolder = statSync(resolvedArg).isDirectory();
} catch {
// Not a directory, fall through to file resolution.
}
if (isFolder) {
if (!hasMarkdownFiles(resolvedArg, FILE_BROWSER_EXCLUDED, getAnnotatableDocRegex())) {
client.app.log({ level: "error", message: `No annotatable files (markdown, plain-text, config, or HTML) found in ${resolvedArg}` });
return;
}
folderPath = resolvedArg;
absolutePath = resolvedArg;
markdown = "";
annotateMode = "annotate-folder";
client.app.log({ level: "info", message: `Opening annotation UI for folder ${resolvedArg}...` });
} else if (/\.html?$/i.test(resolvedArg)) {
try {
statSync(resolvedArg);
} catch {
client.app.log({ level: "error", message: `File not found: ${filePath}` });
return;
}
const html = await Bun.file(resolvedArg).text();
const renderHtmlForFile = !renderMarkdownFlag;
if (renderHtmlForFile) {
rawHtml = html;
markdown = "";
} else {
markdown = htmlToMarkdown(html);
sourceConverted = true;
}
absolutePath = resolvedArg;
sourceInfo = path.basename(resolvedArg);
client.app.log({ level: "info", message: `${renderHtmlForFile ? "Raw HTML" : "Converted"}: ${absolutePath}` });
} else {
// Markdown file annotation
client.app.log({ level: "info", message: `Opening annotation UI for ${filePath}...` });
// Strip-first with literal-@ fallback (scoped-package-style names).
let resolved = await resolveMarkdownFile(filePath, projectRoot);
if (resolved.kind === "not_found" && rawFilePath !== filePath) {
resolved = await resolveMarkdownFile(rawFilePath, 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;
}
absolutePath = resolved.path;
if (Bun.file(absolutePath).size > MAX_ANNOTATABLE_FILE_BYTES) {
client.app.log({ level: "error", message: `File too large to annotate (max 2MB): ${absolutePath}` });
return;
}
client.app.log({ level: "info", message: `Resolved: ${absolutePath}` });
markdown = await Bun.file(absolutePath).text();
}
}
// Per-project scoping for the annotate version history — matches the hook
// and Pi runtimes, which both pass it (otherwise history lands in the
// shared "_unknown" bucket).
const annotateProject = (await detectProjectName()) ?? undefined;
const server = await startServer({
markdown,
filePath: absolutePath,
origin: "opencode",
mode: annotateMode,
project: annotateProject,
folderPath,
sourceInfo,
sourceConverted,
rawHtml,
renderHtml: !!rawHtml,
convertHtml: renderMarkdownFlag,
sharingEnabled: await getSharingEnabled(),
shareBaseUrl: getShareBaseUrl(),
pasteApiUrl: getPasteApiUrl(),
gate,
approvalNotesSupported: Boolean(sessionId),
agentCwd,
htmlContent,
onReady: (url, isRemote, port) => {
handleAnnotateServerReady(url, isRemote, port);
client.app.log({ level: "info", message: `[Plannotator] Open annotation UI: ${url}` });
},
});
const result = await server.waitForDecision();
await Bun.sleep(1500);
server.stop();
if (result.exit || (result.approved && !result.feedback)) {
return;
}
if (result.feedback) {
if (sessionId) {
const text = result.approved
? getAnnotateApprovedWithNotesPrompt("opencode", undefined, {
context: `${isFolder ? "Folder" : "File"}: ${absolutePath}`,
feedback: result.feedback,
})
: getAnnotateFileFeedbackPrompt("opencode", undefined, {
fileHeader: isFolder ? "Folder" : "File",
filePath: absolutePath,
feedback: result.feedback,
});
await deliverOpenCodePrompt({
client,
prompt: {
path: { id: sessionId },
body: {
parts: [{
type: "text",
text,
}],
},
},
failureMessage: result.approved
? "Could not deliver approved annotation notes to the OpenCode session."
: "Could not deliver annotation feedback to the OpenCode session.",
});
}
}
}
/**
* Handle /plannotator-last command.
* Called from command.execute.before — returns approval-aware feedback so the
* caller can choose the correct prompt semantics before injecting it.
*/
export async function handleAnnotateLastCommand(
event: any,
deps: CommandDeps
): Promise<{ approved: boolean; feedback: string } | null> {
const { client, htmlContent, getSharingEnabled, getShareBaseUrl, getPasteApiUrl } = deps;
const startServer = deps.startAnnotateServer ?? startAnnotateServer;
// @ts-ignore - Event properties contain arguments
const rawArgs = event.properties?.arguments || event.arguments || "";
// Support --gate on /plannotator-last (Stop-hook review-gate pattern).
const { gate } = parseAnnotateArgs(rawArgs);
// @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;
const RECENT_LIMIT = 25;
const recentMessages: { messageId: string; text: string; timestamp?: string }[] = [];
if (messages) {
for (let i = messages.length - 1; i >= 0 && recentMessages.length < RECENT_LIMIT; i--) {
const msg = messages[i];
if (msg.info.role !== "assistant") continue;
const textParts = msg.parts
.filter((p: any) => p.type === "text" && p.text?.trim())
.map((p: any) => p.text);
if (textParts.length === 0) continue;
recentMessages.push({
messageId: msg.info.id ?? `opencode-${i}`,
text: textParts.join("\n"),
timestamp: msg.info.time?.created ? new Date(msg.info.time.created).toISOString() : undefined,
});
}
}
const lastText = recentMessages[0]?.text ?? null;
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 pickerMessages = recentMessages.length > 1 ? recentMessages : undefined;
const lastProject = (await detectProjectName()) ?? undefined;
const server = await startServer({
markdown: lastText,
filePath: "last-message",
origin: "opencode",
mode: "annotate-last",
project: lastProject,
recentMessages: pickerMessages,
sharingEnabled: await getSharingEnabled(),
shareBaseUrl: getShareBaseUrl(),
pasteApiUrl: getPasteApiUrl(),
gate,
approvalNotesSupported: true,
htmlContent,
onReady: (url, isRemote, port) => {
handleAnnotateServerReady(url, isRemote, port);
client.app.log({ level: "info", message: `[Plannotator] Open annotation UI: ${url}` });
},
});
const result = await server.waitForDecision();
await Bun.sleep(1500);
server.stop();
if (result.exit || (result.approved && !result.feedback)) {
return null;
}
return result.feedback
? { approved: Boolean(result.approved), feedback: result.feedback }
: null;
}