getgloss 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -18
- package/dist/cli/index.js +397 -299
- package/dist/cli/index.js.map +1 -1
- package/dist/server/daemon.js +239 -28
- package/dist/server/daemon.js.map +1 -1
- package/dist/web/assets/index-Da2TNJX9.js +188 -0
- package/dist/web/assets/index-uGiivUSv.css +1 -0
- package/dist/web/index.html +2 -2
- package/dist/web/prompt.md +13 -6
- package/dist/web/setup/index.html +4 -1
- package/dist/web/setup.md +18 -4
- package/package.json +2 -4
- package/skill/SKILL.md +7 -2
- package/dist/mcp/index.js +0 -378
- package/dist/mcp/index.js.map +0 -1
- package/dist/web/assets/index-DuGSsf8O.js +0 -181
- package/dist/web/assets/index-SRKfUpIg.css +0 -1
package/dist/cli/index.js
CHANGED
|
@@ -4,18 +4,6 @@
|
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
import openBrowser from "open";
|
|
6
6
|
|
|
7
|
-
// src/mcp/index.ts
|
|
8
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
|
-
import { z } from "zod/v4";
|
|
11
|
-
|
|
12
|
-
// src/cli/lifecycle.ts
|
|
13
|
-
import { spawn } from "child_process";
|
|
14
|
-
import { existsSync, openSync } from "fs";
|
|
15
|
-
import { readFile, rm, writeFile } from "fs/promises";
|
|
16
|
-
import { fileURLToPath } from "url";
|
|
17
|
-
import getPort from "get-port";
|
|
18
|
-
|
|
19
7
|
// src/shared/paths.ts
|
|
20
8
|
import { mkdir } from "fs/promises";
|
|
21
9
|
import { homedir } from "os";
|
|
@@ -24,7 +12,7 @@ import path from "path";
|
|
|
24
12
|
// package.json
|
|
25
13
|
var package_default = {
|
|
26
14
|
name: "getgloss",
|
|
27
|
-
version: "0.
|
|
15
|
+
version: "0.4.1",
|
|
28
16
|
description: "Local browser-based diff review for coding-agent loops.",
|
|
29
17
|
type: "module",
|
|
30
18
|
packageManager: "pnpm@10.33.2",
|
|
@@ -55,7 +43,6 @@ var package_default = {
|
|
|
55
43
|
},
|
|
56
44
|
dependencies: {
|
|
57
45
|
"@hono/node-server": "^1.14.4",
|
|
58
|
-
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
59
46
|
"@pierre/diffs": "^1.2.1",
|
|
60
47
|
"@tailwindcss/vite": "^4.1.7",
|
|
61
48
|
commander: "^14.0.0",
|
|
@@ -86,8 +73,7 @@ var package_default = {
|
|
|
86
73
|
keywords: [
|
|
87
74
|
"diff",
|
|
88
75
|
"review",
|
|
89
|
-
"coding-agents"
|
|
90
|
-
"mcp"
|
|
76
|
+
"coding-agents"
|
|
91
77
|
],
|
|
92
78
|
author: "Raj Joshi",
|
|
93
79
|
license: "MIT",
|
|
@@ -152,265 +138,6 @@ async function ensureDir(dir) {
|
|
|
152
138
|
await mkdir(dir, { recursive: true });
|
|
153
139
|
}
|
|
154
140
|
|
|
155
|
-
// src/cli/server-client.ts
|
|
156
|
-
var ServerClient = class {
|
|
157
|
-
constructor(baseUrl) {
|
|
158
|
-
this.baseUrl = baseUrl;
|
|
159
|
-
}
|
|
160
|
-
baseUrl;
|
|
161
|
-
async health() {
|
|
162
|
-
return this.get("/api/health");
|
|
163
|
-
}
|
|
164
|
-
async createReview(diff) {
|
|
165
|
-
return this.post("/api/reviews", diff);
|
|
166
|
-
}
|
|
167
|
-
async getReview(reviewId) {
|
|
168
|
-
return this.get(`/api/reviews/${reviewId}`);
|
|
169
|
-
}
|
|
170
|
-
async listReviews() {
|
|
171
|
-
return this.get("/api/reviews");
|
|
172
|
-
}
|
|
173
|
-
async getFeedback(reviewId) {
|
|
174
|
-
return this.get(`/api/reviews/${reviewId}/feedback`);
|
|
175
|
-
}
|
|
176
|
-
async markResolved(reviewId, summary) {
|
|
177
|
-
return this.post(`/api/reviews/${reviewId}/resolved`, { summary });
|
|
178
|
-
}
|
|
179
|
-
async submitReview(reviewId, comments) {
|
|
180
|
-
return this.post(`/api/reviews/${reviewId}/submit`, { comments });
|
|
181
|
-
}
|
|
182
|
-
async watchReview(reviewId, timeoutSeconds) {
|
|
183
|
-
const controller = new AbortController();
|
|
184
|
-
const timeout = timeoutSeconds && timeoutSeconds > 0 ? setTimeout(() => controller.abort(), timeoutSeconds * 1e3) : null;
|
|
185
|
-
try {
|
|
186
|
-
const response = await fetch(`${this.baseUrl}/api/reviews/${reviewId}/events`, {
|
|
187
|
-
signal: controller.signal
|
|
188
|
-
});
|
|
189
|
-
if (!response.ok || !response.body) {
|
|
190
|
-
throw new Error(`watch failed: ${response.status} ${await response.text()}`);
|
|
191
|
-
}
|
|
192
|
-
const reader = response.body.getReader();
|
|
193
|
-
const decoder = new TextDecoder();
|
|
194
|
-
let buffer = "";
|
|
195
|
-
while (true) {
|
|
196
|
-
const { value, done } = await reader.read();
|
|
197
|
-
if (done) {
|
|
198
|
-
throw new Error("watch stream ended before completion");
|
|
199
|
-
}
|
|
200
|
-
buffer += decoder.decode(value, { stream: true });
|
|
201
|
-
const events = buffer.split("\n\n");
|
|
202
|
-
buffer = events.pop() ?? "";
|
|
203
|
-
for (const eventChunk of events) {
|
|
204
|
-
const dataLine = eventChunk.split("\n").find((line) => line.startsWith("data:"));
|
|
205
|
-
if (!dataLine) {
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
const event = JSON.parse(dataLine.slice(5).trim());
|
|
209
|
-
if (event.type === "review.completed" || event.type === "review.cancelled") {
|
|
210
|
-
return event;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
} finally {
|
|
215
|
-
if (timeout) {
|
|
216
|
-
clearTimeout(timeout);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
async get(path3) {
|
|
221
|
-
const response = await fetch(`${this.baseUrl}${path3}`);
|
|
222
|
-
return parseResponse(response);
|
|
223
|
-
}
|
|
224
|
-
async post(path3, body) {
|
|
225
|
-
const response = await fetch(`${this.baseUrl}${path3}`, {
|
|
226
|
-
method: "POST",
|
|
227
|
-
headers: { "content-type": "application/json" },
|
|
228
|
-
body: JSON.stringify(body)
|
|
229
|
-
});
|
|
230
|
-
return parseResponse(response);
|
|
231
|
-
}
|
|
232
|
-
};
|
|
233
|
-
async function parseResponse(response) {
|
|
234
|
-
if (!response.ok) {
|
|
235
|
-
throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`);
|
|
236
|
-
}
|
|
237
|
-
return await response.json();
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// src/cli/lifecycle.ts
|
|
241
|
-
async function readServerInfo() {
|
|
242
|
-
try {
|
|
243
|
-
return JSON.parse(await readFile(globalServerFile(), "utf8"));
|
|
244
|
-
} catch {
|
|
245
|
-
return null;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
function serverUrl(info) {
|
|
249
|
-
return `http://localhost:${info.port}`;
|
|
250
|
-
}
|
|
251
|
-
async function isServerResponsive(info) {
|
|
252
|
-
if (!isPidAlive(info.pid)) {
|
|
253
|
-
return false;
|
|
254
|
-
}
|
|
255
|
-
try {
|
|
256
|
-
const health = await new ServerClient(serverUrl(info)).health();
|
|
257
|
-
return health.ok === true;
|
|
258
|
-
} catch {
|
|
259
|
-
return false;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
async function ensureServer(options = {}) {
|
|
263
|
-
const existing = await readServerInfo();
|
|
264
|
-
if (existing && await isServerResponsive(existing)) {
|
|
265
|
-
return existing;
|
|
266
|
-
}
|
|
267
|
-
return startServer(options);
|
|
268
|
-
}
|
|
269
|
-
async function startServer(options = {}) {
|
|
270
|
-
const existing = await readServerInfo();
|
|
271
|
-
if (existing && await isServerResponsive(existing)) {
|
|
272
|
-
return existing;
|
|
273
|
-
}
|
|
274
|
-
await ensureDir(globalStateDir());
|
|
275
|
-
await ensureDir(globalLogDir());
|
|
276
|
-
const port = options.port ?? await getPort();
|
|
277
|
-
const daemonPath = fileURLToPath(new URL("../server/daemon.js", import.meta.url));
|
|
278
|
-
if (!existsSync(daemonPath)) {
|
|
279
|
-
throw new Error(`Cannot find server daemon at ${daemonPath}. Run pnpm build first.`);
|
|
280
|
-
}
|
|
281
|
-
const logFd = openSync(globalServerLogFile(), "a");
|
|
282
|
-
const child = spawn(process.execPath, [daemonPath], {
|
|
283
|
-
detached: true,
|
|
284
|
-
env: {
|
|
285
|
-
...process.env,
|
|
286
|
-
GLOSS_PORT: String(port),
|
|
287
|
-
GLOSS_STATE_DIR: globalStateDir()
|
|
288
|
-
},
|
|
289
|
-
stdio: ["ignore", logFd, logFd]
|
|
290
|
-
});
|
|
291
|
-
child.unref();
|
|
292
|
-
const info = {
|
|
293
|
-
pid: child.pid ?? -1,
|
|
294
|
-
port,
|
|
295
|
-
version: packageVersion,
|
|
296
|
-
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
297
|
-
stateDir: globalStateDir()
|
|
298
|
-
};
|
|
299
|
-
await writeFile(globalServerFile(), `${JSON.stringify(info, null, 2)}
|
|
300
|
-
`);
|
|
301
|
-
const deadline = Date.now() + 8e3;
|
|
302
|
-
while (Date.now() < deadline) {
|
|
303
|
-
if (await isServerResponsive(info)) {
|
|
304
|
-
return info;
|
|
305
|
-
}
|
|
306
|
-
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
307
|
-
}
|
|
308
|
-
throw new Error(`Server did not become responsive. See ${globalServerLogFile()}`);
|
|
309
|
-
}
|
|
310
|
-
async function stopServer() {
|
|
311
|
-
const info = await readServerInfo();
|
|
312
|
-
if (!info) {
|
|
313
|
-
return { stopped: false, info: null };
|
|
314
|
-
}
|
|
315
|
-
if (isPidAlive(info.pid)) {
|
|
316
|
-
process.kill(info.pid, "SIGTERM");
|
|
317
|
-
}
|
|
318
|
-
await rm(globalServerFile(), { force: true });
|
|
319
|
-
return { stopped: true, info };
|
|
320
|
-
}
|
|
321
|
-
function isPidAlive(pid) {
|
|
322
|
-
if (pid <= 0) {
|
|
323
|
-
return false;
|
|
324
|
-
}
|
|
325
|
-
try {
|
|
326
|
-
process.kill(pid, 0);
|
|
327
|
-
return true;
|
|
328
|
-
} catch {
|
|
329
|
-
return false;
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// src/mcp/index.ts
|
|
334
|
-
function textResult(value) {
|
|
335
|
-
return {
|
|
336
|
-
content: [
|
|
337
|
-
{
|
|
338
|
-
type: "text",
|
|
339
|
-
text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
340
|
-
}
|
|
341
|
-
]
|
|
342
|
-
};
|
|
343
|
-
}
|
|
344
|
-
async function client() {
|
|
345
|
-
const info = await ensureServer();
|
|
346
|
-
return new ServerClient(serverUrl(info));
|
|
347
|
-
}
|
|
348
|
-
async function startMcpServer() {
|
|
349
|
-
const server = new McpServer({
|
|
350
|
-
name: "gloss",
|
|
351
|
-
version: packageVersion
|
|
352
|
-
});
|
|
353
|
-
server.registerTool(
|
|
354
|
-
"list_pending_reviews",
|
|
355
|
-
{
|
|
356
|
-
title: "List pending Gloss reviews",
|
|
357
|
-
description: "List pending local Gloss review sessions."
|
|
358
|
-
},
|
|
359
|
-
async () => {
|
|
360
|
-
const api = await client();
|
|
361
|
-
const { reviews } = await api.listReviews();
|
|
362
|
-
return textResult({ reviews: reviews.filter((review) => review.status === "pending") });
|
|
363
|
-
}
|
|
364
|
-
);
|
|
365
|
-
server.registerTool(
|
|
366
|
-
"get_review",
|
|
367
|
-
{
|
|
368
|
-
title: "Get Gloss review",
|
|
369
|
-
description: "Fetch review metadata and diff payload.",
|
|
370
|
-
inputSchema: { id: z.string() }
|
|
371
|
-
},
|
|
372
|
-
async ({ id }) => textResult(await (await client()).getReview(id))
|
|
373
|
-
);
|
|
374
|
-
server.registerTool(
|
|
375
|
-
"watch_review",
|
|
376
|
-
{
|
|
377
|
-
title: "Watch Gloss review",
|
|
378
|
-
description: "Block until a review completes, then return feedback.",
|
|
379
|
-
inputSchema: {
|
|
380
|
-
id: z.string(),
|
|
381
|
-
timeout: z.number().optional()
|
|
382
|
-
}
|
|
383
|
-
},
|
|
384
|
-
async ({ id, timeout }) => {
|
|
385
|
-
const api = await client();
|
|
386
|
-
await api.watchReview(id, timeout);
|
|
387
|
-
return textResult(await api.getFeedback(id));
|
|
388
|
-
}
|
|
389
|
-
);
|
|
390
|
-
server.registerTool(
|
|
391
|
-
"get_review_feedback",
|
|
392
|
-
{
|
|
393
|
-
title: "Get Gloss review feedback",
|
|
394
|
-
description: "Fetch completed review feedback.",
|
|
395
|
-
inputSchema: { id: z.string() }
|
|
396
|
-
},
|
|
397
|
-
async ({ id }) => textResult(await (await client()).getFeedback(id))
|
|
398
|
-
);
|
|
399
|
-
server.registerTool(
|
|
400
|
-
"mark_review_resolved",
|
|
401
|
-
{
|
|
402
|
-
title: "Mark Gloss review resolved",
|
|
403
|
-
description: "Write a resolved marker for a completed review.",
|
|
404
|
-
inputSchema: {
|
|
405
|
-
id: z.string(),
|
|
406
|
-
summary: z.string().optional()
|
|
407
|
-
}
|
|
408
|
-
},
|
|
409
|
-
async ({ id, summary }) => textResult(await (await client()).markResolved(id, summary))
|
|
410
|
-
);
|
|
411
|
-
await server.connect(new StdioServerTransport());
|
|
412
|
-
}
|
|
413
|
-
|
|
414
141
|
// src/cli/git.ts
|
|
415
142
|
import { execa } from "execa";
|
|
416
143
|
|
|
@@ -738,6 +465,228 @@ async function assertGitAvailable() {
|
|
|
738
465
|
await execa("git", ["--version"]);
|
|
739
466
|
}
|
|
740
467
|
|
|
468
|
+
// src/cli/lifecycle.ts
|
|
469
|
+
import { spawn } from "child_process";
|
|
470
|
+
import { existsSync, openSync } from "fs";
|
|
471
|
+
import { readFile, rm, writeFile } from "fs/promises";
|
|
472
|
+
import { fileURLToPath } from "url";
|
|
473
|
+
import getPort from "get-port";
|
|
474
|
+
|
|
475
|
+
// src/cli/server-client.ts
|
|
476
|
+
var ServerClient = class {
|
|
477
|
+
constructor(baseUrl) {
|
|
478
|
+
this.baseUrl = baseUrl;
|
|
479
|
+
}
|
|
480
|
+
baseUrl;
|
|
481
|
+
async health() {
|
|
482
|
+
return this.get("/api/health");
|
|
483
|
+
}
|
|
484
|
+
async createReview(diff) {
|
|
485
|
+
return this.post("/api/reviews", diff);
|
|
486
|
+
}
|
|
487
|
+
async getReview(reviewId) {
|
|
488
|
+
return this.get(`/api/reviews/${reviewId}`);
|
|
489
|
+
}
|
|
490
|
+
async listReviews() {
|
|
491
|
+
return this.get("/api/reviews");
|
|
492
|
+
}
|
|
493
|
+
async getFeedback(reviewId) {
|
|
494
|
+
return this.get(`/api/reviews/${reviewId}/feedback`);
|
|
495
|
+
}
|
|
496
|
+
async markResolved(reviewId, summary) {
|
|
497
|
+
return this.post(`/api/reviews/${reviewId}/resolved`, { summary });
|
|
498
|
+
}
|
|
499
|
+
async resolveComment(reviewId, commentId, summary) {
|
|
500
|
+
return this.post(`/api/reviews/${reviewId}/comments/${commentId}/resolved`, { summary });
|
|
501
|
+
}
|
|
502
|
+
async reopenComment(reviewId, commentId) {
|
|
503
|
+
return this.delete(`/api/reviews/${reviewId}/comments/${commentId}/resolved`);
|
|
504
|
+
}
|
|
505
|
+
async submitReview(reviewId, comments) {
|
|
506
|
+
return this.post(`/api/reviews/${reviewId}/submit`, { comments });
|
|
507
|
+
}
|
|
508
|
+
async watchReview(reviewId, timeoutSeconds) {
|
|
509
|
+
const deadline = timeoutSeconds && timeoutSeconds > 0 ? Date.now() + timeoutSeconds * 1e3 : null;
|
|
510
|
+
while (true) {
|
|
511
|
+
const remainingMs = deadline ? deadline - Date.now() : null;
|
|
512
|
+
if (remainingMs !== null && remainingMs <= 0) {
|
|
513
|
+
throw new Error(`watch timed out after ${timeoutSeconds} seconds`);
|
|
514
|
+
}
|
|
515
|
+
const controller = new AbortController();
|
|
516
|
+
const timeout = remainingMs ? setTimeout(() => controller.abort(), remainingMs) : null;
|
|
517
|
+
try {
|
|
518
|
+
return await this.readReviewEvents(reviewId, controller.signal);
|
|
519
|
+
} catch (error) {
|
|
520
|
+
if (isAbortError(error)) {
|
|
521
|
+
throw new Error(`watch timed out after ${timeoutSeconds} seconds`);
|
|
522
|
+
}
|
|
523
|
+
if (!isPrematureWatchEnd(error)) {
|
|
524
|
+
throw error;
|
|
525
|
+
}
|
|
526
|
+
await sleep(500);
|
|
527
|
+
} finally {
|
|
528
|
+
if (timeout) {
|
|
529
|
+
clearTimeout(timeout);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
async readReviewEvents(reviewId, signal) {
|
|
535
|
+
const response = await fetch(`${this.baseUrl}/api/reviews/${reviewId}/events`, {
|
|
536
|
+
signal
|
|
537
|
+
});
|
|
538
|
+
if (!response.ok || !response.body) {
|
|
539
|
+
throw new Error(`watch failed: ${response.status} ${await response.text()}`);
|
|
540
|
+
}
|
|
541
|
+
const reader = response.body.getReader();
|
|
542
|
+
const decoder = new TextDecoder();
|
|
543
|
+
let buffer = "";
|
|
544
|
+
while (true) {
|
|
545
|
+
const { value, done } = await reader.read();
|
|
546
|
+
if (done) {
|
|
547
|
+
throw new Error("watch stream ended before completion");
|
|
548
|
+
}
|
|
549
|
+
buffer += decoder.decode(value, { stream: true });
|
|
550
|
+
const events = buffer.split("\n\n");
|
|
551
|
+
buffer = events.pop() ?? "";
|
|
552
|
+
for (const eventChunk of events) {
|
|
553
|
+
const dataLine = eventChunk.split("\n").find((line) => line.startsWith("data:"));
|
|
554
|
+
if (!dataLine) {
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
const event = JSON.parse(dataLine.slice(5).trim());
|
|
558
|
+
if (event.type === "review.submitted" || event.type === "review.cancelled") {
|
|
559
|
+
return event;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
async get(path3) {
|
|
565
|
+
const response = await fetch(`${this.baseUrl}${path3}`);
|
|
566
|
+
return parseResponse(response);
|
|
567
|
+
}
|
|
568
|
+
async post(path3, body) {
|
|
569
|
+
const response = await fetch(`${this.baseUrl}${path3}`, {
|
|
570
|
+
method: "POST",
|
|
571
|
+
headers: { "content-type": "application/json" },
|
|
572
|
+
body: JSON.stringify(body)
|
|
573
|
+
});
|
|
574
|
+
return parseResponse(response);
|
|
575
|
+
}
|
|
576
|
+
async delete(path3) {
|
|
577
|
+
const response = await fetch(`${this.baseUrl}${path3}`, { method: "DELETE" });
|
|
578
|
+
return parseResponse(response);
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
async function parseResponse(response) {
|
|
582
|
+
if (!response.ok) {
|
|
583
|
+
throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`);
|
|
584
|
+
}
|
|
585
|
+
return await response.json();
|
|
586
|
+
}
|
|
587
|
+
function isPrematureWatchEnd(error) {
|
|
588
|
+
return error instanceof Error && error.message === "watch stream ended before completion";
|
|
589
|
+
}
|
|
590
|
+
function isAbortError(error) {
|
|
591
|
+
return typeof error === "object" && error !== null && "name" in error && error.name === "AbortError";
|
|
592
|
+
}
|
|
593
|
+
async function sleep(milliseconds) {
|
|
594
|
+
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// src/cli/lifecycle.ts
|
|
598
|
+
async function readServerInfo() {
|
|
599
|
+
try {
|
|
600
|
+
return JSON.parse(await readFile(globalServerFile(), "utf8"));
|
|
601
|
+
} catch {
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
function serverUrl(info) {
|
|
606
|
+
return `http://localhost:${info.port}`;
|
|
607
|
+
}
|
|
608
|
+
async function isServerResponsive(info) {
|
|
609
|
+
if (!isPidAlive(info.pid)) {
|
|
610
|
+
return false;
|
|
611
|
+
}
|
|
612
|
+
try {
|
|
613
|
+
const health = await new ServerClient(serverUrl(info)).health();
|
|
614
|
+
return health.ok === true;
|
|
615
|
+
} catch {
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
async function ensureServer(options = {}) {
|
|
620
|
+
const existing = await readServerInfo();
|
|
621
|
+
if (existing && await isServerResponsive(existing)) {
|
|
622
|
+
return existing;
|
|
623
|
+
}
|
|
624
|
+
return startServer(options);
|
|
625
|
+
}
|
|
626
|
+
async function startServer(options = {}) {
|
|
627
|
+
const existing = await readServerInfo();
|
|
628
|
+
if (existing && await isServerResponsive(existing)) {
|
|
629
|
+
return existing;
|
|
630
|
+
}
|
|
631
|
+
await ensureDir(globalStateDir());
|
|
632
|
+
await ensureDir(globalLogDir());
|
|
633
|
+
const port = options.port ?? await getPort();
|
|
634
|
+
const daemonPath = fileURLToPath(new URL("../server/daemon.js", import.meta.url));
|
|
635
|
+
if (!existsSync(daemonPath)) {
|
|
636
|
+
throw new Error(`Cannot find server daemon at ${daemonPath}. Run pnpm build first.`);
|
|
637
|
+
}
|
|
638
|
+
const logFd = openSync(globalServerLogFile(), "a");
|
|
639
|
+
const child = spawn(process.execPath, [daemonPath], {
|
|
640
|
+
detached: true,
|
|
641
|
+
env: {
|
|
642
|
+
...process.env,
|
|
643
|
+
GLOSS_PORT: String(port),
|
|
644
|
+
GLOSS_STATE_DIR: globalStateDir()
|
|
645
|
+
},
|
|
646
|
+
stdio: ["ignore", logFd, logFd]
|
|
647
|
+
});
|
|
648
|
+
child.unref();
|
|
649
|
+
const info = {
|
|
650
|
+
pid: child.pid ?? -1,
|
|
651
|
+
port,
|
|
652
|
+
version: packageVersion,
|
|
653
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
654
|
+
stateDir: globalStateDir()
|
|
655
|
+
};
|
|
656
|
+
await writeFile(globalServerFile(), `${JSON.stringify(info, null, 2)}
|
|
657
|
+
`);
|
|
658
|
+
const deadline = Date.now() + 8e3;
|
|
659
|
+
while (Date.now() < deadline) {
|
|
660
|
+
if (await isServerResponsive(info)) {
|
|
661
|
+
return info;
|
|
662
|
+
}
|
|
663
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
664
|
+
}
|
|
665
|
+
throw new Error(`Server did not become responsive. See ${globalServerLogFile()}`);
|
|
666
|
+
}
|
|
667
|
+
async function stopServer() {
|
|
668
|
+
const info = await readServerInfo();
|
|
669
|
+
if (!info) {
|
|
670
|
+
return { stopped: false, info: null };
|
|
671
|
+
}
|
|
672
|
+
if (isPidAlive(info.pid)) {
|
|
673
|
+
process.kill(info.pid, "SIGTERM");
|
|
674
|
+
}
|
|
675
|
+
await rm(globalServerFile(), { force: true });
|
|
676
|
+
return { stopped: true, info };
|
|
677
|
+
}
|
|
678
|
+
function isPidAlive(pid) {
|
|
679
|
+
if (pid <= 0) {
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
682
|
+
try {
|
|
683
|
+
process.kill(pid, 0);
|
|
684
|
+
return true;
|
|
685
|
+
} catch {
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
741
690
|
// src/server/store.ts
|
|
742
691
|
import { readdir, readFile as readFile2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
|
|
743
692
|
import { ulid } from "ulid";
|
|
@@ -850,6 +799,9 @@ var ReviewStore = class {
|
|
|
850
799
|
if (!record) {
|
|
851
800
|
throw new Error(`Review ${id} not found`);
|
|
852
801
|
}
|
|
802
|
+
if (record.meta.status !== "pending") {
|
|
803
|
+
throw new Error(`Review ${id} is ${record.meta.status} and cannot be submitted`);
|
|
804
|
+
}
|
|
853
805
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
854
806
|
const feedback = {
|
|
855
807
|
version: 1,
|
|
@@ -862,7 +814,7 @@ var ReviewStore = class {
|
|
|
862
814
|
)
|
|
863
815
|
};
|
|
864
816
|
record.feedback = feedback;
|
|
865
|
-
record.meta = { ...record.meta, status: "
|
|
817
|
+
record.meta = { ...record.meta, status: "submitted", submittedAt: timestamp };
|
|
866
818
|
this.reviews.set(id, record);
|
|
867
819
|
const artifactDir = globalReviewDir(id);
|
|
868
820
|
const feedbackPath = globalReviewFeedbackFile(id);
|
|
@@ -882,7 +834,7 @@ var ReviewStore = class {
|
|
|
882
834
|
writeFile2(markdownPath, serializeFeedbackMarkdown(feedback))
|
|
883
835
|
]);
|
|
884
836
|
this.emit({
|
|
885
|
-
type: "review.
|
|
837
|
+
type: "review.submitted",
|
|
886
838
|
reviewId: id,
|
|
887
839
|
counts: {
|
|
888
840
|
files: new Set(feedback.comments.map((comment) => comment.filePath)).size,
|
|
@@ -900,19 +852,88 @@ var ReviewStore = class {
|
|
|
900
852
|
if (!record) {
|
|
901
853
|
throw new Error(`Review ${id} not found`);
|
|
902
854
|
}
|
|
855
|
+
this.assertResolvable(record, id);
|
|
903
856
|
const resolvedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
904
|
-
const
|
|
857
|
+
const existingById = new Map(
|
|
858
|
+
(record.resolution?.comments ?? []).map((comment) => [comment.commentId, comment])
|
|
859
|
+
);
|
|
860
|
+
const comments = this.sortResolvedComments(
|
|
861
|
+
(record.feedback?.comments ?? []).map((comment) => ({
|
|
862
|
+
...existingById.get(comment.id),
|
|
863
|
+
commentId: comment.id,
|
|
864
|
+
status: "resolved",
|
|
865
|
+
resolvedAt: existingById.get(comment.id)?.resolvedAt ?? resolvedAt
|
|
866
|
+
})),
|
|
867
|
+
record
|
|
868
|
+
);
|
|
869
|
+
const resolution = {
|
|
870
|
+
reviewId: id,
|
|
871
|
+
status: "resolved",
|
|
872
|
+
summary: summary ?? record.resolution?.summary ?? null,
|
|
873
|
+
resolvedAt,
|
|
874
|
+
comments
|
|
875
|
+
};
|
|
905
876
|
record.meta = { ...record.meta, status: "resolved", resolvedAt };
|
|
906
|
-
this.
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
`
|
|
877
|
+
return this.persistResolution(record, resolution);
|
|
878
|
+
}
|
|
879
|
+
async resolveComment(id, commentId, summary) {
|
|
880
|
+
const record = await this.get(id);
|
|
881
|
+
if (!record) {
|
|
882
|
+
throw new Error(`Review ${id} not found`);
|
|
883
|
+
}
|
|
884
|
+
this.assertResolvable(record, id);
|
|
885
|
+
this.assertCommentExists(record, commentId);
|
|
886
|
+
const resolvedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
887
|
+
const previous = record.resolution?.comments.find((comment) => comment.commentId === commentId);
|
|
888
|
+
const nextSummary = summary ?? previous?.summary;
|
|
889
|
+
const nextComment = {
|
|
890
|
+
commentId,
|
|
891
|
+
status: "resolved",
|
|
892
|
+
...nextSummary ? { summary: nextSummary } : {},
|
|
893
|
+
resolvedAt
|
|
894
|
+
};
|
|
895
|
+
const comments = this.sortResolvedComments(
|
|
896
|
+
[
|
|
897
|
+
...(record.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
|
|
898
|
+
nextComment
|
|
899
|
+
],
|
|
900
|
+
record
|
|
912
901
|
);
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
902
|
+
const counts = this.resolutionCounts(record, comments);
|
|
903
|
+
const fullyResolved = counts.total === counts.resolved;
|
|
904
|
+
const resolution = {
|
|
905
|
+
reviewId: id,
|
|
906
|
+
status: fullyResolved ? "resolved" : "partial",
|
|
907
|
+
summary: fullyResolved ? record.resolution?.summary ?? null : null,
|
|
908
|
+
resolvedAt: fullyResolved ? resolvedAt : null,
|
|
909
|
+
comments
|
|
910
|
+
};
|
|
911
|
+
record.meta = fullyResolved ? { ...record.meta, status: "resolved", resolvedAt } : { ...record.meta, status: "submitted", resolvedAt: void 0 };
|
|
912
|
+
return this.persistResolution(record, resolution);
|
|
913
|
+
}
|
|
914
|
+
async reopenComment(id, commentId) {
|
|
915
|
+
const record = await this.get(id);
|
|
916
|
+
if (!record) {
|
|
917
|
+
throw new Error(`Review ${id} not found`);
|
|
918
|
+
}
|
|
919
|
+
this.assertResolvable(record, id);
|
|
920
|
+
this.assertCommentExists(record, commentId);
|
|
921
|
+
const comments = this.sortResolvedComments(
|
|
922
|
+
(record.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
|
|
923
|
+
record
|
|
924
|
+
);
|
|
925
|
+
const counts = this.resolutionCounts(record, comments);
|
|
926
|
+
const fullyResolved = counts.total > 0 && counts.total === counts.resolved;
|
|
927
|
+
const resolvedAt = fullyResolved ? (/* @__PURE__ */ new Date()).toISOString() : null;
|
|
928
|
+
const resolution = {
|
|
929
|
+
reviewId: id,
|
|
930
|
+
status: fullyResolved ? "resolved" : "partial",
|
|
931
|
+
summary: fullyResolved ? record.resolution?.summary ?? null : null,
|
|
932
|
+
resolvedAt,
|
|
933
|
+
comments
|
|
934
|
+
};
|
|
935
|
+
record.meta = fullyResolved ? { ...record.meta, status: "resolved", resolvedAt: resolvedAt ?? void 0 } : { ...record.meta, status: "submitted", resolvedAt: void 0 };
|
|
936
|
+
return this.persistResolution(record, resolution);
|
|
916
937
|
}
|
|
917
938
|
subscribe(reviewId, listener) {
|
|
918
939
|
const listeners = this.listeners.get(reviewId) ?? /* @__PURE__ */ new Set();
|
|
@@ -967,6 +988,7 @@ var ReviewStore = class {
|
|
|
967
988
|
const meta = JSON.parse(metaRaw);
|
|
968
989
|
const diff = JSON.parse(diffRaw);
|
|
969
990
|
let feedback;
|
|
991
|
+
let resolution;
|
|
970
992
|
try {
|
|
971
993
|
feedback = JSON.parse(
|
|
972
994
|
await readFile2(globalReviewFeedbackFile(id), "utf8")
|
|
@@ -974,6 +996,13 @@ var ReviewStore = class {
|
|
|
974
996
|
} catch {
|
|
975
997
|
feedback = void 0;
|
|
976
998
|
}
|
|
999
|
+
try {
|
|
1000
|
+
resolution = JSON.parse(
|
|
1001
|
+
await readFile2(globalReviewResolvedFile(id), "utf8")
|
|
1002
|
+
);
|
|
1003
|
+
} catch {
|
|
1004
|
+
resolution = void 0;
|
|
1005
|
+
}
|
|
977
1006
|
const record = {
|
|
978
1007
|
meta: {
|
|
979
1008
|
...meta,
|
|
@@ -982,7 +1011,8 @@ var ReviewStore = class {
|
|
|
982
1011
|
markdownPath: meta.markdownPath ?? (feedback ? globalReviewMarkdownFile(id) : void 0)
|
|
983
1012
|
},
|
|
984
1013
|
diff,
|
|
985
|
-
feedback
|
|
1014
|
+
feedback,
|
|
1015
|
+
resolution
|
|
986
1016
|
};
|
|
987
1017
|
this.reviews.set(id, record);
|
|
988
1018
|
return record;
|
|
@@ -990,6 +1020,60 @@ var ReviewStore = class {
|
|
|
990
1020
|
return null;
|
|
991
1021
|
}
|
|
992
1022
|
}
|
|
1023
|
+
assertResolvable(record, id) {
|
|
1024
|
+
if (record.meta.status !== "submitted" && record.meta.status !== "resolved") {
|
|
1025
|
+
throw new Error(`Review ${id} is ${record.meta.status} and cannot be resolved`);
|
|
1026
|
+
}
|
|
1027
|
+
if (!record.feedback) {
|
|
1028
|
+
throw new Error(`Review ${id} has no submitted feedback`);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
assertCommentExists(record, commentId) {
|
|
1032
|
+
if (!record.feedback.comments.some((comment) => comment.id === commentId)) {
|
|
1033
|
+
throw new Error(`Comment ${commentId} not found`);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
async persistResolution(record, resolution) {
|
|
1037
|
+
record.resolution = resolution;
|
|
1038
|
+
this.reviews.set(record.meta.id, record);
|
|
1039
|
+
const resolvedPath = globalReviewResolvedFile(record.meta.id);
|
|
1040
|
+
await ensureDir(globalReviewDir(record.meta.id));
|
|
1041
|
+
await Promise.all([
|
|
1042
|
+
writeFile2(resolvedPath, `${JSON.stringify(resolution, null, 2)}
|
|
1043
|
+
`),
|
|
1044
|
+
writeFile2(globalReviewMetaFile(record.meta.id), `${JSON.stringify(record.meta, null, 2)}
|
|
1045
|
+
`)
|
|
1046
|
+
]);
|
|
1047
|
+
return {
|
|
1048
|
+
ok: true,
|
|
1049
|
+
reviewId: record.meta.id,
|
|
1050
|
+
status: record.meta.status,
|
|
1051
|
+
resolutionStatus: resolution.status,
|
|
1052
|
+
comments: this.resolutionCounts(record, resolution.comments),
|
|
1053
|
+
path: resolvedPath,
|
|
1054
|
+
resolution
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
sortResolvedComments(comments, record) {
|
|
1058
|
+
const feedbackIndex = new Map(
|
|
1059
|
+
record.feedback.comments.map((comment, index) => [comment.id, index])
|
|
1060
|
+
);
|
|
1061
|
+
return comments.filter((comment) => feedbackIndex.has(comment.commentId)).sort(
|
|
1062
|
+
(a, b) => (feedbackIndex.get(a.commentId) ?? Number.MAX_SAFE_INTEGER) - (feedbackIndex.get(b.commentId) ?? Number.MAX_SAFE_INTEGER)
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
resolutionCounts(record, comments) {
|
|
1066
|
+
const total = record.feedback.comments.length;
|
|
1067
|
+
const resolvedIds = new Set(comments.map((comment) => comment.commentId));
|
|
1068
|
+
const resolved = record.feedback.comments.filter(
|
|
1069
|
+
(comment) => resolvedIds.has(comment.id)
|
|
1070
|
+
).length;
|
|
1071
|
+
return {
|
|
1072
|
+
total,
|
|
1073
|
+
resolved,
|
|
1074
|
+
open: total - resolved
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
993
1077
|
};
|
|
994
1078
|
var reviewStore = new ReviewStore();
|
|
995
1079
|
|
|
@@ -1022,9 +1106,9 @@ program.command("open").description("Capture local changes and open them for rev
|
|
|
1022
1106
|
async (options) => {
|
|
1023
1107
|
const globals = program.opts();
|
|
1024
1108
|
const info = await ensureServer();
|
|
1025
|
-
const
|
|
1109
|
+
const client = new ServerClient(serverUrl(info));
|
|
1026
1110
|
const diff = await captureDiff(options.base);
|
|
1027
|
-
const { meta, url } = await
|
|
1111
|
+
const { meta, url } = await client.createReview(diff);
|
|
1028
1112
|
if (options.printUrl) {
|
|
1029
1113
|
printPlain(url);
|
|
1030
1114
|
}
|
|
@@ -1042,16 +1126,16 @@ program.command("open").description("Capture local changes and open them for rev
|
|
|
1042
1126
|
globals.json ? printJson(result2) : printPlain(`Review ${meta.id}: ${url}`);
|
|
1043
1127
|
return;
|
|
1044
1128
|
}
|
|
1045
|
-
const event = await
|
|
1129
|
+
const event = await client.watchReview(meta.id, options.timeout);
|
|
1046
1130
|
if (event.type === "review.cancelled") {
|
|
1047
1131
|
process.exitCode = 2;
|
|
1048
1132
|
globals.json ? printJson(event) : printPlain(`Review ${meta.id} cancelled`);
|
|
1049
1133
|
return;
|
|
1050
1134
|
}
|
|
1051
|
-
if (event.type !== "review.
|
|
1135
|
+
if (event.type !== "review.submitted") {
|
|
1052
1136
|
throw new Error(`Unexpected review event ${event.type}`);
|
|
1053
1137
|
}
|
|
1054
|
-
const feedback = await
|
|
1138
|
+
const feedback = await client.getFeedback(meta.id);
|
|
1055
1139
|
const result = {
|
|
1056
1140
|
reviewId: meta.id,
|
|
1057
1141
|
url,
|
|
@@ -1062,14 +1146,14 @@ program.command("open").description("Capture local changes and open them for rev
|
|
|
1062
1146
|
artifactDir: globalReviewDir(meta.id),
|
|
1063
1147
|
feedback
|
|
1064
1148
|
};
|
|
1065
|
-
globals.json ? printJson(result) : printPlain(`Review ${meta.id}
|
|
1149
|
+
globals.json ? printJson(result) : printPlain(`Review ${meta.id} submitted with ${event.counts.comments} comments`);
|
|
1066
1150
|
}
|
|
1067
1151
|
);
|
|
1068
|
-
program.command("watch").argument("<reviewId>", "review id").description("Wait for review.
|
|
1152
|
+
program.command("watch").argument("<reviewId>", "review id").description("Wait for review.submitted for an existing review").option("--timeout <seconds>", "watch timeout in seconds", Number).action(async (reviewId, options) => {
|
|
1069
1153
|
const globals = program.opts();
|
|
1070
1154
|
const info = await ensureServer();
|
|
1071
|
-
const
|
|
1072
|
-
const event = await
|
|
1155
|
+
const client = new ServerClient(serverUrl(info));
|
|
1156
|
+
const event = await client.watchReview(reviewId, options.timeout);
|
|
1073
1157
|
globals.json ? printJson(event) : printPlain(`${event.type} ${event.reviewId}`);
|
|
1074
1158
|
});
|
|
1075
1159
|
program.command("start").description("Start or reuse the background server").option("--port <port>", "port to bind", Number).action(async (options) => {
|
|
@@ -1092,8 +1176,22 @@ program.command("stop").description("Stop the managed background server").option
|
|
|
1092
1176
|
const result = await stopServer();
|
|
1093
1177
|
globals.json ? printJson(result) : printPlain(result.stopped ? "Gloss server stopped" : "Gloss server was not running");
|
|
1094
1178
|
});
|
|
1095
|
-
program.command("
|
|
1096
|
-
|
|
1179
|
+
program.command("resolve").argument("<reviewId>", "review id").description("Mark a submitted review or one feedback comment as resolved").option("--comment <commentId>", "resolve one submitted feedback comment").option("--summary <text>", "brief summary of the fixes applied").action(async (reviewId, options) => {
|
|
1180
|
+
const globals = program.opts();
|
|
1181
|
+
const info = await ensureServer();
|
|
1182
|
+
const client = new ServerClient(serverUrl(info));
|
|
1183
|
+
const result = options.comment ? await client.resolveComment(reviewId, options.comment, options.summary) : await client.markResolved(reviewId, options.summary);
|
|
1184
|
+
if (globals.json) {
|
|
1185
|
+
printJson({
|
|
1186
|
+
commentId: options.comment ?? null,
|
|
1187
|
+
summary: options.summary ?? null,
|
|
1188
|
+
...result
|
|
1189
|
+
});
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
printPlain(
|
|
1193
|
+
options.comment ? `Comment ${options.comment} resolved in review ${reviewId}` : `Review ${reviewId} resolved`
|
|
1194
|
+
);
|
|
1097
1195
|
});
|
|
1098
1196
|
program.command("doctor").description("Diagnose setup and validate git/state").action(async () => {
|
|
1099
1197
|
const globals = program.opts();
|