getgloss 0.7.1 → 0.8.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 +54 -18
- package/dist/cli/index.js +1009 -220
- package/dist/cli/index.js.map +1 -1
- package/dist/server/daemon.js +1126 -188
- package/dist/server/daemon.js.map +1 -1
- package/dist/web/assets/index-DBK7WjlV.css +1 -0
- package/dist/web/assets/index-DkylW082.js +244 -0
- package/dist/web/assets/syntax-DdLzhglo.js +13 -0
- package/dist/web/index.html +2 -2
- package/dist/web/prompt.md +14 -6
- package/dist/web/setup.md +24 -8
- package/package.json +4 -1
- package/skill/SKILL.md +32 -11
- package/dist/web/assets/index-BsRo7I09.css +0 -1
- package/dist/web/assets/index-ENS6WF0D.js +0 -179
package/dist/server/daemon.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// src/server/daemon.ts
|
|
2
|
+
import { rm as rm2 } from "fs/promises";
|
|
2
3
|
import { serve } from "@hono/node-server";
|
|
3
4
|
|
|
4
5
|
// src/shared/paths.ts
|
|
@@ -9,7 +10,7 @@ import path from "path";
|
|
|
9
10
|
// package.json
|
|
10
11
|
var package_default = {
|
|
11
12
|
name: "getgloss",
|
|
12
|
-
version: "0.
|
|
13
|
+
version: "0.8.0",
|
|
13
14
|
description: "Local browser-based diff review for coding-agent loops.",
|
|
14
15
|
type: "module",
|
|
15
16
|
packageManager: "pnpm@10.33.2",
|
|
@@ -40,6 +41,8 @@ var package_default = {
|
|
|
40
41
|
},
|
|
41
42
|
dependencies: {
|
|
42
43
|
"@hono/node-server": "1.19.14",
|
|
44
|
+
"@shikijs/langs": "4.1.0",
|
|
45
|
+
"@shikijs/themes": "4.1.0",
|
|
43
46
|
"@tailwindcss/vite": "4.3.0",
|
|
44
47
|
commander: "14.0.3",
|
|
45
48
|
execa: "9.6.1",
|
|
@@ -49,6 +52,7 @@ var package_default = {
|
|
|
49
52
|
open: "10.2.0",
|
|
50
53
|
react: "19.2.6",
|
|
51
54
|
"react-dom": "19.2.6",
|
|
55
|
+
shiki: "4.1.0",
|
|
52
56
|
ulid: "3.0.2",
|
|
53
57
|
zustand: "5.0.13"
|
|
54
58
|
},
|
|
@@ -108,6 +112,27 @@ function globalReviewsDir() {
|
|
|
108
112
|
function globalReviewDir(reviewId) {
|
|
109
113
|
return path.join(globalReviewsDir(), reviewId);
|
|
110
114
|
}
|
|
115
|
+
function globalReviewTurnsDir(reviewId) {
|
|
116
|
+
return path.join(globalReviewDir(reviewId), "turns");
|
|
117
|
+
}
|
|
118
|
+
function globalReviewTurnDir(reviewId, turnId) {
|
|
119
|
+
return path.join(globalReviewTurnsDir(reviewId), turnId);
|
|
120
|
+
}
|
|
121
|
+
function globalReviewTurnMetaFile(reviewId, turnId) {
|
|
122
|
+
return path.join(globalReviewTurnDir(reviewId, turnId), "turn.json");
|
|
123
|
+
}
|
|
124
|
+
function globalReviewTurnDiffFile(reviewId, turnId) {
|
|
125
|
+
return path.join(globalReviewTurnDir(reviewId, turnId), "diff.json");
|
|
126
|
+
}
|
|
127
|
+
function globalReviewTurnFeedbackFile(reviewId, turnId) {
|
|
128
|
+
return path.join(globalReviewTurnDir(reviewId, turnId), "feedback.json");
|
|
129
|
+
}
|
|
130
|
+
function globalReviewTurnMarkdownFile(reviewId, turnId) {
|
|
131
|
+
return path.join(globalReviewTurnDir(reviewId, turnId), "feedback.md");
|
|
132
|
+
}
|
|
133
|
+
function globalReviewTurnResolvedFile(reviewId, turnId) {
|
|
134
|
+
return path.join(globalReviewTurnDir(reviewId, turnId), "resolved.json");
|
|
135
|
+
}
|
|
111
136
|
function globalReviewMetaFile(reviewId) {
|
|
112
137
|
return path.join(globalReviewDir(reviewId), "meta.json");
|
|
113
138
|
}
|
|
@@ -130,23 +155,49 @@ async function ensureDir(dir) {
|
|
|
130
155
|
// src/shared/server-info.ts
|
|
131
156
|
import { readFile } from "fs/promises";
|
|
132
157
|
|
|
158
|
+
// src/shared/errors.ts
|
|
159
|
+
function formatError(error) {
|
|
160
|
+
return error instanceof Error ? error.message : String(error);
|
|
161
|
+
}
|
|
162
|
+
function isFileNotFound(error) {
|
|
163
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
164
|
+
}
|
|
165
|
+
|
|
133
166
|
// src/shared/json.ts
|
|
134
|
-
import {
|
|
167
|
+
import { randomUUID } from "crypto";
|
|
168
|
+
import { rename, rm, writeFile } from "fs/promises";
|
|
169
|
+
import path2 from "path";
|
|
135
170
|
function serializeJson(value) {
|
|
136
171
|
return `${JSON.stringify(value, null, 2)}
|
|
137
172
|
`;
|
|
138
173
|
}
|
|
139
174
|
async function writeJsonFile(filePath, value) {
|
|
140
|
-
await
|
|
175
|
+
await writeTextFile(filePath, serializeJson(value));
|
|
141
176
|
}
|
|
177
|
+
async function writeTextFile(filePath, value) {
|
|
178
|
+
const tempPath = path2.join(
|
|
179
|
+
path2.dirname(filePath),
|
|
180
|
+
`.${path2.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`
|
|
181
|
+
);
|
|
182
|
+
try {
|
|
183
|
+
await writeFile(tempPath, value);
|
|
184
|
+
await rename(tempPath, filePath);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
await rm(tempPath, { force: true }).catch(() => void 0);
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// src/shared/types.ts
|
|
192
|
+
var SIDES = ["L", "R"];
|
|
193
|
+
var REVIEW_STATUSES = ["pending", "submitted", "cancelled", "resolved"];
|
|
194
|
+
var DIFF_LINE_TYPES = ["context", "add", "delete"];
|
|
195
|
+
var DIFF_SCOPE_MODES = ["working", "branch", "explicit"];
|
|
196
|
+
var DIFF_FALLBACK_REASONS = ["working-tree-clean", "missing-branch-base"];
|
|
197
|
+
var REVIEW_SCOPE_MODES = ["all", "single", "range"];
|
|
198
|
+
var RESOLUTION_STATUSES = ["partial", "resolved"];
|
|
142
199
|
|
|
143
200
|
// 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
201
|
function parseJson(raw, guard, label) {
|
|
151
202
|
const parsed = JSON.parse(raw);
|
|
152
203
|
return parseJsonValue(parsed, guard, label);
|
|
@@ -157,26 +208,50 @@ function parseJsonValue(value, guard, label) {
|
|
|
157
208
|
}
|
|
158
209
|
return value;
|
|
159
210
|
}
|
|
211
|
+
function isServerInfo(value) {
|
|
212
|
+
return isRecord(value) && isNumber(value.pid) && isNumber(value.port) && isString(value.version) && isString(value.startedAt) && isString(value.stateDir);
|
|
213
|
+
}
|
|
214
|
+
function isOpenFileRequest(value) {
|
|
215
|
+
return isRecord(value) && isString(value.filePath) && isOptionalString(value.turnId);
|
|
216
|
+
}
|
|
217
|
+
function isCommitRangeDiffRequest(value) {
|
|
218
|
+
return isRecord(value) && isString(value.fromSha) && isString(value.toSha) && isOptionalString(value.turnId);
|
|
219
|
+
}
|
|
160
220
|
function isSubmitReviewRequest(value) {
|
|
161
|
-
return isRecord(value) && isArrayOf(value.comments, isComment);
|
|
221
|
+
return isRecord(value) && isArrayOf(value.comments, isComment) && isOptional(value.reviewScope, isReviewScope);
|
|
162
222
|
}
|
|
163
223
|
function isResolutionRequest(value) {
|
|
164
|
-
return isRecord(value) && isOptionalString(value.summary);
|
|
224
|
+
return isRecord(value) && isOptionalString(value.summary) && isOptionalString(value.turn);
|
|
165
225
|
}
|
|
166
226
|
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.
|
|
227
|
+
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.activeTurnId) && isOptional(
|
|
228
|
+
value.turns,
|
|
229
|
+
(turns) => isArrayOf(turns, isReviewTurnSummary)
|
|
230
|
+
) && isOptionalString(value.feedbackPath) && isOptionalString(value.markdownPath);
|
|
168
231
|
}
|
|
169
232
|
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) &&
|
|
233
|
+
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) && isOptional(
|
|
234
|
+
value.commitDiffs,
|
|
235
|
+
(commitDiffs) => isArrayOf(commitDiffs, isCommitDiff)
|
|
236
|
+
) && isString(value.capturedAt);
|
|
171
237
|
}
|
|
172
238
|
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);
|
|
239
|
+
return isRecord(value) && value.version === 1 && isString(value.reviewId) && isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isString(value.timestamp) && isBaseRef(value.base) && isNullableString(value.branch) && isOptional(value.reviewScope, isReviewScope) && isArrayOf(value.comments, isComment);
|
|
174
240
|
}
|
|
175
241
|
function isResolutionBundle(value) {
|
|
176
|
-
return isRecord(value) && isString(value.reviewId) && isResolutionStatus(value.status) && isNullableString(value.summary) && isNullableString(value.resolvedAt) && isArrayOf(value.comments, isResolvedComment);
|
|
242
|
+
return isRecord(value) && isString(value.reviewId) && isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isResolutionStatus(value.status) && isNullableString(value.summary) && isNullableString(value.resolvedAt) && isArrayOf(value.comments, isResolvedComment);
|
|
243
|
+
}
|
|
244
|
+
function isReviewTurnMeta(value) {
|
|
245
|
+
return isRecord(value) && isString(value.id) && isNumber(value.index) && isReviewStatus(value.status) && isString(value.createdAt) && isOptionalString(value.submittedAt) && isOptionalString(value.resolvedAt) && isString(value.artifactDir) && isString(value.diffPath) && isOptionalString(value.feedbackPath) && isOptionalString(value.markdownPath) && isOptionalString(value.resolvedPath);
|
|
246
|
+
}
|
|
247
|
+
function isReviewTurnSummary(value) {
|
|
248
|
+
if (!isRecord(value) || !isReviewTurnMeta(value)) {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
return isString(value.capturedAt) && isDiffStats(value.stats) && isResolutionCounts(value.comments);
|
|
177
252
|
}
|
|
178
253
|
function isDiffScope(value) {
|
|
179
|
-
return isRecord(value) && isOneOf(value.mode,
|
|
254
|
+
return isRecord(value) && isOneOf(value.mode, DIFF_SCOPE_MODES) && isNullableString(value.requestedBase) && isBaseRef(value.base) && isDiffRef(value.comparison) && (value.fallbackReason === null || isOneOf(value.fallbackReason, DIFF_FALLBACK_REASONS));
|
|
180
255
|
}
|
|
181
256
|
function isDiffRef(value) {
|
|
182
257
|
return isRecord(value) && isString(value.ref) && isNullableString(value.sha);
|
|
@@ -187,6 +262,25 @@ function isBaseRef(value) {
|
|
|
187
262
|
function isDiffStats(value) {
|
|
188
263
|
return isRecord(value) && isNumber(value.files) && isNumber(value.additions) && isNumber(value.deletions);
|
|
189
264
|
}
|
|
265
|
+
function isDiffCommit(value) {
|
|
266
|
+
return isRecord(value) && isString(value.sha) && isString(value.shortSha) && isString(value.subject) && isString(value.authorName) && isString(value.authorEmail) && isString(value.authoredAt) && isString(value.committedAt);
|
|
267
|
+
}
|
|
268
|
+
function isCommitDiff(value) {
|
|
269
|
+
return isRecord(value) && isDiffCommit(value.commit) && isDiffStats(value.stats) && isString(value.rawDiff) && isArrayOf(value.files, isDiffFile);
|
|
270
|
+
}
|
|
271
|
+
function isReviewScope(value) {
|
|
272
|
+
if (!isRecord(value) || !isOneOf(value.mode, REVIEW_SCOPE_MODES)) {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
switch (value.mode) {
|
|
276
|
+
case "all":
|
|
277
|
+
return true;
|
|
278
|
+
case "single":
|
|
279
|
+
return isString(value.sha);
|
|
280
|
+
case "range":
|
|
281
|
+
return isString(value.fromSha) && isString(value.toSha);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
190
284
|
function isDiffFile(value) {
|
|
191
285
|
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
286
|
}
|
|
@@ -194,19 +288,22 @@ function isDiffHunk(value) {
|
|
|
194
288
|
return isRecord(value) && isNumber(value.oldStart) && isNumber(value.oldLines) && isNumber(value.newStart) && isNumber(value.newLines) && isString(value.header) && isArrayOf(value.lines, isDiffLine);
|
|
195
289
|
}
|
|
196
290
|
function isDiffLine(value) {
|
|
197
|
-
return isRecord(value) && isOneOf(value.type,
|
|
291
|
+
return isRecord(value) && isOneOf(value.type, DIFF_LINE_TYPES) && isNullableNumber(value.oldLine) && isNullableNumber(value.newLine) && isString(value.content);
|
|
198
292
|
}
|
|
199
293
|
function isComment(value) {
|
|
200
|
-
return isRecord(value) && isString(value.id) && isString(value.filePath) && isNumber(value.startLine) && isNumber(value.endLine) && isOneOf(value.side,
|
|
294
|
+
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
295
|
}
|
|
202
296
|
function isResolvedComment(value) {
|
|
203
297
|
return isRecord(value) && isString(value.commentId) && value.status === "resolved" && isOptionalString(value.summary) && isString(value.resolvedAt);
|
|
204
298
|
}
|
|
299
|
+
function isResolutionCounts(value) {
|
|
300
|
+
return isRecord(value) && isNumber(value.total) && isNumber(value.resolved) && isNumber(value.open);
|
|
301
|
+
}
|
|
205
302
|
function isReviewStatus(value) {
|
|
206
|
-
return isOneOf(value,
|
|
303
|
+
return isOneOf(value, REVIEW_STATUSES);
|
|
207
304
|
}
|
|
208
305
|
function isResolutionStatus(value) {
|
|
209
|
-
return isOneOf(value,
|
|
306
|
+
return isOneOf(value, RESOLUTION_STATUSES);
|
|
210
307
|
}
|
|
211
308
|
function isRecord(value) {
|
|
212
309
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -214,6 +311,9 @@ function isRecord(value) {
|
|
|
214
311
|
function isArrayOf(value, guard) {
|
|
215
312
|
return Array.isArray(value) && value.every(guard);
|
|
216
313
|
}
|
|
314
|
+
function isOptional(value, guard) {
|
|
315
|
+
return value === void 0 || guard(value);
|
|
316
|
+
}
|
|
217
317
|
function isString(value) {
|
|
218
318
|
return typeof value === "string";
|
|
219
319
|
}
|
|
@@ -226,6 +326,9 @@ function isNullableString(value) {
|
|
|
226
326
|
function isNumber(value) {
|
|
227
327
|
return typeof value === "number" && Number.isFinite(value);
|
|
228
328
|
}
|
|
329
|
+
function isOptionalNumber(value) {
|
|
330
|
+
return value === void 0 || isNumber(value);
|
|
331
|
+
}
|
|
229
332
|
function isNullableNumber(value) {
|
|
230
333
|
return value === null || isNumber(value);
|
|
231
334
|
}
|
|
@@ -237,14 +340,34 @@ function isOneOf(value, options) {
|
|
|
237
340
|
}
|
|
238
341
|
|
|
239
342
|
// src/shared/server-info.ts
|
|
343
|
+
async function readServerInfo() {
|
|
344
|
+
let raw;
|
|
345
|
+
try {
|
|
346
|
+
raw = await readFile(globalServerFile(), "utf8");
|
|
347
|
+
} catch (error) {
|
|
348
|
+
if (isFileNotFound(error)) {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
throw new Error(`Could not read server info at ${globalServerFile()}: ${formatError(error)}`, {
|
|
352
|
+
cause: error
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
return parseJson(raw, isServerInfo, "server info");
|
|
357
|
+
} catch (error) {
|
|
358
|
+
throw new Error(`Invalid server info at ${globalServerFile()}: ${formatError(error)}`, {
|
|
359
|
+
cause: error
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
240
363
|
async function writeServerInfo(info) {
|
|
241
364
|
await ensureDir(globalStateDir());
|
|
242
365
|
await writeJsonFile(globalServerFile(), info);
|
|
243
366
|
}
|
|
244
367
|
|
|
245
368
|
// src/server/index.ts
|
|
246
|
-
import { readFile as readFile3 } from "fs/promises";
|
|
247
|
-
import
|
|
369
|
+
import { readFile as readFile3, realpath, stat } from "fs/promises";
|
|
370
|
+
import path5 from "path";
|
|
248
371
|
import { fileURLToPath } from "url";
|
|
249
372
|
import { Hono } from "hono";
|
|
250
373
|
import { streamSSE } from "hono/streaming";
|
|
@@ -276,17 +399,11 @@ function resolutionCounts(feedback, resolvedComments = []) {
|
|
|
276
399
|
};
|
|
277
400
|
}
|
|
278
401
|
|
|
279
|
-
// src/shared/
|
|
280
|
-
|
|
281
|
-
return status === "submitted" || status === "resolved";
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// src/server/store.ts
|
|
285
|
-
import { readdir, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
286
|
-
import { ulid } from "ulid";
|
|
402
|
+
// src/shared/git-diff.ts
|
|
403
|
+
import { execa } from "execa";
|
|
287
404
|
|
|
288
405
|
// src/shared/language.ts
|
|
289
|
-
import
|
|
406
|
+
import path3 from "path";
|
|
290
407
|
var languageByExtension = {
|
|
291
408
|
cjs: "js",
|
|
292
409
|
css: "css",
|
|
@@ -308,13 +425,240 @@ var languageByExtension = {
|
|
|
308
425
|
yml: "yaml"
|
|
309
426
|
};
|
|
310
427
|
function languageForPath(filePath) {
|
|
311
|
-
const ext =
|
|
428
|
+
const ext = path3.extname(filePath).slice(1).toLowerCase();
|
|
312
429
|
if (!ext) {
|
|
313
430
|
return null;
|
|
314
431
|
}
|
|
315
432
|
return languageByExtension[ext] ?? ext;
|
|
316
433
|
}
|
|
317
434
|
|
|
435
|
+
// src/shared/diff-parser.ts
|
|
436
|
+
var hunkHeaderPattern = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/;
|
|
437
|
+
function stripGitPath(input) {
|
|
438
|
+
return input.replace(/^[ab]\//, "");
|
|
439
|
+
}
|
|
440
|
+
function emptyFile() {
|
|
441
|
+
return {
|
|
442
|
+
path: "",
|
|
443
|
+
oldPath: null,
|
|
444
|
+
additions: 0,
|
|
445
|
+
deletions: 0,
|
|
446
|
+
isBinary: false,
|
|
447
|
+
isDeleted: false,
|
|
448
|
+
isNew: false,
|
|
449
|
+
isRenamed: false,
|
|
450
|
+
language: null,
|
|
451
|
+
hunks: []
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
function parseUnifiedDiff(diffText) {
|
|
455
|
+
const files = [];
|
|
456
|
+
let current = null;
|
|
457
|
+
let currentHunk = null;
|
|
458
|
+
let oldCursor = 0;
|
|
459
|
+
let newCursor = 0;
|
|
460
|
+
const finalizeFile = () => {
|
|
461
|
+
if (current?.path) {
|
|
462
|
+
current.language = languageForPath(current.path);
|
|
463
|
+
files.push(current);
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
for (const line of diffText.split("\n")) {
|
|
467
|
+
if (line.startsWith("diff --git ")) {
|
|
468
|
+
finalizeFile();
|
|
469
|
+
current = emptyFile();
|
|
470
|
+
currentHunk = null;
|
|
471
|
+
oldCursor = 0;
|
|
472
|
+
newCursor = 0;
|
|
473
|
+
const match = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
|
|
474
|
+
if (match) {
|
|
475
|
+
current.oldPath = match[1];
|
|
476
|
+
current.path = match[2];
|
|
477
|
+
}
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (!current) {
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (line.startsWith("new file mode")) {
|
|
484
|
+
current.isNew = true;
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (line.startsWith("deleted file mode")) {
|
|
488
|
+
current.isDeleted = true;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (line.startsWith("rename from ")) {
|
|
492
|
+
current.oldPath = line.slice("rename from ".length);
|
|
493
|
+
current.isRenamed = true;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (line.startsWith("rename to ")) {
|
|
497
|
+
current.path = line.slice("rename to ".length);
|
|
498
|
+
current.isRenamed = true;
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (line.startsWith("Binary files ") || line.startsWith("GIT binary patch")) {
|
|
502
|
+
current.isBinary = true;
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (line.startsWith("--- ")) {
|
|
506
|
+
const oldPath = line.slice(4).trim();
|
|
507
|
+
current.oldPath = oldPath === "/dev/null" ? null : stripGitPath(oldPath);
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (line.startsWith("+++ ")) {
|
|
511
|
+
const newPath = line.slice(4).trim();
|
|
512
|
+
current.path = newPath === "/dev/null" ? current.oldPath ?? current.path : stripGitPath(newPath);
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
const hunkMatch = hunkHeaderPattern.exec(line);
|
|
516
|
+
if (hunkMatch) {
|
|
517
|
+
const oldStart = Number(hunkMatch[1]);
|
|
518
|
+
const oldLines = Number(hunkMatch[2] ?? "1");
|
|
519
|
+
const newStart = Number(hunkMatch[3]);
|
|
520
|
+
const newLines = Number(hunkMatch[4] ?? "1");
|
|
521
|
+
currentHunk = {
|
|
522
|
+
oldStart,
|
|
523
|
+
oldLines,
|
|
524
|
+
newStart,
|
|
525
|
+
newLines,
|
|
526
|
+
header: hunkMatch[5]?.trim() ?? "",
|
|
527
|
+
lines: []
|
|
528
|
+
};
|
|
529
|
+
current.hunks.push(currentHunk);
|
|
530
|
+
oldCursor = oldStart;
|
|
531
|
+
newCursor = newStart;
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (!currentHunk) {
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
const marker = line[0];
|
|
538
|
+
const content = line.slice(1);
|
|
539
|
+
let diffLine = null;
|
|
540
|
+
if (marker === "+") {
|
|
541
|
+
diffLine = { type: "add", oldLine: null, newLine: newCursor, content };
|
|
542
|
+
current.additions += 1;
|
|
543
|
+
newCursor += 1;
|
|
544
|
+
} else if (marker === "-") {
|
|
545
|
+
diffLine = { type: "delete", oldLine: oldCursor, newLine: null, content };
|
|
546
|
+
current.deletions += 1;
|
|
547
|
+
oldCursor += 1;
|
|
548
|
+
} else if (marker === " ") {
|
|
549
|
+
diffLine = { type: "context", oldLine: oldCursor, newLine: newCursor, content };
|
|
550
|
+
oldCursor += 1;
|
|
551
|
+
newCursor += 1;
|
|
552
|
+
} else if (line.startsWith("\")) {
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
if (diffLine) {
|
|
556
|
+
currentHunk.lines.push(diffLine);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
finalizeFile();
|
|
560
|
+
return files;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// src/shared/diff-stats.ts
|
|
564
|
+
function summarizeDiffFiles(files) {
|
|
565
|
+
return files.reduce(
|
|
566
|
+
(stats, file) => ({
|
|
567
|
+
files: stats.files + 1,
|
|
568
|
+
additions: stats.additions + file.additions,
|
|
569
|
+
deletions: stats.deletions + file.deletions
|
|
570
|
+
}),
|
|
571
|
+
{ files: 0, additions: 0, deletions: 0 }
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// src/shared/git-diff.ts
|
|
576
|
+
var DIFF_ARGS = ["diff", "--no-color", "--find-renames", "--find-copies"];
|
|
577
|
+
async function git(args, cwd) {
|
|
578
|
+
const result = await execa("git", args, { cwd });
|
|
579
|
+
return result.stdout.trimEnd();
|
|
580
|
+
}
|
|
581
|
+
async function captureCommitRangeDiff(fromSha, toSha, repoRoot) {
|
|
582
|
+
const rawDiff = await git([...DIFF_ARGS, `${fromSha}^`, toSha, "--"], repoRoot);
|
|
583
|
+
const files = parseUnifiedDiff(rawDiff);
|
|
584
|
+
return {
|
|
585
|
+
stats: summarizeDiffFiles(files),
|
|
586
|
+
rawDiff,
|
|
587
|
+
files
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// src/shared/reviews.ts
|
|
592
|
+
function isResolvableReviewStatus(status) {
|
|
593
|
+
return status === "submitted" || status === "resolved";
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// src/server/local-open.ts
|
|
597
|
+
import open from "open";
|
|
598
|
+
async function openLocalPath(filePath) {
|
|
599
|
+
await open(filePath, { wait: false });
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// src/server/store.ts
|
|
603
|
+
import { createHash } from "crypto";
|
|
604
|
+
import { readdir, readFile as readFile2 } from "fs/promises";
|
|
605
|
+
import path4 from "path";
|
|
606
|
+
import { ulid } from "ulid";
|
|
607
|
+
|
|
608
|
+
// src/shared/review-scope.ts
|
|
609
|
+
var ALL_REVIEW_SCOPE = { mode: "all" };
|
|
610
|
+
function normalizeReviewScope(diff, scope = ALL_REVIEW_SCOPE) {
|
|
611
|
+
if (scope.mode === "all") {
|
|
612
|
+
return ALL_REVIEW_SCOPE;
|
|
613
|
+
}
|
|
614
|
+
const commitDiffs = diff.commitDiffs ?? [];
|
|
615
|
+
if (commitDiffs.length === 0) {
|
|
616
|
+
throw new Error("Review scope requires a review with per-commit diffs");
|
|
617
|
+
}
|
|
618
|
+
if (scope.mode === "single") {
|
|
619
|
+
const commit = commitDiffs.find((commitDiff) => commitDiff.commit.sha === scope.sha);
|
|
620
|
+
if (!commit) {
|
|
621
|
+
throw new Error("Review scope must use commits from this review");
|
|
622
|
+
}
|
|
623
|
+
return { mode: "single", sha: commit.commit.sha };
|
|
624
|
+
}
|
|
625
|
+
const fromIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.fromSha);
|
|
626
|
+
const toIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.toSha);
|
|
627
|
+
if (fromIndex < 0 || toIndex < 0) {
|
|
628
|
+
throw new Error("Review scope must use commits from this review");
|
|
629
|
+
}
|
|
630
|
+
if (fromIndex > toIndex) {
|
|
631
|
+
throw new Error("Review scope range must be in review order");
|
|
632
|
+
}
|
|
633
|
+
return {
|
|
634
|
+
mode: "range",
|
|
635
|
+
fromSha: commitDiffs[fromIndex].commit.sha,
|
|
636
|
+
toSha: commitDiffs[toIndex].commit.sha
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
function sameReviewScope(left, right) {
|
|
640
|
+
return JSON.stringify(left ?? ALL_REVIEW_SCOPE) === JSON.stringify(right ?? ALL_REVIEW_SCOPE);
|
|
641
|
+
}
|
|
642
|
+
function reviewScopeLabel(scope = ALL_REVIEW_SCOPE, commitDiffs = []) {
|
|
643
|
+
if (scope.mode === "all") {
|
|
644
|
+
return "All commits";
|
|
645
|
+
}
|
|
646
|
+
if (scope.mode === "single") {
|
|
647
|
+
const commit = commitDiffs.find((commitDiff) => commitDiff.commit.sha === scope.sha);
|
|
648
|
+
return commit ? `${commit.commit.shortSha} ${commit.commit.subject}` : `Commit ${shortSha(scope.sha)}`;
|
|
649
|
+
}
|
|
650
|
+
const fromIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.fromSha);
|
|
651
|
+
const toIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.toSha);
|
|
652
|
+
if (fromIndex >= 0 && toIndex >= fromIndex) {
|
|
653
|
+
const count = toIndex - fromIndex + 1;
|
|
654
|
+
return `${count} commits \xB7 ${commitDiffs[fromIndex].commit.shortSha} to ${commitDiffs[toIndex].commit.shortSha}`;
|
|
655
|
+
}
|
|
656
|
+
return `Commit range ${shortSha(scope.fromSha)} to ${shortSha(scope.toSha)}`;
|
|
657
|
+
}
|
|
658
|
+
function shortSha(sha) {
|
|
659
|
+
return sha.slice(0, 7);
|
|
660
|
+
}
|
|
661
|
+
|
|
318
662
|
// src/shared/markdown.ts
|
|
319
663
|
function fenceFor(snippet) {
|
|
320
664
|
let fence = "```";
|
|
@@ -329,20 +673,32 @@ function languageForSnippet(filePath, snippet) {
|
|
|
329
673
|
return looksLikeUnifiedDiff ? "diff" : languageForPath(filePath) ?? "";
|
|
330
674
|
}
|
|
331
675
|
function serializeFeedbackMarkdown(bundle) {
|
|
332
|
-
const comments =
|
|
333
|
-
const
|
|
676
|
+
const comments = bundle.comments.toSorted(compareCommentsByLocation);
|
|
677
|
+
const commentsByFile = /* @__PURE__ */ new Map();
|
|
678
|
+
const files = [];
|
|
679
|
+
for (const comment of comments) {
|
|
680
|
+
const fileComments = commentsByFile.get(comment.filePath);
|
|
681
|
+
if (fileComments) {
|
|
682
|
+
fileComments.push(comment);
|
|
683
|
+
} else {
|
|
684
|
+
commentsByFile.set(comment.filePath, [comment]);
|
|
685
|
+
files.push(comment.filePath);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
334
688
|
const lines = [
|
|
335
689
|
`# Gloss feedback - ${bundle.timestamp}`,
|
|
336
690
|
`Review: ${bundle.reviewId}`,
|
|
691
|
+
...bundle.turnIndex ? [`Turn: ${bundle.turnIndex} (${bundle.turnId ?? "unknown"})`] : [],
|
|
692
|
+
...bundle.reviewScope ? [`Review scope: ${reviewScopeLabel(bundle.reviewScope)}`] : [],
|
|
337
693
|
`Base: ${bundle.base.ref} (${bundle.base.sha.slice(0, 7)}) Branch: ${bundle.branch ?? "(detached)"}`,
|
|
338
694
|
`Files: ${files.length} Comments: ${comments.length}`,
|
|
339
695
|
""
|
|
340
696
|
];
|
|
341
697
|
for (const filePath of files) {
|
|
342
698
|
lines.push(`## ${filePath}`, "");
|
|
343
|
-
for (const comment of
|
|
699
|
+
for (const comment of commentsByFile.get(filePath) ?? []) {
|
|
344
700
|
const snippet = comment.originalSnippet.trimEnd();
|
|
345
|
-
const firstSnippetLine = snippet
|
|
701
|
+
const firstSnippetLine = firstNonEmptyLine(snippet);
|
|
346
702
|
const heading = comment.startLine === comment.endLine && firstSnippetLine ? `### ${formatLineRange(comment)} - \`${firstSnippetLine.trim().slice(0, 80)}\`` : `### ${formatLineRange(comment)}`;
|
|
347
703
|
lines.push(heading, comment.body.trim(), "");
|
|
348
704
|
if (snippet) {
|
|
@@ -354,6 +710,14 @@ function serializeFeedbackMarkdown(bundle) {
|
|
|
354
710
|
return `${lines.join("\n").trimEnd()}
|
|
355
711
|
`;
|
|
356
712
|
}
|
|
713
|
+
function firstNonEmptyLine(text) {
|
|
714
|
+
for (const line of text.split("\n")) {
|
|
715
|
+
if (line.trim().length > 0) {
|
|
716
|
+
return line;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return void 0;
|
|
720
|
+
}
|
|
357
721
|
|
|
358
722
|
// src/server/store.ts
|
|
359
723
|
var ReviewStore = class {
|
|
@@ -362,6 +726,7 @@ var ReviewStore = class {
|
|
|
362
726
|
async create(diff) {
|
|
363
727
|
const id = ulid();
|
|
364
728
|
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
729
|
+
const turn = createTurn(id, 1, diff, createdAt);
|
|
365
730
|
const meta = {
|
|
366
731
|
id,
|
|
367
732
|
cwd: diff.cwd,
|
|
@@ -369,108 +734,188 @@ var ReviewStore = class {
|
|
|
369
734
|
branch: diff.branch,
|
|
370
735
|
status: "pending",
|
|
371
736
|
createdAt,
|
|
372
|
-
artifactDir: globalReviewDir(id)
|
|
737
|
+
artifactDir: globalReviewDir(id),
|
|
738
|
+
activeTurnId: turn.id
|
|
373
739
|
};
|
|
374
|
-
const record = { meta, diff };
|
|
740
|
+
const record = normalizeRecord({ meta, turns: [turn], diff: turn.diff });
|
|
375
741
|
this.reviews.set(id, record);
|
|
376
|
-
await this.persistInitial(record);
|
|
742
|
+
await this.persistInitial(record, turn);
|
|
377
743
|
this.emit({ type: "review.opened", reviewId: id });
|
|
378
744
|
return record;
|
|
379
745
|
}
|
|
746
|
+
async appendTurn(id, diff) {
|
|
747
|
+
const record = await this.get(id);
|
|
748
|
+
if (!record) {
|
|
749
|
+
throw new Error(`Review ${id} not found`);
|
|
750
|
+
}
|
|
751
|
+
if (record.meta.cwd !== diff.cwd) {
|
|
752
|
+
throw new Error(`Review ${id} belongs to ${record.meta.cwd}, not ${diff.cwd}`);
|
|
753
|
+
}
|
|
754
|
+
const latest = latestTurn(record);
|
|
755
|
+
if (latest.status === "pending") {
|
|
756
|
+
if (diffFingerprint(latest.diff) === diffFingerprint(diff)) {
|
|
757
|
+
this.emit({
|
|
758
|
+
type: "review.turn.created",
|
|
759
|
+
reviewId: id,
|
|
760
|
+
turnId: latest.id,
|
|
761
|
+
turnIndex: latest.index,
|
|
762
|
+
reused: true
|
|
763
|
+
});
|
|
764
|
+
return { record, turn: latest, reused: true };
|
|
765
|
+
}
|
|
766
|
+
throw new Error(`Review ${id} already has a pending turn`);
|
|
767
|
+
}
|
|
768
|
+
if (latest.status === "cancelled") {
|
|
769
|
+
throw new Error(`Review ${id} is cancelled and cannot be continued`);
|
|
770
|
+
}
|
|
771
|
+
const turn = createTurn(id, latest.index + 1, diff, (/* @__PURE__ */ new Date()).toISOString());
|
|
772
|
+
const nextRecord = normalizeRecord({
|
|
773
|
+
...record,
|
|
774
|
+
meta: { ...record.meta, activeTurnId: turn.id },
|
|
775
|
+
turns: [...record.turns, turn]
|
|
776
|
+
});
|
|
777
|
+
this.reviews.set(id, nextRecord);
|
|
778
|
+
await this.persistInitial(nextRecord, turn);
|
|
779
|
+
this.emit({
|
|
780
|
+
type: "review.turn.created",
|
|
781
|
+
reviewId: id,
|
|
782
|
+
turnId: turn.id,
|
|
783
|
+
turnIndex: turn.index,
|
|
784
|
+
reused: false
|
|
785
|
+
});
|
|
786
|
+
return { record: nextRecord, turn, reused: false };
|
|
787
|
+
}
|
|
380
788
|
async list() {
|
|
381
789
|
await this.loadAllReviews();
|
|
382
|
-
return
|
|
790
|
+
return Array.from(this.reviews.values()).map((record) => record.meta).toSorted((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
383
791
|
}
|
|
384
792
|
async get(id) {
|
|
385
793
|
return this.reviews.get(id) ?? await this.loadKnownReview(id);
|
|
386
794
|
}
|
|
387
|
-
async
|
|
795
|
+
async getTurn(id, turnId) {
|
|
796
|
+
const record = await this.get(id);
|
|
797
|
+
return record?.turns.find((turn) => turn.id === turnId) ?? null;
|
|
798
|
+
}
|
|
799
|
+
async submit(id, comments, reviewScope) {
|
|
388
800
|
const record = await this.get(id);
|
|
389
801
|
if (!record) {
|
|
390
802
|
throw new Error(`Review ${id} not found`);
|
|
391
803
|
}
|
|
392
|
-
|
|
393
|
-
|
|
804
|
+
const turn = activeTurn(record);
|
|
805
|
+
const sortedComments = comments.toSorted(compareCommentsByLocation);
|
|
806
|
+
const normalizedReviewScope = normalizeReviewScope(turn.diff, reviewScope);
|
|
807
|
+
if (turn.status !== "pending") {
|
|
808
|
+
if (turn.feedback && sameComments(turn.feedback.comments, sortedComments) && sameReviewScope(turn.feedback.reviewScope, normalizedReviewScope)) {
|
|
809
|
+
return {
|
|
810
|
+
record,
|
|
811
|
+
feedbackPath: requiredPath(turn.feedbackPath, "feedback path"),
|
|
812
|
+
markdownPath: requiredPath(turn.markdownPath, "markdown path"),
|
|
813
|
+
turn
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
throw new Error(`Review ${id} turn ${turn.index} is ${turn.status} and cannot be submitted`);
|
|
394
817
|
}
|
|
395
818
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
819
|
+
const feedbackPath = globalReviewTurnFeedbackFile(id, turn.id);
|
|
820
|
+
const markdownPath = globalReviewTurnMarkdownFile(id, turn.id);
|
|
396
821
|
const feedback = {
|
|
397
822
|
version: 1,
|
|
398
823
|
reviewId: id,
|
|
824
|
+
turnId: turn.id,
|
|
825
|
+
turnIndex: turn.index,
|
|
399
826
|
timestamp,
|
|
400
|
-
base:
|
|
401
|
-
branch:
|
|
402
|
-
|
|
827
|
+
base: turn.diff.base,
|
|
828
|
+
branch: turn.diff.branch,
|
|
829
|
+
reviewScope: normalizedReviewScope,
|
|
830
|
+
comments: sortedComments
|
|
403
831
|
};
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const feedbackPath = globalReviewFeedbackFile(id);
|
|
409
|
-
const markdownPath = globalReviewMarkdownFile(id);
|
|
410
|
-
record.meta = {
|
|
411
|
-
...record.meta,
|
|
412
|
-
artifactDir,
|
|
832
|
+
const nextTurn = {
|
|
833
|
+
...turn,
|
|
834
|
+
status: "submitted",
|
|
835
|
+
submittedAt: timestamp,
|
|
413
836
|
feedbackPath,
|
|
414
|
-
markdownPath
|
|
837
|
+
markdownPath,
|
|
838
|
+
feedback
|
|
415
839
|
};
|
|
416
|
-
|
|
840
|
+
const nextRecord = normalizeRecord(replaceTurn(record, nextTurn));
|
|
841
|
+
this.reviews.set(id, nextRecord);
|
|
842
|
+
await ensureDir(globalReviewTurnDir(id, nextTurn.id));
|
|
417
843
|
await Promise.all([
|
|
418
|
-
writeJsonFile(
|
|
844
|
+
writeJsonFile(globalReviewTurnMetaFile(id, nextTurn.id), turnMeta(nextTurn)),
|
|
419
845
|
writeJsonFile(feedbackPath, feedback),
|
|
420
|
-
|
|
846
|
+
writeTextFile(markdownPath, serializeFeedbackMarkdown(feedback))
|
|
421
847
|
]);
|
|
848
|
+
await this.persistMeta(nextRecord);
|
|
422
849
|
this.emit({
|
|
423
850
|
type: "review.submitted",
|
|
424
851
|
reviewId: id,
|
|
852
|
+
turnId: nextTurn.id,
|
|
853
|
+
turnIndex: nextTurn.index,
|
|
425
854
|
counts: {
|
|
426
855
|
files: countCommentFiles(feedback.comments),
|
|
427
856
|
comments: feedback.comments.length
|
|
428
857
|
}
|
|
429
858
|
});
|
|
430
|
-
return { record, feedbackPath, markdownPath };
|
|
859
|
+
return { record: nextRecord, feedbackPath, markdownPath, turn: nextTurn };
|
|
431
860
|
}
|
|
432
861
|
async feedback(id) {
|
|
433
862
|
const record = await this.get(id);
|
|
434
863
|
return record?.feedback ?? null;
|
|
435
864
|
}
|
|
436
|
-
async markResolved(id, summary) {
|
|
865
|
+
async markResolved(id, summary, turnSelector) {
|
|
437
866
|
const record = await this.get(id);
|
|
438
867
|
if (!record) {
|
|
439
868
|
throw new Error(`Review ${id} not found`);
|
|
440
869
|
}
|
|
441
|
-
this.
|
|
870
|
+
const turn = this.resolveTurnSelector(record, turnSelector);
|
|
871
|
+
this.assertResolvable(turn, id);
|
|
442
872
|
const resolvedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
443
873
|
const existingById = new Map(
|
|
444
|
-
(
|
|
874
|
+
(turn.resolution?.comments ?? []).map((comment) => [comment.commentId, comment])
|
|
445
875
|
);
|
|
446
876
|
const comments = this.sortResolvedComments(
|
|
447
|
-
(
|
|
877
|
+
(turn.feedback?.comments ?? []).map((comment) => ({
|
|
448
878
|
...existingById.get(comment.id),
|
|
449
879
|
commentId: comment.id,
|
|
450
880
|
status: "resolved",
|
|
451
881
|
resolvedAt: existingById.get(comment.id)?.resolvedAt ?? resolvedAt
|
|
452
882
|
})),
|
|
453
|
-
|
|
883
|
+
turn
|
|
454
884
|
);
|
|
455
885
|
const resolution = {
|
|
456
886
|
reviewId: id,
|
|
887
|
+
turnId: turn.id,
|
|
888
|
+
turnIndex: turn.index,
|
|
457
889
|
status: "resolved",
|
|
458
|
-
summary: summary ??
|
|
890
|
+
summary: summary ?? turn.resolution?.summary ?? null,
|
|
459
891
|
resolvedAt,
|
|
460
892
|
comments
|
|
461
893
|
};
|
|
462
|
-
|
|
463
|
-
|
|
894
|
+
const nextTurn = {
|
|
895
|
+
...turn,
|
|
896
|
+
status: "resolved",
|
|
897
|
+
resolvedAt
|
|
898
|
+
};
|
|
899
|
+
return this.persistResolution(record, nextTurn, resolution, "review-resolved");
|
|
464
900
|
}
|
|
465
901
|
async resolveComment(id, commentId, summary) {
|
|
466
902
|
const record = await this.get(id);
|
|
467
903
|
if (!record) {
|
|
468
904
|
throw new Error(`Review ${id} not found`);
|
|
469
905
|
}
|
|
470
|
-
this.
|
|
471
|
-
|
|
906
|
+
const turn = this.findTurnForComment(record, commentId);
|
|
907
|
+
if (!turn) {
|
|
908
|
+
const currentTurn = activeTurn(record);
|
|
909
|
+
if (!isResolvableReviewStatus(currentTurn.status)) {
|
|
910
|
+
throw new Error(
|
|
911
|
+
`Review ${id} turn ${currentTurn.index} is ${currentTurn.status} and cannot be resolved`
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
throw new Error(`Comment ${commentId} not found`);
|
|
915
|
+
}
|
|
916
|
+
this.assertResolvable(turn, id);
|
|
472
917
|
const resolvedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
473
|
-
const previous =
|
|
918
|
+
const previous = turn.resolution?.comments.find((comment) => comment.commentId === commentId);
|
|
474
919
|
const nextSummary = summary ?? previous?.summary;
|
|
475
920
|
const nextComment = {
|
|
476
921
|
commentId,
|
|
@@ -480,46 +925,59 @@ var ReviewStore = class {
|
|
|
480
925
|
};
|
|
481
926
|
const comments = this.sortResolvedComments(
|
|
482
927
|
[
|
|
483
|
-
...(
|
|
928
|
+
...(turn.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
|
|
484
929
|
nextComment
|
|
485
930
|
],
|
|
486
|
-
|
|
931
|
+
turn
|
|
487
932
|
);
|
|
488
|
-
const counts = resolutionCounts(
|
|
933
|
+
const counts = resolutionCounts(turn.feedback, comments);
|
|
489
934
|
const fullyResolved = counts.total === counts.resolved;
|
|
490
935
|
const resolution = {
|
|
491
936
|
reviewId: id,
|
|
937
|
+
turnId: turn.id,
|
|
938
|
+
turnIndex: turn.index,
|
|
492
939
|
status: fullyResolved ? "resolved" : "partial",
|
|
493
|
-
summary: fullyResolved ?
|
|
940
|
+
summary: fullyResolved ? turn.resolution?.summary ?? null : null,
|
|
494
941
|
resolvedAt: fullyResolved ? resolvedAt : null,
|
|
495
942
|
comments
|
|
496
943
|
};
|
|
497
|
-
|
|
498
|
-
return this.persistResolution(record, resolution, "comment-resolved");
|
|
944
|
+
const nextTurn = fullyResolved ? { ...turn, status: "resolved", resolvedAt } : { ...turn, status: "submitted", resolvedAt: void 0 };
|
|
945
|
+
return this.persistResolution(record, nextTurn, resolution, "comment-resolved");
|
|
499
946
|
}
|
|
500
947
|
async reopenComment(id, commentId) {
|
|
501
948
|
const record = await this.get(id);
|
|
502
949
|
if (!record) {
|
|
503
950
|
throw new Error(`Review ${id} not found`);
|
|
504
951
|
}
|
|
505
|
-
this.
|
|
506
|
-
|
|
952
|
+
const turn = this.findTurnForComment(record, commentId);
|
|
953
|
+
if (!turn) {
|
|
954
|
+
const currentTurn = activeTurn(record);
|
|
955
|
+
if (!isResolvableReviewStatus(currentTurn.status)) {
|
|
956
|
+
throw new Error(
|
|
957
|
+
`Review ${id} turn ${currentTurn.index} is ${currentTurn.status} and cannot be resolved`
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
throw new Error(`Comment ${commentId} not found`);
|
|
961
|
+
}
|
|
962
|
+
this.assertResolvable(turn, id);
|
|
507
963
|
const comments = this.sortResolvedComments(
|
|
508
|
-
(
|
|
509
|
-
|
|
964
|
+
(turn.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
|
|
965
|
+
turn
|
|
510
966
|
);
|
|
511
|
-
const counts = resolutionCounts(
|
|
967
|
+
const counts = resolutionCounts(turn.feedback, comments);
|
|
512
968
|
const fullyResolved = counts.total > 0 && counts.total === counts.resolved;
|
|
513
969
|
const resolvedAt = fullyResolved ? (/* @__PURE__ */ new Date()).toISOString() : null;
|
|
514
970
|
const resolution = {
|
|
515
971
|
reviewId: id,
|
|
972
|
+
turnId: turn.id,
|
|
973
|
+
turnIndex: turn.index,
|
|
516
974
|
status: fullyResolved ? "resolved" : "partial",
|
|
517
|
-
summary: fullyResolved ?
|
|
975
|
+
summary: fullyResolved ? turn.resolution?.summary ?? null : null,
|
|
518
976
|
resolvedAt,
|
|
519
977
|
comments
|
|
520
978
|
};
|
|
521
|
-
|
|
522
|
-
return this.persistResolution(record, resolution, "comment-reopened");
|
|
979
|
+
const nextTurn = fullyResolved ? { ...turn, status: "resolved", resolvedAt: resolvedAt ?? void 0 } : { ...turn, status: "submitted", resolvedAt: void 0 };
|
|
980
|
+
return this.persistResolution(record, nextTurn, resolution, "comment-reopened");
|
|
523
981
|
}
|
|
524
982
|
subscribe(reviewId, listener) {
|
|
525
983
|
const listeners = this.listeners.get(reviewId) ?? /* @__PURE__ */ new Set();
|
|
@@ -537,13 +995,17 @@ var ReviewStore = class {
|
|
|
537
995
|
listener(event);
|
|
538
996
|
}
|
|
539
997
|
}
|
|
540
|
-
async persistInitial(record) {
|
|
541
|
-
|
|
542
|
-
await ensureDir(dir);
|
|
998
|
+
async persistInitial(record, turn) {
|
|
999
|
+
await ensureDir(turn.artifactDir);
|
|
543
1000
|
await Promise.all([
|
|
544
|
-
writeJsonFile(
|
|
545
|
-
writeJsonFile(
|
|
1001
|
+
writeJsonFile(globalReviewTurnMetaFile(record.meta.id, turn.id), turnMeta(turn)),
|
|
1002
|
+
writeJsonFile(turn.diffPath, turn.diff)
|
|
546
1003
|
]);
|
|
1004
|
+
await this.persistMeta(record);
|
|
1005
|
+
}
|
|
1006
|
+
async persistMeta(record) {
|
|
1007
|
+
await ensureDir(globalReviewDir(record.meta.id));
|
|
1008
|
+
await writeJsonFile(globalReviewMetaFile(record.meta.id), record.meta);
|
|
547
1009
|
}
|
|
548
1010
|
async loadKnownReview(id) {
|
|
549
1011
|
const existing = this.reviews.get(id);
|
|
@@ -567,13 +1029,96 @@ var ReviewStore = class {
|
|
|
567
1029
|
}
|
|
568
1030
|
);
|
|
569
1031
|
}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
1032
|
+
const reviewLoads = [];
|
|
1033
|
+
for (const entry of entries) {
|
|
1034
|
+
if (entry.isDirectory()) {
|
|
1035
|
+
reviewLoads.push(this.loadReview(entry.name));
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
await Promise.all(reviewLoads);
|
|
573
1039
|
}
|
|
574
1040
|
async loadReview(id) {
|
|
575
1041
|
const metaPath = globalReviewMetaFile(id);
|
|
576
|
-
|
|
1042
|
+
let metaRaw;
|
|
1043
|
+
try {
|
|
1044
|
+
metaRaw = await readFile2(metaPath, "utf8");
|
|
1045
|
+
} catch (error) {
|
|
1046
|
+
if (isFileNotFound(error)) {
|
|
1047
|
+
return this.loadReviewFromTurnsOnly(id);
|
|
1048
|
+
}
|
|
1049
|
+
throw new Error(`Could not load review ${id}: ${formatError(error)}`, { cause: error });
|
|
1050
|
+
}
|
|
1051
|
+
const storedMeta = parseJsonFile(metaRaw, isStoredReviewMeta, "review metadata", metaPath);
|
|
1052
|
+
const persistedTurns = await this.loadPersistedTurns(id);
|
|
1053
|
+
const legacyTurn = await this.loadLegacyTurn(id, storedMeta);
|
|
1054
|
+
const turns = mergeRecoveredTurns(legacyTurn, persistedTurns);
|
|
1055
|
+
if (turns.length === 0) {
|
|
1056
|
+
throw new Error(`Review ${id} has no recoverable turns`);
|
|
1057
|
+
}
|
|
1058
|
+
const latest = latestTurn({ turns });
|
|
1059
|
+
const record = normalizeRecord({
|
|
1060
|
+
meta: {
|
|
1061
|
+
...storedMeta,
|
|
1062
|
+
artifactDir: storedMeta.artifactDir ?? globalReviewDir(id),
|
|
1063
|
+
activeTurnId: latest.id
|
|
1064
|
+
},
|
|
1065
|
+
turns,
|
|
1066
|
+
diff: latest.diff
|
|
1067
|
+
});
|
|
1068
|
+
this.reviews.set(id, record);
|
|
1069
|
+
return record;
|
|
1070
|
+
}
|
|
1071
|
+
async loadReviewFromTurnsOnly(id) {
|
|
1072
|
+
const turns = await this.loadPersistedTurns(id);
|
|
1073
|
+
if (turns.length === 0) {
|
|
1074
|
+
return null;
|
|
1075
|
+
}
|
|
1076
|
+
const latest = latestTurn({ turns });
|
|
1077
|
+
const record = normalizeRecord({
|
|
1078
|
+
meta: {
|
|
1079
|
+
id,
|
|
1080
|
+
cwd: latest.diff.cwd,
|
|
1081
|
+
base: latest.diff.base,
|
|
1082
|
+
branch: latest.diff.branch,
|
|
1083
|
+
status: latest.status,
|
|
1084
|
+
createdAt: turns[0]?.createdAt ?? latest.createdAt,
|
|
1085
|
+
artifactDir: globalReviewDir(id),
|
|
1086
|
+
activeTurnId: latest.id
|
|
1087
|
+
},
|
|
1088
|
+
turns,
|
|
1089
|
+
diff: latest.diff
|
|
1090
|
+
});
|
|
1091
|
+
this.reviews.set(id, record);
|
|
1092
|
+
await this.persistMeta(record);
|
|
1093
|
+
return record;
|
|
1094
|
+
}
|
|
1095
|
+
async loadPersistedTurns(id) {
|
|
1096
|
+
let entries;
|
|
1097
|
+
try {
|
|
1098
|
+
entries = await readdir(globalReviewTurnsDir(id), { withFileTypes: true });
|
|
1099
|
+
} catch (error) {
|
|
1100
|
+
if (isFileNotFound(error)) {
|
|
1101
|
+
return [];
|
|
1102
|
+
}
|
|
1103
|
+
throw new Error(`Could not read review turns for ${id}: ${formatError(error)}`, {
|
|
1104
|
+
cause: error
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
const turns = [];
|
|
1108
|
+
for (const entry of entries) {
|
|
1109
|
+
if (!entry.isDirectory()) {
|
|
1110
|
+
continue;
|
|
1111
|
+
}
|
|
1112
|
+
const turn = await this.loadPersistedTurn(id, entry.name);
|
|
1113
|
+
if (turn) {
|
|
1114
|
+
turns.push(turn);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
return turns.toSorted((a, b) => a.index - b.index);
|
|
1118
|
+
}
|
|
1119
|
+
async loadPersistedTurn(id, turnId) {
|
|
1120
|
+
const metaPath = globalReviewTurnMetaFile(id, turnId);
|
|
1121
|
+
const diffPath = globalReviewTurnDiffFile(id, turnId);
|
|
577
1122
|
let metaRaw;
|
|
578
1123
|
let diffRaw;
|
|
579
1124
|
try {
|
|
@@ -585,68 +1130,116 @@ var ReviewStore = class {
|
|
|
585
1130
|
if (isFileNotFound(error)) {
|
|
586
1131
|
return null;
|
|
587
1132
|
}
|
|
588
|
-
throw new Error(`Could not load review ${id}: ${formatError(error)}`, {
|
|
1133
|
+
throw new Error(`Could not load review ${id} turn ${turnId}: ${formatError(error)}`, {
|
|
1134
|
+
cause: error
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
const meta = parseJsonFile(metaRaw, isReviewTurnMeta, "review turn metadata", metaPath);
|
|
1138
|
+
const diff = parseJsonFile(diffRaw, isDiffPayload, "review turn diff", diffPath);
|
|
1139
|
+
const [feedback, resolution] = await Promise.all([
|
|
1140
|
+
readOptionalJsonFile(
|
|
1141
|
+
globalReviewTurnFeedbackFile(id, turnId),
|
|
1142
|
+
isFeedbackBundle,
|
|
1143
|
+
"review feedback"
|
|
1144
|
+
),
|
|
1145
|
+
readOptionalJsonFile(
|
|
1146
|
+
globalReviewTurnResolvedFile(id, turnId),
|
|
1147
|
+
isResolutionBundle,
|
|
1148
|
+
"review resolution"
|
|
1149
|
+
)
|
|
1150
|
+
]);
|
|
1151
|
+
return reconcileTurn(meta, diff, feedback, resolution);
|
|
1152
|
+
}
|
|
1153
|
+
async loadLegacyTurn(id, storedMeta) {
|
|
1154
|
+
const diffPath = globalReviewDiffFile(id);
|
|
1155
|
+
let diffRaw;
|
|
1156
|
+
try {
|
|
1157
|
+
diffRaw = await readFile2(diffPath, "utf8");
|
|
1158
|
+
} catch (error) {
|
|
1159
|
+
if (isFileNotFound(error)) {
|
|
1160
|
+
return null;
|
|
1161
|
+
}
|
|
1162
|
+
throw new Error(`Could not load legacy review ${id}: ${formatError(error)}`, {
|
|
1163
|
+
cause: error
|
|
1164
|
+
});
|
|
589
1165
|
}
|
|
590
|
-
const meta = parseJsonFile(metaRaw, isStoredReviewMeta, "review metadata", metaPath);
|
|
591
1166
|
const diff = parseJsonFile(diffRaw, isDiffPayload, "review diff", diffPath);
|
|
592
|
-
const feedback = await
|
|
593
|
-
globalReviewFeedbackFile(id),
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
);
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
resolution
|
|
1167
|
+
const [feedback, resolution] = await Promise.all([
|
|
1168
|
+
readOptionalJsonFile(globalReviewFeedbackFile(id), isFeedbackBundle, "review feedback"),
|
|
1169
|
+
readOptionalJsonFile(globalReviewResolvedFile(id), isResolutionBundle, "review resolution")
|
|
1170
|
+
]);
|
|
1171
|
+
const artifactDir = storedMeta.artifactDir ?? globalReviewDir(id);
|
|
1172
|
+
const legacySummary = storedMeta.turns?.find(
|
|
1173
|
+
(turn) => turn.artifactDir === artifactDir || turn.diffPath === diffPath
|
|
1174
|
+
) ?? storedMeta.turns?.find((turn) => turn.index === 1) ?? storedMeta.turns?.[0];
|
|
1175
|
+
const meta = {
|
|
1176
|
+
id: legacySummary?.id ?? storedMeta.activeTurnId ?? "turn-1",
|
|
1177
|
+
index: legacySummary?.index ?? 1,
|
|
1178
|
+
status: legacySummary?.status ?? storedMeta.status,
|
|
1179
|
+
createdAt: legacySummary?.createdAt ?? storedMeta.createdAt,
|
|
1180
|
+
submittedAt: legacySummary?.submittedAt ?? storedMeta.submittedAt,
|
|
1181
|
+
resolvedAt: legacySummary?.resolvedAt ?? storedMeta.resolvedAt,
|
|
1182
|
+
artifactDir: legacySummary?.artifactDir ?? artifactDir,
|
|
1183
|
+
diffPath,
|
|
1184
|
+
...feedback ? { feedbackPath: globalReviewFeedbackFile(id), markdownPath: globalReviewMarkdownFile(id) } : {},
|
|
1185
|
+
...resolution ? { resolvedPath: globalReviewResolvedFile(id) } : {}
|
|
612
1186
|
};
|
|
613
|
-
|
|
614
|
-
return record;
|
|
1187
|
+
return reconcileTurn(meta, diff, feedback, resolution);
|
|
615
1188
|
}
|
|
616
|
-
assertResolvable(
|
|
617
|
-
if (!isResolvableReviewStatus(
|
|
618
|
-
throw new Error(`Review ${id} is ${
|
|
1189
|
+
assertResolvable(turn, id) {
|
|
1190
|
+
if (!isResolvableReviewStatus(turn.status)) {
|
|
1191
|
+
throw new Error(`Review ${id} turn ${turn.index} is ${turn.status} and cannot be resolved`);
|
|
619
1192
|
}
|
|
620
|
-
if (!
|
|
621
|
-
throw new Error(`Review ${id} has no submitted feedback`);
|
|
1193
|
+
if (!turn.feedback) {
|
|
1194
|
+
throw new Error(`Review ${id} turn ${turn.index} has no submitted feedback`);
|
|
622
1195
|
}
|
|
623
1196
|
}
|
|
624
|
-
|
|
625
|
-
if (!
|
|
626
|
-
|
|
1197
|
+
resolveTurnSelector(record, selector) {
|
|
1198
|
+
if (!selector) {
|
|
1199
|
+
return activeTurn(record);
|
|
627
1200
|
}
|
|
1201
|
+
const turn = record.turns.find((candidate) => candidate.id === selector) ?? record.turns.find((candidate) => String(candidate.index) === selector);
|
|
1202
|
+
if (!turn) {
|
|
1203
|
+
throw new Error(`Turn ${selector} not found in review ${record.meta.id}`);
|
|
1204
|
+
}
|
|
1205
|
+
return turn;
|
|
628
1206
|
}
|
|
629
|
-
|
|
630
|
-
record.
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
1207
|
+
findTurnForComment(record, commentId) {
|
|
1208
|
+
return [...record.turns].reverse().find(
|
|
1209
|
+
(candidate) => candidate.feedback?.comments.some((comment) => comment.id === commentId)
|
|
1210
|
+
) ?? null;
|
|
1211
|
+
}
|
|
1212
|
+
async persistResolution(record, turn, resolution, reason) {
|
|
1213
|
+
const resolvedPath = globalReviewTurnResolvedFile(record.meta.id, turn.id);
|
|
1214
|
+
const nextTurn = {
|
|
1215
|
+
...turn,
|
|
1216
|
+
resolvedPath,
|
|
1217
|
+
resolution
|
|
1218
|
+
};
|
|
1219
|
+
const nextRecord = normalizeRecord(replaceTurn(record, nextTurn));
|
|
1220
|
+
this.reviews.set(record.meta.id, nextRecord);
|
|
1221
|
+
await ensureDir(globalReviewTurnDir(record.meta.id, nextTurn.id));
|
|
634
1222
|
await Promise.all([
|
|
635
1223
|
writeJsonFile(resolvedPath, resolution),
|
|
636
|
-
writeJsonFile(
|
|
1224
|
+
writeJsonFile(globalReviewTurnMetaFile(record.meta.id, nextTurn.id), turnMeta(nextTurn))
|
|
637
1225
|
]);
|
|
1226
|
+
await this.persistMeta(nextRecord);
|
|
638
1227
|
const result = {
|
|
639
1228
|
ok: true,
|
|
640
1229
|
reviewId: record.meta.id,
|
|
641
|
-
|
|
1230
|
+
turnId: nextTurn.id,
|
|
1231
|
+
turnIndex: nextTurn.index,
|
|
1232
|
+
status: nextTurn.status,
|
|
642
1233
|
resolutionStatus: resolution.status,
|
|
643
|
-
comments: resolutionCounts(
|
|
1234
|
+
comments: resolutionCounts(nextTurn.feedback, resolution.comments),
|
|
644
1235
|
path: resolvedPath,
|
|
645
1236
|
resolution
|
|
646
1237
|
};
|
|
647
1238
|
this.emit({
|
|
648
1239
|
type: "review.updated",
|
|
649
1240
|
reviewId: record.meta.id,
|
|
1241
|
+
turnId: nextTurn.id,
|
|
1242
|
+
turnIndex: nextTurn.index,
|
|
650
1243
|
reason,
|
|
651
1244
|
status: result.status,
|
|
652
1245
|
resolutionStatus: result.resolutionStatus,
|
|
@@ -654,15 +1247,125 @@ var ReviewStore = class {
|
|
|
654
1247
|
});
|
|
655
1248
|
return result;
|
|
656
1249
|
}
|
|
657
|
-
sortResolvedComments(comments,
|
|
1250
|
+
sortResolvedComments(comments, turn) {
|
|
658
1251
|
const feedbackIndex = new Map(
|
|
659
|
-
|
|
660
|
-
);
|
|
661
|
-
return comments.filter((comment) => feedbackIndex.has(comment.commentId)).sort(
|
|
662
|
-
(a, b) => (feedbackIndex.get(a.commentId) ?? Number.MAX_SAFE_INTEGER) - (feedbackIndex.get(b.commentId) ?? Number.MAX_SAFE_INTEGER)
|
|
1252
|
+
turn.feedback.comments.map((comment, index) => [comment.id, index])
|
|
663
1253
|
);
|
|
1254
|
+
return comments.map((comment) => ({ comment, index: feedbackIndex.get(comment.commentId) })).filter(
|
|
1255
|
+
(entry) => entry.index !== void 0
|
|
1256
|
+
).sort((a, b) => a.index - b.index).map(({ comment }) => comment);
|
|
664
1257
|
}
|
|
665
1258
|
};
|
|
1259
|
+
function createTurn(reviewId, index, diff, createdAt) {
|
|
1260
|
+
const id = ulid();
|
|
1261
|
+
return {
|
|
1262
|
+
id,
|
|
1263
|
+
index,
|
|
1264
|
+
status: "pending",
|
|
1265
|
+
createdAt,
|
|
1266
|
+
artifactDir: globalReviewTurnDir(reviewId, id),
|
|
1267
|
+
diffPath: globalReviewTurnDiffFile(reviewId, id),
|
|
1268
|
+
diff
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
function normalizeRecord(record) {
|
|
1272
|
+
const turns = record.turns.toSorted((a, b) => a.index - b.index);
|
|
1273
|
+
const active = turns.find((turn) => turn.id === record.meta.activeTurnId) ?? turns[turns.length - 1];
|
|
1274
|
+
const meta = {
|
|
1275
|
+
...record.meta,
|
|
1276
|
+
base: active.diff.base,
|
|
1277
|
+
branch: active.diff.branch,
|
|
1278
|
+
status: active.status,
|
|
1279
|
+
submittedAt: active.submittedAt,
|
|
1280
|
+
resolvedAt: active.resolvedAt,
|
|
1281
|
+
artifactDir: record.meta.artifactDir ?? globalReviewDir(record.meta.id),
|
|
1282
|
+
activeTurnId: active.id,
|
|
1283
|
+
turns: turns.map(turnSummary),
|
|
1284
|
+
feedbackPath: active.feedbackPath,
|
|
1285
|
+
markdownPath: active.markdownPath
|
|
1286
|
+
};
|
|
1287
|
+
return {
|
|
1288
|
+
meta,
|
|
1289
|
+
turns,
|
|
1290
|
+
diff: active.diff,
|
|
1291
|
+
...active.feedback ? { feedback: active.feedback } : {},
|
|
1292
|
+
...active.resolution ? { resolution: active.resolution } : {}
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
function replaceTurn(record, nextTurn) {
|
|
1296
|
+
return {
|
|
1297
|
+
...record,
|
|
1298
|
+
turns: record.turns.map((turn) => turn.id === nextTurn.id ? nextTurn : turn)
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
function activeTurn(record) {
|
|
1302
|
+
return record.turns.find((turn) => turn.id === record.meta.activeTurnId) ?? record.turns[record.turns.length - 1];
|
|
1303
|
+
}
|
|
1304
|
+
function latestTurn(record) {
|
|
1305
|
+
return record.turns.toSorted((a, b) => a.index - b.index)[record.turns.length - 1];
|
|
1306
|
+
}
|
|
1307
|
+
function turnMeta(turn) {
|
|
1308
|
+
return {
|
|
1309
|
+
id: turn.id,
|
|
1310
|
+
index: turn.index,
|
|
1311
|
+
status: turn.status,
|
|
1312
|
+
createdAt: turn.createdAt,
|
|
1313
|
+
submittedAt: turn.submittedAt,
|
|
1314
|
+
resolvedAt: turn.resolvedAt,
|
|
1315
|
+
artifactDir: turn.artifactDir,
|
|
1316
|
+
diffPath: turn.diffPath,
|
|
1317
|
+
feedbackPath: turn.feedbackPath,
|
|
1318
|
+
markdownPath: turn.markdownPath,
|
|
1319
|
+
resolvedPath: turn.resolvedPath
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
function turnSummary(turn) {
|
|
1323
|
+
return {
|
|
1324
|
+
...turnMeta(turn),
|
|
1325
|
+
capturedAt: turn.diff.capturedAt,
|
|
1326
|
+
stats: turn.diff.stats,
|
|
1327
|
+
comments: resolutionCounts(turn.feedback, turn.resolution?.comments ?? [])
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
function reconcileTurn(meta, diff, feedback, resolution) {
|
|
1331
|
+
const status = resolution?.status === "resolved" ? "resolved" : feedback ? "submitted" : "pending";
|
|
1332
|
+
return {
|
|
1333
|
+
...meta,
|
|
1334
|
+
status,
|
|
1335
|
+
submittedAt: feedback?.timestamp ?? meta.submittedAt,
|
|
1336
|
+
resolvedAt: status === "resolved" ? resolution?.resolvedAt ?? meta.resolvedAt : void 0,
|
|
1337
|
+
feedbackPath: feedback ? meta.feedbackPath ?? path4.join(meta.artifactDir, "feedback.json") : void 0,
|
|
1338
|
+
markdownPath: feedback ? meta.markdownPath ?? path4.join(meta.artifactDir, "feedback.md") : void 0,
|
|
1339
|
+
resolvedPath: resolution ? meta.resolvedPath ?? path4.join(meta.artifactDir, "resolved.json") : void 0,
|
|
1340
|
+
diff,
|
|
1341
|
+
...feedback ? { feedback } : {},
|
|
1342
|
+
...resolution ? { resolution } : {}
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
function mergeRecoveredTurns(legacyTurn, persistedTurns) {
|
|
1346
|
+
const turns = legacyTurn && !persistedTurns.some((turn) => turn.id === legacyTurn.id || turn.index === legacyTurn.index) ? [legacyTurn, ...persistedTurns] : persistedTurns;
|
|
1347
|
+
return turns.toSorted((a, b) => a.index - b.index);
|
|
1348
|
+
}
|
|
1349
|
+
function diffFingerprint(diff) {
|
|
1350
|
+
return createHash("sha256").update(
|
|
1351
|
+
JSON.stringify({
|
|
1352
|
+
base: diff.base,
|
|
1353
|
+
branch: diff.branch,
|
|
1354
|
+
cwd: diff.cwd,
|
|
1355
|
+
scope: diff.scope,
|
|
1356
|
+
rawDiff: diff.rawDiff
|
|
1357
|
+
})
|
|
1358
|
+
).digest("hex");
|
|
1359
|
+
}
|
|
1360
|
+
function sameComments(left, right) {
|
|
1361
|
+
return JSON.stringify(left.toSorted(compareCommentsByLocation)) === JSON.stringify(right.toSorted(compareCommentsByLocation));
|
|
1362
|
+
}
|
|
1363
|
+
function requiredPath(value, label) {
|
|
1364
|
+
if (!value) {
|
|
1365
|
+
throw new Error(`Submitted review is missing ${label}`);
|
|
1366
|
+
}
|
|
1367
|
+
return value;
|
|
1368
|
+
}
|
|
666
1369
|
async function readOptionalJsonFile(filePath, guard, label) {
|
|
667
1370
|
let raw;
|
|
668
1371
|
try {
|
|
@@ -684,12 +1387,6 @@ function parseJsonFile(raw, guard, label, filePath) {
|
|
|
684
1387
|
throw new Error(`Invalid ${label} at ${filePath}: ${formatError(error)}`, { cause: error });
|
|
685
1388
|
}
|
|
686
1389
|
}
|
|
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
|
-
}
|
|
693
1390
|
var reviewStore = new ReviewStore();
|
|
694
1391
|
|
|
695
1392
|
// src/server/index.ts
|
|
@@ -705,7 +1402,7 @@ var mimeTypes = {
|
|
|
705
1402
|
".sh": "text/x-shellscript; charset=utf-8",
|
|
706
1403
|
".svg": "image/svg+xml"
|
|
707
1404
|
};
|
|
708
|
-
function createApp(origin2) {
|
|
1405
|
+
function createApp(origin2, options = {}) {
|
|
709
1406
|
const app = new Hono();
|
|
710
1407
|
app.get("/api/health", async (c) => {
|
|
711
1408
|
const reviews = await reviewStore.list();
|
|
@@ -729,10 +1426,36 @@ function createApp(origin2) {
|
|
|
729
1426
|
const record = await reviewStore.create(diff);
|
|
730
1427
|
const response = {
|
|
731
1428
|
meta: record.meta,
|
|
1429
|
+
turn: activeTurnSummary(record.meta),
|
|
732
1430
|
url: `${origin2}/review/${record.meta.id}`
|
|
733
1431
|
};
|
|
1432
|
+
options.onReviewActivity?.();
|
|
734
1433
|
return c.json(response, 201);
|
|
735
1434
|
});
|
|
1435
|
+
app.post("/api/reviews/:id/turns", async (c) => {
|
|
1436
|
+
const id = c.req.param("id");
|
|
1437
|
+
const existing = await reviewStore.get(id);
|
|
1438
|
+
if (!existing) {
|
|
1439
|
+
return c.json({ error: "review not found" }, 404);
|
|
1440
|
+
}
|
|
1441
|
+
const parsed = await readJsonBody(c, isDiffPayload, "review diff");
|
|
1442
|
+
if (!parsed.ok) {
|
|
1443
|
+
return parsed.response;
|
|
1444
|
+
}
|
|
1445
|
+
try {
|
|
1446
|
+
const { record, turn, reused } = await reviewStore.appendTurn(id, parsed.body);
|
|
1447
|
+
const response = {
|
|
1448
|
+
meta: record.meta,
|
|
1449
|
+
turn: turnSummary2(record.meta, turn.id),
|
|
1450
|
+
url: `${origin2}/review/${id}`,
|
|
1451
|
+
reused
|
|
1452
|
+
};
|
|
1453
|
+
options.onReviewActivity?.();
|
|
1454
|
+
return c.json(response);
|
|
1455
|
+
} catch (error) {
|
|
1456
|
+
return c.json({ error: formatError(error) }, 409);
|
|
1457
|
+
}
|
|
1458
|
+
});
|
|
736
1459
|
app.get("/api/reviews/:id", async (c) => {
|
|
737
1460
|
const record = await reviewStore.get(c.req.param("id"));
|
|
738
1461
|
if (!record) {
|
|
@@ -740,6 +1463,13 @@ function createApp(origin2) {
|
|
|
740
1463
|
}
|
|
741
1464
|
return c.json(record);
|
|
742
1465
|
});
|
|
1466
|
+
app.get("/api/reviews/:id/turns/:turnId", async (c) => {
|
|
1467
|
+
const turn = await reviewStore.getTurn(c.req.param("id"), c.req.param("turnId"));
|
|
1468
|
+
if (!turn) {
|
|
1469
|
+
return c.json({ error: "turn not found" }, 404);
|
|
1470
|
+
}
|
|
1471
|
+
return c.json(turn);
|
|
1472
|
+
});
|
|
743
1473
|
app.get("/api/reviews/:id/feedback", async (c) => {
|
|
744
1474
|
const feedback = await reviewStore.feedback(c.req.param("id"));
|
|
745
1475
|
if (!feedback) {
|
|
@@ -758,6 +1488,7 @@ function createApp(origin2) {
|
|
|
758
1488
|
let pending = Promise.resolve();
|
|
759
1489
|
let cleanup = null;
|
|
760
1490
|
let close = null;
|
|
1491
|
+
let unregisterEventStream = null;
|
|
761
1492
|
const closedPromise = new Promise((resolve) => {
|
|
762
1493
|
close = () => {
|
|
763
1494
|
if (closed) {
|
|
@@ -768,6 +1499,7 @@ function createApp(origin2) {
|
|
|
768
1499
|
resolve();
|
|
769
1500
|
};
|
|
770
1501
|
});
|
|
1502
|
+
unregisterEventStream = options.registerEventStream?.(() => close?.()) ?? null;
|
|
771
1503
|
const send = (event) => {
|
|
772
1504
|
pending = pending.then(() => stream.writeSSE({ data: JSON.stringify(event) })).then(() => {
|
|
773
1505
|
if (event.type === "review.cancelled") {
|
|
@@ -788,6 +1520,8 @@ function createApp(origin2) {
|
|
|
788
1520
|
cleanup = () => {
|
|
789
1521
|
clearInterval(heartbeat);
|
|
790
1522
|
unsubscribe();
|
|
1523
|
+
unregisterEventStream?.();
|
|
1524
|
+
unregisterEventStream = null;
|
|
791
1525
|
};
|
|
792
1526
|
stream.onAbort(() => close?.());
|
|
793
1527
|
send({ type: "review.opened", reviewId: id });
|
|
@@ -795,6 +1529,8 @@ function createApp(origin2) {
|
|
|
795
1529
|
send({
|
|
796
1530
|
type: "review.submitted",
|
|
797
1531
|
reviewId: id,
|
|
1532
|
+
turnId: record.meta.activeTurnId,
|
|
1533
|
+
turnIndex: record.meta.turns?.find((turn) => turn.id === record.meta.activeTurnId)?.index,
|
|
798
1534
|
counts: {
|
|
799
1535
|
files: countCommentFiles(record.feedback.comments),
|
|
800
1536
|
comments: record.feedback.comments.length
|
|
@@ -810,44 +1546,151 @@ function createApp(origin2) {
|
|
|
810
1546
|
if (!existing) {
|
|
811
1547
|
return c.json({ error: "review not found" }, 404);
|
|
812
1548
|
}
|
|
813
|
-
if (existing.meta.status !== "pending") {
|
|
814
|
-
return c.json({ error: `review is ${existing.meta.status} and cannot be submitted` }, 409);
|
|
815
|
-
}
|
|
816
1549
|
const parsed = await readJsonBody(c, isSubmitReviewRequest, "submit review request");
|
|
817
1550
|
if (!parsed.ok) {
|
|
818
1551
|
return parsed.response;
|
|
819
1552
|
}
|
|
820
1553
|
const body = parsed.body;
|
|
821
|
-
|
|
1554
|
+
let submitted;
|
|
1555
|
+
try {
|
|
1556
|
+
submitted = await reviewStore.submit(id, body.comments, body.reviewScope);
|
|
1557
|
+
} catch (error) {
|
|
1558
|
+
return c.json({ error: formatError(error) }, 409);
|
|
1559
|
+
}
|
|
1560
|
+
const { feedbackPath, markdownPath, turn } = submitted;
|
|
822
1561
|
const response = {
|
|
823
1562
|
reviewId: id,
|
|
1563
|
+
turnId: turn.id,
|
|
1564
|
+
turnIndex: turn.index,
|
|
824
1565
|
url: `${origin2}/review/${id}`,
|
|
825
|
-
files:
|
|
1566
|
+
files: turn.diff.files.length,
|
|
826
1567
|
comments: body.comments.length,
|
|
827
|
-
artifactDir:
|
|
1568
|
+
artifactDir: turn.artifactDir,
|
|
828
1569
|
feedbackPath,
|
|
829
1570
|
markdownPath
|
|
830
1571
|
};
|
|
1572
|
+
options.onReviewActivity?.();
|
|
831
1573
|
return c.json(response);
|
|
832
1574
|
});
|
|
833
|
-
app.post("/api/reviews/:id/
|
|
1575
|
+
app.post("/api/reviews/:id/commits/range", async (c) => {
|
|
834
1576
|
const id = c.req.param("id");
|
|
835
1577
|
const existing = await reviewStore.get(id);
|
|
836
1578
|
if (!existing) {
|
|
837
1579
|
return c.json({ error: "review not found" }, 404);
|
|
838
1580
|
}
|
|
839
|
-
|
|
840
|
-
|
|
1581
|
+
const parsed = await readJsonBody(c, isCommitRangeDiffRequest, "commit range diff request");
|
|
1582
|
+
if (!parsed.ok) {
|
|
1583
|
+
return parsed.response;
|
|
1584
|
+
}
|
|
1585
|
+
const requestedTurnId = parsed.body.turnId;
|
|
1586
|
+
const turn = requestedTurnId ? await reviewStore.getTurn(id, requestedTurnId) : null;
|
|
1587
|
+
if (requestedTurnId && !turn) {
|
|
1588
|
+
return c.json({ error: "turn not found" }, 404);
|
|
1589
|
+
}
|
|
1590
|
+
const diffPayload = turn?.diff ?? existing.diff;
|
|
1591
|
+
const commitDiffs = diffPayload.commitDiffs ?? [];
|
|
1592
|
+
if (commitDiffs.length === 0) {
|
|
1593
|
+
return c.json({ error: "commit ranges are only available for branch reviews" }, 409);
|
|
1594
|
+
}
|
|
1595
|
+
const { fromSha, toSha } = parsed.body;
|
|
1596
|
+
const fromIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === fromSha);
|
|
1597
|
+
const toIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === toSha);
|
|
1598
|
+
if (fromIndex < 0 || toIndex < 0) {
|
|
1599
|
+
return c.json({ error: "commit range must use commits from this review" }, 404);
|
|
1600
|
+
}
|
|
1601
|
+
if (fromIndex > toIndex) {
|
|
1602
|
+
return c.json({ error: "fromSha must come before or match toSha" }, 400);
|
|
841
1603
|
}
|
|
842
|
-
|
|
843
|
-
|
|
1604
|
+
const diff = fromSha === toSha ? commitDiffs[fromIndex] : await captureCommitRangeDiff(fromSha, toSha, diffPayload.cwd);
|
|
1605
|
+
const response = {
|
|
1606
|
+
fromSha,
|
|
1607
|
+
toSha,
|
|
1608
|
+
stats: diff.stats,
|
|
1609
|
+
rawDiff: diff.rawDiff,
|
|
1610
|
+
files: diff.files
|
|
1611
|
+
};
|
|
1612
|
+
return c.json(response);
|
|
1613
|
+
});
|
|
1614
|
+
app.post("/api/reviews/:id/files/open", async (c) => {
|
|
1615
|
+
const id = c.req.param("id");
|
|
1616
|
+
const existing = await reviewStore.get(id);
|
|
1617
|
+
if (!existing) {
|
|
1618
|
+
return c.json({ error: "review not found" }, 404);
|
|
1619
|
+
}
|
|
1620
|
+
const parsed = await readJsonBody(c, isOpenFileRequest, "open file request");
|
|
1621
|
+
if (!parsed.ok) {
|
|
1622
|
+
return parsed.response;
|
|
1623
|
+
}
|
|
1624
|
+
const { filePath, turnId } = parsed.body;
|
|
1625
|
+
if (!filePath || filePath.includes("\0") || path5.isAbsolute(filePath)) {
|
|
1626
|
+
return c.json({ error: "filePath must be a repo-relative path" }, 400);
|
|
1627
|
+
}
|
|
1628
|
+
const repoRoot = path5.resolve(existing.diff.cwd);
|
|
1629
|
+
const requestedAbsolutePath = path5.resolve(repoRoot, filePath);
|
|
1630
|
+
if (!isPathWithin(repoRoot, requestedAbsolutePath)) {
|
|
1631
|
+
return c.json({ error: "filePath must stay within the review cwd" }, 400);
|
|
1632
|
+
}
|
|
1633
|
+
const turn = turnId ? await reviewStore.getTurn(id, turnId) : null;
|
|
1634
|
+
if (turnId && !turn) {
|
|
1635
|
+
return c.json({ error: "turn not found" }, 404);
|
|
1636
|
+
}
|
|
1637
|
+
const diffPayload = turn?.diff ?? existing.diff;
|
|
1638
|
+
const reviewFiles = [
|
|
1639
|
+
...diffPayload.files,
|
|
1640
|
+
...(diffPayload.commitDiffs ?? []).flatMap((commitDiff) => commitDiff.files)
|
|
1641
|
+
].filter((file) => file.path === filePath);
|
|
1642
|
+
if (reviewFiles.length === 0) {
|
|
1643
|
+
return c.json({ error: "file is not part of this review" }, 404);
|
|
1644
|
+
}
|
|
1645
|
+
if (reviewFiles.every((file) => file.isDeleted)) {
|
|
1646
|
+
return c.json({ error: "deleted files cannot be opened locally" }, 409);
|
|
1647
|
+
}
|
|
1648
|
+
let realRepoRoot;
|
|
1649
|
+
let realFilePath;
|
|
1650
|
+
try {
|
|
1651
|
+
[realRepoRoot, realFilePath] = await Promise.all([
|
|
1652
|
+
realpath(repoRoot),
|
|
1653
|
+
realpath(requestedAbsolutePath)
|
|
1654
|
+
]);
|
|
1655
|
+
} catch (error) {
|
|
1656
|
+
if (isFileNotFound(error)) {
|
|
1657
|
+
return c.json({ error: "file no longer exists on disk" }, 404);
|
|
1658
|
+
}
|
|
1659
|
+
throw error;
|
|
1660
|
+
}
|
|
1661
|
+
if (!isPathWithin(realRepoRoot, realFilePath)) {
|
|
1662
|
+
return c.json({ error: "filePath must stay within the review cwd" }, 400);
|
|
1663
|
+
}
|
|
1664
|
+
const fileStats = await stat(realFilePath);
|
|
1665
|
+
if (!fileStats.isFile()) {
|
|
1666
|
+
return c.json({ error: "path is not a file" }, 409);
|
|
1667
|
+
}
|
|
1668
|
+
try {
|
|
1669
|
+
await openLocalPath(realFilePath);
|
|
1670
|
+
} catch (error) {
|
|
1671
|
+
return c.json({ error: `could not open file: ${formatError(error)}` }, 500);
|
|
1672
|
+
}
|
|
1673
|
+
const response = { ok: true, path: realFilePath };
|
|
1674
|
+
return c.json(response);
|
|
1675
|
+
});
|
|
1676
|
+
app.post("/api/reviews/:id/resolved", async (c) => {
|
|
1677
|
+
const id = c.req.param("id");
|
|
1678
|
+
const existing = await reviewStore.get(id);
|
|
1679
|
+
if (!existing) {
|
|
1680
|
+
return c.json({ error: "review not found" }, 404);
|
|
844
1681
|
}
|
|
845
1682
|
const parsed = await readJsonBody(c, isResolutionRequest, "resolution request");
|
|
846
1683
|
if (!parsed.ok) {
|
|
847
1684
|
return parsed.response;
|
|
848
1685
|
}
|
|
849
1686
|
const body = parsed.body;
|
|
850
|
-
|
|
1687
|
+
try {
|
|
1688
|
+
const result = await reviewStore.markResolved(id, body.summary, body.turn);
|
|
1689
|
+
options.onReviewActivity?.();
|
|
1690
|
+
return c.json(result);
|
|
1691
|
+
} catch (error) {
|
|
1692
|
+
return c.json({ error: formatError(error) }, statusForStoreError(error));
|
|
1693
|
+
}
|
|
851
1694
|
});
|
|
852
1695
|
app.post("/api/reviews/:id/comments/:commentId/resolved", async (c) => {
|
|
853
1696
|
const id = c.req.param("id");
|
|
@@ -856,18 +1699,18 @@ function createApp(origin2) {
|
|
|
856
1699
|
if (!existing) {
|
|
857
1700
|
return c.json({ error: "review not found" }, 404);
|
|
858
1701
|
}
|
|
859
|
-
if (!isResolvableReviewStatus(existing.meta.status)) {
|
|
860
|
-
return c.json({ error: `review is ${existing.meta.status} and cannot be resolved` }, 409);
|
|
861
|
-
}
|
|
862
|
-
if (!existing.feedback?.comments.some((comment) => comment.id === commentId)) {
|
|
863
|
-
return c.json({ error: "comment not found" }, 404);
|
|
864
|
-
}
|
|
865
1702
|
const parsed = await readJsonBody(c, isResolutionRequest, "resolution request");
|
|
866
1703
|
if (!parsed.ok) {
|
|
867
1704
|
return parsed.response;
|
|
868
1705
|
}
|
|
869
1706
|
const body = parsed.body;
|
|
870
|
-
|
|
1707
|
+
try {
|
|
1708
|
+
const result = await reviewStore.resolveComment(id, commentId, body.summary);
|
|
1709
|
+
options.onReviewActivity?.();
|
|
1710
|
+
return c.json(result);
|
|
1711
|
+
} catch (error) {
|
|
1712
|
+
return c.json({ error: formatError(error) }, statusForStoreError(error));
|
|
1713
|
+
}
|
|
871
1714
|
});
|
|
872
1715
|
app.delete("/api/reviews/:id/comments/:commentId/resolved", async (c) => {
|
|
873
1716
|
const id = c.req.param("id");
|
|
@@ -876,13 +1719,13 @@ function createApp(origin2) {
|
|
|
876
1719
|
if (!existing) {
|
|
877
1720
|
return c.json({ error: "review not found" }, 404);
|
|
878
1721
|
}
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
1722
|
+
try {
|
|
1723
|
+
const result = await reviewStore.reopenComment(id, commentId);
|
|
1724
|
+
options.onReviewActivity?.();
|
|
1725
|
+
return c.json(result);
|
|
1726
|
+
} catch (error) {
|
|
1727
|
+
return c.json({ error: formatError(error) }, statusForStoreError(error));
|
|
884
1728
|
}
|
|
885
|
-
return c.json(await reviewStore.reopenComment(id, commentId));
|
|
886
1729
|
});
|
|
887
1730
|
app.get("/logo.svg", serveRootFile("logo.svg", mimeTypes[".svg"]));
|
|
888
1731
|
app.get("/logo-mark.svg", serveRootFile("logo-mark.svg", mimeTypes[".svg"]));
|
|
@@ -899,37 +1742,46 @@ function createApp(origin2) {
|
|
|
899
1742
|
}
|
|
900
1743
|
async function serveAsset(c) {
|
|
901
1744
|
const requestPath = new URL(c.req.url).pathname.replace(/^\/assets\//, "");
|
|
902
|
-
const normalized =
|
|
903
|
-
const assetPath =
|
|
1745
|
+
const normalized = path5.normalize(requestPath).replace(/^(\.\.(\/|\\|$))+/, "");
|
|
1746
|
+
const assetPath = path5.join(webRoot, "assets", normalized);
|
|
904
1747
|
try {
|
|
905
1748
|
const body = await readFile3(assetPath);
|
|
906
1749
|
return new Response(body, {
|
|
907
1750
|
headers: {
|
|
908
|
-
"content-type": mimeTypes[
|
|
1751
|
+
"content-type": mimeTypes[path5.extname(assetPath)] ?? "application/octet-stream"
|
|
909
1752
|
}
|
|
910
1753
|
});
|
|
911
|
-
} catch {
|
|
1754
|
+
} catch (error) {
|
|
1755
|
+
if (!isFileNotFound(error)) {
|
|
1756
|
+
throw error;
|
|
1757
|
+
}
|
|
912
1758
|
return new Response("Not found", { status: 404 });
|
|
913
1759
|
}
|
|
914
1760
|
}
|
|
915
1761
|
async function serveIndex() {
|
|
916
1762
|
try {
|
|
917
|
-
const body = await readFile3(
|
|
1763
|
+
const body = await readFile3(path5.join(webRoot, "index.html"));
|
|
918
1764
|
return new Response(body, {
|
|
919
1765
|
headers: { "content-type": "text/html; charset=utf-8" }
|
|
920
1766
|
});
|
|
921
|
-
} catch {
|
|
1767
|
+
} catch (error) {
|
|
1768
|
+
if (!isFileNotFound(error)) {
|
|
1769
|
+
throw error;
|
|
1770
|
+
}
|
|
922
1771
|
return new Response("Gloss web assets are missing. Run pnpm build.", { status: 500 });
|
|
923
1772
|
}
|
|
924
1773
|
}
|
|
925
1774
|
function serveRootFile(fileName, contentType) {
|
|
926
1775
|
return async () => {
|
|
927
1776
|
try {
|
|
928
|
-
const body = await readFile3(
|
|
1777
|
+
const body = await readFile3(path5.join(webRoot, fileName));
|
|
929
1778
|
return new Response(body, {
|
|
930
1779
|
headers: { "content-type": contentType }
|
|
931
1780
|
});
|
|
932
|
-
} catch {
|
|
1781
|
+
} catch (error) {
|
|
1782
|
+
if (!isFileNotFound(error)) {
|
|
1783
|
+
throw error;
|
|
1784
|
+
}
|
|
933
1785
|
return new Response(`${fileName} is missing. Run pnpm build.`, { status: 404 });
|
|
934
1786
|
}
|
|
935
1787
|
};
|
|
@@ -941,7 +1793,7 @@ async function readJsonBody(c, guard, label) {
|
|
|
941
1793
|
} catch (error) {
|
|
942
1794
|
return {
|
|
943
1795
|
ok: false,
|
|
944
|
-
response: c.json({ error: `invalid JSON body: ${
|
|
1796
|
+
response: c.json({ error: `invalid JSON body: ${formatError(error)}` }, 400)
|
|
945
1797
|
};
|
|
946
1798
|
}
|
|
947
1799
|
try {
|
|
@@ -949,22 +1801,53 @@ async function readJsonBody(c, guard, label) {
|
|
|
949
1801
|
} catch (error) {
|
|
950
1802
|
return {
|
|
951
1803
|
ok: false,
|
|
952
|
-
response: c.json({ error:
|
|
1804
|
+
response: c.json({ error: formatError(error) }, 400)
|
|
953
1805
|
};
|
|
954
1806
|
}
|
|
955
1807
|
}
|
|
956
|
-
function
|
|
957
|
-
|
|
1808
|
+
function isPathWithin(parentPath, childPath) {
|
|
1809
|
+
const relative = path5.relative(parentPath, childPath);
|
|
1810
|
+
return relative === "" || !relative.startsWith("..") && !path5.isAbsolute(relative);
|
|
1811
|
+
}
|
|
1812
|
+
function activeTurnSummary(meta) {
|
|
1813
|
+
if (!meta.activeTurnId) {
|
|
1814
|
+
throw new Error(`Review ${meta.id} has no active turn`);
|
|
1815
|
+
}
|
|
1816
|
+
return turnSummary2(meta, meta.activeTurnId);
|
|
1817
|
+
}
|
|
1818
|
+
function turnSummary2(meta, turnId) {
|
|
1819
|
+
const summary = meta.turns?.find((turn) => turn.id === turnId);
|
|
1820
|
+
if (!summary) {
|
|
1821
|
+
throw new Error(`Review ${meta.id} is missing turn ${turnId}`);
|
|
1822
|
+
}
|
|
1823
|
+
return summary;
|
|
1824
|
+
}
|
|
1825
|
+
function statusForStoreError(error) {
|
|
1826
|
+
return /not found/i.test(formatError(error)) ? 404 : 409;
|
|
958
1827
|
}
|
|
959
1828
|
|
|
960
1829
|
// src/server/daemon.ts
|
|
961
1830
|
var port = Number(process.env.GLOSS_PORT ?? "0");
|
|
1831
|
+
var idleTimeoutMs = Number(process.env.GLOSS_IDLE_TIMEOUT_MS ?? "120000");
|
|
962
1832
|
if (!port) {
|
|
963
1833
|
throw new Error("GLOSS_PORT is required");
|
|
964
1834
|
}
|
|
965
1835
|
var origin = `http://localhost:${port}`;
|
|
1836
|
+
var eventStreams = /* @__PURE__ */ new Set();
|
|
1837
|
+
var idleTimer = null;
|
|
1838
|
+
var shuttingDown = false;
|
|
966
1839
|
var server = serve({
|
|
967
|
-
fetch: createApp(origin
|
|
1840
|
+
fetch: createApp(origin, {
|
|
1841
|
+
onReviewActivity: () => {
|
|
1842
|
+
void scheduleIdleShutdown();
|
|
1843
|
+
},
|
|
1844
|
+
registerEventStream: (close) => {
|
|
1845
|
+
eventStreams.add(close);
|
|
1846
|
+
return () => {
|
|
1847
|
+
eventStreams.delete(close);
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
1850
|
+
}).fetch,
|
|
968
1851
|
port
|
|
969
1852
|
});
|
|
970
1853
|
await writeServerInfo({
|
|
@@ -974,9 +1857,64 @@ await writeServerInfo({
|
|
|
974
1857
|
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
975
1858
|
stateDir: globalStateDir()
|
|
976
1859
|
});
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1860
|
+
await scheduleIdleShutdown();
|
|
1861
|
+
for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
|
|
1862
|
+
process.on(signal, () => {
|
|
1863
|
+
void shutdown(0);
|
|
980
1864
|
});
|
|
981
|
-
}
|
|
1865
|
+
}
|
|
1866
|
+
async function scheduleIdleShutdown() {
|
|
1867
|
+
if (shuttingDown || idleTimeoutMs <= 0) {
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
const activeReviews = await countActiveReviews();
|
|
1871
|
+
if (activeReviews > 0) {
|
|
1872
|
+
if (idleTimer) {
|
|
1873
|
+
clearTimeout(idleTimer);
|
|
1874
|
+
idleTimer = null;
|
|
1875
|
+
}
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
if (!idleTimer) {
|
|
1879
|
+
idleTimer = setTimeout(() => {
|
|
1880
|
+
idleTimer = null;
|
|
1881
|
+
void shutdownIfIdle();
|
|
1882
|
+
}, idleTimeoutMs);
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
async function shutdownIfIdle() {
|
|
1886
|
+
if (await countActiveReviews() > 0) {
|
|
1887
|
+
await scheduleIdleShutdown();
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
await shutdown(0);
|
|
1891
|
+
}
|
|
1892
|
+
async function countActiveReviews() {
|
|
1893
|
+
const reviews = await reviewStore.list();
|
|
1894
|
+
return reviews.filter((review) => review.status === "pending").length;
|
|
1895
|
+
}
|
|
1896
|
+
async function shutdown(exitCode) {
|
|
1897
|
+
if (shuttingDown) {
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
shuttingDown = true;
|
|
1901
|
+
if (idleTimer) {
|
|
1902
|
+
clearTimeout(idleTimer);
|
|
1903
|
+
idleTimer = null;
|
|
1904
|
+
}
|
|
1905
|
+
for (const close of [...eventStreams]) {
|
|
1906
|
+
close();
|
|
1907
|
+
}
|
|
1908
|
+
await new Promise((resolve) => {
|
|
1909
|
+
server.close(() => resolve());
|
|
1910
|
+
});
|
|
1911
|
+
await removeCurrentServerInfo();
|
|
1912
|
+
process.exit(exitCode);
|
|
1913
|
+
}
|
|
1914
|
+
async function removeCurrentServerInfo() {
|
|
1915
|
+
const info = await readServerInfo().catch(() => null);
|
|
1916
|
+
if (!info || info.pid === process.pid) {
|
|
1917
|
+
await rm2(globalServerFile(), { force: true });
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
982
1920
|
//# sourceMappingURL=daemon.js.map
|