Files
backnotprop__plannotator/packages/server/annotate.ts
T
Michael Ramos dd04e6b1cd feat: add Conventional Comments support to code review (#501)
* feat: add Conventional Comments support to code review

Add structured label system based on the Conventional Comments spec
(conventionalcomments.org) to the code review annotation toolbar.

- Label picker with 9 default labels (suggestion, nit, question, issue,
  praise, thought, note, todo, chore) shown above the comment textarea
- Per-label blocking/non-blocking toggle for labels where severity is
  ambiguous (issue, suggestion, todo, chore)
- Labels exported as `**label** (decoration): text` in markdown feedback,
  naturally readable on GitHub and machine-parseable
- Label badges shown on inline annotations and sidebar cards
- Settings tab (Comments) with: master on/off toggle, editable label
  list with add/remove/rename, per-label blocking decorator toggle,
  educational intro with spec link and example output
- Feature is off by default, persisted via config store
- Custom labels supported via settings (up to 12)

For provenance purposes, this commit was AI assisted.

* fix(review): address conventional comments review feedback

Fix label clearing bug where editing an annotation to remove its
conventional label silently preserved the old value. Fix parseCCLabels
accepting 'non-blocking' as a truthy blocking value. Fix duplicate
custom label identities when adding multiple labels. Prepend
conventional prefix to platform PR/MR inline comments.

For provenance purposes, this commit was AI assisted.

* fix(server): persist conventionalComments setting to config.json

All six server config handlers (bun plan/review/annotate + pi
plan/review/annotate) now accept and save the conventionalComments
boolean, so the setting survives server restarts.

For provenance purposes, this commit was AI assisted.

* fix: add conventionalComments to server config round-trip

getServerConfig() was not returning the conventionalComments value
from config.json, so the setting silently reset to false on cookie
clear or new browser sessions. Also adds the field to the
PlannotatorConfig interface so saveConfig() is properly typed.

For provenance purposes, this commit was AI assisted.

* fix: label display, export, and draft persistence for conventional comments

- Remove slug derivation from label editing — store display text
  verbatim so badges and exports show what the user typed
- Emit conventional prefix in export even when annotation has no
  prose text (suggestion-only annotations were losing their label)
- Include conventionalLabel in draft save condition so label
  selection persists across file switches

For provenance purposes, this commit was AI assisted.

* fix: spec-compliant export format and empty label support

- Wrap label + decorations + colon in markdown bold per the
  Conventional Comments spec examples (was bolding only the label)
- Allow users to clear all labels — empty array no longer falls
  back to defaults in either the picker or the settings parser
- Enable blocking decorator on the question label by default; the
  spec permits blocking questions and they sometimes are critical

For provenance purposes, this commit was AI assisted.

* fix: server-sync conventionalLabels and small cleanups

- Sync conventionalLabels to ~/.plannotator/config.json so custom
  label configurations survive cookie clears and remote/SSH
  sessions, matching the persistence story for conventionalComments.
  Stored as a parsed array on disk; serialized as JSON string in
  the local config store to match the cookie format.
- Remove no-op intermediate variable in handleSubmitAnnotation;
  pass conventionalLabel/decorations directly with proper coalescing
  per call path (edit preserves null, add coalesces to undefined).
- Use index-prefixed key in the picker label list to avoid React
  warnings if duplicate labels ever exist.

For provenance purposes, this commit was AI assisted.
2026-04-06 22:27:47 -07:00

282 lines
9.3 KiB
TypeScript

/**
* Annotate Server
*
* Provides a server for annotating arbitrary markdown files.
* Follows the same patterns as the review server but serves
* markdown content via /api/plan so the plan editor UI can
* render it without modifications.
*
* Environment variables:
* PLANNOTATOR_REMOTE - Set to "1"/"true" for remote, "0"/"false" for local
* PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote)
*/
import { isRemoteSession, getServerPort } from "./remote";
import { getRepoInfo } from "./repo";
import type { Origin } from "@plannotator/shared/agents";
import { handleImage, handleUpload, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleFavicon } from "./shared-handlers";
import { handleDoc, handleFileBrowserFiles } from "./reference-handlers";
import { contentHash, deleteDraft } from "./draft";
import { createExternalAnnotationHandler } from "./external-annotations";
import { saveConfig, detectGitUser, getServerConfig } from "./config";
import { dirname } from "path";
import { isWSL } from "./browser";
// Re-export utilities
export { isRemoteSession, getServerPort } from "./remote";
export { openBrowser } from "./browser";
export { handleServerReady as handleAnnotateServerReady } from "./shared-handlers";
// --- Types ---
export interface AnnotateServerOptions {
/** Markdown content of the file to annotate */
markdown: string;
/** Original file path (for display purposes) */
filePath: string;
/** HTML content to serve for the UI */
htmlContent: string;
/** Origin identifier for UI customization */
origin?: Origin;
/** UI mode: "annotate" for files, "annotate-last" for last agent message, "annotate-folder" for folders */
mode?: "annotate" | "annotate-last" | "annotate-folder";
/** Folder path when annotating a directory (used as projectRoot for file browser) */
folderPath?: string;
/** Whether URL sharing is enabled (default: true) */
sharingEnabled?: boolean;
/** Custom base URL for share links */
shareBaseUrl?: string;
/** Base URL of the paste service API for short URL sharing */
pasteApiUrl?: string;
/** Called when server starts with the URL, remote status, and port */
onReady?: (url: string, isRemote: boolean, port: number) => void;
}
export interface AnnotateServerResult {
/** 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[];
}>;
/** Stop the server */
stop: () => void;
}
// --- Server Implementation ---
const MAX_RETRIES = 5;
const RETRY_DELAY_MS = 500;
/**
* Start the Annotate server
*
* Handles:
* - Remote detection and port configuration
* - API routes (/api/plan with mode:"annotate", /api/feedback)
* - Port conflict retries
*/
export async function startAnnotateServer(
options: AnnotateServerOptions
): Promise<AnnotateServerResult> {
const {
markdown,
filePath,
htmlContent,
origin,
mode = "annotate",
folderPath,
sharingEnabled = true,
shareBaseUrl,
pasteApiUrl,
onReady,
} = options;
const isRemote = isRemoteSession();
const configuredPort = getServerPort();
const wslFlag = await isWSL();
const gitUser = detectGitUser();
const draftKey = contentHash(markdown);
const externalAnnotations = createExternalAnnotationHandler("plan");
// Detect repo info (cached for this session)
const repoInfo = await getRepoInfo();
// Decision promise
let resolveDecision: (result: {
feedback: string;
annotations: unknown[];
}) => void;
const decisionPromise = new Promise<{
feedback: string;
annotations: unknown[];
}>((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, server) {
const url = new URL(req.url);
// API: Get plan content (reuse /api/plan so the plan editor UI works)
if (url.pathname === "/api/plan" && req.method === "GET") {
return Response.json({
plan: markdown,
origin,
mode,
filePath,
sharingEnabled,
shareBaseUrl,
pasteApiUrl,
repoInfo,
projectRoot: folderPath || process.cwd(),
isWSL: wslFlag,
serverConfig: getServerConfig(gitUser),
});
}
// API: Update user config (write-back to ~/.plannotator/config.json)
if (url.pathname === "/api/config" && req.method === "POST") {
try {
const body = (await req.json()) as { displayName?: string; diffOptions?: Record<string, unknown>; conventionalComments?: boolean; conventionalLabels?: unknown[] | null };
const toSave: Record<string, unknown> = {};
if (body.displayName !== undefined) toSave.displayName = body.displayName;
if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions;
if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments;
if (body.conventionalLabels !== undefined) toSave.conventionalLabels = body.conventionalLabels;
if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters<typeof saveConfig>[0]);
return Response.json({ ok: true });
} catch {
return Response.json({ error: "Invalid request" }, { status: 400 });
}
}
// API: Serve images (local paths or temp uploads)
if (url.pathname === "/api/image") {
return handleImage(req);
}
// API: Serve a linked markdown document
// Inject source file's directory as base for relative path resolution
if (url.pathname === "/api/doc" && req.method === "GET") {
if (!url.searchParams.has("base")) {
const docUrl = new URL(req.url);
docUrl.searchParams.set("base", dirname(filePath));
return handleDoc(new Request(docUrl.toString()));
}
return handleDoc(req);
}
// API: List markdown files in a directory as a tree
if (url.pathname === "/api/reference/files" && req.method === "GET") {
return handleFileBrowserFiles(req);
}
// API: Upload image -> save to temp -> return path
if (url.pathname === "/api/upload" && req.method === "POST") {
return handleUpload(req);
}
// 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);
}
// API: External annotations (SSE-based, for any external tool)
const externalResponse = await externalAnnotations.handle(req, url, {
disableIdleTimeout: () => server.timeout(req, 0),
});
if (externalResponse) return externalResponse;
// API: Submit annotation feedback
if (url.pathname === "/api/feedback" && req.method === "POST") {
try {
const body = (await req.json()) as {
feedback: string;
annotations: unknown[];
};
deleteDraft(draftKey);
resolveDecision({
feedback: body.feedback || "",
annotations: body.annotations || [],
});
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 });
}
}
// Favicon
if (url.pathname === "/favicon.svg") return handleFavicon();
// 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 port = server.port!;
const serverUrl = `http://localhost:${port}`;
// Notify caller that server is ready
if (onReady) {
onReady(serverUrl, isRemote, port);
}
return {
port,
url: serverUrl,
isRemote,
waitForDecision: () => decisionPromise,
stop: () => server.stop(),
};
}