-
Notifications
You must be signed in to change notification settings - Fork 322
Expand file tree
/
Copy pathreview.ts
More file actions
328 lines (283 loc) · 11 KB
/
review.ts
File metadata and controls
328 lines (283 loc) · 11 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
/**
* 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, getServerHostname } from "./remote";
import { type DiffType, type GitContext, runGitDiff, getFileContentsForDiff, gitAddFile, gitResetFile, parseWorktreeDiffType, validateFilePath } from "./git";
import { getRepoInfo } from "./repo";
import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, type OpencodeClient } from "./shared-handlers";
import { contentHash, deleteDraft } from "./draft";
import { createEditorAnnotationHandler } from "./editor-annotations";
// Re-export utilities
export { isRemoteSession, getServerPort } from "./remote";
export { openBrowser } from "./browser";
export { type DiffType, type DiffOption, type GitContext, type WorktreeInfo } from "./git";
export { handleServerReady as handleReviewServerReady } from "./shared-handlers";
// --- 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;
/** Error message if git diff failed */
error?: string;
/** HTML content to serve for the UI */
htmlContent: string;
/** Origin identifier for UI customization */
origin?: "opencode" | "claude-code" | "pi";
/** Current diff type being displayed */
diffType?: DiffType;
/** Git context with branch info and available diff options */
gitContext?: GitContext;
/** Whether URL sharing is enabled (default: true) */
sharingEnabled?: boolean;
/** Custom base URL for share links (default: https://share.plannotator.ai) */
shareBaseUrl?: string;
/** Called when server starts with the URL, remote status, and port */
onReady?: (url: string, isRemote: boolean, port: number) => void;
/** OpenCode client for querying available agents (OpenCode only) */
opencodeClient?: OpencodeClient;
}
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> {
const { htmlContent, origin, gitContext, sharingEnabled = true, shareBaseUrl, onReady } = options;
const draftKey = contentHash(options.rawPatch);
const editorAnnotations = createEditorAnnotationHandler();
// Mutable state for diff switching
let currentPatch = options.rawPatch;
let currentGitRef = options.gitRef;
let currentDiffType: DiffType = options.diffType || "uncommitted";
let currentError = options.error;
const isRemote = isRemoteSession();
const configuredPort = getServerPort();
const hostname = await getServerHostname();
// Detect repo info (cached for this session)
const repoInfo = await getRepoInfo();
// 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,
hostname: hostname !== "localhost" ? "0.0.0.0" : undefined,
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,
sharingEnabled,
shareBaseUrl,
repoInfo,
...(currentError && { error: currentError }),
});
}
// API: Switch diff type
if (url.pathname === "/api/diff/switch" && req.method === "POST") {
try {
const body = (await req.json()) as { diffType: DiffType };
let newDiffType = body.diffType;
if (!newDiffType) {
return Response.json(
{ error: "Missing diffType" },
{ status: 400 }
);
}
const defaultBranch = gitContext?.defaultBranch || "main";
// Run the new diff
const result = await runGitDiff(newDiffType, defaultBranch);
// Update state
currentPatch = result.patch;
currentGitRef = result.label;
currentDiffType = newDiffType;
currentError = result.error;
return Response.json({
rawPatch: currentPatch,
gitRef: currentGitRef,
diffType: currentDiffType,
...(currentError && { error: currentError }),
});
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to switch diff";
return Response.json({ error: message }, { status: 500 });
}
}
// API: Get file content for expandable diff context
if (url.pathname === "/api/file-content" && req.method === "GET") {
const filePath = url.searchParams.get("path");
if (!filePath) {
return Response.json({ error: "Missing path" }, { status: 400 });
}
try { validateFilePath(filePath); } catch {
return Response.json({ error: "Invalid path" }, { status: 400 });
}
const oldPath = url.searchParams.get("oldPath") || undefined;
if (oldPath) {
try { validateFilePath(oldPath); } catch {
return Response.json({ error: "Invalid path" }, { status: 400 });
}
}
const defaultBranch = gitContext?.defaultBranch || "main";
const result = await getFileContentsForDiff(
currentDiffType,
defaultBranch,
filePath,
oldPath,
);
return Response.json(result);
}
// API: Git add / reset (stage / unstage) a file
if (url.pathname === "/api/git-add" && req.method === "POST") {
try {
const body = (await req.json()) as { filePath: string; undo?: boolean };
if (!body.filePath) {
return Response.json({ error: "Missing filePath" }, { status: 400 });
}
// Determine cwd for worktree support
let cwd: string | undefined;
if (currentDiffType.startsWith("worktree:")) {
const parsed = parseWorktreeDiffType(currentDiffType);
if (parsed) cwd = parsed.path;
}
if (body.undo) {
await gitResetFile(body.filePath, cwd);
} else {
await gitAddFile(body.filePath, cwd);
}
return Response.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to git add";
return Response.json({ error: message }, { status: 500 });
}
}
// API: Serve images (local paths or temp uploads)
if (url.pathname === "/api/image") {
return handleImage(req);
}
// API: Upload image -> save to temp -> return path
if (url.pathname === "/api/upload" && req.method === "POST") {
return handleUpload(req);
}
// API: Get available agents (OpenCode only)
if (url.pathname === "/api/agents") {
return handleAgents(options.opencodeClient);
}
// 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: Editor annotations (VS Code extension)
const editorResponse = await editorAnnotations.handle(req, url);
if (editorResponse) return editorResponse;
// 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;
};
deleteDraft(draftKey);
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://${hostname}:${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(),
};
}