2026-01-12 19:36:09 -08:00
|
|
|
/**
|
|
|
|
|
* Code Review Server
|
|
|
|
|
*
|
|
|
|
|
* Provides a server implementation for code review with git diff rendering.
|
|
|
|
|
* Follows the same patterns as the plan server.
|
|
|
|
|
*
|
|
|
|
|
* Environment variables:
|
|
|
|
|
* PLANNOTATOR_REMOTE - Set to "1" or "true" for remote/devcontainer mode
|
|
|
|
|
* PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote)
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { isRemoteSession, getServerPort } from "./remote";
|
2026-03-07 10:06:55 -08:00
|
|
|
import { type DiffType, type GitContext, runGitDiff, getWorktreeDiffOptions } from "./git";
|
2026-01-28 10:29:52 -08:00
|
|
|
import { getRepoInfo } from "./repo";
|
2026-03-04 10:12:58 -08:00
|
|
|
import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, type OpencodeClient } from "./shared-handlers";
|
|
|
|
|
import { contentHash, deleteDraft } from "./draft";
|
2026-01-12 19:36:09 -08:00
|
|
|
|
|
|
|
|
// Re-export utilities
|
|
|
|
|
export { isRemoteSession, getServerPort } from "./remote";
|
|
|
|
|
export { openBrowser } from "./browser";
|
|
|
|
|
export { type DiffType, type DiffOption, type GitContext } from "./git";
|
2026-03-03 23:17:16 -08:00
|
|
|
export { handleServerReady as handleReviewServerReady } from "./shared-handlers";
|
2026-01-12 19:36:09 -08:00
|
|
|
|
|
|
|
|
// --- Types ---
|
|
|
|
|
|
|
|
|
|
export interface ReviewServerOptions {
|
|
|
|
|
/** Raw git diff patch string */
|
|
|
|
|
rawPatch: string;
|
|
|
|
|
/** Git ref used for the diff (e.g., "HEAD", "main..HEAD", "--staged") */
|
|
|
|
|
gitRef: string;
|
2026-03-02 15:26:07 +09:00
|
|
|
/** Error message if git diff failed */
|
|
|
|
|
error?: string;
|
2026-01-12 19:36:09 -08:00
|
|
|
/** HTML content to serve for the UI */
|
|
|
|
|
htmlContent: string;
|
|
|
|
|
/** Origin identifier for UI customization */
|
|
|
|
|
origin?: "opencode" | "claude-code";
|
|
|
|
|
/** Current diff type being displayed */
|
|
|
|
|
diffType?: DiffType;
|
|
|
|
|
/** Git context with branch info and available diff options */
|
|
|
|
|
gitContext?: GitContext;
|
2026-01-14 13:12:37 -08:00
|
|
|
/** Whether URL sharing is enabled (default: true) */
|
|
|
|
|
sharingEnabled?: boolean;
|
2026-02-12 15:07:43 -08:00
|
|
|
/** Custom base URL for share links (default: https://share.plannotator.ai) */
|
|
|
|
|
shareBaseUrl?: string;
|
2026-01-12 19:36:09 -08:00
|
|
|
/** Called when server starts with the URL, remote status, and port */
|
|
|
|
|
onReady?: (url: string, isRemote: boolean, port: number) => void;
|
2026-01-19 11:07:22 -08:00
|
|
|
/** OpenCode client for querying available agents (OpenCode only) */
|
2026-03-03 23:17:16 -08:00
|
|
|
opencodeClient?: OpencodeClient;
|
2026-01-12 19:36:09 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ReviewServerResult {
|
|
|
|
|
/** The port the server is running on */
|
|
|
|
|
port: number;
|
|
|
|
|
/** The full URL to access the server */
|
|
|
|
|
url: string;
|
|
|
|
|
/** Whether running in remote mode */
|
|
|
|
|
isRemote: boolean;
|
|
|
|
|
/** Wait for user feedback submission */
|
|
|
|
|
waitForDecision: () => Promise<{
|
|
|
|
|
feedback: string;
|
|
|
|
|
annotations: unknown[];
|
|
|
|
|
agentSwitch?: string;
|
|
|
|
|
}>;
|
|
|
|
|
/** Stop the server */
|
|
|
|
|
stop: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Server Implementation ---
|
|
|
|
|
|
|
|
|
|
const MAX_RETRIES = 5;
|
|
|
|
|
const RETRY_DELAY_MS = 500;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Start the Code Review server
|
|
|
|
|
*
|
|
|
|
|
* Handles:
|
|
|
|
|
* - Remote detection and port configuration
|
|
|
|
|
* - API routes (/api/diff, /api/feedback)
|
|
|
|
|
* - Port conflict retries
|
|
|
|
|
*/
|
|
|
|
|
export async function startReviewServer(
|
|
|
|
|
options: ReviewServerOptions
|
|
|
|
|
): Promise<ReviewServerResult> {
|
2026-02-12 15:07:43 -08:00
|
|
|
const { htmlContent, origin, gitContext, sharingEnabled = true, shareBaseUrl, onReady } = options;
|
2026-01-12 19:36:09 -08:00
|
|
|
|
2026-03-04 10:12:58 -08:00
|
|
|
const draftKey = contentHash(options.rawPatch);
|
|
|
|
|
|
2026-01-12 19:36:09 -08:00
|
|
|
// Mutable state for diff switching
|
|
|
|
|
let currentPatch = options.rawPatch;
|
|
|
|
|
let currentGitRef = options.gitRef;
|
|
|
|
|
let currentDiffType: DiffType = options.diffType || "uncommitted";
|
2026-03-02 15:26:07 +09:00
|
|
|
let currentError = options.error;
|
2026-01-12 19:36:09 -08:00
|
|
|
|
|
|
|
|
const isRemote = isRemoteSession();
|
|
|
|
|
const configuredPort = getServerPort();
|
|
|
|
|
|
2026-01-28 10:29:52 -08:00
|
|
|
// Detect repo info (cached for this session)
|
|
|
|
|
const repoInfo = await getRepoInfo();
|
|
|
|
|
|
2026-01-12 19:36:09 -08:00
|
|
|
// Decision promise
|
|
|
|
|
let resolveDecision: (result: {
|
|
|
|
|
feedback: string;
|
|
|
|
|
annotations: unknown[];
|
|
|
|
|
agentSwitch?: string;
|
|
|
|
|
}) => void;
|
|
|
|
|
const decisionPromise = new Promise<{
|
|
|
|
|
feedback: string;
|
|
|
|
|
annotations: unknown[];
|
|
|
|
|
agentSwitch?: string;
|
|
|
|
|
}>((resolve) => {
|
|
|
|
|
resolveDecision = resolve;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Start server with retry logic
|
|
|
|
|
let server: ReturnType<typeof Bun.serve> | null = null;
|
|
|
|
|
|
|
|
|
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
|
|
|
try {
|
|
|
|
|
server = Bun.serve({
|
|
|
|
|
port: configuredPort,
|
|
|
|
|
|
|
|
|
|
async fetch(req) {
|
|
|
|
|
const url = new URL(req.url);
|
|
|
|
|
|
|
|
|
|
// API: Get diff content
|
|
|
|
|
if (url.pathname === "/api/diff" && req.method === "GET") {
|
|
|
|
|
return Response.json({
|
|
|
|
|
rawPatch: currentPatch,
|
|
|
|
|
gitRef: currentGitRef,
|
|
|
|
|
origin,
|
|
|
|
|
diffType: currentDiffType,
|
|
|
|
|
gitContext,
|
2026-01-14 13:12:37 -08:00
|
|
|
sharingEnabled,
|
2026-02-12 15:07:43 -08:00
|
|
|
shareBaseUrl,
|
2026-01-28 10:29:52 -08:00
|
|
|
repoInfo,
|
2026-03-02 15:26:07 +09:00
|
|
|
...(currentError && { error: currentError }),
|
2026-01-12 19:36:09 -08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// API: Switch diff type
|
|
|
|
|
if (url.pathname === "/api/diff/switch" && req.method === "POST") {
|
|
|
|
|
try {
|
|
|
|
|
const body = (await req.json()) as { diffType: DiffType };
|
2026-03-07 10:06:55 -08:00
|
|
|
let newDiffType = body.diffType;
|
2026-01-12 19:36:09 -08:00
|
|
|
|
|
|
|
|
if (!newDiffType) {
|
|
|
|
|
return Response.json(
|
|
|
|
|
{ error: "Missing diffType" },
|
|
|
|
|
{ status: 400 }
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const defaultBranch = gitContext?.defaultBranch || "main";
|
2026-03-07 10:06:55 -08:00
|
|
|
const previousDiffType = currentDiffType;
|
|
|
|
|
|
|
|
|
|
// Handle "back to main repo" sentinel
|
|
|
|
|
const isBackToMain = newDiffType === ("back-to-main" as DiffType);
|
|
|
|
|
if (isBackToMain) {
|
|
|
|
|
newDiffType = "uncommitted" as DiffType;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Run the new diff
|
2026-01-12 19:36:09 -08:00
|
|
|
const result = await runGitDiff(newDiffType, defaultBranch);
|
|
|
|
|
|
|
|
|
|
// Update state
|
|
|
|
|
currentPatch = result.patch;
|
|
|
|
|
currentGitRef = result.label;
|
|
|
|
|
currentDiffType = newDiffType;
|
2026-03-02 15:26:07 +09:00
|
|
|
currentError = result.error;
|
2026-01-12 19:36:09 -08:00
|
|
|
|
2026-03-07 10:06:55 -08:00
|
|
|
// Build response — include diffOptions on worktree mode transitions
|
|
|
|
|
const response: Record<string, unknown> = {
|
2026-01-12 19:36:09 -08:00
|
|
|
rawPatch: currentPatch,
|
|
|
|
|
gitRef: currentGitRef,
|
|
|
|
|
diffType: currentDiffType,
|
2026-03-02 15:26:07 +09:00
|
|
|
...(currentError && { error: currentError }),
|
2026-03-07 10:06:55 -08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (isBackToMain) {
|
|
|
|
|
// Exiting worktree mode: restore original dropdown options
|
|
|
|
|
response.diffOptions = gitContext?.diffOptions;
|
|
|
|
|
} else if (
|
|
|
|
|
newDiffType.startsWith("worktree:") &&
|
|
|
|
|
!previousDiffType.startsWith("worktree:")
|
|
|
|
|
) {
|
|
|
|
|
// Entering worktree mode: send worktree-scoped dropdown options
|
|
|
|
|
const wtPath = newDiffType.slice("worktree:".length).replace(/:(?:uncommitted|last-commit|branch)$/, "");
|
|
|
|
|
response.diffOptions = getWorktreeDiffOptions(wtPath, defaultBranch);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Response.json(response);
|
2026-01-12 19:36:09 -08:00
|
|
|
} catch (err) {
|
|
|
|
|
const message =
|
|
|
|
|
err instanceof Error ? err.message : "Failed to switch diff";
|
|
|
|
|
return Response.json({ error: message }, { status: 500 });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// API: Serve images (local paths or temp uploads)
|
|
|
|
|
if (url.pathname === "/api/image") {
|
2026-03-03 23:17:16 -08:00
|
|
|
return handleImage(req);
|
2026-01-12 19:36:09 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// API: Upload image -> save to temp -> return path
|
|
|
|
|
if (url.pathname === "/api/upload" && req.method === "POST") {
|
2026-03-03 23:17:16 -08:00
|
|
|
return handleUpload(req);
|
2026-01-12 19:36:09 -08:00
|
|
|
}
|
|
|
|
|
|
2026-01-19 11:07:22 -08:00
|
|
|
// API: Get available agents (OpenCode only)
|
|
|
|
|
if (url.pathname === "/api/agents") {
|
2026-03-03 23:17:16 -08:00
|
|
|
return handleAgents(options.opencodeClient);
|
2026-01-19 11:07:22 -08:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 10:12:58 -08:00
|
|
|
// API: Annotation draft persistence
|
|
|
|
|
if (url.pathname === "/api/draft") {
|
|
|
|
|
if (req.method === "POST") return handleDraftSave(req, draftKey);
|
|
|
|
|
if (req.method === "DELETE") return handleDraftDelete(draftKey);
|
|
|
|
|
return handleDraftLoad(draftKey);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-12 19:36:09 -08:00
|
|
|
// API: Submit review feedback
|
|
|
|
|
if (url.pathname === "/api/feedback" && req.method === "POST") {
|
|
|
|
|
try {
|
|
|
|
|
const body = (await req.json()) as {
|
|
|
|
|
feedback: string;
|
|
|
|
|
annotations: unknown[];
|
|
|
|
|
agentSwitch?: string;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-04 10:12:58 -08:00
|
|
|
deleteDraft(draftKey);
|
2026-01-12 19:36:09 -08:00
|
|
|
resolveDecision({
|
|
|
|
|
feedback: body.feedback || "",
|
|
|
|
|
annotations: body.annotations || [],
|
|
|
|
|
agentSwitch: body.agentSwitch,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return Response.json({ ok: true });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const message =
|
|
|
|
|
err instanceof Error ? err.message : "Failed to process feedback";
|
|
|
|
|
return Response.json({ error: message }, { status: 500 });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Serve embedded HTML for all other routes (SPA)
|
|
|
|
|
return new Response(htmlContent, {
|
|
|
|
|
headers: { "Content-Type": "text/html" },
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
break; // Success, exit retry loop
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
const isAddressInUse =
|
|
|
|
|
err instanceof Error && err.message.includes("EADDRINUSE");
|
|
|
|
|
|
|
|
|
|
if (isAddressInUse && attempt < MAX_RETRIES) {
|
|
|
|
|
await Bun.sleep(RETRY_DELAY_MS);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isAddressInUse) {
|
|
|
|
|
const hint = isRemote ? " (set PLANNOTATOR_PORT to use different port)" : "";
|
|
|
|
|
throw new Error(`Port ${configuredPort} in use after ${MAX_RETRIES} retries${hint}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!server) {
|
|
|
|
|
throw new Error("Failed to start server");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const serverUrl = `http://localhost:${server.port}`;
|
|
|
|
|
|
|
|
|
|
// Notify caller that server is ready
|
|
|
|
|
if (onReady) {
|
|
|
|
|
onReady(serverUrl, isRemote, server.port);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
port: server.port,
|
|
|
|
|
url: serverUrl,
|
|
|
|
|
isRemote,
|
|
|
|
|
waitForDecision: () => decisionPromise,
|
|
|
|
|
stop: () => server.stop(),
|
|
|
|
|
};
|
|
|
|
|
}
|