mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
dd04e6b1cd
* 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.
282 lines
9.3 KiB
TypeScript
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(),
|
|
};
|
|
}
|