getgloss 0.5.0 → 0.6.0
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 +1 -11
- package/dist/cli/index.js +404 -184
- package/dist/cli/index.js.map +1 -1
- package/dist/server/daemon.js +360 -169
- package/dist/server/daemon.js.map +1 -1
- package/dist/web/assets/index-Bj1LDmIl.js +179 -0
- package/dist/web/assets/{index-rvNgmEHK.css → index-BsRo7I09.css} +1 -1
- package/dist/web/gloss-demo-captions.vtt +16 -0
- package/dist/web/gloss-demo-poster.jpg +0 -0
- package/dist/web/gloss-demo.mp4 +0 -0
- package/dist/web/index.html +2 -2
- package/package.json +24 -24
- package/skill/SKILL.md +31 -5
- package/dist/web/assets/index-AW7l4N7K.js +0 -188
package/dist/server/daemon.js
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
// src/server/daemon.ts
|
|
2
2
|
import { serve } from "@hono/node-server";
|
|
3
3
|
|
|
4
|
-
// src/cli/lifecycle.ts
|
|
5
|
-
import { spawn } from "child_process";
|
|
6
|
-
import { existsSync, openSync } from "fs";
|
|
7
|
-
import { readFile, rm, writeFile } from "fs/promises";
|
|
8
|
-
import { fileURLToPath } from "url";
|
|
9
|
-
import getPort from "get-port";
|
|
10
|
-
|
|
11
4
|
// src/shared/paths.ts
|
|
12
5
|
import { mkdir } from "fs/promises";
|
|
13
6
|
import { homedir } from "os";
|
|
@@ -16,7 +9,7 @@ import path from "path";
|
|
|
16
9
|
// package.json
|
|
17
10
|
var package_default = {
|
|
18
11
|
name: "getgloss",
|
|
19
|
-
version: "0.
|
|
12
|
+
version: "0.6.0",
|
|
20
13
|
description: "Local browser-based diff review for coding-agent loops.",
|
|
21
14
|
type: "module",
|
|
22
15
|
packageManager: "pnpm@10.33.2",
|
|
@@ -46,31 +39,31 @@ var package_default = {
|
|
|
46
39
|
node: ">=20"
|
|
47
40
|
},
|
|
48
41
|
dependencies: {
|
|
49
|
-
"@hono/node-server": "
|
|
50
|
-
"@
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
react: "
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
zustand: "^5.0.5"
|
|
42
|
+
"@hono/node-server": "1.19.14",
|
|
43
|
+
"@tailwindcss/vite": "4.3.0",
|
|
44
|
+
commander: "14.0.3",
|
|
45
|
+
execa: "9.6.1",
|
|
46
|
+
"get-port": "7.2.0",
|
|
47
|
+
hono: "4.12.21",
|
|
48
|
+
"lucide-react": "1.16.0",
|
|
49
|
+
open: "10.2.0",
|
|
50
|
+
react: "19.2.6",
|
|
51
|
+
"react-dom": "19.2.6",
|
|
52
|
+
ulid: "3.0.2",
|
|
53
|
+
zustand: "5.0.13"
|
|
62
54
|
},
|
|
63
55
|
devDependencies: {
|
|
64
|
-
"@biomejs/biome": "
|
|
65
|
-
"@types/node": "
|
|
66
|
-
"@types/react": "
|
|
67
|
-
"@types/react-dom": "
|
|
68
|
-
"@vitejs/plugin-react": "
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
56
|
+
"@biomejs/biome": "2.4.15",
|
|
57
|
+
"@types/node": "24.12.4",
|
|
58
|
+
"@types/react": "19.2.15",
|
|
59
|
+
"@types/react-dom": "19.2.3",
|
|
60
|
+
"@vitejs/plugin-react": "4.7.0",
|
|
61
|
+
tailwindcss: "4.3.0",
|
|
62
|
+
tsup: "8.5.1",
|
|
63
|
+
tsx: "4.22.3",
|
|
64
|
+
typescript: "5.9.3",
|
|
65
|
+
vite: "6.4.2",
|
|
66
|
+
vitest: "3.2.4"
|
|
74
67
|
},
|
|
75
68
|
keywords: [
|
|
76
69
|
"diff",
|
|
@@ -134,32 +127,195 @@ async function ensureDir(dir) {
|
|
|
134
127
|
await mkdir(dir, { recursive: true });
|
|
135
128
|
}
|
|
136
129
|
|
|
137
|
-
// src/
|
|
130
|
+
// src/shared/server-info.ts
|
|
131
|
+
import { readFile } from "fs/promises";
|
|
132
|
+
|
|
133
|
+
// src/shared/json.ts
|
|
134
|
+
import { writeFile } from "fs/promises";
|
|
135
|
+
function serializeJson(value) {
|
|
136
|
+
return `${JSON.stringify(value, null, 2)}
|
|
137
|
+
`;
|
|
138
|
+
}
|
|
139
|
+
async function writeJsonFile(filePath, value) {
|
|
140
|
+
await writeFile(filePath, serializeJson(value));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/shared/validation.ts
|
|
144
|
+
var reviewStatuses = ["pending", "submitted", "cancelled", "resolved"];
|
|
145
|
+
var resolutionStatuses = ["partial", "resolved"];
|
|
146
|
+
var sides = ["L", "R"];
|
|
147
|
+
var diffLineTypes = ["context", "add", "delete"];
|
|
148
|
+
var diffScopeModes = ["working", "branch", "explicit"];
|
|
149
|
+
var diffFallbackReasons = ["working-tree-clean", "missing-branch-base"];
|
|
150
|
+
function parseJson(raw, guard, label) {
|
|
151
|
+
const parsed = JSON.parse(raw);
|
|
152
|
+
return parseJsonValue(parsed, guard, label);
|
|
153
|
+
}
|
|
154
|
+
function parseJsonValue(value, guard, label) {
|
|
155
|
+
if (!guard(value)) {
|
|
156
|
+
throw new Error(`Invalid ${label}`);
|
|
157
|
+
}
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
function isSubmitReviewRequest(value) {
|
|
161
|
+
return isRecord(value) && isArrayOf(value.comments, isComment);
|
|
162
|
+
}
|
|
163
|
+
function isResolutionRequest(value) {
|
|
164
|
+
return isRecord(value) && isOptionalString(value.summary);
|
|
165
|
+
}
|
|
166
|
+
function isStoredReviewMeta(value) {
|
|
167
|
+
return isRecord(value) && isString(value.id) && isString(value.cwd) && isBaseRef(value.base) && isNullableString(value.branch) && isReviewStatus(value.status) && isString(value.createdAt) && isOptionalString(value.submittedAt) && isOptionalString(value.resolvedAt) && isOptionalString(value.artifactDir) && isOptionalString(value.feedbackPath) && isOptionalString(value.markdownPath);
|
|
168
|
+
}
|
|
169
|
+
function isDiffPayload(value) {
|
|
170
|
+
return isRecord(value) && isBaseRef(value.base) && isNullableString(value.branch) && isString(value.cwd) && isDiffScope(value.scope) && isDiffStats(value.stats) && isString(value.rawDiff) && isArrayOf(value.files, isDiffFile) && isString(value.capturedAt);
|
|
171
|
+
}
|
|
172
|
+
function isFeedbackBundle(value) {
|
|
173
|
+
return isRecord(value) && value.version === 1 && isString(value.reviewId) && isString(value.timestamp) && isBaseRef(value.base) && isNullableString(value.branch) && isArrayOf(value.comments, isComment);
|
|
174
|
+
}
|
|
175
|
+
function isResolutionBundle(value) {
|
|
176
|
+
return isRecord(value) && isString(value.reviewId) && isResolutionStatus(value.status) && isNullableString(value.summary) && isNullableString(value.resolvedAt) && isArrayOf(value.comments, isResolvedComment);
|
|
177
|
+
}
|
|
178
|
+
function isDiffScope(value) {
|
|
179
|
+
return isRecord(value) && isOneOf(value.mode, diffScopeModes) && isNullableString(value.requestedBase) && isBaseRef(value.base) && isDiffRef(value.comparison) && (value.fallbackReason === null || isOneOf(value.fallbackReason, diffFallbackReasons));
|
|
180
|
+
}
|
|
181
|
+
function isDiffRef(value) {
|
|
182
|
+
return isRecord(value) && isString(value.ref) && isNullableString(value.sha);
|
|
183
|
+
}
|
|
184
|
+
function isBaseRef(value) {
|
|
185
|
+
return isRecord(value) && isString(value.ref) && isString(value.sha);
|
|
186
|
+
}
|
|
187
|
+
function isDiffStats(value) {
|
|
188
|
+
return isRecord(value) && isNumber(value.files) && isNumber(value.additions) && isNumber(value.deletions);
|
|
189
|
+
}
|
|
190
|
+
function isDiffFile(value) {
|
|
191
|
+
return isRecord(value) && isString(value.path) && isNullableString(value.oldPath) && isNumber(value.additions) && isNumber(value.deletions) && isBoolean(value.isBinary) && isBoolean(value.isDeleted) && isBoolean(value.isNew) && isBoolean(value.isRenamed) && isNullableString(value.language) && isArrayOf(value.hunks, isDiffHunk);
|
|
192
|
+
}
|
|
193
|
+
function isDiffHunk(value) {
|
|
194
|
+
return isRecord(value) && isNumber(value.oldStart) && isNumber(value.oldLines) && isNumber(value.newStart) && isNumber(value.newLines) && isString(value.header) && isArrayOf(value.lines, isDiffLine);
|
|
195
|
+
}
|
|
196
|
+
function isDiffLine(value) {
|
|
197
|
+
return isRecord(value) && isOneOf(value.type, diffLineTypes) && isNullableNumber(value.oldLine) && isNullableNumber(value.newLine) && isString(value.content);
|
|
198
|
+
}
|
|
199
|
+
function isComment(value) {
|
|
200
|
+
return isRecord(value) && isString(value.id) && isString(value.filePath) && isNumber(value.startLine) && isNumber(value.endLine) && isOneOf(value.side, sides) && isString(value.body) && isString(value.originalSnippet) && isString(value.createdAt);
|
|
201
|
+
}
|
|
202
|
+
function isResolvedComment(value) {
|
|
203
|
+
return isRecord(value) && isString(value.commentId) && value.status === "resolved" && isOptionalString(value.summary) && isString(value.resolvedAt);
|
|
204
|
+
}
|
|
205
|
+
function isReviewStatus(value) {
|
|
206
|
+
return isOneOf(value, reviewStatuses);
|
|
207
|
+
}
|
|
208
|
+
function isResolutionStatus(value) {
|
|
209
|
+
return isOneOf(value, resolutionStatuses);
|
|
210
|
+
}
|
|
211
|
+
function isRecord(value) {
|
|
212
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
213
|
+
}
|
|
214
|
+
function isArrayOf(value, guard) {
|
|
215
|
+
return Array.isArray(value) && value.every(guard);
|
|
216
|
+
}
|
|
217
|
+
function isString(value) {
|
|
218
|
+
return typeof value === "string";
|
|
219
|
+
}
|
|
220
|
+
function isOptionalString(value) {
|
|
221
|
+
return value === void 0 || isString(value);
|
|
222
|
+
}
|
|
223
|
+
function isNullableString(value) {
|
|
224
|
+
return value === null || isString(value);
|
|
225
|
+
}
|
|
226
|
+
function isNumber(value) {
|
|
227
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
228
|
+
}
|
|
229
|
+
function isNullableNumber(value) {
|
|
230
|
+
return value === null || isNumber(value);
|
|
231
|
+
}
|
|
232
|
+
function isBoolean(value) {
|
|
233
|
+
return typeof value === "boolean";
|
|
234
|
+
}
|
|
235
|
+
function isOneOf(value, options) {
|
|
236
|
+
return typeof value === "string" && options.includes(value);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// src/shared/server-info.ts
|
|
138
240
|
async function writeServerInfo(info) {
|
|
139
241
|
await ensureDir(globalStateDir());
|
|
140
|
-
await
|
|
141
|
-
`);
|
|
242
|
+
await writeJsonFile(globalServerFile(), info);
|
|
142
243
|
}
|
|
143
244
|
|
|
144
245
|
// src/server/index.ts
|
|
145
246
|
import { readFile as readFile3 } from "fs/promises";
|
|
146
|
-
import
|
|
147
|
-
import { fileURLToPath
|
|
247
|
+
import path3 from "path";
|
|
248
|
+
import { fileURLToPath } from "url";
|
|
148
249
|
import { Hono } from "hono";
|
|
149
250
|
import { streamSSE } from "hono/streaming";
|
|
150
251
|
|
|
252
|
+
// src/shared/comments.ts
|
|
253
|
+
function compareCommentsByLocation(a, b) {
|
|
254
|
+
return a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine || a.endLine - b.endLine || a.side.localeCompare(b.side);
|
|
255
|
+
}
|
|
256
|
+
function countCommentFiles(comments) {
|
|
257
|
+
return new Set(comments.map((comment) => comment.filePath)).size;
|
|
258
|
+
}
|
|
259
|
+
function formatLineRange(range, options = {}) {
|
|
260
|
+
const startLine = Math.min(range.startLine, range.endLine);
|
|
261
|
+
const endLine = Math.max(range.startLine, range.endLine);
|
|
262
|
+
if (startLine === endLine) {
|
|
263
|
+
return `${range.side}${startLine}`;
|
|
264
|
+
}
|
|
265
|
+
const endPrefix = options.repeatSideOnEnd === false ? "" : range.side;
|
|
266
|
+
return `${range.side}${startLine}-${endPrefix}${endLine}`;
|
|
267
|
+
}
|
|
268
|
+
function resolutionCounts(feedback, resolvedComments = []) {
|
|
269
|
+
const comments = feedback?.comments ?? [];
|
|
270
|
+
const resolvedIds = new Set(resolvedComments.map((comment) => comment.commentId));
|
|
271
|
+
const resolved = comments.filter((comment) => resolvedIds.has(comment.id)).length;
|
|
272
|
+
return {
|
|
273
|
+
total: comments.length,
|
|
274
|
+
resolved,
|
|
275
|
+
open: comments.length - resolved
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// src/shared/reviews.ts
|
|
280
|
+
function isResolvableReviewStatus(status) {
|
|
281
|
+
return status === "submitted" || status === "resolved";
|
|
282
|
+
}
|
|
283
|
+
|
|
151
284
|
// src/server/store.ts
|
|
152
|
-
import { readdir, readFile as readFile2,
|
|
285
|
+
import { readdir, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
153
286
|
import { ulid } from "ulid";
|
|
154
287
|
|
|
155
|
-
// src/shared/
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
288
|
+
// src/shared/language.ts
|
|
289
|
+
import path2 from "path";
|
|
290
|
+
var languageByExtension = {
|
|
291
|
+
cjs: "js",
|
|
292
|
+
css: "css",
|
|
293
|
+
go: "go",
|
|
294
|
+
html: "html",
|
|
295
|
+
js: "js",
|
|
296
|
+
json: "json",
|
|
297
|
+
jsx: "jsx",
|
|
298
|
+
md: "markdown",
|
|
299
|
+
mjs: "js",
|
|
300
|
+
py: "python",
|
|
301
|
+
rb: "ruby",
|
|
302
|
+
rs: "rust",
|
|
303
|
+
sh: "bash",
|
|
304
|
+
swift: "swift",
|
|
305
|
+
ts: "ts",
|
|
306
|
+
tsx: "tsx",
|
|
307
|
+
yaml: "yaml",
|
|
308
|
+
yml: "yaml"
|
|
309
|
+
};
|
|
310
|
+
function languageForPath(filePath) {
|
|
311
|
+
const ext = path2.extname(filePath).slice(1).toLowerCase();
|
|
312
|
+
if (!ext) {
|
|
313
|
+
return null;
|
|
160
314
|
}
|
|
161
|
-
return
|
|
315
|
+
return languageByExtension[ext] ?? ext;
|
|
162
316
|
}
|
|
317
|
+
|
|
318
|
+
// src/shared/markdown.ts
|
|
163
319
|
function fenceFor(snippet) {
|
|
164
320
|
let fence = "```";
|
|
165
321
|
while (snippet.includes(fence)) {
|
|
@@ -167,40 +323,13 @@ function fenceFor(snippet) {
|
|
|
167
323
|
}
|
|
168
324
|
return fence;
|
|
169
325
|
}
|
|
170
|
-
function languageForPath(filePath) {
|
|
171
|
-
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
172
|
-
const map = {
|
|
173
|
-
cjs: "js",
|
|
174
|
-
css: "css",
|
|
175
|
-
go: "go",
|
|
176
|
-
html: "html",
|
|
177
|
-
js: "js",
|
|
178
|
-
json: "json",
|
|
179
|
-
jsx: "jsx",
|
|
180
|
-
md: "markdown",
|
|
181
|
-
mjs: "js",
|
|
182
|
-
py: "python",
|
|
183
|
-
rb: "ruby",
|
|
184
|
-
rs: "rust",
|
|
185
|
-
sh: "bash",
|
|
186
|
-
swift: "swift",
|
|
187
|
-
ts: "ts",
|
|
188
|
-
tsx: "tsx",
|
|
189
|
-
yaml: "yaml",
|
|
190
|
-
yml: "yaml"
|
|
191
|
-
};
|
|
192
|
-
return ext ? map[ext] ?? ext : "";
|
|
193
|
-
}
|
|
194
326
|
function languageForSnippet(filePath, snippet) {
|
|
195
327
|
const lines = snippet.split("\n").filter((line) => line.length > 0);
|
|
196
328
|
const looksLikeUnifiedDiff = lines.length > 0 && lines.some((line) => line.startsWith("+") || line.startsWith("-")) && lines.every((line) => line.startsWith("+") || line.startsWith("-") || line.startsWith(" "));
|
|
197
|
-
return looksLikeUnifiedDiff ? "diff" : languageForPath(filePath);
|
|
198
|
-
}
|
|
199
|
-
function byFileThenLine(a, b) {
|
|
200
|
-
return a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine || a.endLine - b.endLine || a.side.localeCompare(b.side);
|
|
329
|
+
return looksLikeUnifiedDiff ? "diff" : languageForPath(filePath) ?? "";
|
|
201
330
|
}
|
|
202
331
|
function serializeFeedbackMarkdown(bundle) {
|
|
203
|
-
const comments = [...bundle.comments].sort(
|
|
332
|
+
const comments = [...bundle.comments].sort(compareCommentsByLocation);
|
|
204
333
|
const files = [...new Set(comments.map((comment) => comment.filePath))];
|
|
205
334
|
const lines = [
|
|
206
335
|
`# Gloss feedback - ${bundle.timestamp}`,
|
|
@@ -270,9 +399,7 @@ var ReviewStore = class {
|
|
|
270
399
|
timestamp,
|
|
271
400
|
base: record.diff.base,
|
|
272
401
|
branch: record.diff.branch,
|
|
273
|
-
comments: [...comments].sort(
|
|
274
|
-
(a, b) => a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine || a.endLine - b.endLine || a.side.localeCompare(b.side)
|
|
275
|
-
)
|
|
402
|
+
comments: [...comments].sort(compareCommentsByLocation)
|
|
276
403
|
};
|
|
277
404
|
record.feedback = feedback;
|
|
278
405
|
record.meta = { ...record.meta, status: "submitted", submittedAt: timestamp };
|
|
@@ -288,17 +415,15 @@ var ReviewStore = class {
|
|
|
288
415
|
};
|
|
289
416
|
await ensureDir(artifactDir);
|
|
290
417
|
await Promise.all([
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
writeFile2(feedbackPath, `${JSON.stringify(feedback, null, 2)}
|
|
294
|
-
`),
|
|
418
|
+
writeJsonFile(globalReviewMetaFile(id), record.meta),
|
|
419
|
+
writeJsonFile(feedbackPath, feedback),
|
|
295
420
|
writeFile2(markdownPath, serializeFeedbackMarkdown(feedback))
|
|
296
421
|
]);
|
|
297
422
|
this.emit({
|
|
298
423
|
type: "review.submitted",
|
|
299
424
|
reviewId: id,
|
|
300
425
|
counts: {
|
|
301
|
-
files:
|
|
426
|
+
files: countCommentFiles(feedback.comments),
|
|
302
427
|
comments: feedback.comments.length
|
|
303
428
|
}
|
|
304
429
|
});
|
|
@@ -360,7 +485,7 @@ var ReviewStore = class {
|
|
|
360
485
|
],
|
|
361
486
|
record
|
|
362
487
|
);
|
|
363
|
-
const counts =
|
|
488
|
+
const counts = resolutionCounts(record.feedback, comments);
|
|
364
489
|
const fullyResolved = counts.total === counts.resolved;
|
|
365
490
|
const resolution = {
|
|
366
491
|
reviewId: id,
|
|
@@ -383,7 +508,7 @@ var ReviewStore = class {
|
|
|
383
508
|
(record.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
|
|
384
509
|
record
|
|
385
510
|
);
|
|
386
|
-
const counts =
|
|
511
|
+
const counts = resolutionCounts(record.feedback, comments);
|
|
387
512
|
const fullyResolved = counts.total > 0 && counts.total === counts.resolved;
|
|
388
513
|
const resolvedAt = fullyResolved ? (/* @__PURE__ */ new Date()).toISOString() : null;
|
|
389
514
|
const resolution = {
|
|
@@ -416,10 +541,8 @@ var ReviewStore = class {
|
|
|
416
541
|
const dir = globalReviewDir(record.meta.id);
|
|
417
542
|
await ensureDir(dir);
|
|
418
543
|
await Promise.all([
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
writeFile2(globalReviewDiffFile(record.meta.id), `${JSON.stringify(record.diff, null, 2)}
|
|
422
|
-
`)
|
|
544
|
+
writeJsonFile(globalReviewMetaFile(record.meta.id), record.meta),
|
|
545
|
+
writeJsonFile(globalReviewDiffFile(record.meta.id), record.diff)
|
|
423
546
|
]);
|
|
424
547
|
}
|
|
425
548
|
async loadKnownReview(id) {
|
|
@@ -433,56 +556,65 @@ var ReviewStore = class {
|
|
|
433
556
|
let entries;
|
|
434
557
|
try {
|
|
435
558
|
entries = await readdir(globalReviewsDir(), { withFileTypes: true });
|
|
436
|
-
} catch {
|
|
437
|
-
|
|
559
|
+
} catch (error) {
|
|
560
|
+
if (isFileNotFound(error)) {
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
throw new Error(
|
|
564
|
+
`Could not read reviews directory at ${globalReviewsDir()}: ${formatError(error)}`,
|
|
565
|
+
{
|
|
566
|
+
cause: error
|
|
567
|
+
}
|
|
568
|
+
);
|
|
438
569
|
}
|
|
439
570
|
await Promise.all(
|
|
440
571
|
entries.filter((entry) => entry.isDirectory()).map((entry) => this.loadReview(entry.name))
|
|
441
572
|
);
|
|
442
573
|
}
|
|
443
574
|
async loadReview(id) {
|
|
575
|
+
const metaPath = globalReviewMetaFile(id);
|
|
576
|
+
const diffPath = globalReviewDiffFile(id);
|
|
577
|
+
let metaRaw;
|
|
578
|
+
let diffRaw;
|
|
444
579
|
try {
|
|
445
|
-
|
|
446
|
-
readFile2(
|
|
447
|
-
readFile2(
|
|
580
|
+
[metaRaw, diffRaw] = await Promise.all([
|
|
581
|
+
readFile2(metaPath, "utf8"),
|
|
582
|
+
readFile2(diffPath, "utf8")
|
|
448
583
|
]);
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
let resolution;
|
|
453
|
-
try {
|
|
454
|
-
feedback = JSON.parse(
|
|
455
|
-
await readFile2(globalReviewFeedbackFile(id), "utf8")
|
|
456
|
-
);
|
|
457
|
-
} catch {
|
|
458
|
-
feedback = void 0;
|
|
459
|
-
}
|
|
460
|
-
try {
|
|
461
|
-
resolution = JSON.parse(
|
|
462
|
-
await readFile2(globalReviewResolvedFile(id), "utf8")
|
|
463
|
-
);
|
|
464
|
-
} catch {
|
|
465
|
-
resolution = void 0;
|
|
584
|
+
} catch (error) {
|
|
585
|
+
if (isFileNotFound(error)) {
|
|
586
|
+
return null;
|
|
466
587
|
}
|
|
467
|
-
|
|
468
|
-
meta: {
|
|
469
|
-
...meta,
|
|
470
|
-
artifactDir: meta.artifactDir ?? globalReviewDir(id),
|
|
471
|
-
feedbackPath: meta.feedbackPath ?? (feedback ? globalReviewFeedbackFile(id) : void 0),
|
|
472
|
-
markdownPath: meta.markdownPath ?? (feedback ? globalReviewMarkdownFile(id) : void 0)
|
|
473
|
-
},
|
|
474
|
-
diff,
|
|
475
|
-
feedback,
|
|
476
|
-
resolution
|
|
477
|
-
};
|
|
478
|
-
this.reviews.set(id, record);
|
|
479
|
-
return record;
|
|
480
|
-
} catch {
|
|
481
|
-
return null;
|
|
588
|
+
throw new Error(`Could not load review ${id}: ${formatError(error)}`, { cause: error });
|
|
482
589
|
}
|
|
590
|
+
const meta = parseJsonFile(metaRaw, isStoredReviewMeta, "review metadata", metaPath);
|
|
591
|
+
const diff = parseJsonFile(diffRaw, isDiffPayload, "review diff", diffPath);
|
|
592
|
+
const feedback = await readOptionalJsonFile(
|
|
593
|
+
globalReviewFeedbackFile(id),
|
|
594
|
+
isFeedbackBundle,
|
|
595
|
+
"review feedback"
|
|
596
|
+
);
|
|
597
|
+
const resolution = await readOptionalJsonFile(
|
|
598
|
+
globalReviewResolvedFile(id),
|
|
599
|
+
isResolutionBundle,
|
|
600
|
+
"review resolution"
|
|
601
|
+
);
|
|
602
|
+
const record = {
|
|
603
|
+
meta: {
|
|
604
|
+
...meta,
|
|
605
|
+
artifactDir: meta.artifactDir ?? globalReviewDir(id),
|
|
606
|
+
feedbackPath: meta.feedbackPath ?? (feedback ? globalReviewFeedbackFile(id) : void 0),
|
|
607
|
+
markdownPath: meta.markdownPath ?? (feedback ? globalReviewMarkdownFile(id) : void 0)
|
|
608
|
+
},
|
|
609
|
+
diff,
|
|
610
|
+
feedback,
|
|
611
|
+
resolution
|
|
612
|
+
};
|
|
613
|
+
this.reviews.set(id, record);
|
|
614
|
+
return record;
|
|
483
615
|
}
|
|
484
616
|
assertResolvable(record, id) {
|
|
485
|
-
if (record.meta.status
|
|
617
|
+
if (!isResolvableReviewStatus(record.meta.status)) {
|
|
486
618
|
throw new Error(`Review ${id} is ${record.meta.status} and cannot be resolved`);
|
|
487
619
|
}
|
|
488
620
|
if (!record.feedback) {
|
|
@@ -500,17 +632,15 @@ var ReviewStore = class {
|
|
|
500
632
|
const resolvedPath = globalReviewResolvedFile(record.meta.id);
|
|
501
633
|
await ensureDir(globalReviewDir(record.meta.id));
|
|
502
634
|
await Promise.all([
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
writeFile2(globalReviewMetaFile(record.meta.id), `${JSON.stringify(record.meta, null, 2)}
|
|
506
|
-
`)
|
|
635
|
+
writeJsonFile(resolvedPath, resolution),
|
|
636
|
+
writeJsonFile(globalReviewMetaFile(record.meta.id), record.meta)
|
|
507
637
|
]);
|
|
508
638
|
const result = {
|
|
509
639
|
ok: true,
|
|
510
640
|
reviewId: record.meta.id,
|
|
511
641
|
status: record.meta.status,
|
|
512
642
|
resolutionStatus: resolution.status,
|
|
513
|
-
comments:
|
|
643
|
+
comments: resolutionCounts(record.feedback, resolution.comments),
|
|
514
644
|
path: resolvedPath,
|
|
515
645
|
resolution
|
|
516
646
|
};
|
|
@@ -532,23 +662,38 @@ var ReviewStore = class {
|
|
|
532
662
|
(a, b) => (feedbackIndex.get(a.commentId) ?? Number.MAX_SAFE_INTEGER) - (feedbackIndex.get(b.commentId) ?? Number.MAX_SAFE_INTEGER)
|
|
533
663
|
);
|
|
534
664
|
}
|
|
535
|
-
resolutionCounts(record, comments) {
|
|
536
|
-
const total = record.feedback.comments.length;
|
|
537
|
-
const resolvedIds = new Set(comments.map((comment) => comment.commentId));
|
|
538
|
-
const resolved = record.feedback.comments.filter(
|
|
539
|
-
(comment) => resolvedIds.has(comment.id)
|
|
540
|
-
).length;
|
|
541
|
-
return {
|
|
542
|
-
total,
|
|
543
|
-
resolved,
|
|
544
|
-
open: total - resolved
|
|
545
|
-
};
|
|
546
|
-
}
|
|
547
665
|
};
|
|
666
|
+
async function readOptionalJsonFile(filePath, guard, label) {
|
|
667
|
+
let raw;
|
|
668
|
+
try {
|
|
669
|
+
raw = await readFile2(filePath, "utf8");
|
|
670
|
+
} catch (error) {
|
|
671
|
+
if (isFileNotFound(error)) {
|
|
672
|
+
return void 0;
|
|
673
|
+
}
|
|
674
|
+
throw new Error(`Could not read ${label} at ${filePath}: ${formatError(error)}`, {
|
|
675
|
+
cause: error
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
return parseJsonFile(raw, guard, label, filePath);
|
|
679
|
+
}
|
|
680
|
+
function parseJsonFile(raw, guard, label, filePath) {
|
|
681
|
+
try {
|
|
682
|
+
return parseJson(raw, guard, label);
|
|
683
|
+
} catch (error) {
|
|
684
|
+
throw new Error(`Invalid ${label} at ${filePath}: ${formatError(error)}`, { cause: error });
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
function isFileNotFound(error) {
|
|
688
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
689
|
+
}
|
|
690
|
+
function formatError(error) {
|
|
691
|
+
return error instanceof Error ? error.message : String(error);
|
|
692
|
+
}
|
|
548
693
|
var reviewStore = new ReviewStore();
|
|
549
694
|
|
|
550
695
|
// src/server/index.ts
|
|
551
|
-
var webRoot =
|
|
696
|
+
var webRoot = fileURLToPath(new URL("../web", import.meta.url));
|
|
552
697
|
var eventStreamHeartbeatMs = 15e3;
|
|
553
698
|
var mimeTypes = {
|
|
554
699
|
".css": "text/css; charset=utf-8",
|
|
@@ -564,17 +709,29 @@ function createApp(origin2) {
|
|
|
564
709
|
const app = new Hono();
|
|
565
710
|
app.get("/api/health", async (c) => {
|
|
566
711
|
const reviews = await reviewStore.list();
|
|
567
|
-
|
|
712
|
+
const response = {
|
|
568
713
|
ok: true,
|
|
569
714
|
version: packageVersion,
|
|
570
715
|
activeReviews: reviews.filter((review) => review.status === "pending").length
|
|
571
|
-
}
|
|
716
|
+
};
|
|
717
|
+
return c.json(response);
|
|
718
|
+
});
|
|
719
|
+
app.get("/api/reviews", async (c) => {
|
|
720
|
+
const response = { reviews: await reviewStore.list() };
|
|
721
|
+
return c.json(response);
|
|
572
722
|
});
|
|
573
|
-
app.get("/api/reviews", async (c) => c.json({ reviews: await reviewStore.list() }));
|
|
574
723
|
app.post("/api/reviews", async (c) => {
|
|
575
|
-
const
|
|
724
|
+
const parsed = await readJsonBody(c, isDiffPayload, "review diff");
|
|
725
|
+
if (!parsed.ok) {
|
|
726
|
+
return parsed.response;
|
|
727
|
+
}
|
|
728
|
+
const diff = parsed.body;
|
|
576
729
|
const record = await reviewStore.create(diff);
|
|
577
|
-
|
|
730
|
+
const response = {
|
|
731
|
+
meta: record.meta,
|
|
732
|
+
url: `${origin2}/review/${record.meta.id}`
|
|
733
|
+
};
|
|
734
|
+
return c.json(response, 201);
|
|
578
735
|
});
|
|
579
736
|
app.get("/api/reviews/:id", async (c) => {
|
|
580
737
|
const record = await reviewStore.get(c.req.param("id"));
|
|
@@ -621,9 +778,11 @@ function createApp(origin2) {
|
|
|
621
778
|
};
|
|
622
779
|
const unsubscribe = reviewStore.subscribe(id, send);
|
|
623
780
|
const heartbeat = setInterval(() => {
|
|
624
|
-
pending = pending.then(() =>
|
|
781
|
+
pending = pending.then(async () => {
|
|
782
|
+
await stream.write(`: keep-alive ${Date.now()}
|
|
625
783
|
|
|
626
|
-
`)
|
|
784
|
+
`);
|
|
785
|
+
});
|
|
627
786
|
void pending.catch(() => close?.());
|
|
628
787
|
}, eventStreamHeartbeatMs);
|
|
629
788
|
cleanup = () => {
|
|
@@ -632,12 +791,12 @@ function createApp(origin2) {
|
|
|
632
791
|
};
|
|
633
792
|
stream.onAbort(() => close?.());
|
|
634
793
|
send({ type: "review.opened", reviewId: id });
|
|
635
|
-
if ((record.meta.status
|
|
794
|
+
if (isResolvableReviewStatus(record.meta.status) && record.feedback) {
|
|
636
795
|
send({
|
|
637
796
|
type: "review.submitted",
|
|
638
797
|
reviewId: id,
|
|
639
798
|
counts: {
|
|
640
|
-
files:
|
|
799
|
+
files: countCommentFiles(record.feedback.comments),
|
|
641
800
|
comments: record.feedback.comments.length
|
|
642
801
|
}
|
|
643
802
|
});
|
|
@@ -654,20 +813,22 @@ function createApp(origin2) {
|
|
|
654
813
|
if (existing.meta.status !== "pending") {
|
|
655
814
|
return c.json({ error: `review is ${existing.meta.status} and cannot be submitted` }, 409);
|
|
656
815
|
}
|
|
657
|
-
const
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
816
|
+
const parsed = await readJsonBody(c, isSubmitReviewRequest, "submit review request");
|
|
817
|
+
if (!parsed.ok) {
|
|
818
|
+
return parsed.response;
|
|
819
|
+
}
|
|
820
|
+
const body = parsed.body;
|
|
821
|
+
const { record, feedbackPath, markdownPath } = await reviewStore.submit(id, body.comments);
|
|
822
|
+
const response = {
|
|
663
823
|
reviewId: id,
|
|
664
824
|
url: `${origin2}/review/${id}`,
|
|
665
825
|
files: record.diff.files.length,
|
|
666
|
-
comments: body.comments
|
|
826
|
+
comments: body.comments.length,
|
|
667
827
|
artifactDir: record.meta.artifactDir,
|
|
668
828
|
feedbackPath,
|
|
669
829
|
markdownPath
|
|
670
|
-
}
|
|
830
|
+
};
|
|
831
|
+
return c.json(response);
|
|
671
832
|
});
|
|
672
833
|
app.post("/api/reviews/:id/resolved", async (c) => {
|
|
673
834
|
const id = c.req.param("id");
|
|
@@ -675,13 +836,17 @@ function createApp(origin2) {
|
|
|
675
836
|
if (!existing) {
|
|
676
837
|
return c.json({ error: "review not found" }, 404);
|
|
677
838
|
}
|
|
678
|
-
if (existing.meta.status
|
|
839
|
+
if (!isResolvableReviewStatus(existing.meta.status)) {
|
|
679
840
|
return c.json({ error: `review is ${existing.meta.status} and cannot be resolved` }, 409);
|
|
680
841
|
}
|
|
681
842
|
if (!existing.feedback) {
|
|
682
843
|
return c.json({ error: "submitted feedback not found" }, 409);
|
|
683
844
|
}
|
|
684
|
-
const
|
|
845
|
+
const parsed = await readJsonBody(c, isResolutionRequest, "resolution request");
|
|
846
|
+
if (!parsed.ok) {
|
|
847
|
+
return parsed.response;
|
|
848
|
+
}
|
|
849
|
+
const body = parsed.body;
|
|
685
850
|
return c.json(await reviewStore.markResolved(id, body.summary));
|
|
686
851
|
});
|
|
687
852
|
app.post("/api/reviews/:id/comments/:commentId/resolved", async (c) => {
|
|
@@ -691,13 +856,17 @@ function createApp(origin2) {
|
|
|
691
856
|
if (!existing) {
|
|
692
857
|
return c.json({ error: "review not found" }, 404);
|
|
693
858
|
}
|
|
694
|
-
if (existing.meta.status
|
|
859
|
+
if (!isResolvableReviewStatus(existing.meta.status)) {
|
|
695
860
|
return c.json({ error: `review is ${existing.meta.status} and cannot be resolved` }, 409);
|
|
696
861
|
}
|
|
697
862
|
if (!existing.feedback?.comments.some((comment) => comment.id === commentId)) {
|
|
698
863
|
return c.json({ error: "comment not found" }, 404);
|
|
699
864
|
}
|
|
700
|
-
const
|
|
865
|
+
const parsed = await readJsonBody(c, isResolutionRequest, "resolution request");
|
|
866
|
+
if (!parsed.ok) {
|
|
867
|
+
return parsed.response;
|
|
868
|
+
}
|
|
869
|
+
const body = parsed.body;
|
|
701
870
|
return c.json(await reviewStore.resolveComment(id, commentId, body.summary));
|
|
702
871
|
});
|
|
703
872
|
app.delete("/api/reviews/:id/comments/:commentId/resolved", async (c) => {
|
|
@@ -707,7 +876,7 @@ function createApp(origin2) {
|
|
|
707
876
|
if (!existing) {
|
|
708
877
|
return c.json({ error: "review not found" }, 404);
|
|
709
878
|
}
|
|
710
|
-
if (existing.meta.status
|
|
879
|
+
if (!isResolvableReviewStatus(existing.meta.status)) {
|
|
711
880
|
return c.json({ error: `review is ${existing.meta.status} and cannot be resolved` }, 409);
|
|
712
881
|
}
|
|
713
882
|
if (!existing.feedback?.comments.some((comment) => comment.id === commentId)) {
|
|
@@ -730,13 +899,13 @@ function createApp(origin2) {
|
|
|
730
899
|
}
|
|
731
900
|
async function serveAsset(c) {
|
|
732
901
|
const requestPath = new URL(c.req.url).pathname.replace(/^\/assets\//, "");
|
|
733
|
-
const normalized =
|
|
734
|
-
const assetPath =
|
|
902
|
+
const normalized = path3.normalize(requestPath).replace(/^(\.\.(\/|\\|$))+/, "");
|
|
903
|
+
const assetPath = path3.join(webRoot, "assets", normalized);
|
|
735
904
|
try {
|
|
736
905
|
const body = await readFile3(assetPath);
|
|
737
906
|
return new Response(body, {
|
|
738
907
|
headers: {
|
|
739
|
-
"content-type": mimeTypes[
|
|
908
|
+
"content-type": mimeTypes[path3.extname(assetPath)] ?? "application/octet-stream"
|
|
740
909
|
}
|
|
741
910
|
});
|
|
742
911
|
} catch {
|
|
@@ -745,7 +914,7 @@ async function serveAsset(c) {
|
|
|
745
914
|
}
|
|
746
915
|
async function serveIndex() {
|
|
747
916
|
try {
|
|
748
|
-
const body = await readFile3(
|
|
917
|
+
const body = await readFile3(path3.join(webRoot, "index.html"));
|
|
749
918
|
return new Response(body, {
|
|
750
919
|
headers: { "content-type": "text/html; charset=utf-8" }
|
|
751
920
|
});
|
|
@@ -756,7 +925,7 @@ async function serveIndex() {
|
|
|
756
925
|
function serveRootFile(fileName, contentType) {
|
|
757
926
|
return async () => {
|
|
758
927
|
try {
|
|
759
|
-
const body = await readFile3(
|
|
928
|
+
const body = await readFile3(path3.join(webRoot, fileName));
|
|
760
929
|
return new Response(body, {
|
|
761
930
|
headers: { "content-type": contentType }
|
|
762
931
|
});
|
|
@@ -765,6 +934,28 @@ function serveRootFile(fileName, contentType) {
|
|
|
765
934
|
}
|
|
766
935
|
};
|
|
767
936
|
}
|
|
937
|
+
async function readJsonBody(c, guard, label) {
|
|
938
|
+
let body;
|
|
939
|
+
try {
|
|
940
|
+
body = await c.req.json();
|
|
941
|
+
} catch (error) {
|
|
942
|
+
return {
|
|
943
|
+
ok: false,
|
|
944
|
+
response: c.json({ error: `invalid JSON body: ${formatError2(error)}` }, 400)
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
try {
|
|
948
|
+
return { ok: true, body: parseJsonValue(body, guard, label) };
|
|
949
|
+
} catch (error) {
|
|
950
|
+
return {
|
|
951
|
+
ok: false,
|
|
952
|
+
response: c.json({ error: formatError2(error) }, 400)
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
function formatError2(error) {
|
|
957
|
+
return error instanceof Error ? error.message : String(error);
|
|
958
|
+
}
|
|
768
959
|
|
|
769
960
|
// src/server/daemon.ts
|
|
770
961
|
var port = Number(process.env.GLOSS_PORT ?? "0");
|