/** * Temporary local web review surface (ADR-0003). * * Spins up an ephemeral 127.0.0.1 HTTP server for one review session, * opens the system browser, and resolves with all comments (user + agent) * when the user submits/closes the page or the server errors. * Everything lives in memory; nothing is written to disk. */ import { execFile } from "node:child_process"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import type { AddressInfo, Socket } from "node:net"; import { basename } from "node:path"; import { commentsForSha, detachedConversationIds, findCommentAt, findCommentLineIndex, removeCommentAt, resolveAnchorState, setCommentStatus, upsertComment, type AnchorState, type ReviewComment, } from "./comments"; import { pruneAgentResponses, setConversationAction, type AgentResponse, type ConversationAction, } from "./conversations"; import type { CommitEntry } from "./git"; import { parseUnifiedDiff, type DiffLine } from "./parse-unidiff"; import type { WalkthroughStop } from "./walkthrough"; export interface WebReviewOptions { cwd: string; items: CommitEntry[]; /** Read-only agent walkthrough notes (author: "agent"). */ agentComments: ReviewComment[]; /** Overview summaries (no file anchor) shown as chrome. */ summaries: WalkthroughStop[]; /** Session-persisted user comments to seed the session. */ initialComments: ReviewComment[]; /** Append-only responses from the agent, grouped by stable user comment id. */ agentResponses: AgentResponse[]; loadDiffFor: (sha: string | null) => Promise; /** Full file content at one side of an item; null when unavailable. */ loadFileFor?: (sha: string | null, file: string, side: "new" | "old") => Promise; /** Browser launcher override for deterministic integration tests. */ openBrowser?: (url: string) => void; } type CommentAnchor = Omit; function sendJSON(res: ServerResponse, status: number, data: unknown): void { res.writeHead(status, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify(data)); } function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { let data = ""; req.setEncoding("utf8"); req.on("data", (chunk: string) => { data += chunk; if (data.length > 5 * 1024 * 1024) { reject(new Error("body too large")); req.destroy(); } }); req.on("end", () => resolve(data)); req.on("error", reject); }); } /** Validate a client-supplied comment anchor. */ function parseAnchor(value: unknown): CommentAnchor | null { if (typeof value !== "object" || value === null) return null; const a = value as Record; if (a.sha !== null && typeof a.sha !== "string") return null; if (typeof a.file !== "string" || a.file.length === 0) return null; if (a.side !== "new" && a.side !== "old") return null; if (typeof a.line !== "number" || !Number.isInteger(a.line) || a.line < 1) return null; const anchor: CommentAnchor = { sha: a.sha as string | null, file: a.file, side: a.side, line: a.line, }; if (typeof a.lineText === "string") anchor.lineText = a.lineText; if (typeof a.replyTo === "string") anchor.replyTo = a.replyTo; if (typeof a.kind === "string") anchor.kind = a.kind; return anchor; } function openBrowser(url: string): void { const [cmd, args]: [string, string[]] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]]; execFile(cmd, args, () => { // best effort — the URL is on stdout as a fallback }); } /** Open the web review; resolves with ALL comments (user + agent) when the user closes/submits. */ export function openWebReview( opts: WebReviewOptions, ): Promise<{ comments: ReviewComment[]; agentResponses: AgentResponse[]; dismissedFindingIds: string[] }> { const items = opts.items; let agentComments = opts.agentComments; const summaries = opts.summaries; let userComments = opts.initialComments.filter((c) => c.author === "user"); let agentResponses = pruneAgentResponses(opts.agentResponses, userComments); const dismissedFindingIds = new Set(); const diffCache = new Map(); const loadDiffLines = async (i: number): Promise => { let lines = diffCache.get(i); if (!lines) { try { lines = parseUnifiedDiff(await opts.loadDiffFor(items[i]!.sha)); } catch { // A persisted conversation may outlive an amended/rebased commit. // Keep it actionable as a detached conversation. lines = []; } diffCache.set(i, lines); } return lines; }; const collectAnchorStates = async (): Promise> => { const states: Record = {}; const comments = [...userComments, ...agentComments]; for (let i = 0; i < items.length; i += 1) { const itemComments = commentsForSha(comments, items[i]!.sha); if (itemComments.length === 0) continue; const lines = await loadDiffLines(i); for (const comment of itemComments) { const lineIndex = findCommentLineIndex(lines, comment); if (comment.lineText === undefined && lineIndex >= 0) comment.lineText = lines[lineIndex]!.text; states[comment.id] = resolveAnchorState(lines, comment); } } return states; }; return new Promise((resolve) => { const sockets = new Set(); let settled = false; let listening = false; const server = createServer((req, res) => { handle(req, res).catch(() => { if (!res.headersSent) sendJSON(res, 500, { error: "internal error" }); else res.end(); }); }); const finish = (): void => { if (settled) return; settled = true; for (const s of sockets) s.destroy(); const done = (): void => resolve({ comments: [...userComments, ...agentComments], agentResponses, dismissedFindingIds: [...dismissedFindingIds], }); if (listening) server.close(() => done()); else done(); }; async function handle(req: IncomingMessage, res: ServerResponse): Promise { const method = req.method ?? "GET"; const url = new URL(req.url ?? "/", "http://127.0.0.1"); if (method === "GET" && url.pathname === "/") { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(webReviewDocument()); return; } if (method === "GET" && url.pathname === "/api/session") { const commentStates = await collectAnchorStates(); sendJSON(res, 200, { repo: basename(opts.cwd), items, agentComments, summaries, userComments, agentResponses, commentStates, detachedCommentIds: detachedConversationIds(userComments, agentComments, commentStates), }); return; } if (method === "GET" && url.pathname === "/api/diff") { const i = Number(url.searchParams.get("i")); if (!Number.isInteger(i) || i < 0 || i >= items.length) { sendJSON(res, 400, { error: "invalid item index" }); return; } const lines = await loadDiffLines(i); const states: Record = {}; for (const c of commentsForSha([...userComments, ...agentComments], items[i]!.sha)) { states[c.id] = resolveAnchorState(lines, c); } sendJSON(res, 200, { lines, states }); return; } if (method === "GET" && url.pathname === "/api/file") { const i = Number(url.searchParams.get("i")); const file = url.searchParams.get("file"); const side = url.searchParams.get("side") ?? "new"; if (!Number.isInteger(i) || i < 0 || i >= items.length || !file || (side !== "new" && side !== "old")) { sendJSON(res, 400, { error: "expected i, file, and side new|old" }); return; } if (!opts.loadFileFor) { sendJSON(res, 404, { error: "file content unavailable" }); return; } const content = await opts.loadFileFor(items[i]!.sha, file, side); if (content === null) { sendJSON(res, 404, { error: "file not found" }); return; } sendJSON(res, 200, { content }); return; } if (method === "POST" && url.pathname === "/api/comments") { let parsed: unknown; try { parsed = JSON.parse(await readBody(req)); } catch { sendJSON(res, 400, { error: "invalid JSON body" }); return; } const body = parsed as { anchor?: unknown; body?: unknown; status?: unknown }; const anchor = parseAnchor(body?.anchor); const status = body?.status === "open" || body?.status === "resolved" ? body.status : null; if (!anchor || (typeof body?.body !== "string" && !status)) { sendJSON(res, 400, { error: "expected { anchor, body } or { anchor, status }" }); return; } if (status) userComments = setCommentStatus(userComments, anchor, status); if (typeof body?.body === "string") { userComments = upsertComment(userComments, anchor, body.body); agentResponses = pruneAgentResponses(agentResponses, userComments); } sendJSON(res, 200, { userComments, agentResponses }); return; } if (method === "POST" && url.pathname === "/api/comments/delete") { let parsed: unknown; try { parsed = JSON.parse(await readBody(req)); } catch { sendJSON(res, 400, { error: "invalid JSON body" }); return; } const anchor = parseAnchor((parsed as { anchor?: unknown })?.anchor); if (!anchor) { sendJSON(res, 400, { error: "expected { anchor }" }); return; } const removed = findCommentAt(userComments, anchor); userComments = removeCommentAt(userComments, anchor); if (removed) agentResponses = pruneAgentResponses(agentResponses, userComments); sendJSON(res, 200, { userComments, agentResponses }); return; } if (method === "POST" && url.pathname === "/api/comments/action") { let parsed: unknown; try { parsed = JSON.parse(await readBody(req)); } catch { sendJSON(res, 400, { error: "invalid JSON body" }); return; } const body = parsed as { id?: unknown; action?: unknown }; const action = body.action === "fix" || body.action === "explain" || body.action === "add_test" || body.action === "show_alternative" ? (body.action as ConversationAction) : body.action === null ? null : undefined; if (typeof body.id !== "string" || action === undefined) { sendJSON(res, 400, { error: "expected { id, action: fix|explain|add_test|show_alternative|null }" }); return; } try { userComments = setConversationAction(userComments, body.id, action); } catch { sendJSON(res, 404, { error: "conversation not found" }); return; } sendJSON(res, 200, { userComments }); return; } if (method === "POST" && url.pathname === "/api/findings/dismiss") { let parsed: unknown; try { parsed = JSON.parse(await readBody(req)); } catch { sendJSON(res, 400, { error: "invalid JSON body" }); return; } const id = (parsed as { id?: unknown })?.id; const finding = typeof id === "string" ? agentComments.find((comment) => comment.id === id) : undefined; if (!finding?.fingerprint) { sendJSON(res, 404, { error: "structured finding not found" }); return; } dismissedFindingIds.add(finding.id); agentComments = agentComments.filter((comment) => comment.id !== finding.id); const commentStates = await collectAnchorStates(); sendJSON(res, 200, { agentComments, detachedCommentIds: detachedConversationIds(userComments, agentComments, commentStates), }); return; } if (method === "POST" && url.pathname === "/api/close") { sendJSON(res, 200, { ok: true }); finish(); return; } sendJSON(res, 404, { error: "not found" }); } server.on("connection", (socket) => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); }); server.on("error", finish); server.listen(0, "127.0.0.1", () => { listening = true; const addr = server.address() as AddressInfo; const url = `http://127.0.0.1:${addr.port}/`; // stderr, not stdout: under MCP stdio (ADR-0004) stdout is the protocol channel. console.error(`code-eye web review: ${url}`); (opts.openBrowser ?? openBrowser)(url); }); }); } /** Runtime review document, exported for a syntax smoke test of the shipped client. */ export function webReviewDocument(): string { return PAGE; } /** * The single self-contained review page. No external assets — must work * offline. Built with plain string-safe JS (no template literals inside, * so the outer backtick literal stays intact). */ const PAGE = ` Code Review
/Review
n/p findings · [/] mine
Ready for review
`;