getgloss 0.7.2 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -18
- package/dist/cli/index.js +1407 -343
- package/dist/cli/index.js.map +1 -1
- package/dist/server/daemon.js +1397 -193
- 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 +27 -8
- package/package.json +4 -1
- package/skill/SKILL.md +55 -36
- package/dist/web/assets/index-Dj8yXNw3.css +0 -1
- package/dist/web/assets/index-mwzx-OHz.js +0 -189
package/dist/cli/index.js
CHANGED
|
@@ -4,6 +4,17 @@
|
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
import openBrowser from "open";
|
|
6
6
|
|
|
7
|
+
// src/shared/cleanup.ts
|
|
8
|
+
import { readdir, readFile, rm } from "fs/promises";
|
|
9
|
+
|
|
10
|
+
// src/shared/errors.ts
|
|
11
|
+
function formatError(error) {
|
|
12
|
+
return error instanceof Error ? error.message : String(error);
|
|
13
|
+
}
|
|
14
|
+
function isFileNotFound(error) {
|
|
15
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
16
|
+
}
|
|
17
|
+
|
|
7
18
|
// src/shared/paths.ts
|
|
8
19
|
import { mkdir } from "fs/promises";
|
|
9
20
|
import { homedir } from "os";
|
|
@@ -12,7 +23,7 @@ import path from "path";
|
|
|
12
23
|
// package.json
|
|
13
24
|
var package_default = {
|
|
14
25
|
name: "getgloss",
|
|
15
|
-
version: "0.
|
|
26
|
+
version: "0.8.1",
|
|
16
27
|
description: "Local browser-based diff review for coding-agent loops.",
|
|
17
28
|
type: "module",
|
|
18
29
|
packageManager: "pnpm@10.33.2",
|
|
@@ -43,6 +54,8 @@ var package_default = {
|
|
|
43
54
|
},
|
|
44
55
|
dependencies: {
|
|
45
56
|
"@hono/node-server": "1.19.14",
|
|
57
|
+
"@shikijs/langs": "4.1.0",
|
|
58
|
+
"@shikijs/themes": "4.1.0",
|
|
46
59
|
"@tailwindcss/vite": "4.3.0",
|
|
47
60
|
commander: "14.0.3",
|
|
48
61
|
execa: "9.6.1",
|
|
@@ -52,6 +65,7 @@ var package_default = {
|
|
|
52
65
|
open: "10.2.0",
|
|
53
66
|
react: "19.2.6",
|
|
54
67
|
"react-dom": "19.2.6",
|
|
68
|
+
shiki: "4.1.0",
|
|
55
69
|
ulid: "3.0.2",
|
|
56
70
|
zustand: "5.0.13"
|
|
57
71
|
},
|
|
@@ -117,6 +131,27 @@ function globalReviewsDir() {
|
|
|
117
131
|
function globalReviewDir(reviewId) {
|
|
118
132
|
return path.join(globalReviewsDir(), reviewId);
|
|
119
133
|
}
|
|
134
|
+
function globalReviewTurnsDir(reviewId) {
|
|
135
|
+
return path.join(globalReviewDir(reviewId), "turns");
|
|
136
|
+
}
|
|
137
|
+
function globalReviewTurnDir(reviewId, turnId) {
|
|
138
|
+
return path.join(globalReviewTurnsDir(reviewId), turnId);
|
|
139
|
+
}
|
|
140
|
+
function globalReviewTurnMetaFile(reviewId, turnId) {
|
|
141
|
+
return path.join(globalReviewTurnDir(reviewId, turnId), "turn.json");
|
|
142
|
+
}
|
|
143
|
+
function globalReviewTurnDiffFile(reviewId, turnId) {
|
|
144
|
+
return path.join(globalReviewTurnDir(reviewId, turnId), "diff.json");
|
|
145
|
+
}
|
|
146
|
+
function globalReviewTurnFeedbackFile(reviewId, turnId) {
|
|
147
|
+
return path.join(globalReviewTurnDir(reviewId, turnId), "feedback.json");
|
|
148
|
+
}
|
|
149
|
+
function globalReviewTurnMarkdownFile(reviewId, turnId) {
|
|
150
|
+
return path.join(globalReviewTurnDir(reviewId, turnId), "feedback.md");
|
|
151
|
+
}
|
|
152
|
+
function globalReviewTurnResolvedFile(reviewId, turnId) {
|
|
153
|
+
return path.join(globalReviewTurnDir(reviewId, turnId), "resolved.json");
|
|
154
|
+
}
|
|
120
155
|
function globalReviewMetaFile(reviewId) {
|
|
121
156
|
return path.join(globalReviewDir(reviewId), "meta.json");
|
|
122
157
|
}
|
|
@@ -136,6 +171,427 @@ async function ensureDir(dir) {
|
|
|
136
171
|
await mkdir(dir, { recursive: true });
|
|
137
172
|
}
|
|
138
173
|
|
|
174
|
+
// src/shared/types.ts
|
|
175
|
+
var SIDES = ["L", "R"];
|
|
176
|
+
var REVIEW_STATUSES = ["pending", "submitted", "cancelled", "resolved"];
|
|
177
|
+
var DIFF_LINE_TYPES = ["context", "add", "delete"];
|
|
178
|
+
var DIFF_SCOPE_MODES = ["working", "branch", "explicit"];
|
|
179
|
+
var DIFF_FALLBACK_REASONS = ["working-tree-clean", "missing-branch-base"];
|
|
180
|
+
var REVIEW_SCOPE_MODES = ["all", "single", "range"];
|
|
181
|
+
var RESOLUTION_STATUSES = ["partial", "resolved"];
|
|
182
|
+
var REVIEW_UPDATE_REASONS = [
|
|
183
|
+
"review-resolved",
|
|
184
|
+
"comment-resolved",
|
|
185
|
+
"comment-reopened",
|
|
186
|
+
"turn-created"
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
// src/shared/validation.ts
|
|
190
|
+
function parseJson(raw, guard, label) {
|
|
191
|
+
const parsed = JSON.parse(raw);
|
|
192
|
+
return parseJsonValue(parsed, guard, label);
|
|
193
|
+
}
|
|
194
|
+
function parseJsonValue(value, guard, label) {
|
|
195
|
+
if (!guard(value)) {
|
|
196
|
+
throw new Error(`Invalid ${label}`);
|
|
197
|
+
}
|
|
198
|
+
return value;
|
|
199
|
+
}
|
|
200
|
+
function isServerInfo(value) {
|
|
201
|
+
return isRecord(value) && isNumber(value.pid) && isNumber(value.port) && isString(value.version) && isString(value.startedAt) && isString(value.stateDir);
|
|
202
|
+
}
|
|
203
|
+
function isHealthResponse(value) {
|
|
204
|
+
return isRecord(value) && isBoolean(value.ok) && isString(value.version) && isNumber(value.activeReviews);
|
|
205
|
+
}
|
|
206
|
+
function isClearReviewsResult(value) {
|
|
207
|
+
return isRecord(value) && isString(value.reviewsDir) && isString(value.cutoff) && isNumber(value.olderThanDays) && isBoolean(value.dryRun) && isArrayOf(value.candidates, isClearReviewEntry) && isArrayOf(value.deleted, isClearReviewEntry) && isArrayOf(value.skipped, isClearReviewSkipped) && isRecord(value.counts) && isNumber(value.counts.candidates) && isNumber(value.counts.deleted) && isNumber(value.counts.skipped);
|
|
208
|
+
}
|
|
209
|
+
function isCreateReviewResponse(value) {
|
|
210
|
+
return isRecord(value) && hasReviewRegistrationFields(value) && isOptional(value.turn, isReviewTurnSummary);
|
|
211
|
+
}
|
|
212
|
+
function isCreateReviewTurnResponse(value) {
|
|
213
|
+
return isRecord(value) && hasReviewRegistrationFields(value) && isReviewTurnSummary(value.turn) && isBoolean(value.reused);
|
|
214
|
+
}
|
|
215
|
+
function isListReviewsResponse(value) {
|
|
216
|
+
return isRecord(value) && isArrayOf(value.reviews, isReviewMeta);
|
|
217
|
+
}
|
|
218
|
+
function isOpenResult(value) {
|
|
219
|
+
return isRecord(value) && isString(value.reviewId) && isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isString(value.url) && isNumber(value.files) && isOptionalNumber(value.comments) && isOptionalString(value.feedbackPath) && isOptionalString(value.markdownPath) && isOptionalString(value.artifactDir);
|
|
220
|
+
}
|
|
221
|
+
function isResolveResult(value) {
|
|
222
|
+
return isRecord(value) && value.ok === true && isString(value.reviewId) && isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isReviewStatus(value.status) && isResolutionStatus(value.resolutionStatus) && isResolutionCounts(value.comments) && isString(value.path) && isResolutionBundle(value.resolution);
|
|
223
|
+
}
|
|
224
|
+
function isReviewRecord(value) {
|
|
225
|
+
return isRecord(value) && isReviewMeta(value.meta) && isArrayOf(value.turns, isReviewTurn) && isDiffPayload(value.diff) && isOptional(value.feedback, isFeedbackBundle) && isOptional(value.resolution, isResolutionBundle);
|
|
226
|
+
}
|
|
227
|
+
function isStoredReviewMeta(value) {
|
|
228
|
+
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(
|
|
229
|
+
value.turns,
|
|
230
|
+
(turns) => isArrayOf(turns, isReviewTurnSummary)
|
|
231
|
+
) && isOptionalString(value.feedbackPath) && isOptionalString(value.markdownPath);
|
|
232
|
+
}
|
|
233
|
+
function isReviewMeta(value) {
|
|
234
|
+
return isStoredReviewMeta(value) && isString(value.artifactDir);
|
|
235
|
+
}
|
|
236
|
+
function hasReviewRegistrationFields(value) {
|
|
237
|
+
return isReviewMeta(value.meta) && isString(value.url);
|
|
238
|
+
}
|
|
239
|
+
function isDiffPayload(value) {
|
|
240
|
+
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(
|
|
241
|
+
value.commitDiffs,
|
|
242
|
+
(commitDiffs) => isArrayOf(commitDiffs, isCommitDiff)
|
|
243
|
+
) && isString(value.capturedAt);
|
|
244
|
+
}
|
|
245
|
+
function isFeedbackBundle(value) {
|
|
246
|
+
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);
|
|
247
|
+
}
|
|
248
|
+
function isResolutionBundle(value) {
|
|
249
|
+
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);
|
|
250
|
+
}
|
|
251
|
+
function isReviewEvent(value) {
|
|
252
|
+
if (!isRecord(value) || !isString(value.reviewId) || !isString(value.type)) {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
switch (value.type) {
|
|
256
|
+
case "review.opened":
|
|
257
|
+
case "review.cancelled":
|
|
258
|
+
return true;
|
|
259
|
+
case "review.turn.created":
|
|
260
|
+
return isString(value.turnId) && isNumber(value.turnIndex) && isBoolean(value.reused);
|
|
261
|
+
case "review.submitted":
|
|
262
|
+
return isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isRecord(value.counts) && isNumber(value.counts.files) && isNumber(value.counts.comments);
|
|
263
|
+
case "review.updated":
|
|
264
|
+
return isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isReviewUpdateReason(value.reason) && isReviewStatus(value.status) && isResolutionStatus(value.resolutionStatus) && isResolutionCounts(value.counts);
|
|
265
|
+
default:
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function isReviewTurnMeta(value) {
|
|
270
|
+
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);
|
|
271
|
+
}
|
|
272
|
+
function isReviewTurnSummary(value) {
|
|
273
|
+
if (!isRecord(value) || !isReviewTurnMeta(value)) {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
return isString(value.capturedAt) && isDiffStats(value.stats) && isResolutionCounts(value.comments);
|
|
277
|
+
}
|
|
278
|
+
function isReviewTurn(value) {
|
|
279
|
+
if (!isRecord(value) || !isReviewTurnMeta(value)) {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
return isDiffPayload(value.diff) && isOptional(value.feedback, isFeedbackBundle) && isOptional(value.resolution, isResolutionBundle);
|
|
283
|
+
}
|
|
284
|
+
function isClearReviewEntry(value) {
|
|
285
|
+
return isRecord(value) && isString(value.reviewId) && isReviewStatus(value.status) && isString(value.artifactDir) && isString(value.lastActivityAt);
|
|
286
|
+
}
|
|
287
|
+
function isClearReviewSkipped(value) {
|
|
288
|
+
return isRecord(value) && isString(value.reviewId) && isString(value.artifactDir) && isString(value.reason);
|
|
289
|
+
}
|
|
290
|
+
function isDiffScope(value) {
|
|
291
|
+
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));
|
|
292
|
+
}
|
|
293
|
+
function isDiffRef(value) {
|
|
294
|
+
return isRecord(value) && isString(value.ref) && isNullableString(value.sha);
|
|
295
|
+
}
|
|
296
|
+
function isBaseRef(value) {
|
|
297
|
+
return isRecord(value) && isString(value.ref) && isString(value.sha);
|
|
298
|
+
}
|
|
299
|
+
function isDiffStats(value) {
|
|
300
|
+
return isRecord(value) && isNumber(value.files) && isNumber(value.additions) && isNumber(value.deletions);
|
|
301
|
+
}
|
|
302
|
+
function isDiffCommit(value) {
|
|
303
|
+
return isRecord(value) && isString(value.sha) && isString(value.shortSha) && isString(value.subject) && isString(value.authorName) && isString(value.authorEmail) && isString(value.authoredAt) && isString(value.committedAt);
|
|
304
|
+
}
|
|
305
|
+
function isCommitDiff(value) {
|
|
306
|
+
return isRecord(value) && isDiffCommit(value.commit) && isDiffStats(value.stats) && isString(value.rawDiff) && isArrayOf(value.files, isDiffFile);
|
|
307
|
+
}
|
|
308
|
+
function isReviewScope(value) {
|
|
309
|
+
if (!isRecord(value) || !isOneOf(value.mode, REVIEW_SCOPE_MODES)) {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
switch (value.mode) {
|
|
313
|
+
case "all":
|
|
314
|
+
return true;
|
|
315
|
+
case "single":
|
|
316
|
+
return isString(value.sha);
|
|
317
|
+
case "range":
|
|
318
|
+
return isString(value.fromSha) && isString(value.toSha);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function isDiffFile(value) {
|
|
322
|
+
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);
|
|
323
|
+
}
|
|
324
|
+
function isDiffHunk(value) {
|
|
325
|
+
return isRecord(value) && isNumber(value.oldStart) && isNumber(value.oldLines) && isNumber(value.newStart) && isNumber(value.newLines) && isString(value.header) && isArrayOf(value.lines, isDiffLine);
|
|
326
|
+
}
|
|
327
|
+
function isDiffLine(value) {
|
|
328
|
+
return isRecord(value) && isOneOf(value.type, DIFF_LINE_TYPES) && isNullableNumber(value.oldLine) && isNullableNumber(value.newLine) && isString(value.content);
|
|
329
|
+
}
|
|
330
|
+
function isComment(value) {
|
|
331
|
+
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);
|
|
332
|
+
}
|
|
333
|
+
function isResolvedComment(value) {
|
|
334
|
+
return isRecord(value) && isString(value.commentId) && value.status === "resolved" && isOptionalString(value.summary) && isString(value.resolvedAt);
|
|
335
|
+
}
|
|
336
|
+
function isResolutionCounts(value) {
|
|
337
|
+
return isRecord(value) && isNumber(value.total) && isNumber(value.resolved) && isNumber(value.open);
|
|
338
|
+
}
|
|
339
|
+
function isReviewStatus(value) {
|
|
340
|
+
return isOneOf(value, REVIEW_STATUSES);
|
|
341
|
+
}
|
|
342
|
+
function isResolutionStatus(value) {
|
|
343
|
+
return isOneOf(value, RESOLUTION_STATUSES);
|
|
344
|
+
}
|
|
345
|
+
function isReviewUpdateReason(value) {
|
|
346
|
+
return isOneOf(value, REVIEW_UPDATE_REASONS);
|
|
347
|
+
}
|
|
348
|
+
function isRecord(value) {
|
|
349
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
350
|
+
}
|
|
351
|
+
function isArrayOf(value, guard) {
|
|
352
|
+
return Array.isArray(value) && value.every(guard);
|
|
353
|
+
}
|
|
354
|
+
function isOptional(value, guard) {
|
|
355
|
+
return value === void 0 || guard(value);
|
|
356
|
+
}
|
|
357
|
+
function isString(value) {
|
|
358
|
+
return typeof value === "string";
|
|
359
|
+
}
|
|
360
|
+
function isOptionalString(value) {
|
|
361
|
+
return value === void 0 || isString(value);
|
|
362
|
+
}
|
|
363
|
+
function isNullableString(value) {
|
|
364
|
+
return value === null || isString(value);
|
|
365
|
+
}
|
|
366
|
+
function isNumber(value) {
|
|
367
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
368
|
+
}
|
|
369
|
+
function isOptionalNumber(value) {
|
|
370
|
+
return value === void 0 || isNumber(value);
|
|
371
|
+
}
|
|
372
|
+
function isNullableNumber(value) {
|
|
373
|
+
return value === null || isNumber(value);
|
|
374
|
+
}
|
|
375
|
+
function isBoolean(value) {
|
|
376
|
+
return typeof value === "boolean";
|
|
377
|
+
}
|
|
378
|
+
function isOneOf(value, options) {
|
|
379
|
+
return typeof value === "string" && options.includes(value);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// src/shared/cleanup.ts
|
|
383
|
+
var DEFAULT_REVIEW_RETENTION_DAYS = 30;
|
|
384
|
+
var clearableStatuses = /* @__PURE__ */ new Set(["submitted", "resolved", "cancelled"]);
|
|
385
|
+
var millisecondsPerDay = 24 * 60 * 60 * 1e3;
|
|
386
|
+
async function clearReviewArtifacts(options = {}) {
|
|
387
|
+
const olderThanDays = normalizeRetentionDays(options.olderThanDays);
|
|
388
|
+
const dryRun = options.dryRun === true;
|
|
389
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
390
|
+
const cutoff = new Date(now.getTime() - olderThanDays * millisecondsPerDay);
|
|
391
|
+
const reviewsDir = globalReviewsDir();
|
|
392
|
+
const candidates = [];
|
|
393
|
+
const deleted = [];
|
|
394
|
+
const skipped = [];
|
|
395
|
+
let entries;
|
|
396
|
+
try {
|
|
397
|
+
entries = await readdir(reviewsDir, { withFileTypes: true });
|
|
398
|
+
} catch (error) {
|
|
399
|
+
if (isFileNotFound(error)) {
|
|
400
|
+
return cleanupResult({
|
|
401
|
+
reviewsDir,
|
|
402
|
+
cutoff,
|
|
403
|
+
olderThanDays,
|
|
404
|
+
dryRun,
|
|
405
|
+
candidates,
|
|
406
|
+
deleted,
|
|
407
|
+
skipped
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
throw new Error(`Could not read reviews directory at ${reviewsDir}: ${formatError(error)}`, {
|
|
411
|
+
cause: error
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
for (const entry of entries) {
|
|
415
|
+
if (!entry.isDirectory()) {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const reviewId = entry.name;
|
|
419
|
+
const artifactDir = globalReviewDir(reviewId);
|
|
420
|
+
const candidate = await cleanupCandidate(reviewId, artifactDir, cutoff, skipped);
|
|
421
|
+
if (!candidate) {
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
candidates.push(candidate);
|
|
425
|
+
if (!dryRun) {
|
|
426
|
+
await rm(artifactDir, { recursive: true, force: true });
|
|
427
|
+
deleted.push(candidate);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return cleanupResult({ reviewsDir, cutoff, olderThanDays, dryRun, candidates, deleted, skipped });
|
|
431
|
+
}
|
|
432
|
+
function normalizeRetentionDays(value) {
|
|
433
|
+
const days = value ?? DEFAULT_REVIEW_RETENTION_DAYS;
|
|
434
|
+
if (!Number.isInteger(days) || days < 0) {
|
|
435
|
+
throw new Error("olderThanDays must be a non-negative integer");
|
|
436
|
+
}
|
|
437
|
+
return days;
|
|
438
|
+
}
|
|
439
|
+
async function cleanupCandidate(reviewId, artifactDir, cutoff, skipped) {
|
|
440
|
+
let raw;
|
|
441
|
+
try {
|
|
442
|
+
raw = await readFile(globalReviewMetaFile(reviewId), "utf8");
|
|
443
|
+
} catch (error) {
|
|
444
|
+
if (isFileNotFound(error)) {
|
|
445
|
+
skipped.push({ reviewId, artifactDir, reason: "missing metadata" });
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
skipped.push({ reviewId, artifactDir, reason: `unreadable metadata: ${formatError(error)}` });
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
let meta;
|
|
452
|
+
try {
|
|
453
|
+
meta = parseJson(raw, isStoredReviewMeta, "review metadata");
|
|
454
|
+
} catch (error) {
|
|
455
|
+
skipped.push({ reviewId, artifactDir, reason: `invalid metadata: ${formatError(error)}` });
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
if (meta.id !== reviewId) {
|
|
459
|
+
skipped.push({ reviewId, artifactDir, reason: `metadata id mismatch: ${meta.id}` });
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
if (!clearableStatuses.has(meta.status)) {
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
const turnState = await persistedTurnCleanupState(reviewId, artifactDir, skipped);
|
|
466
|
+
if (turnState === "preserve") {
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
const lastActivityAt = latestTimestamp([
|
|
470
|
+
...metadataTimestamps(meta),
|
|
471
|
+
...turnState === "none" ? [] : turnState.timestamps
|
|
472
|
+
]);
|
|
473
|
+
if (!lastActivityAt) {
|
|
474
|
+
skipped.push({ reviewId, artifactDir, reason: "missing valid activity timestamp" });
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
if (Date.parse(lastActivityAt) >= cutoff.getTime()) {
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
return {
|
|
481
|
+
reviewId,
|
|
482
|
+
status: meta.status,
|
|
483
|
+
artifactDir,
|
|
484
|
+
lastActivityAt
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
async function persistedTurnCleanupState(reviewId, artifactDir, skipped) {
|
|
488
|
+
let entries;
|
|
489
|
+
try {
|
|
490
|
+
entries = await readdir(globalReviewTurnsDir(reviewId), { withFileTypes: true });
|
|
491
|
+
} catch (error) {
|
|
492
|
+
if (isFileNotFound(error)) {
|
|
493
|
+
return "none";
|
|
494
|
+
}
|
|
495
|
+
skipped.push({
|
|
496
|
+
reviewId,
|
|
497
|
+
artifactDir,
|
|
498
|
+
reason: `unreadable turns directory: ${formatError(error)}`
|
|
499
|
+
});
|
|
500
|
+
return "preserve";
|
|
501
|
+
}
|
|
502
|
+
const turnDirs = entries.filter((entry) => entry.isDirectory());
|
|
503
|
+
if (turnDirs.length === 0) {
|
|
504
|
+
return "none";
|
|
505
|
+
}
|
|
506
|
+
const timestamps = [];
|
|
507
|
+
for (const entry of turnDirs) {
|
|
508
|
+
const turn = await readPersistedTurnMeta(reviewId, entry.name, artifactDir, skipped);
|
|
509
|
+
if (!turn) {
|
|
510
|
+
return "preserve";
|
|
511
|
+
}
|
|
512
|
+
if (turn.status === "pending" || !clearableStatuses.has(turn.status)) {
|
|
513
|
+
return "preserve";
|
|
514
|
+
}
|
|
515
|
+
timestamps.push(turn.createdAt, turn.submittedAt, turn.resolvedAt);
|
|
516
|
+
}
|
|
517
|
+
return { timestamps };
|
|
518
|
+
}
|
|
519
|
+
async function readPersistedTurnMeta(reviewId, turnDirName, artifactDir, skipped) {
|
|
520
|
+
let raw;
|
|
521
|
+
try {
|
|
522
|
+
raw = await readFile(globalReviewTurnMetaFile(reviewId, turnDirName), "utf8");
|
|
523
|
+
} catch (error) {
|
|
524
|
+
skipped.push({
|
|
525
|
+
reviewId,
|
|
526
|
+
artifactDir,
|
|
527
|
+
reason: `${isFileNotFound(error) ? "missing" : "unreadable"} turn metadata for ${turnDirName}${isFileNotFound(error) ? "" : `: ${formatError(error)}`}`
|
|
528
|
+
});
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
const turn = parseJson(raw, isReviewTurnMeta, "review turn metadata");
|
|
533
|
+
if (turn.id !== turnDirName) {
|
|
534
|
+
skipped.push({
|
|
535
|
+
reviewId,
|
|
536
|
+
artifactDir,
|
|
537
|
+
reason: `turn metadata id mismatch for ${turnDirName}: ${turn.id}`
|
|
538
|
+
});
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
return turn;
|
|
542
|
+
} catch (error) {
|
|
543
|
+
skipped.push({
|
|
544
|
+
reviewId,
|
|
545
|
+
artifactDir,
|
|
546
|
+
reason: `invalid turn metadata for ${turnDirName}: ${formatError(error)}`
|
|
547
|
+
});
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
function metadataTimestamps(meta) {
|
|
552
|
+
return [
|
|
553
|
+
meta.createdAt,
|
|
554
|
+
meta.submittedAt,
|
|
555
|
+
meta.resolvedAt,
|
|
556
|
+
...(meta.turns ?? []).flatMap((turn) => [
|
|
557
|
+
turn.createdAt,
|
|
558
|
+
turn.capturedAt,
|
|
559
|
+
turn.submittedAt,
|
|
560
|
+
turn.resolvedAt
|
|
561
|
+
])
|
|
562
|
+
];
|
|
563
|
+
}
|
|
564
|
+
function latestTimestamp(timestamps) {
|
|
565
|
+
const latest = Math.max(
|
|
566
|
+
...timestamps.map((timestamp) => timestamp ? Date.parse(timestamp) : Number.NaN).filter((timestamp) => Number.isFinite(timestamp))
|
|
567
|
+
);
|
|
568
|
+
return Number.isFinite(latest) ? new Date(latest).toISOString() : null;
|
|
569
|
+
}
|
|
570
|
+
function cleanupResult({
|
|
571
|
+
reviewsDir,
|
|
572
|
+
cutoff,
|
|
573
|
+
olderThanDays,
|
|
574
|
+
dryRun,
|
|
575
|
+
candidates,
|
|
576
|
+
deleted,
|
|
577
|
+
skipped
|
|
578
|
+
}) {
|
|
579
|
+
return {
|
|
580
|
+
reviewsDir,
|
|
581
|
+
cutoff: cutoff.toISOString(),
|
|
582
|
+
olderThanDays,
|
|
583
|
+
dryRun,
|
|
584
|
+
candidates,
|
|
585
|
+
deleted,
|
|
586
|
+
skipped,
|
|
587
|
+
counts: {
|
|
588
|
+
candidates: candidates.length,
|
|
589
|
+
deleted: deleted.length,
|
|
590
|
+
skipped: skipped.length
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
139
595
|
// src/cli/git.ts
|
|
140
596
|
import { execa } from "execa";
|
|
141
597
|
|
|
@@ -169,7 +625,7 @@ function languageForPath(filePath) {
|
|
|
169
625
|
return languageByExtension[ext] ?? ext;
|
|
170
626
|
}
|
|
171
627
|
|
|
172
|
-
// src/
|
|
628
|
+
// src/shared/diff-parser.ts
|
|
173
629
|
var hunkHeaderPattern = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/;
|
|
174
630
|
function stripGitPath(input) {
|
|
175
631
|
return input.replace(/^[ab]\//, "");
|
|
@@ -297,6 +753,18 @@ function parseUnifiedDiff(diffText) {
|
|
|
297
753
|
return files;
|
|
298
754
|
}
|
|
299
755
|
|
|
756
|
+
// src/shared/diff-stats.ts
|
|
757
|
+
function summarizeDiffFiles(files) {
|
|
758
|
+
return files.reduce(
|
|
759
|
+
(stats, file) => ({
|
|
760
|
+
files: stats.files + 1,
|
|
761
|
+
additions: stats.additions + file.additions,
|
|
762
|
+
deletions: stats.deletions + file.deletions
|
|
763
|
+
}),
|
|
764
|
+
{ files: 0, additions: 0, deletions: 0 }
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
|
|
300
768
|
// src/cli/git.ts
|
|
301
769
|
var DIFF_ARGS = ["diff", "--no-color", "--find-renames", "--find-copies"];
|
|
302
770
|
async function git(args, cwd = process.cwd()) {
|
|
@@ -317,16 +785,6 @@ async function gitLenient(args, cwd) {
|
|
|
317
785
|
async function getRepoRoot(cwd = process.cwd()) {
|
|
318
786
|
return git(["rev-parse", "--show-toplevel"], cwd);
|
|
319
787
|
}
|
|
320
|
-
function summarize(files) {
|
|
321
|
-
return files.reduce(
|
|
322
|
-
(stats, file) => ({
|
|
323
|
-
files: stats.files + 1,
|
|
324
|
-
additions: stats.additions + file.additions,
|
|
325
|
-
deletions: stats.deletions + file.deletions
|
|
326
|
-
}),
|
|
327
|
-
{ files: 0, additions: 0, deletions: 0 }
|
|
328
|
-
);
|
|
329
|
-
}
|
|
330
788
|
function buildPayload({
|
|
331
789
|
repoRoot,
|
|
332
790
|
branch,
|
|
@@ -335,7 +793,8 @@ function buildPayload({
|
|
|
335
793
|
mode,
|
|
336
794
|
requestedBase,
|
|
337
795
|
comparison,
|
|
338
|
-
fallbackReason
|
|
796
|
+
fallbackReason,
|
|
797
|
+
commitDiffs
|
|
339
798
|
}) {
|
|
340
799
|
const files = parseUnifiedDiff(rawDiff);
|
|
341
800
|
return {
|
|
@@ -349,9 +808,10 @@ function buildPayload({
|
|
|
349
808
|
comparison,
|
|
350
809
|
fallbackReason
|
|
351
810
|
},
|
|
352
|
-
stats:
|
|
811
|
+
stats: summarizeDiffFiles(files),
|
|
353
812
|
rawDiff,
|
|
354
813
|
files,
|
|
814
|
+
...commitDiffs ? { commitDiffs } : {},
|
|
355
815
|
capturedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
356
816
|
};
|
|
357
817
|
}
|
|
@@ -407,6 +867,52 @@ async function resolveBranchBase(repoRoot) {
|
|
|
407
867
|
}
|
|
408
868
|
return null;
|
|
409
869
|
}
|
|
870
|
+
async function captureCommitDiffs(baseSha, comparisonRef, repoRoot) {
|
|
871
|
+
const rawLog = await gitMaybe(
|
|
872
|
+
[
|
|
873
|
+
"log",
|
|
874
|
+
"--reverse",
|
|
875
|
+
"--format=%H%x00%h%x00%an%x00%ae%x00%aI%x00%cI%x00%s%x1e",
|
|
876
|
+
`${baseSha}..${comparisonRef}`
|
|
877
|
+
],
|
|
878
|
+
repoRoot
|
|
879
|
+
);
|
|
880
|
+
if (!rawLog) {
|
|
881
|
+
return [];
|
|
882
|
+
}
|
|
883
|
+
const commits = rawLog.split("").map((entry) => entry.trim()).filter(Boolean).map((entry) => {
|
|
884
|
+
const [
|
|
885
|
+
sha = "",
|
|
886
|
+
shortSha2 = "",
|
|
887
|
+
authorName = "",
|
|
888
|
+
authorEmail = "",
|
|
889
|
+
authoredAt = "",
|
|
890
|
+
committedAt = "",
|
|
891
|
+
...subjectParts
|
|
892
|
+
] = entry.split("\0");
|
|
893
|
+
return {
|
|
894
|
+
sha,
|
|
895
|
+
shortSha: shortSha2,
|
|
896
|
+
subject: subjectParts.join("\0"),
|
|
897
|
+
authorName,
|
|
898
|
+
authorEmail,
|
|
899
|
+
authoredAt,
|
|
900
|
+
committedAt
|
|
901
|
+
};
|
|
902
|
+
}).filter((commit) => commit.sha && commit.shortSha);
|
|
903
|
+
const commitDiffs = [];
|
|
904
|
+
for (const commit of commits) {
|
|
905
|
+
const rawDiff = await git([...DIFF_ARGS, `${commit.sha}^`, commit.sha, "--"], repoRoot);
|
|
906
|
+
const files = parseUnifiedDiff(rawDiff);
|
|
907
|
+
commitDiffs.push({
|
|
908
|
+
commit,
|
|
909
|
+
stats: summarizeDiffFiles(files),
|
|
910
|
+
rawDiff,
|
|
911
|
+
files
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
return commitDiffs;
|
|
915
|
+
}
|
|
410
916
|
async function captureDiff(baseRef, cwd = process.cwd()) {
|
|
411
917
|
const repoRoot = await getRepoRoot(cwd);
|
|
412
918
|
const [headSha, branch] = await Promise.all([
|
|
@@ -455,7 +961,10 @@ async function captureDiff(baseRef, cwd = process.cwd()) {
|
|
|
455
961
|
fallbackReason: "missing-branch-base"
|
|
456
962
|
});
|
|
457
963
|
}
|
|
458
|
-
const rawDiff = await
|
|
964
|
+
const [rawDiff, commitDiffs] = await Promise.all([
|
|
965
|
+
git([...DIFF_ARGS, branchBase.mergeBaseSha, "HEAD", "--"], repoRoot),
|
|
966
|
+
captureCommitDiffs(branchBase.mergeBaseSha, "HEAD", repoRoot)
|
|
967
|
+
]);
|
|
459
968
|
return buildPayload({
|
|
460
969
|
repoRoot,
|
|
461
970
|
branch,
|
|
@@ -464,7 +973,8 @@ async function captureDiff(baseRef, cwd = process.cwd()) {
|
|
|
464
973
|
mode: "branch",
|
|
465
974
|
requestedBase: null,
|
|
466
975
|
comparison: { ref: "HEAD", sha: headSha },
|
|
467
|
-
fallbackReason: "working-tree-clean"
|
|
976
|
+
fallbackReason: "working-tree-clean",
|
|
977
|
+
commitDiffs
|
|
468
978
|
});
|
|
469
979
|
}
|
|
470
980
|
async function assertGitAvailable() {
|
|
@@ -472,173 +982,47 @@ async function assertGitAvailable() {
|
|
|
472
982
|
}
|
|
473
983
|
|
|
474
984
|
// src/cli/lifecycle.ts
|
|
475
|
-
import { spawn } from "child_process";
|
|
476
|
-
import { existsSync, openSync } from "fs";
|
|
477
|
-
import { rm } from "fs/promises";
|
|
985
|
+
import { execFile, spawn } from "child_process";
|
|
986
|
+
import { closeSync, existsSync, openSync } from "fs";
|
|
987
|
+
import { rm as rm3 } from "fs/promises";
|
|
988
|
+
import { userInfo } from "os";
|
|
478
989
|
import { fileURLToPath } from "url";
|
|
990
|
+
import { promisify } from "util";
|
|
479
991
|
import getPort from "get-port";
|
|
480
992
|
|
|
481
993
|
// src/shared/server-info.ts
|
|
482
|
-
import { readFile } from "fs/promises";
|
|
994
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
483
995
|
|
|
484
996
|
// src/shared/json.ts
|
|
485
|
-
import {
|
|
997
|
+
import { randomUUID } from "crypto";
|
|
998
|
+
import { rename, rm as rm2, writeFile } from "fs/promises";
|
|
999
|
+
import path3 from "path";
|
|
486
1000
|
function serializeJson(value) {
|
|
487
1001
|
return `${JSON.stringify(value, null, 2)}
|
|
488
1002
|
`;
|
|
489
1003
|
}
|
|
490
1004
|
async function writeJsonFile(filePath, value) {
|
|
491
|
-
await
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// src/shared/validation.ts
|
|
495
|
-
var reviewStatuses = ["pending", "submitted", "cancelled", "resolved"];
|
|
496
|
-
var resolutionStatuses = ["partial", "resolved"];
|
|
497
|
-
var reviewUpdateReasons = ["review-resolved", "comment-resolved", "comment-reopened"];
|
|
498
|
-
var sides = ["L", "R"];
|
|
499
|
-
var diffLineTypes = ["context", "add", "delete"];
|
|
500
|
-
var diffScopeModes = ["working", "branch", "explicit"];
|
|
501
|
-
var diffFallbackReasons = ["working-tree-clean", "missing-branch-base"];
|
|
502
|
-
function parseJson(raw, guard, label) {
|
|
503
|
-
const parsed = JSON.parse(raw);
|
|
504
|
-
return parseJsonValue(parsed, guard, label);
|
|
1005
|
+
await writeTextFile(filePath, serializeJson(value));
|
|
505
1006
|
}
|
|
506
|
-
function
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
}
|
|
518
|
-
function isCreateReviewResponse(value) {
|
|
519
|
-
return isRecord(value) && isReviewMeta(value.meta) && isString(value.url);
|
|
520
|
-
}
|
|
521
|
-
function isListReviewsResponse(value) {
|
|
522
|
-
return isRecord(value) && isArrayOf(value.reviews, isReviewMeta);
|
|
523
|
-
}
|
|
524
|
-
function isOpenResult(value) {
|
|
525
|
-
return isRecord(value) && isString(value.reviewId) && isString(value.url) && isNumber(value.files) && isOptionalNumber(value.comments) && isOptionalString(value.feedbackPath) && isOptionalString(value.markdownPath) && isOptionalString(value.artifactDir);
|
|
526
|
-
}
|
|
527
|
-
function isResolveResult(value) {
|
|
528
|
-
return isRecord(value) && value.ok === true && isString(value.reviewId) && isReviewStatus(value.status) && isResolutionStatus(value.resolutionStatus) && isResolutionCounts(value.comments) && isString(value.path) && isResolutionBundle(value.resolution);
|
|
529
|
-
}
|
|
530
|
-
function isReviewRecord(value) {
|
|
531
|
-
return isRecord(value) && isReviewMeta(value.meta) && isDiffPayload(value.diff) && isOptional(value.feedback, isFeedbackBundle) && isOptional(value.resolution, isResolutionBundle);
|
|
532
|
-
}
|
|
533
|
-
function isStoredReviewMeta(value) {
|
|
534
|
-
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);
|
|
535
|
-
}
|
|
536
|
-
function isReviewMeta(value) {
|
|
537
|
-
return isStoredReviewMeta(value) && isString(value.artifactDir);
|
|
538
|
-
}
|
|
539
|
-
function isDiffPayload(value) {
|
|
540
|
-
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);
|
|
541
|
-
}
|
|
542
|
-
function isFeedbackBundle(value) {
|
|
543
|
-
return isRecord(value) && value.version === 1 && isString(value.reviewId) && isString(value.timestamp) && isBaseRef(value.base) && isNullableString(value.branch) && isArrayOf(value.comments, isComment);
|
|
544
|
-
}
|
|
545
|
-
function isResolutionBundle(value) {
|
|
546
|
-
return isRecord(value) && isString(value.reviewId) && isResolutionStatus(value.status) && isNullableString(value.summary) && isNullableString(value.resolvedAt) && isArrayOf(value.comments, isResolvedComment);
|
|
547
|
-
}
|
|
548
|
-
function isReviewEvent(value) {
|
|
549
|
-
if (!isRecord(value) || !isString(value.reviewId) || !isString(value.type)) {
|
|
550
|
-
return false;
|
|
551
|
-
}
|
|
552
|
-
switch (value.type) {
|
|
553
|
-
case "review.opened":
|
|
554
|
-
case "review.cancelled":
|
|
555
|
-
return true;
|
|
556
|
-
case "review.submitted":
|
|
557
|
-
return isRecord(value.counts) && isNumber(value.counts.files) && isNumber(value.counts.comments);
|
|
558
|
-
case "review.updated":
|
|
559
|
-
return isReviewUpdateReason(value.reason) && isReviewStatus(value.status) && isResolutionStatus(value.resolutionStatus) && isResolutionCounts(value.counts);
|
|
560
|
-
default:
|
|
561
|
-
return false;
|
|
1007
|
+
async function writeTextFile(filePath, value) {
|
|
1008
|
+
const tempPath = path3.join(
|
|
1009
|
+
path3.dirname(filePath),
|
|
1010
|
+
`.${path3.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`
|
|
1011
|
+
);
|
|
1012
|
+
try {
|
|
1013
|
+
await writeFile(tempPath, value);
|
|
1014
|
+
await rename(tempPath, filePath);
|
|
1015
|
+
} catch (error) {
|
|
1016
|
+
await rm2(tempPath, { force: true }).catch(() => void 0);
|
|
1017
|
+
throw error;
|
|
562
1018
|
}
|
|
563
1019
|
}
|
|
564
|
-
function isDiffScope(value) {
|
|
565
|
-
return isRecord(value) && isOneOf(value.mode, diffScopeModes) && isNullableString(value.requestedBase) && isBaseRef(value.base) && isDiffRef(value.comparison) && (value.fallbackReason === null || isOneOf(value.fallbackReason, diffFallbackReasons));
|
|
566
|
-
}
|
|
567
|
-
function isDiffRef(value) {
|
|
568
|
-
return isRecord(value) && isString(value.ref) && isNullableString(value.sha);
|
|
569
|
-
}
|
|
570
|
-
function isBaseRef(value) {
|
|
571
|
-
return isRecord(value) && isString(value.ref) && isString(value.sha);
|
|
572
|
-
}
|
|
573
|
-
function isDiffStats(value) {
|
|
574
|
-
return isRecord(value) && isNumber(value.files) && isNumber(value.additions) && isNumber(value.deletions);
|
|
575
|
-
}
|
|
576
|
-
function isDiffFile(value) {
|
|
577
|
-
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);
|
|
578
|
-
}
|
|
579
|
-
function isDiffHunk(value) {
|
|
580
|
-
return isRecord(value) && isNumber(value.oldStart) && isNumber(value.oldLines) && isNumber(value.newStart) && isNumber(value.newLines) && isString(value.header) && isArrayOf(value.lines, isDiffLine);
|
|
581
|
-
}
|
|
582
|
-
function isDiffLine(value) {
|
|
583
|
-
return isRecord(value) && isOneOf(value.type, diffLineTypes) && isNullableNumber(value.oldLine) && isNullableNumber(value.newLine) && isString(value.content);
|
|
584
|
-
}
|
|
585
|
-
function isComment(value) {
|
|
586
|
-
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);
|
|
587
|
-
}
|
|
588
|
-
function isResolvedComment(value) {
|
|
589
|
-
return isRecord(value) && isString(value.commentId) && value.status === "resolved" && isOptionalString(value.summary) && isString(value.resolvedAt);
|
|
590
|
-
}
|
|
591
|
-
function isResolutionCounts(value) {
|
|
592
|
-
return isRecord(value) && isNumber(value.total) && isNumber(value.resolved) && isNumber(value.open);
|
|
593
|
-
}
|
|
594
|
-
function isReviewStatus(value) {
|
|
595
|
-
return isOneOf(value, reviewStatuses);
|
|
596
|
-
}
|
|
597
|
-
function isResolutionStatus(value) {
|
|
598
|
-
return isOneOf(value, resolutionStatuses);
|
|
599
|
-
}
|
|
600
|
-
function isReviewUpdateReason(value) {
|
|
601
|
-
return isOneOf(value, reviewUpdateReasons);
|
|
602
|
-
}
|
|
603
|
-
function isRecord(value) {
|
|
604
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
605
|
-
}
|
|
606
|
-
function isArrayOf(value, guard) {
|
|
607
|
-
return Array.isArray(value) && value.every(guard);
|
|
608
|
-
}
|
|
609
|
-
function isOptional(value, guard) {
|
|
610
|
-
return value === void 0 || guard(value);
|
|
611
|
-
}
|
|
612
|
-
function isString(value) {
|
|
613
|
-
return typeof value === "string";
|
|
614
|
-
}
|
|
615
|
-
function isOptionalString(value) {
|
|
616
|
-
return value === void 0 || isString(value);
|
|
617
|
-
}
|
|
618
|
-
function isNullableString(value) {
|
|
619
|
-
return value === null || isString(value);
|
|
620
|
-
}
|
|
621
|
-
function isNumber(value) {
|
|
622
|
-
return typeof value === "number" && Number.isFinite(value);
|
|
623
|
-
}
|
|
624
|
-
function isOptionalNumber(value) {
|
|
625
|
-
return value === void 0 || isNumber(value);
|
|
626
|
-
}
|
|
627
|
-
function isNullableNumber(value) {
|
|
628
|
-
return value === null || isNumber(value);
|
|
629
|
-
}
|
|
630
|
-
function isBoolean(value) {
|
|
631
|
-
return typeof value === "boolean";
|
|
632
|
-
}
|
|
633
|
-
function isOneOf(value, options) {
|
|
634
|
-
return typeof value === "string" && options.includes(value);
|
|
635
|
-
}
|
|
636
1020
|
|
|
637
1021
|
// src/shared/server-info.ts
|
|
638
1022
|
async function readServerInfo() {
|
|
639
1023
|
let raw;
|
|
640
1024
|
try {
|
|
641
|
-
raw = await
|
|
1025
|
+
raw = await readFile2(globalServerFile(), "utf8");
|
|
642
1026
|
} catch (error) {
|
|
643
1027
|
if (isFileNotFound(error)) {
|
|
644
1028
|
return null;
|
|
@@ -659,12 +1043,6 @@ async function writeServerInfo(info) {
|
|
|
659
1043
|
await ensureDir(globalStateDir());
|
|
660
1044
|
await writeJsonFile(globalServerFile(), info);
|
|
661
1045
|
}
|
|
662
|
-
function isFileNotFound(error) {
|
|
663
|
-
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
664
|
-
}
|
|
665
|
-
function formatError(error) {
|
|
666
|
-
return error instanceof Error ? error.message : String(error);
|
|
667
|
-
}
|
|
668
1046
|
|
|
669
1047
|
// src/cli/server-client.ts
|
|
670
1048
|
var ServerClient = class {
|
|
@@ -678,17 +1056,33 @@ var ServerClient = class {
|
|
|
678
1056
|
async createReview(diff) {
|
|
679
1057
|
return this.post("/api/reviews", diff, isCreateReviewResponse, "create review response");
|
|
680
1058
|
}
|
|
1059
|
+
async appendReviewTurn(reviewId, diff) {
|
|
1060
|
+
return this.post(
|
|
1061
|
+
`/api/reviews/${reviewId}/turns`,
|
|
1062
|
+
diff,
|
|
1063
|
+
isCreateReviewTurnResponse,
|
|
1064
|
+
"create review turn response"
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
681
1067
|
async getReview(reviewId) {
|
|
682
1068
|
return this.get(`/api/reviews/${reviewId}`, isReviewRecord, "review response");
|
|
683
1069
|
}
|
|
684
1070
|
async listReviews() {
|
|
685
1071
|
return this.get("/api/reviews", isListReviewsResponse, "review list response");
|
|
686
1072
|
}
|
|
1073
|
+
async clearReviews(request) {
|
|
1074
|
+
return this.post(
|
|
1075
|
+
"/api/maintenance/clear-reviews",
|
|
1076
|
+
request,
|
|
1077
|
+
isClearReviewsResult,
|
|
1078
|
+
"clear reviews response"
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
687
1081
|
async getFeedback(reviewId) {
|
|
688
1082
|
return this.get(`/api/reviews/${reviewId}/feedback`, isFeedbackBundle, "feedback response");
|
|
689
1083
|
}
|
|
690
|
-
async markResolved(reviewId, summary) {
|
|
691
|
-
const request = { summary };
|
|
1084
|
+
async markResolved(reviewId, summary, turn) {
|
|
1085
|
+
const request = { summary, turn };
|
|
692
1086
|
return this.post(
|
|
693
1087
|
`/api/reviews/${reviewId}/resolved`,
|
|
694
1088
|
request,
|
|
@@ -778,20 +1172,20 @@ var ServerClient = class {
|
|
|
778
1172
|
}
|
|
779
1173
|
}
|
|
780
1174
|
}
|
|
781
|
-
async get(
|
|
782
|
-
const response = await fetch(`${this.baseUrl}${
|
|
1175
|
+
async get(path5, guard, label) {
|
|
1176
|
+
const response = await fetch(`${this.baseUrl}${path5}`);
|
|
783
1177
|
return parseResponse(response, guard, label);
|
|
784
1178
|
}
|
|
785
|
-
async post(
|
|
786
|
-
const response = await fetch(`${this.baseUrl}${
|
|
1179
|
+
async post(path5, body, guard, label) {
|
|
1180
|
+
const response = await fetch(`${this.baseUrl}${path5}`, {
|
|
787
1181
|
method: "POST",
|
|
788
1182
|
headers: { "content-type": "application/json" },
|
|
789
1183
|
body: JSON.stringify(body)
|
|
790
1184
|
});
|
|
791
1185
|
return parseResponse(response, guard, label);
|
|
792
1186
|
}
|
|
793
|
-
async delete(
|
|
794
|
-
const response = await fetch(`${this.baseUrl}${
|
|
1187
|
+
async delete(path5, guard, label) {
|
|
1188
|
+
const response = await fetch(`${this.baseUrl}${path5}`, { method: "DELETE" });
|
|
795
1189
|
return parseResponse(response, guard, label);
|
|
796
1190
|
}
|
|
797
1191
|
};
|
|
@@ -806,13 +1200,16 @@ function isPrematureWatchEnd(error) {
|
|
|
806
1200
|
return error instanceof Error && error.message === "watch stream ended before completion";
|
|
807
1201
|
}
|
|
808
1202
|
function isAbortError(error) {
|
|
809
|
-
return
|
|
1203
|
+
return error instanceof Error && error.name === "AbortError";
|
|
810
1204
|
}
|
|
811
1205
|
async function sleep(milliseconds) {
|
|
812
1206
|
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
813
1207
|
}
|
|
814
1208
|
|
|
815
1209
|
// src/cli/lifecycle.ts
|
|
1210
|
+
var execFileAsync = promisify(execFile);
|
|
1211
|
+
var gracefulShutdownTimeoutMs = 2e3;
|
|
1212
|
+
var forceShutdownTimeoutMs = 1e3;
|
|
816
1213
|
function serverUrl(info) {
|
|
817
1214
|
return `http://localhost:${info.port}`;
|
|
818
1215
|
}
|
|
@@ -839,9 +1236,23 @@ async function startServer(options = {}) {
|
|
|
839
1236
|
if (existing && await isServerResponsive(existing)) {
|
|
840
1237
|
return existing;
|
|
841
1238
|
}
|
|
1239
|
+
if (existing) {
|
|
1240
|
+
await retireServer(existing);
|
|
1241
|
+
}
|
|
1242
|
+
const preferredPort = options.port ?? existing?.port ?? await getPort();
|
|
1243
|
+
try {
|
|
1244
|
+
return await launchServer(preferredPort);
|
|
1245
|
+
} catch (error) {
|
|
1246
|
+
if (options.port || !existing?.port) {
|
|
1247
|
+
throw error;
|
|
1248
|
+
}
|
|
1249
|
+
await removeServerInfoForPid(existing.pid);
|
|
1250
|
+
return launchServer(await getPort());
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
async function launchServer(port) {
|
|
842
1254
|
await ensureDir(globalStateDir());
|
|
843
1255
|
await ensureDir(globalLogDir());
|
|
844
|
-
const port = options.port ?? await getPort();
|
|
845
1256
|
const daemonPath = fileURLToPath(new URL("../server/daemon.js", import.meta.url));
|
|
846
1257
|
if (!existsSync(daemonPath)) {
|
|
847
1258
|
throw new Error(`Cannot find server daemon at ${daemonPath}. Run pnpm build first.`);
|
|
@@ -856,6 +1267,7 @@ async function startServer(options = {}) {
|
|
|
856
1267
|
},
|
|
857
1268
|
stdio: ["ignore", logFd, logFd]
|
|
858
1269
|
});
|
|
1270
|
+
closeSync(logFd);
|
|
859
1271
|
child.unref();
|
|
860
1272
|
const info = {
|
|
861
1273
|
pid: child.pid ?? -1,
|
|
@@ -872,18 +1284,40 @@ async function startServer(options = {}) {
|
|
|
872
1284
|
}
|
|
873
1285
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
874
1286
|
}
|
|
1287
|
+
await terminatePid(info.pid);
|
|
1288
|
+
await removeServerInfoForPid(info.pid);
|
|
875
1289
|
throw new Error(`Server did not become responsive. See ${globalServerLogFile()}`);
|
|
876
1290
|
}
|
|
877
|
-
async function stopServer() {
|
|
1291
|
+
async function stopServer(options = {}) {
|
|
1292
|
+
if (options.all) {
|
|
1293
|
+
const info2 = await readServerInfo();
|
|
1294
|
+
const daemonPids = await listGlossDaemonPids();
|
|
1295
|
+
const stoppedPids = [];
|
|
1296
|
+
for (const pid of daemonPids) {
|
|
1297
|
+
if (await terminatePid(pid)) {
|
|
1298
|
+
stoppedPids.push(pid);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
await rm3(globalServerFile(), { force: true });
|
|
1302
|
+
return { stopped: stoppedPids.length > 0, info: info2, stoppedPids };
|
|
1303
|
+
}
|
|
878
1304
|
const info = await readServerInfo();
|
|
879
1305
|
if (!info) {
|
|
880
1306
|
return { stopped: false, info: null };
|
|
881
1307
|
}
|
|
882
|
-
if (isPidAlive(info.pid)) {
|
|
883
|
-
|
|
1308
|
+
if (!isPidAlive(info.pid)) {
|
|
1309
|
+
await removeServerInfoForPid(info.pid);
|
|
1310
|
+
return { stopped: false, info };
|
|
1311
|
+
}
|
|
1312
|
+
if (!await isGlossDaemonPid(info.pid)) {
|
|
1313
|
+
await removeServerInfoForPid(info.pid);
|
|
1314
|
+
return { stopped: false, info };
|
|
884
1315
|
}
|
|
885
|
-
|
|
886
|
-
|
|
1316
|
+
const stopped = await terminatePid(info.pid);
|
|
1317
|
+
if (stopped) {
|
|
1318
|
+
await removeServerInfoForPid(info.pid);
|
|
1319
|
+
}
|
|
1320
|
+
return { stopped, info };
|
|
887
1321
|
}
|
|
888
1322
|
function isPidAlive(pid) {
|
|
889
1323
|
if (pid <= 0) {
|
|
@@ -896,9 +1330,86 @@ function isPidAlive(pid) {
|
|
|
896
1330
|
return false;
|
|
897
1331
|
}
|
|
898
1332
|
}
|
|
1333
|
+
async function retireServer(info) {
|
|
1334
|
+
if (isPidAlive(info.pid) && await isGlossDaemonPid(info.pid)) {
|
|
1335
|
+
await terminatePid(info.pid);
|
|
1336
|
+
}
|
|
1337
|
+
await removeServerInfoForPid(info.pid);
|
|
1338
|
+
}
|
|
1339
|
+
async function terminatePid(pid) {
|
|
1340
|
+
if (!isPidAlive(pid)) {
|
|
1341
|
+
return true;
|
|
1342
|
+
}
|
|
1343
|
+
try {
|
|
1344
|
+
process.kill(pid, "SIGTERM");
|
|
1345
|
+
} catch {
|
|
1346
|
+
return !isPidAlive(pid);
|
|
1347
|
+
}
|
|
1348
|
+
if (await waitForPidExit(pid, gracefulShutdownTimeoutMs)) {
|
|
1349
|
+
return true;
|
|
1350
|
+
}
|
|
1351
|
+
try {
|
|
1352
|
+
process.kill(pid, "SIGKILL");
|
|
1353
|
+
} catch {
|
|
1354
|
+
return !isPidAlive(pid);
|
|
1355
|
+
}
|
|
1356
|
+
return waitForPidExit(pid, forceShutdownTimeoutMs);
|
|
1357
|
+
}
|
|
1358
|
+
async function waitForPidExit(pid, timeoutMs) {
|
|
1359
|
+
const deadline = Date.now() + timeoutMs;
|
|
1360
|
+
while (Date.now() < deadline) {
|
|
1361
|
+
if (!isPidAlive(pid)) {
|
|
1362
|
+
return true;
|
|
1363
|
+
}
|
|
1364
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1365
|
+
}
|
|
1366
|
+
return !isPidAlive(pid);
|
|
1367
|
+
}
|
|
1368
|
+
async function removeServerInfoForPid(pid) {
|
|
1369
|
+
const current = await readServerInfo().catch(() => null);
|
|
1370
|
+
if (!current || current.pid === pid) {
|
|
1371
|
+
await rm3(globalServerFile(), { force: true });
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
async function isGlossDaemonPid(pid) {
|
|
1375
|
+
const command = await readProcessCommand(pid);
|
|
1376
|
+
return command ? isGlossDaemonCommand(command) : false;
|
|
1377
|
+
}
|
|
1378
|
+
async function readProcessCommand(pid) {
|
|
1379
|
+
try {
|
|
1380
|
+
const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(pid), "-ww"]);
|
|
1381
|
+
return stdout.trim() || null;
|
|
1382
|
+
} catch {
|
|
1383
|
+
return null;
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
async function listGlossDaemonPids() {
|
|
1387
|
+
let stdout;
|
|
1388
|
+
try {
|
|
1389
|
+
({ stdout } = await execFileAsync("ps", ["-axo", "pid=,user=,command=", "-ww"]));
|
|
1390
|
+
} catch {
|
|
1391
|
+
return [];
|
|
1392
|
+
}
|
|
1393
|
+
const currentUser = userInfo().username;
|
|
1394
|
+
return parseGlossDaemonPids(stdout, currentUser, process.pid);
|
|
1395
|
+
}
|
|
1396
|
+
function parseGlossDaemonPids(stdout, currentUser, currentPid = process.pid) {
|
|
1397
|
+
return stdout.split("\n").map((line) => /^\s*(\d+)\s+(\S+)\s+(.+)$/.exec(line)).filter((match) => Boolean(match)).map((match) => ({
|
|
1398
|
+
pid: Number(match[1]),
|
|
1399
|
+
user: match[2],
|
|
1400
|
+
command: match[3]
|
|
1401
|
+
})).filter(
|
|
1402
|
+
({ pid, user, command }) => pid !== currentPid && user === currentUser && isGlossDaemonCommand(command)
|
|
1403
|
+
).map(({ pid }) => pid);
|
|
1404
|
+
}
|
|
1405
|
+
function isGlossDaemonCommand(command) {
|
|
1406
|
+
return /(?:^|\s)(?:\S*\/)?node\s+\S*dist\/server\/daemon\.js(?:\s|$)/.test(command);
|
|
1407
|
+
}
|
|
899
1408
|
|
|
900
1409
|
// src/server/store.ts
|
|
901
|
-
import {
|
|
1410
|
+
import { createHash } from "crypto";
|
|
1411
|
+
import { readdir as readdir2, readFile as readFile3 } from "fs/promises";
|
|
1412
|
+
import path4 from "path";
|
|
902
1413
|
import { ulid } from "ulid";
|
|
903
1414
|
|
|
904
1415
|
// src/shared/comments.ts
|
|
@@ -928,6 +1439,60 @@ function resolutionCounts(feedback, resolvedComments = []) {
|
|
|
928
1439
|
};
|
|
929
1440
|
}
|
|
930
1441
|
|
|
1442
|
+
// src/shared/review-scope.ts
|
|
1443
|
+
var ALL_REVIEW_SCOPE = { mode: "all" };
|
|
1444
|
+
function normalizeReviewScope(diff, scope = ALL_REVIEW_SCOPE) {
|
|
1445
|
+
if (scope.mode === "all") {
|
|
1446
|
+
return ALL_REVIEW_SCOPE;
|
|
1447
|
+
}
|
|
1448
|
+
const commitDiffs = diff.commitDiffs ?? [];
|
|
1449
|
+
if (commitDiffs.length === 0) {
|
|
1450
|
+
throw new Error("Review scope requires a review with per-commit diffs");
|
|
1451
|
+
}
|
|
1452
|
+
if (scope.mode === "single") {
|
|
1453
|
+
const commit = commitDiffs.find((commitDiff) => commitDiff.commit.sha === scope.sha);
|
|
1454
|
+
if (!commit) {
|
|
1455
|
+
throw new Error("Review scope must use commits from this review");
|
|
1456
|
+
}
|
|
1457
|
+
return { mode: "single", sha: commit.commit.sha };
|
|
1458
|
+
}
|
|
1459
|
+
const fromIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.fromSha);
|
|
1460
|
+
const toIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.toSha);
|
|
1461
|
+
if (fromIndex < 0 || toIndex < 0) {
|
|
1462
|
+
throw new Error("Review scope must use commits from this review");
|
|
1463
|
+
}
|
|
1464
|
+
if (fromIndex > toIndex) {
|
|
1465
|
+
throw new Error("Review scope range must be in review order");
|
|
1466
|
+
}
|
|
1467
|
+
return {
|
|
1468
|
+
mode: "range",
|
|
1469
|
+
fromSha: commitDiffs[fromIndex].commit.sha,
|
|
1470
|
+
toSha: commitDiffs[toIndex].commit.sha
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
function sameReviewScope(left, right) {
|
|
1474
|
+
return JSON.stringify(left ?? ALL_REVIEW_SCOPE) === JSON.stringify(right ?? ALL_REVIEW_SCOPE);
|
|
1475
|
+
}
|
|
1476
|
+
function reviewScopeLabel(scope = ALL_REVIEW_SCOPE, commitDiffs = []) {
|
|
1477
|
+
if (scope.mode === "all") {
|
|
1478
|
+
return "All commits";
|
|
1479
|
+
}
|
|
1480
|
+
if (scope.mode === "single") {
|
|
1481
|
+
const commit = commitDiffs.find((commitDiff) => commitDiff.commit.sha === scope.sha);
|
|
1482
|
+
return commit ? `${commit.commit.shortSha} ${commit.commit.subject}` : `Commit ${shortSha(scope.sha)}`;
|
|
1483
|
+
}
|
|
1484
|
+
const fromIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.fromSha);
|
|
1485
|
+
const toIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.toSha);
|
|
1486
|
+
if (fromIndex >= 0 && toIndex >= fromIndex) {
|
|
1487
|
+
const count = toIndex - fromIndex + 1;
|
|
1488
|
+
return `${count} commits \xB7 ${commitDiffs[fromIndex].commit.shortSha} to ${commitDiffs[toIndex].commit.shortSha}`;
|
|
1489
|
+
}
|
|
1490
|
+
return `Commit range ${shortSha(scope.fromSha)} to ${shortSha(scope.toSha)}`;
|
|
1491
|
+
}
|
|
1492
|
+
function shortSha(sha) {
|
|
1493
|
+
return sha.slice(0, 7);
|
|
1494
|
+
}
|
|
1495
|
+
|
|
931
1496
|
// src/shared/markdown.ts
|
|
932
1497
|
function fenceFor(snippet) {
|
|
933
1498
|
let fence = "```";
|
|
@@ -942,20 +1507,32 @@ function languageForSnippet(filePath, snippet) {
|
|
|
942
1507
|
return looksLikeUnifiedDiff ? "diff" : languageForPath(filePath) ?? "";
|
|
943
1508
|
}
|
|
944
1509
|
function serializeFeedbackMarkdown(bundle) {
|
|
945
|
-
const comments =
|
|
946
|
-
const
|
|
1510
|
+
const comments = bundle.comments.toSorted(compareCommentsByLocation);
|
|
1511
|
+
const commentsByFile = /* @__PURE__ */ new Map();
|
|
1512
|
+
const files = [];
|
|
1513
|
+
for (const comment of comments) {
|
|
1514
|
+
const fileComments = commentsByFile.get(comment.filePath);
|
|
1515
|
+
if (fileComments) {
|
|
1516
|
+
fileComments.push(comment);
|
|
1517
|
+
} else {
|
|
1518
|
+
commentsByFile.set(comment.filePath, [comment]);
|
|
1519
|
+
files.push(comment.filePath);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
947
1522
|
const lines = [
|
|
948
1523
|
`# Gloss feedback - ${bundle.timestamp}`,
|
|
949
1524
|
`Review: ${bundle.reviewId}`,
|
|
1525
|
+
...bundle.turnIndex ? [`Turn: ${bundle.turnIndex} (${bundle.turnId ?? "unknown"})`] : [],
|
|
1526
|
+
...bundle.reviewScope ? [`Review scope: ${reviewScopeLabel(bundle.reviewScope)}`] : [],
|
|
950
1527
|
`Base: ${bundle.base.ref} (${bundle.base.sha.slice(0, 7)}) Branch: ${bundle.branch ?? "(detached)"}`,
|
|
951
1528
|
`Files: ${files.length} Comments: ${comments.length}`,
|
|
952
1529
|
""
|
|
953
1530
|
];
|
|
954
1531
|
for (const filePath of files) {
|
|
955
1532
|
lines.push(`## ${filePath}`, "");
|
|
956
|
-
for (const comment of
|
|
1533
|
+
for (const comment of commentsByFile.get(filePath) ?? []) {
|
|
957
1534
|
const snippet = comment.originalSnippet.trimEnd();
|
|
958
|
-
const firstSnippetLine = snippet
|
|
1535
|
+
const firstSnippetLine = firstNonEmptyLine(snippet);
|
|
959
1536
|
const heading = comment.startLine === comment.endLine && firstSnippetLine ? `### ${formatLineRange(comment)} - \`${firstSnippetLine.trim().slice(0, 80)}\`` : `### ${formatLineRange(comment)}`;
|
|
960
1537
|
lines.push(heading, comment.body.trim(), "");
|
|
961
1538
|
if (snippet) {
|
|
@@ -967,6 +1544,14 @@ function serializeFeedbackMarkdown(bundle) {
|
|
|
967
1544
|
return `${lines.join("\n").trimEnd()}
|
|
968
1545
|
`;
|
|
969
1546
|
}
|
|
1547
|
+
function firstNonEmptyLine(text) {
|
|
1548
|
+
for (const line of text.split("\n")) {
|
|
1549
|
+
if (line.trim().length > 0) {
|
|
1550
|
+
return line;
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
return void 0;
|
|
1554
|
+
}
|
|
970
1555
|
|
|
971
1556
|
// src/shared/reviews.ts
|
|
972
1557
|
function isResolvableReviewStatus(status) {
|
|
@@ -980,6 +1565,7 @@ var ReviewStore = class {
|
|
|
980
1565
|
async create(diff) {
|
|
981
1566
|
const id = ulid();
|
|
982
1567
|
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1568
|
+
const turn = createTurn(id, 1, diff, createdAt);
|
|
983
1569
|
const meta = {
|
|
984
1570
|
id,
|
|
985
1571
|
cwd: diff.cwd,
|
|
@@ -987,108 +1573,197 @@ var ReviewStore = class {
|
|
|
987
1573
|
branch: diff.branch,
|
|
988
1574
|
status: "pending",
|
|
989
1575
|
createdAt,
|
|
990
|
-
artifactDir: globalReviewDir(id)
|
|
1576
|
+
artifactDir: globalReviewDir(id),
|
|
1577
|
+
activeTurnId: turn.id
|
|
991
1578
|
};
|
|
992
|
-
const record = { meta, diff };
|
|
1579
|
+
const record = normalizeRecord({ meta, turns: [turn], diff: turn.diff });
|
|
993
1580
|
this.reviews.set(id, record);
|
|
994
|
-
await this.persistInitial(record);
|
|
1581
|
+
await this.persistInitial(record, turn);
|
|
995
1582
|
this.emit({ type: "review.opened", reviewId: id });
|
|
996
1583
|
return record;
|
|
997
1584
|
}
|
|
1585
|
+
async appendTurn(id, diff) {
|
|
1586
|
+
const record = await this.get(id);
|
|
1587
|
+
if (!record) {
|
|
1588
|
+
throw new Error(`Review ${id} not found`);
|
|
1589
|
+
}
|
|
1590
|
+
if (record.meta.cwd !== diff.cwd) {
|
|
1591
|
+
throw new Error(`Review ${id} belongs to ${record.meta.cwd}, not ${diff.cwd}`);
|
|
1592
|
+
}
|
|
1593
|
+
const latest = latestTurn(record);
|
|
1594
|
+
if (latest.status === "pending") {
|
|
1595
|
+
if (diffFingerprint(latest.diff) === diffFingerprint(diff)) {
|
|
1596
|
+
this.emit({
|
|
1597
|
+
type: "review.turn.created",
|
|
1598
|
+
reviewId: id,
|
|
1599
|
+
turnId: latest.id,
|
|
1600
|
+
turnIndex: latest.index,
|
|
1601
|
+
reused: true
|
|
1602
|
+
});
|
|
1603
|
+
return { record, turn: latest, reused: true };
|
|
1604
|
+
}
|
|
1605
|
+
throw new Error(`Review ${id} already has a pending turn`);
|
|
1606
|
+
}
|
|
1607
|
+
if (latest.status === "cancelled") {
|
|
1608
|
+
throw new Error(`Review ${id} is cancelled and cannot be continued`);
|
|
1609
|
+
}
|
|
1610
|
+
const turn = createTurn(id, latest.index + 1, diff, (/* @__PURE__ */ new Date()).toISOString());
|
|
1611
|
+
const nextRecord = normalizeRecord({
|
|
1612
|
+
...record,
|
|
1613
|
+
meta: { ...record.meta, activeTurnId: turn.id },
|
|
1614
|
+
turns: [...record.turns, turn]
|
|
1615
|
+
});
|
|
1616
|
+
this.reviews.set(id, nextRecord);
|
|
1617
|
+
await this.persistInitial(nextRecord, turn);
|
|
1618
|
+
this.emit({
|
|
1619
|
+
type: "review.turn.created",
|
|
1620
|
+
reviewId: id,
|
|
1621
|
+
turnId: turn.id,
|
|
1622
|
+
turnIndex: turn.index,
|
|
1623
|
+
reused: false
|
|
1624
|
+
});
|
|
1625
|
+
return { record: nextRecord, turn, reused: false };
|
|
1626
|
+
}
|
|
998
1627
|
async list() {
|
|
999
1628
|
await this.loadAllReviews();
|
|
1000
|
-
return
|
|
1629
|
+
return Array.from(this.reviews.values()).map((record) => record.meta).toSorted((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
1630
|
+
}
|
|
1631
|
+
async clearReviewArtifacts(options = {}) {
|
|
1632
|
+
const result = await clearReviewArtifacts(options);
|
|
1633
|
+
if (!result.dryRun) {
|
|
1634
|
+
for (const review of result.deleted) {
|
|
1635
|
+
this.reviews.delete(review.reviewId);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
return result;
|
|
1001
1639
|
}
|
|
1002
1640
|
async get(id) {
|
|
1003
1641
|
return this.reviews.get(id) ?? await this.loadKnownReview(id);
|
|
1004
1642
|
}
|
|
1005
|
-
async
|
|
1643
|
+
async getTurn(id, turnId) {
|
|
1644
|
+
const record = await this.get(id);
|
|
1645
|
+
return record?.turns.find((turn) => turn.id === turnId) ?? null;
|
|
1646
|
+
}
|
|
1647
|
+
async submit(id, comments, reviewScope) {
|
|
1006
1648
|
const record = await this.get(id);
|
|
1007
1649
|
if (!record) {
|
|
1008
1650
|
throw new Error(`Review ${id} not found`);
|
|
1009
1651
|
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1652
|
+
const turn = activeTurn(record);
|
|
1653
|
+
const sortedComments = comments.toSorted(compareCommentsByLocation);
|
|
1654
|
+
const normalizedReviewScope = normalizeReviewScope(turn.diff, reviewScope);
|
|
1655
|
+
if (turn.status !== "pending") {
|
|
1656
|
+
if (turn.feedback && sameComments(turn.feedback.comments, sortedComments) && sameReviewScope(turn.feedback.reviewScope, normalizedReviewScope)) {
|
|
1657
|
+
return {
|
|
1658
|
+
record,
|
|
1659
|
+
feedbackPath: requiredPath(turn.feedbackPath, "feedback path"),
|
|
1660
|
+
markdownPath: requiredPath(turn.markdownPath, "markdown path"),
|
|
1661
|
+
turn
|
|
1662
|
+
};
|
|
1663
|
+
}
|
|
1664
|
+
throw new Error(`Review ${id} turn ${turn.index} is ${turn.status} and cannot be submitted`);
|
|
1012
1665
|
}
|
|
1013
1666
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
1667
|
+
const feedbackPath = globalReviewTurnFeedbackFile(id, turn.id);
|
|
1668
|
+
const markdownPath = globalReviewTurnMarkdownFile(id, turn.id);
|
|
1014
1669
|
const feedback = {
|
|
1015
1670
|
version: 1,
|
|
1016
1671
|
reviewId: id,
|
|
1672
|
+
turnId: turn.id,
|
|
1673
|
+
turnIndex: turn.index,
|
|
1017
1674
|
timestamp,
|
|
1018
|
-
base:
|
|
1019
|
-
branch:
|
|
1020
|
-
|
|
1675
|
+
base: turn.diff.base,
|
|
1676
|
+
branch: turn.diff.branch,
|
|
1677
|
+
reviewScope: normalizedReviewScope,
|
|
1678
|
+
comments: sortedComments
|
|
1021
1679
|
};
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
const feedbackPath = globalReviewFeedbackFile(id);
|
|
1027
|
-
const markdownPath = globalReviewMarkdownFile(id);
|
|
1028
|
-
record.meta = {
|
|
1029
|
-
...record.meta,
|
|
1030
|
-
artifactDir,
|
|
1680
|
+
const nextTurn = {
|
|
1681
|
+
...turn,
|
|
1682
|
+
status: "submitted",
|
|
1683
|
+
submittedAt: timestamp,
|
|
1031
1684
|
feedbackPath,
|
|
1032
|
-
markdownPath
|
|
1685
|
+
markdownPath,
|
|
1686
|
+
feedback
|
|
1033
1687
|
};
|
|
1034
|
-
|
|
1688
|
+
const nextRecord = normalizeRecord(replaceTurn(record, nextTurn));
|
|
1689
|
+
this.reviews.set(id, nextRecord);
|
|
1690
|
+
await ensureDir(globalReviewTurnDir(id, nextTurn.id));
|
|
1035
1691
|
await Promise.all([
|
|
1036
|
-
writeJsonFile(
|
|
1692
|
+
writeJsonFile(globalReviewTurnMetaFile(id, nextTurn.id), turnMeta(nextTurn)),
|
|
1037
1693
|
writeJsonFile(feedbackPath, feedback),
|
|
1038
|
-
|
|
1694
|
+
writeTextFile(markdownPath, serializeFeedbackMarkdown(feedback))
|
|
1039
1695
|
]);
|
|
1696
|
+
await this.persistMeta(nextRecord);
|
|
1040
1697
|
this.emit({
|
|
1041
1698
|
type: "review.submitted",
|
|
1042
1699
|
reviewId: id,
|
|
1700
|
+
turnId: nextTurn.id,
|
|
1701
|
+
turnIndex: nextTurn.index,
|
|
1043
1702
|
counts: {
|
|
1044
1703
|
files: countCommentFiles(feedback.comments),
|
|
1045
1704
|
comments: feedback.comments.length
|
|
1046
1705
|
}
|
|
1047
1706
|
});
|
|
1048
|
-
return { record, feedbackPath, markdownPath };
|
|
1707
|
+
return { record: nextRecord, feedbackPath, markdownPath, turn: nextTurn };
|
|
1049
1708
|
}
|
|
1050
1709
|
async feedback(id) {
|
|
1051
1710
|
const record = await this.get(id);
|
|
1052
1711
|
return record?.feedback ?? null;
|
|
1053
1712
|
}
|
|
1054
|
-
async markResolved(id, summary) {
|
|
1713
|
+
async markResolved(id, summary, turnSelector) {
|
|
1055
1714
|
const record = await this.get(id);
|
|
1056
1715
|
if (!record) {
|
|
1057
1716
|
throw new Error(`Review ${id} not found`);
|
|
1058
1717
|
}
|
|
1059
|
-
this.
|
|
1718
|
+
const turn = this.resolveTurnSelector(record, turnSelector);
|
|
1719
|
+
this.assertResolvable(turn, id);
|
|
1060
1720
|
const resolvedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1061
1721
|
const existingById = new Map(
|
|
1062
|
-
(
|
|
1722
|
+
(turn.resolution?.comments ?? []).map((comment) => [comment.commentId, comment])
|
|
1063
1723
|
);
|
|
1064
1724
|
const comments = this.sortResolvedComments(
|
|
1065
|
-
(
|
|
1725
|
+
(turn.feedback?.comments ?? []).map((comment) => ({
|
|
1066
1726
|
...existingById.get(comment.id),
|
|
1067
1727
|
commentId: comment.id,
|
|
1068
1728
|
status: "resolved",
|
|
1069
1729
|
resolvedAt: existingById.get(comment.id)?.resolvedAt ?? resolvedAt
|
|
1070
1730
|
})),
|
|
1071
|
-
|
|
1731
|
+
turn
|
|
1072
1732
|
);
|
|
1073
1733
|
const resolution = {
|
|
1074
1734
|
reviewId: id,
|
|
1735
|
+
turnId: turn.id,
|
|
1736
|
+
turnIndex: turn.index,
|
|
1075
1737
|
status: "resolved",
|
|
1076
|
-
summary: summary ??
|
|
1738
|
+
summary: summary ?? turn.resolution?.summary ?? null,
|
|
1077
1739
|
resolvedAt,
|
|
1078
1740
|
comments
|
|
1079
1741
|
};
|
|
1080
|
-
|
|
1081
|
-
|
|
1742
|
+
const nextTurn = {
|
|
1743
|
+
...turn,
|
|
1744
|
+
status: "resolved",
|
|
1745
|
+
resolvedAt
|
|
1746
|
+
};
|
|
1747
|
+
return this.persistResolution(record, nextTurn, resolution, "review-resolved");
|
|
1082
1748
|
}
|
|
1083
1749
|
async resolveComment(id, commentId, summary) {
|
|
1084
1750
|
const record = await this.get(id);
|
|
1085
1751
|
if (!record) {
|
|
1086
1752
|
throw new Error(`Review ${id} not found`);
|
|
1087
1753
|
}
|
|
1088
|
-
this.
|
|
1089
|
-
|
|
1754
|
+
const turn = this.findTurnForComment(record, commentId);
|
|
1755
|
+
if (!turn) {
|
|
1756
|
+
const currentTurn = activeTurn(record);
|
|
1757
|
+
if (!isResolvableReviewStatus(currentTurn.status)) {
|
|
1758
|
+
throw new Error(
|
|
1759
|
+
`Review ${id} turn ${currentTurn.index} is ${currentTurn.status} and cannot be resolved`
|
|
1760
|
+
);
|
|
1761
|
+
}
|
|
1762
|
+
throw new Error(`Comment ${commentId} not found`);
|
|
1763
|
+
}
|
|
1764
|
+
this.assertResolvable(turn, id);
|
|
1090
1765
|
const resolvedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1091
|
-
const previous =
|
|
1766
|
+
const previous = turn.resolution?.comments.find((comment) => comment.commentId === commentId);
|
|
1092
1767
|
const nextSummary = summary ?? previous?.summary;
|
|
1093
1768
|
const nextComment = {
|
|
1094
1769
|
commentId,
|
|
@@ -1098,46 +1773,59 @@ var ReviewStore = class {
|
|
|
1098
1773
|
};
|
|
1099
1774
|
const comments = this.sortResolvedComments(
|
|
1100
1775
|
[
|
|
1101
|
-
...(
|
|
1776
|
+
...(turn.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
|
|
1102
1777
|
nextComment
|
|
1103
1778
|
],
|
|
1104
|
-
|
|
1779
|
+
turn
|
|
1105
1780
|
);
|
|
1106
|
-
const counts = resolutionCounts(
|
|
1781
|
+
const counts = resolutionCounts(turn.feedback, comments);
|
|
1107
1782
|
const fullyResolved = counts.total === counts.resolved;
|
|
1108
1783
|
const resolution = {
|
|
1109
1784
|
reviewId: id,
|
|
1785
|
+
turnId: turn.id,
|
|
1786
|
+
turnIndex: turn.index,
|
|
1110
1787
|
status: fullyResolved ? "resolved" : "partial",
|
|
1111
|
-
summary: fullyResolved ?
|
|
1788
|
+
summary: fullyResolved ? turn.resolution?.summary ?? null : null,
|
|
1112
1789
|
resolvedAt: fullyResolved ? resolvedAt : null,
|
|
1113
1790
|
comments
|
|
1114
1791
|
};
|
|
1115
|
-
|
|
1116
|
-
return this.persistResolution(record, resolution, "comment-resolved");
|
|
1792
|
+
const nextTurn = fullyResolved ? { ...turn, status: "resolved", resolvedAt } : { ...turn, status: "submitted", resolvedAt: void 0 };
|
|
1793
|
+
return this.persistResolution(record, nextTurn, resolution, "comment-resolved");
|
|
1117
1794
|
}
|
|
1118
1795
|
async reopenComment(id, commentId) {
|
|
1119
1796
|
const record = await this.get(id);
|
|
1120
1797
|
if (!record) {
|
|
1121
1798
|
throw new Error(`Review ${id} not found`);
|
|
1122
1799
|
}
|
|
1123
|
-
this.
|
|
1124
|
-
|
|
1800
|
+
const turn = this.findTurnForComment(record, commentId);
|
|
1801
|
+
if (!turn) {
|
|
1802
|
+
const currentTurn = activeTurn(record);
|
|
1803
|
+
if (!isResolvableReviewStatus(currentTurn.status)) {
|
|
1804
|
+
throw new Error(
|
|
1805
|
+
`Review ${id} turn ${currentTurn.index} is ${currentTurn.status} and cannot be resolved`
|
|
1806
|
+
);
|
|
1807
|
+
}
|
|
1808
|
+
throw new Error(`Comment ${commentId} not found`);
|
|
1809
|
+
}
|
|
1810
|
+
this.assertResolvable(turn, id);
|
|
1125
1811
|
const comments = this.sortResolvedComments(
|
|
1126
|
-
(
|
|
1127
|
-
|
|
1812
|
+
(turn.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
|
|
1813
|
+
turn
|
|
1128
1814
|
);
|
|
1129
|
-
const counts = resolutionCounts(
|
|
1815
|
+
const counts = resolutionCounts(turn.feedback, comments);
|
|
1130
1816
|
const fullyResolved = counts.total > 0 && counts.total === counts.resolved;
|
|
1131
1817
|
const resolvedAt = fullyResolved ? (/* @__PURE__ */ new Date()).toISOString() : null;
|
|
1132
1818
|
const resolution = {
|
|
1133
1819
|
reviewId: id,
|
|
1820
|
+
turnId: turn.id,
|
|
1821
|
+
turnIndex: turn.index,
|
|
1134
1822
|
status: fullyResolved ? "resolved" : "partial",
|
|
1135
|
-
summary: fullyResolved ?
|
|
1823
|
+
summary: fullyResolved ? turn.resolution?.summary ?? null : null,
|
|
1136
1824
|
resolvedAt,
|
|
1137
1825
|
comments
|
|
1138
1826
|
};
|
|
1139
|
-
|
|
1140
|
-
return this.persistResolution(record, resolution, "comment-reopened");
|
|
1827
|
+
const nextTurn = fullyResolved ? { ...turn, status: "resolved", resolvedAt: resolvedAt ?? void 0 } : { ...turn, status: "submitted", resolvedAt: void 0 };
|
|
1828
|
+
return this.persistResolution(record, nextTurn, resolution, "comment-reopened");
|
|
1141
1829
|
}
|
|
1142
1830
|
subscribe(reviewId, listener) {
|
|
1143
1831
|
const listeners = this.listeners.get(reviewId) ?? /* @__PURE__ */ new Set();
|
|
@@ -1155,13 +1843,17 @@ var ReviewStore = class {
|
|
|
1155
1843
|
listener(event);
|
|
1156
1844
|
}
|
|
1157
1845
|
}
|
|
1158
|
-
async persistInitial(record) {
|
|
1159
|
-
|
|
1160
|
-
await ensureDir(dir);
|
|
1846
|
+
async persistInitial(record, turn) {
|
|
1847
|
+
await ensureDir(turn.artifactDir);
|
|
1161
1848
|
await Promise.all([
|
|
1162
|
-
writeJsonFile(
|
|
1163
|
-
writeJsonFile(
|
|
1849
|
+
writeJsonFile(globalReviewTurnMetaFile(record.meta.id, turn.id), turnMeta(turn)),
|
|
1850
|
+
writeJsonFile(turn.diffPath, turn.diff)
|
|
1164
1851
|
]);
|
|
1852
|
+
await this.persistMeta(record);
|
|
1853
|
+
}
|
|
1854
|
+
async persistMeta(record) {
|
|
1855
|
+
await ensureDir(globalReviewDir(record.meta.id));
|
|
1856
|
+
await writeJsonFile(globalReviewMetaFile(record.meta.id), record.meta);
|
|
1165
1857
|
}
|
|
1166
1858
|
async loadKnownReview(id) {
|
|
1167
1859
|
const existing = this.reviews.get(id);
|
|
@@ -1173,98 +1865,229 @@ var ReviewStore = class {
|
|
|
1173
1865
|
async loadAllReviews() {
|
|
1174
1866
|
let entries;
|
|
1175
1867
|
try {
|
|
1176
|
-
entries = await
|
|
1868
|
+
entries = await readdir2(globalReviewsDir(), { withFileTypes: true });
|
|
1177
1869
|
} catch (error) {
|
|
1178
|
-
if (
|
|
1870
|
+
if (isFileNotFound(error)) {
|
|
1179
1871
|
return;
|
|
1180
1872
|
}
|
|
1181
1873
|
throw new Error(
|
|
1182
|
-
`Could not read reviews directory at ${globalReviewsDir()}: ${
|
|
1874
|
+
`Could not read reviews directory at ${globalReviewsDir()}: ${formatError(error)}`,
|
|
1183
1875
|
{
|
|
1184
1876
|
cause: error
|
|
1185
1877
|
}
|
|
1186
1878
|
);
|
|
1187
1879
|
}
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1880
|
+
const reviewLoads = [];
|
|
1881
|
+
for (const entry of entries) {
|
|
1882
|
+
if (entry.isDirectory()) {
|
|
1883
|
+
reviewLoads.push(this.loadReview(entry.name));
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
await Promise.all(reviewLoads);
|
|
1191
1887
|
}
|
|
1192
1888
|
async loadReview(id) {
|
|
1193
1889
|
const metaPath = globalReviewMetaFile(id);
|
|
1194
|
-
|
|
1890
|
+
let metaRaw;
|
|
1891
|
+
try {
|
|
1892
|
+
metaRaw = await readFile3(metaPath, "utf8");
|
|
1893
|
+
} catch (error) {
|
|
1894
|
+
if (isFileNotFound(error)) {
|
|
1895
|
+
return this.loadReviewFromTurnsOnly(id);
|
|
1896
|
+
}
|
|
1897
|
+
throw new Error(`Could not load review ${id}: ${formatError(error)}`, { cause: error });
|
|
1898
|
+
}
|
|
1899
|
+
const storedMeta = parseJsonFile(metaRaw, isStoredReviewMeta, "review metadata", metaPath);
|
|
1900
|
+
const persistedTurns = await this.loadPersistedTurns(id);
|
|
1901
|
+
const legacyTurn = await this.loadLegacyTurn(id, storedMeta);
|
|
1902
|
+
const turns = mergeRecoveredTurns(legacyTurn, persistedTurns);
|
|
1903
|
+
if (turns.length === 0) {
|
|
1904
|
+
throw new Error(`Review ${id} has no recoverable turns`);
|
|
1905
|
+
}
|
|
1906
|
+
const latest = latestTurn({ turns });
|
|
1907
|
+
const record = normalizeRecord({
|
|
1908
|
+
meta: {
|
|
1909
|
+
...storedMeta,
|
|
1910
|
+
artifactDir: storedMeta.artifactDir ?? globalReviewDir(id),
|
|
1911
|
+
activeTurnId: latest.id
|
|
1912
|
+
},
|
|
1913
|
+
turns,
|
|
1914
|
+
diff: latest.diff
|
|
1915
|
+
});
|
|
1916
|
+
this.reviews.set(id, record);
|
|
1917
|
+
return record;
|
|
1918
|
+
}
|
|
1919
|
+
async loadReviewFromTurnsOnly(id) {
|
|
1920
|
+
const turns = await this.loadPersistedTurns(id);
|
|
1921
|
+
if (turns.length === 0) {
|
|
1922
|
+
return null;
|
|
1923
|
+
}
|
|
1924
|
+
const latest = latestTurn({ turns });
|
|
1925
|
+
const record = normalizeRecord({
|
|
1926
|
+
meta: {
|
|
1927
|
+
id,
|
|
1928
|
+
cwd: latest.diff.cwd,
|
|
1929
|
+
base: latest.diff.base,
|
|
1930
|
+
branch: latest.diff.branch,
|
|
1931
|
+
status: latest.status,
|
|
1932
|
+
createdAt: turns[0]?.createdAt ?? latest.createdAt,
|
|
1933
|
+
artifactDir: globalReviewDir(id),
|
|
1934
|
+
activeTurnId: latest.id
|
|
1935
|
+
},
|
|
1936
|
+
turns,
|
|
1937
|
+
diff: latest.diff
|
|
1938
|
+
});
|
|
1939
|
+
this.reviews.set(id, record);
|
|
1940
|
+
await this.persistMeta(record);
|
|
1941
|
+
return record;
|
|
1942
|
+
}
|
|
1943
|
+
async loadPersistedTurns(id) {
|
|
1944
|
+
let entries;
|
|
1945
|
+
try {
|
|
1946
|
+
entries = await readdir2(globalReviewTurnsDir(id), { withFileTypes: true });
|
|
1947
|
+
} catch (error) {
|
|
1948
|
+
if (isFileNotFound(error)) {
|
|
1949
|
+
return [];
|
|
1950
|
+
}
|
|
1951
|
+
throw new Error(`Could not read review turns for ${id}: ${formatError(error)}`, {
|
|
1952
|
+
cause: error
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
const turns = [];
|
|
1956
|
+
for (const entry of entries) {
|
|
1957
|
+
if (!entry.isDirectory()) {
|
|
1958
|
+
continue;
|
|
1959
|
+
}
|
|
1960
|
+
const turn = await this.loadPersistedTurn(id, entry.name);
|
|
1961
|
+
if (turn) {
|
|
1962
|
+
turns.push(turn);
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
return turns.toSorted((a, b) => a.index - b.index);
|
|
1966
|
+
}
|
|
1967
|
+
async loadPersistedTurn(id, turnId) {
|
|
1968
|
+
const metaPath = globalReviewTurnMetaFile(id, turnId);
|
|
1969
|
+
const diffPath = globalReviewTurnDiffFile(id, turnId);
|
|
1195
1970
|
let metaRaw;
|
|
1196
1971
|
let diffRaw;
|
|
1197
1972
|
try {
|
|
1198
1973
|
[metaRaw, diffRaw] = await Promise.all([
|
|
1199
|
-
|
|
1200
|
-
|
|
1974
|
+
readFile3(metaPath, "utf8"),
|
|
1975
|
+
readFile3(diffPath, "utf8")
|
|
1201
1976
|
]);
|
|
1202
1977
|
} catch (error) {
|
|
1203
|
-
if (
|
|
1978
|
+
if (isFileNotFound(error)) {
|
|
1979
|
+
return null;
|
|
1980
|
+
}
|
|
1981
|
+
throw new Error(`Could not load review ${id} turn ${turnId}: ${formatError(error)}`, {
|
|
1982
|
+
cause: error
|
|
1983
|
+
});
|
|
1984
|
+
}
|
|
1985
|
+
const meta = parseJsonFile(metaRaw, isReviewTurnMeta, "review turn metadata", metaPath);
|
|
1986
|
+
const diff = parseJsonFile(diffRaw, isDiffPayload, "review turn diff", diffPath);
|
|
1987
|
+
const [feedback, resolution] = await Promise.all([
|
|
1988
|
+
readOptionalJsonFile(
|
|
1989
|
+
globalReviewTurnFeedbackFile(id, turnId),
|
|
1990
|
+
isFeedbackBundle,
|
|
1991
|
+
"review feedback"
|
|
1992
|
+
),
|
|
1993
|
+
readOptionalJsonFile(
|
|
1994
|
+
globalReviewTurnResolvedFile(id, turnId),
|
|
1995
|
+
isResolutionBundle,
|
|
1996
|
+
"review resolution"
|
|
1997
|
+
)
|
|
1998
|
+
]);
|
|
1999
|
+
return reconcileTurn(meta, diff, feedback, resolution);
|
|
2000
|
+
}
|
|
2001
|
+
async loadLegacyTurn(id, storedMeta) {
|
|
2002
|
+
const diffPath = globalReviewDiffFile(id);
|
|
2003
|
+
let diffRaw;
|
|
2004
|
+
try {
|
|
2005
|
+
diffRaw = await readFile3(diffPath, "utf8");
|
|
2006
|
+
} catch (error) {
|
|
2007
|
+
if (isFileNotFound(error)) {
|
|
1204
2008
|
return null;
|
|
1205
2009
|
}
|
|
1206
|
-
throw new Error(`Could not load review ${id}: ${
|
|
2010
|
+
throw new Error(`Could not load legacy review ${id}: ${formatError(error)}`, {
|
|
2011
|
+
cause: error
|
|
2012
|
+
});
|
|
1207
2013
|
}
|
|
1208
|
-
const meta = parseJsonFile(metaRaw, isStoredReviewMeta, "review metadata", metaPath);
|
|
1209
2014
|
const diff = parseJsonFile(diffRaw, isDiffPayload, "review diff", diffPath);
|
|
1210
|
-
const feedback = await
|
|
1211
|
-
globalReviewFeedbackFile(id),
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
);
|
|
1215
|
-
const
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
resolution
|
|
2015
|
+
const [feedback, resolution] = await Promise.all([
|
|
2016
|
+
readOptionalJsonFile(globalReviewFeedbackFile(id), isFeedbackBundle, "review feedback"),
|
|
2017
|
+
readOptionalJsonFile(globalReviewResolvedFile(id), isResolutionBundle, "review resolution")
|
|
2018
|
+
]);
|
|
2019
|
+
const artifactDir = storedMeta.artifactDir ?? globalReviewDir(id);
|
|
2020
|
+
const legacySummary = storedMeta.turns?.find(
|
|
2021
|
+
(turn) => turn.artifactDir === artifactDir || turn.diffPath === diffPath
|
|
2022
|
+
) ?? storedMeta.turns?.find((turn) => turn.index === 1) ?? storedMeta.turns?.[0];
|
|
2023
|
+
const meta = {
|
|
2024
|
+
id: legacySummary?.id ?? storedMeta.activeTurnId ?? "turn-1",
|
|
2025
|
+
index: legacySummary?.index ?? 1,
|
|
2026
|
+
status: legacySummary?.status ?? storedMeta.status,
|
|
2027
|
+
createdAt: legacySummary?.createdAt ?? storedMeta.createdAt,
|
|
2028
|
+
submittedAt: legacySummary?.submittedAt ?? storedMeta.submittedAt,
|
|
2029
|
+
resolvedAt: legacySummary?.resolvedAt ?? storedMeta.resolvedAt,
|
|
2030
|
+
artifactDir: legacySummary?.artifactDir ?? artifactDir,
|
|
2031
|
+
diffPath,
|
|
2032
|
+
...feedback ? { feedbackPath: globalReviewFeedbackFile(id), markdownPath: globalReviewMarkdownFile(id) } : {},
|
|
2033
|
+
...resolution ? { resolvedPath: globalReviewResolvedFile(id) } : {}
|
|
1230
2034
|
};
|
|
1231
|
-
|
|
1232
|
-
return record;
|
|
2035
|
+
return reconcileTurn(meta, diff, feedback, resolution);
|
|
1233
2036
|
}
|
|
1234
|
-
assertResolvable(
|
|
1235
|
-
if (!isResolvableReviewStatus(
|
|
1236
|
-
throw new Error(`Review ${id} is ${
|
|
2037
|
+
assertResolvable(turn, id) {
|
|
2038
|
+
if (!isResolvableReviewStatus(turn.status)) {
|
|
2039
|
+
throw new Error(`Review ${id} turn ${turn.index} is ${turn.status} and cannot be resolved`);
|
|
1237
2040
|
}
|
|
1238
|
-
if (!
|
|
1239
|
-
throw new Error(`Review ${id} has no submitted feedback`);
|
|
2041
|
+
if (!turn.feedback) {
|
|
2042
|
+
throw new Error(`Review ${id} turn ${turn.index} has no submitted feedback`);
|
|
1240
2043
|
}
|
|
1241
2044
|
}
|
|
1242
|
-
|
|
1243
|
-
if (!
|
|
1244
|
-
|
|
2045
|
+
resolveTurnSelector(record, selector) {
|
|
2046
|
+
if (!selector) {
|
|
2047
|
+
return activeTurn(record);
|
|
1245
2048
|
}
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
2049
|
+
const turn = record.turns.find((candidate) => candidate.id === selector) ?? record.turns.find((candidate) => String(candidate.index) === selector);
|
|
2050
|
+
if (!turn) {
|
|
2051
|
+
throw new Error(`Turn ${selector} not found in review ${record.meta.id}`);
|
|
2052
|
+
}
|
|
2053
|
+
return turn;
|
|
2054
|
+
}
|
|
2055
|
+
findTurnForComment(record, commentId) {
|
|
2056
|
+
return [...record.turns].reverse().find(
|
|
2057
|
+
(candidate) => candidate.feedback?.comments.some((comment) => comment.id === commentId)
|
|
2058
|
+
) ?? null;
|
|
2059
|
+
}
|
|
2060
|
+
async persistResolution(record, turn, resolution, reason) {
|
|
2061
|
+
const resolvedPath = globalReviewTurnResolvedFile(record.meta.id, turn.id);
|
|
2062
|
+
const nextTurn = {
|
|
2063
|
+
...turn,
|
|
2064
|
+
resolvedPath,
|
|
2065
|
+
resolution
|
|
2066
|
+
};
|
|
2067
|
+
const nextRecord = normalizeRecord(replaceTurn(record, nextTurn));
|
|
2068
|
+
this.reviews.set(record.meta.id, nextRecord);
|
|
2069
|
+
await ensureDir(globalReviewTurnDir(record.meta.id, nextTurn.id));
|
|
1252
2070
|
await Promise.all([
|
|
1253
2071
|
writeJsonFile(resolvedPath, resolution),
|
|
1254
|
-
writeJsonFile(
|
|
2072
|
+
writeJsonFile(globalReviewTurnMetaFile(record.meta.id, nextTurn.id), turnMeta(nextTurn))
|
|
1255
2073
|
]);
|
|
2074
|
+
await this.persistMeta(nextRecord);
|
|
1256
2075
|
const result = {
|
|
1257
2076
|
ok: true,
|
|
1258
2077
|
reviewId: record.meta.id,
|
|
1259
|
-
|
|
2078
|
+
turnId: nextTurn.id,
|
|
2079
|
+
turnIndex: nextTurn.index,
|
|
2080
|
+
status: nextTurn.status,
|
|
1260
2081
|
resolutionStatus: resolution.status,
|
|
1261
|
-
comments: resolutionCounts(
|
|
2082
|
+
comments: resolutionCounts(nextTurn.feedback, resolution.comments),
|
|
1262
2083
|
path: resolvedPath,
|
|
1263
2084
|
resolution
|
|
1264
2085
|
};
|
|
1265
2086
|
this.emit({
|
|
1266
2087
|
type: "review.updated",
|
|
1267
2088
|
reviewId: record.meta.id,
|
|
2089
|
+
turnId: nextTurn.id,
|
|
2090
|
+
turnIndex: nextTurn.index,
|
|
1268
2091
|
reason,
|
|
1269
2092
|
status: result.status,
|
|
1270
2093
|
resolutionStatus: result.resolutionStatus,
|
|
@@ -1272,24 +2095,134 @@ var ReviewStore = class {
|
|
|
1272
2095
|
});
|
|
1273
2096
|
return result;
|
|
1274
2097
|
}
|
|
1275
|
-
sortResolvedComments(comments,
|
|
2098
|
+
sortResolvedComments(comments, turn) {
|
|
1276
2099
|
const feedbackIndex = new Map(
|
|
1277
|
-
|
|
1278
|
-
);
|
|
1279
|
-
return comments.filter((comment) => feedbackIndex.has(comment.commentId)).sort(
|
|
1280
|
-
(a, b) => (feedbackIndex.get(a.commentId) ?? Number.MAX_SAFE_INTEGER) - (feedbackIndex.get(b.commentId) ?? Number.MAX_SAFE_INTEGER)
|
|
2100
|
+
turn.feedback.comments.map((comment, index) => [comment.id, index])
|
|
1281
2101
|
);
|
|
2102
|
+
return comments.map((comment) => ({ comment, index: feedbackIndex.get(comment.commentId) })).filter(
|
|
2103
|
+
(entry) => entry.index !== void 0
|
|
2104
|
+
).sort((a, b) => a.index - b.index).map(({ comment }) => comment);
|
|
1282
2105
|
}
|
|
1283
2106
|
};
|
|
2107
|
+
function createTurn(reviewId, index, diff, createdAt) {
|
|
2108
|
+
const id = ulid();
|
|
2109
|
+
return {
|
|
2110
|
+
id,
|
|
2111
|
+
index,
|
|
2112
|
+
status: "pending",
|
|
2113
|
+
createdAt,
|
|
2114
|
+
artifactDir: globalReviewTurnDir(reviewId, id),
|
|
2115
|
+
diffPath: globalReviewTurnDiffFile(reviewId, id),
|
|
2116
|
+
diff
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
2119
|
+
function normalizeRecord(record) {
|
|
2120
|
+
const turns = record.turns.toSorted((a, b) => a.index - b.index);
|
|
2121
|
+
const active = turns.find((turn) => turn.id === record.meta.activeTurnId) ?? turns[turns.length - 1];
|
|
2122
|
+
const meta = {
|
|
2123
|
+
...record.meta,
|
|
2124
|
+
base: active.diff.base,
|
|
2125
|
+
branch: active.diff.branch,
|
|
2126
|
+
status: active.status,
|
|
2127
|
+
submittedAt: active.submittedAt,
|
|
2128
|
+
resolvedAt: active.resolvedAt,
|
|
2129
|
+
artifactDir: record.meta.artifactDir ?? globalReviewDir(record.meta.id),
|
|
2130
|
+
activeTurnId: active.id,
|
|
2131
|
+
turns: turns.map(turnSummary),
|
|
2132
|
+
feedbackPath: active.feedbackPath,
|
|
2133
|
+
markdownPath: active.markdownPath
|
|
2134
|
+
};
|
|
2135
|
+
return {
|
|
2136
|
+
meta,
|
|
2137
|
+
turns,
|
|
2138
|
+
diff: active.diff,
|
|
2139
|
+
...active.feedback ? { feedback: active.feedback } : {},
|
|
2140
|
+
...active.resolution ? { resolution: active.resolution } : {}
|
|
2141
|
+
};
|
|
2142
|
+
}
|
|
2143
|
+
function replaceTurn(record, nextTurn) {
|
|
2144
|
+
return {
|
|
2145
|
+
...record,
|
|
2146
|
+
turns: record.turns.map((turn) => turn.id === nextTurn.id ? nextTurn : turn)
|
|
2147
|
+
};
|
|
2148
|
+
}
|
|
2149
|
+
function activeTurn(record) {
|
|
2150
|
+
return record.turns.find((turn) => turn.id === record.meta.activeTurnId) ?? record.turns[record.turns.length - 1];
|
|
2151
|
+
}
|
|
2152
|
+
function latestTurn(record) {
|
|
2153
|
+
return record.turns.toSorted((a, b) => a.index - b.index)[record.turns.length - 1];
|
|
2154
|
+
}
|
|
2155
|
+
function turnMeta(turn) {
|
|
2156
|
+
return {
|
|
2157
|
+
id: turn.id,
|
|
2158
|
+
index: turn.index,
|
|
2159
|
+
status: turn.status,
|
|
2160
|
+
createdAt: turn.createdAt,
|
|
2161
|
+
submittedAt: turn.submittedAt,
|
|
2162
|
+
resolvedAt: turn.resolvedAt,
|
|
2163
|
+
artifactDir: turn.artifactDir,
|
|
2164
|
+
diffPath: turn.diffPath,
|
|
2165
|
+
feedbackPath: turn.feedbackPath,
|
|
2166
|
+
markdownPath: turn.markdownPath,
|
|
2167
|
+
resolvedPath: turn.resolvedPath
|
|
2168
|
+
};
|
|
2169
|
+
}
|
|
2170
|
+
function turnSummary(turn) {
|
|
2171
|
+
return {
|
|
2172
|
+
...turnMeta(turn),
|
|
2173
|
+
capturedAt: turn.diff.capturedAt,
|
|
2174
|
+
stats: turn.diff.stats,
|
|
2175
|
+
comments: resolutionCounts(turn.feedback, turn.resolution?.comments ?? [])
|
|
2176
|
+
};
|
|
2177
|
+
}
|
|
2178
|
+
function reconcileTurn(meta, diff, feedback, resolution) {
|
|
2179
|
+
const status = resolution?.status === "resolved" ? "resolved" : feedback ? "submitted" : "pending";
|
|
2180
|
+
return {
|
|
2181
|
+
...meta,
|
|
2182
|
+
status,
|
|
2183
|
+
submittedAt: feedback?.timestamp ?? meta.submittedAt,
|
|
2184
|
+
resolvedAt: status === "resolved" ? resolution?.resolvedAt ?? meta.resolvedAt : void 0,
|
|
2185
|
+
feedbackPath: feedback ? meta.feedbackPath ?? path4.join(meta.artifactDir, "feedback.json") : void 0,
|
|
2186
|
+
markdownPath: feedback ? meta.markdownPath ?? path4.join(meta.artifactDir, "feedback.md") : void 0,
|
|
2187
|
+
resolvedPath: resolution ? meta.resolvedPath ?? path4.join(meta.artifactDir, "resolved.json") : void 0,
|
|
2188
|
+
diff,
|
|
2189
|
+
...feedback ? { feedback } : {},
|
|
2190
|
+
...resolution ? { resolution } : {}
|
|
2191
|
+
};
|
|
2192
|
+
}
|
|
2193
|
+
function mergeRecoveredTurns(legacyTurn, persistedTurns) {
|
|
2194
|
+
const turns = legacyTurn && !persistedTurns.some((turn) => turn.id === legacyTurn.id || turn.index === legacyTurn.index) ? [legacyTurn, ...persistedTurns] : persistedTurns;
|
|
2195
|
+
return turns.toSorted((a, b) => a.index - b.index);
|
|
2196
|
+
}
|
|
2197
|
+
function diffFingerprint(diff) {
|
|
2198
|
+
return createHash("sha256").update(
|
|
2199
|
+
JSON.stringify({
|
|
2200
|
+
base: diff.base,
|
|
2201
|
+
branch: diff.branch,
|
|
2202
|
+
cwd: diff.cwd,
|
|
2203
|
+
scope: diff.scope,
|
|
2204
|
+
rawDiff: diff.rawDiff
|
|
2205
|
+
})
|
|
2206
|
+
).digest("hex");
|
|
2207
|
+
}
|
|
2208
|
+
function sameComments(left, right) {
|
|
2209
|
+
return JSON.stringify(left.toSorted(compareCommentsByLocation)) === JSON.stringify(right.toSorted(compareCommentsByLocation));
|
|
2210
|
+
}
|
|
2211
|
+
function requiredPath(value, label) {
|
|
2212
|
+
if (!value) {
|
|
2213
|
+
throw new Error(`Submitted review is missing ${label}`);
|
|
2214
|
+
}
|
|
2215
|
+
return value;
|
|
2216
|
+
}
|
|
1284
2217
|
async function readOptionalJsonFile(filePath, guard, label) {
|
|
1285
2218
|
let raw;
|
|
1286
2219
|
try {
|
|
1287
|
-
raw = await
|
|
2220
|
+
raw = await readFile3(filePath, "utf8");
|
|
1288
2221
|
} catch (error) {
|
|
1289
|
-
if (
|
|
2222
|
+
if (isFileNotFound(error)) {
|
|
1290
2223
|
return void 0;
|
|
1291
2224
|
}
|
|
1292
|
-
throw new Error(`Could not read ${label} at ${filePath}: ${
|
|
2225
|
+
throw new Error(`Could not read ${label} at ${filePath}: ${formatError(error)}`, {
|
|
1293
2226
|
cause: error
|
|
1294
2227
|
});
|
|
1295
2228
|
}
|
|
@@ -1299,15 +2232,9 @@ function parseJsonFile(raw, guard, label, filePath) {
|
|
|
1299
2232
|
try {
|
|
1300
2233
|
return parseJson(raw, guard, label);
|
|
1301
2234
|
} catch (error) {
|
|
1302
|
-
throw new Error(`Invalid ${label} at ${filePath}: ${
|
|
2235
|
+
throw new Error(`Invalid ${label} at ${filePath}: ${formatError(error)}`, { cause: error });
|
|
1303
2236
|
}
|
|
1304
2237
|
}
|
|
1305
|
-
function isFileNotFound2(error) {
|
|
1306
|
-
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
1307
|
-
}
|
|
1308
|
-
function formatError2(error) {
|
|
1309
|
-
return error instanceof Error ? error.message : String(error);
|
|
1310
|
-
}
|
|
1311
2238
|
var reviewStore = new ReviewStore();
|
|
1312
2239
|
|
|
1313
2240
|
// src/cli/status.ts
|
|
@@ -1335,31 +2262,61 @@ function printPlain(value) {
|
|
|
1335
2262
|
}
|
|
1336
2263
|
var program = new Command();
|
|
1337
2264
|
program.name("gloss").description("Local browser-based diff review for coding-agent loops.").version(packageVersion).option("--json", "print JSON for supported commands").option("--no-color", "disable color output");
|
|
1338
|
-
program.command("open").description("Capture local changes and open them for review").option("--base <ref>", "explicit base git ref").option("--print-url", "print review URL").option("--no-open", "do not open a browser").option("--no-watch", "return immediately after registering the review").option("--timeout <seconds>", "watch timeout in seconds", Number).action(
|
|
2265
|
+
program.command("open").description("Capture local changes and open them for review").option("--base <ref>", "explicit base git ref").option("--review <reviewId>", "append or resume a turn in an existing review").option("--print-url", "print review URL").option("--no-open", "do not open a browser").option("--no-watch", "return immediately after registering the review").option("--timeout <seconds>", "watch timeout in seconds", Number).action(
|
|
1339
2266
|
async (options) => {
|
|
1340
2267
|
const globals = program.opts();
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
const
|
|
1344
|
-
const
|
|
2268
|
+
let info = await ensureServer();
|
|
2269
|
+
let client = new ServerClient(serverUrl(info));
|
|
2270
|
+
const inheritedBase = options.review && !options.base ? await baseForExistingReview(client, options.review) : null;
|
|
2271
|
+
const diff = await captureDiff(options.base ?? inheritedBase ?? void 0);
|
|
2272
|
+
const created = options.review ? await client.appendReviewTurn(options.review, diff) : await client.createReview(diff);
|
|
2273
|
+
const meta = created.meta;
|
|
2274
|
+
const turn = created.turn ?? meta.turns?.find((summary) => summary.id === meta.activeTurnId);
|
|
2275
|
+
if (!turn) {
|
|
2276
|
+
throw new Error(`Review ${meta.id} has no active turn`);
|
|
2277
|
+
}
|
|
2278
|
+
const reused = "reused" in created ? created.reused === true : false;
|
|
2279
|
+
let url = created.url;
|
|
2280
|
+
const shouldWatch = options.watch !== false;
|
|
1345
2281
|
if (options.printUrl) {
|
|
1346
2282
|
printPlain(url);
|
|
1347
2283
|
}
|
|
1348
2284
|
if (options.open !== false) {
|
|
1349
2285
|
await openBrowser(url);
|
|
1350
2286
|
}
|
|
1351
|
-
if (
|
|
2287
|
+
if (!shouldWatch) {
|
|
1352
2288
|
const result2 = {
|
|
1353
2289
|
reviewId: meta.id,
|
|
2290
|
+
turnId: turn.id,
|
|
2291
|
+
turnIndex: turn.index,
|
|
1354
2292
|
url,
|
|
1355
2293
|
files: diff.files.length,
|
|
1356
2294
|
scope: diff.scope.mode,
|
|
1357
|
-
artifactDir:
|
|
2295
|
+
artifactDir: turn.artifactDir,
|
|
2296
|
+
reused
|
|
1358
2297
|
};
|
|
1359
2298
|
globals.json ? printJson(result2) : printPlain(`Review ${meta.id}: ${url}`);
|
|
1360
2299
|
return;
|
|
1361
2300
|
}
|
|
1362
|
-
const
|
|
2301
|
+
const watched = await watchReviewWithReconnect(
|
|
2302
|
+
meta.id,
|
|
2303
|
+
info,
|
|
2304
|
+
options.timeout,
|
|
2305
|
+
async (nextInfo) => {
|
|
2306
|
+
info = nextInfo;
|
|
2307
|
+
client = new ServerClient(serverUrl(info));
|
|
2308
|
+
url = `${serverUrl(info)}/review/${meta.id}`;
|
|
2309
|
+
if (options.printUrl) {
|
|
2310
|
+
printPlain(url);
|
|
2311
|
+
}
|
|
2312
|
+
if (options.open !== false) {
|
|
2313
|
+
await openBrowser(url);
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
);
|
|
2317
|
+
info = watched.info;
|
|
2318
|
+
client = new ServerClient(serverUrl(info));
|
|
2319
|
+
const event = watched.event;
|
|
1363
2320
|
if (event.type === "review.cancelled") {
|
|
1364
2321
|
process.exitCode = 2;
|
|
1365
2322
|
globals.json ? printJson(event) : printPlain(`Review ${meta.id} cancelled`);
|
|
@@ -1368,16 +2325,26 @@ program.command("open").description("Capture local changes and open them for rev
|
|
|
1368
2325
|
if (event.type !== "review.submitted") {
|
|
1369
2326
|
throw new Error(`Unexpected review event ${event.type}`);
|
|
1370
2327
|
}
|
|
1371
|
-
const feedback = await
|
|
2328
|
+
const [feedback, submittedRecord] = await Promise.all([
|
|
2329
|
+
client.getFeedback(meta.id),
|
|
2330
|
+
client.getReview(meta.id)
|
|
2331
|
+
]);
|
|
2332
|
+
const submittedTurn = submittedRecord.meta.turns?.find((summary) => summary.id === (event.turnId ?? turn.id)) ?? turn;
|
|
2333
|
+
if (!submittedTurn.feedbackPath || !submittedTurn.markdownPath) {
|
|
2334
|
+
throw new Error(`Review ${meta.id} turn ${submittedTurn.index} is missing feedback paths`);
|
|
2335
|
+
}
|
|
1372
2336
|
const result = {
|
|
1373
2337
|
reviewId: meta.id,
|
|
2338
|
+
turnId: submittedTurn.id,
|
|
2339
|
+
turnIndex: submittedTurn.index,
|
|
1374
2340
|
url,
|
|
1375
2341
|
files: event.counts.files,
|
|
1376
2342
|
comments: event.counts.comments,
|
|
1377
|
-
feedbackPath:
|
|
1378
|
-
markdownPath:
|
|
1379
|
-
artifactDir:
|
|
1380
|
-
feedback
|
|
2343
|
+
feedbackPath: submittedTurn.feedbackPath,
|
|
2344
|
+
markdownPath: submittedTurn.markdownPath,
|
|
2345
|
+
artifactDir: submittedTurn.artifactDir,
|
|
2346
|
+
feedback,
|
|
2347
|
+
reused
|
|
1381
2348
|
};
|
|
1382
2349
|
globals.json ? printJson(result) : printPlain(`Review ${meta.id} submitted with ${event.counts.comments} comments`);
|
|
1383
2350
|
}
|
|
@@ -1385,8 +2352,12 @@ program.command("open").description("Capture local changes and open them for rev
|
|
|
1385
2352
|
program.command("watch").argument("<reviewId>", "review id").description("Wait for review.submitted for an existing review").option("--timeout <seconds>", "watch timeout in seconds", Number).action(async (reviewId, options) => {
|
|
1386
2353
|
const globals = program.opts();
|
|
1387
2354
|
const info = await ensureServer();
|
|
1388
|
-
const
|
|
1389
|
-
|
|
2355
|
+
const { event } = await watchReviewWithReconnect(
|
|
2356
|
+
reviewId,
|
|
2357
|
+
info,
|
|
2358
|
+
options.timeout,
|
|
2359
|
+
async () => void 0
|
|
2360
|
+
);
|
|
1390
2361
|
globals.json ? printJson(event) : printPlain(`${event.type} ${event.reviewId}`);
|
|
1391
2362
|
});
|
|
1392
2363
|
program.command("start").description("Start or reuse the background server").option("--port <port>", "port to bind", Number).action(async (options) => {
|
|
@@ -1404,28 +2375,46 @@ program.command("status").description("Show server and active reviews").action(a
|
|
|
1404
2375
|
responsive && info ? `Gloss server running at ${serverUrl(info)} with ${reviews.length} active review(s)` : "Gloss server is not running"
|
|
1405
2376
|
);
|
|
1406
2377
|
});
|
|
1407
|
-
program.command("stop").description("Stop the managed background server").action(async () => {
|
|
2378
|
+
program.command("stop").description("Stop the managed background server").option("--all", "stop all Gloss daemon processes for the current user").action(async (options) => {
|
|
1408
2379
|
const globals = program.opts();
|
|
1409
|
-
const result = await stopServer();
|
|
1410
|
-
globals.json ? printJson(result) : printPlain(
|
|
2380
|
+
const result = await stopServer({ all: options.all });
|
|
2381
|
+
globals.json ? printJson(result) : printPlain(
|
|
2382
|
+
options.all && result.stoppedPids ? `Stopped ${result.stoppedPids.length} Gloss daemon(s)` : result.stopped ? "Gloss server stopped" : "Gloss server was not running"
|
|
2383
|
+
);
|
|
1411
2384
|
});
|
|
1412
|
-
program.command("
|
|
2385
|
+
program.command("clear").description("Delete old completed review artifacts").option(
|
|
2386
|
+
"--older-than <days>",
|
|
2387
|
+
"delete completed reviews older than this many days",
|
|
2388
|
+
parseOlderThanDays,
|
|
2389
|
+
DEFAULT_REVIEW_RETENTION_DAYS
|
|
2390
|
+
).option("--dry-run", "print cleanup candidates without deleting them").action(async (options) => {
|
|
1413
2391
|
const globals = program.opts();
|
|
1414
|
-
const
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
commentId: options.comment ?? null,
|
|
1420
|
-
summary: options.summary ?? null,
|
|
1421
|
-
...result
|
|
1422
|
-
});
|
|
1423
|
-
return;
|
|
1424
|
-
}
|
|
1425
|
-
printPlain(
|
|
1426
|
-
options.comment ? `Comment ${options.comment} resolved in review ${reviewId}` : `Review ${reviewId} resolved`
|
|
1427
|
-
);
|
|
2392
|
+
const result = await clearReviews({
|
|
2393
|
+
olderThanDays: options.olderThan,
|
|
2394
|
+
dryRun: options.dryRun === true
|
|
2395
|
+
});
|
|
2396
|
+
globals.json ? printJson(result) : printPlain(formatClearResult(result));
|
|
1428
2397
|
});
|
|
2398
|
+
program.command("resolve").argument("<reviewId>", "review id").description("Mark a submitted review or one feedback comment as resolved").option("--comment <commentId>", "resolve one submitted feedback comment").option("--summary <text>", "brief summary of the fixes applied").option("--turn <idOrIndex>", "resolve a specific turn for whole-review resolution").action(
|
|
2399
|
+
async (reviewId, options) => {
|
|
2400
|
+
const globals = program.opts();
|
|
2401
|
+
const info = await ensureServer();
|
|
2402
|
+
const client = new ServerClient(serverUrl(info));
|
|
2403
|
+
const result = options.comment ? await client.resolveComment(reviewId, options.comment, options.summary) : await client.markResolved(reviewId, options.summary, options.turn);
|
|
2404
|
+
if (globals.json) {
|
|
2405
|
+
printJson({
|
|
2406
|
+
commentId: options.comment ?? null,
|
|
2407
|
+
summary: options.summary ?? null,
|
|
2408
|
+
turn: options.turn ?? null,
|
|
2409
|
+
...result
|
|
2410
|
+
});
|
|
2411
|
+
return;
|
|
2412
|
+
}
|
|
2413
|
+
printPlain(
|
|
2414
|
+
options.comment ? `Comment ${options.comment} resolved in review ${reviewId}` : `Review ${reviewId} resolved`
|
|
2415
|
+
);
|
|
2416
|
+
}
|
|
2417
|
+
);
|
|
1429
2418
|
program.command("doctor").description("Diagnose setup and validate git/state").action(async () => {
|
|
1430
2419
|
const globals = program.opts();
|
|
1431
2420
|
const checks = [];
|
|
@@ -1455,6 +2444,21 @@ program.command("doctor").description("Diagnose setup and validate git/state").a
|
|
|
1455
2444
|
ok: info ? await isServerResponsive(info) : false,
|
|
1456
2445
|
detail: info ? serverUrl(info) : "not started"
|
|
1457
2446
|
});
|
|
2447
|
+
try {
|
|
2448
|
+
const daemonPids = await listGlossDaemonPids();
|
|
2449
|
+
const unmanagedDaemonPids = daemonPids.filter((pid) => pid !== info?.pid);
|
|
2450
|
+
checks.push({
|
|
2451
|
+
name: "daemon-processes",
|
|
2452
|
+
ok: unmanagedDaemonPids.length === 0,
|
|
2453
|
+
detail: daemonPids.length === 0 ? "none" : `${daemonPids.length} found${unmanagedDaemonPids.length > 0 ? `; unmanaged pids ${unmanagedDaemonPids.join(", ")}` : ""}`
|
|
2454
|
+
});
|
|
2455
|
+
} catch (error) {
|
|
2456
|
+
checks.push({
|
|
2457
|
+
name: "daemon-processes",
|
|
2458
|
+
ok: false,
|
|
2459
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
2460
|
+
});
|
|
2461
|
+
}
|
|
1458
2462
|
if (globals.json) {
|
|
1459
2463
|
printJson({ checks });
|
|
1460
2464
|
} else {
|
|
@@ -1465,6 +2469,66 @@ program.command("doctor").description("Diagnose setup and validate git/state").a
|
|
|
1465
2469
|
}
|
|
1466
2470
|
}
|
|
1467
2471
|
});
|
|
2472
|
+
async function watchReviewWithReconnect(reviewId, initialInfo, timeoutSeconds, onServerChanged) {
|
|
2473
|
+
const startedAt = Date.now();
|
|
2474
|
+
let info = initialInfo;
|
|
2475
|
+
while (true) {
|
|
2476
|
+
const remainingSeconds = timeoutSeconds && timeoutSeconds > 0 ? timeoutSeconds - (Date.now() - startedAt) / 1e3 : void 0;
|
|
2477
|
+
if (remainingSeconds !== void 0 && remainingSeconds <= 0) {
|
|
2478
|
+
throw new Error(`watch timed out after ${timeoutSeconds} seconds`);
|
|
2479
|
+
}
|
|
2480
|
+
try {
|
|
2481
|
+
const event = await new ServerClient(serverUrl(info)).watchReview(reviewId, remainingSeconds);
|
|
2482
|
+
return { event, info };
|
|
2483
|
+
} catch (error) {
|
|
2484
|
+
if (isWatchTimeout(error)) {
|
|
2485
|
+
throw error;
|
|
2486
|
+
}
|
|
2487
|
+
if (!isReconnectableWatchError(error)) {
|
|
2488
|
+
throw error;
|
|
2489
|
+
}
|
|
2490
|
+
await sleep2(500);
|
|
2491
|
+
const nextInfo = await ensureServer();
|
|
2492
|
+
if (nextInfo.port !== info.port) {
|
|
2493
|
+
await onServerChanged(nextInfo);
|
|
2494
|
+
}
|
|
2495
|
+
info = nextInfo;
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
async function baseForExistingReview(client, reviewId) {
|
|
2500
|
+
const record = await client.getReview(reviewId);
|
|
2501
|
+
return record.diff.scope.mode === "explicit" ? record.diff.scope.requestedBase ?? record.diff.base.ref : null;
|
|
2502
|
+
}
|
|
2503
|
+
async function clearReviews(options) {
|
|
2504
|
+
const info = await readServerInfo();
|
|
2505
|
+
if (info && await isServerResponsive(info)) {
|
|
2506
|
+
return new ServerClient(serverUrl(info)).clearReviews(options);
|
|
2507
|
+
}
|
|
2508
|
+
return clearReviewArtifacts(options);
|
|
2509
|
+
}
|
|
2510
|
+
function parseOlderThanDays(value) {
|
|
2511
|
+
const days = Number(value);
|
|
2512
|
+
if (!Number.isInteger(days) || days < 0) {
|
|
2513
|
+
throw new Error("--older-than must be a non-negative integer");
|
|
2514
|
+
}
|
|
2515
|
+
return days;
|
|
2516
|
+
}
|
|
2517
|
+
function formatClearResult(result) {
|
|
2518
|
+
const action = result.dryRun ? "Would delete" : "Deleted";
|
|
2519
|
+
const count = result.dryRun ? result.counts.candidates : result.counts.deleted;
|
|
2520
|
+
const skipped = result.counts.skipped > 0 ? `; skipped ${result.counts.skipped} invalid review artifact(s)` : "";
|
|
2521
|
+
return `${action} ${count} review artifact(s) older than ${result.olderThanDays} day(s) from ${result.reviewsDir}${skipped}`;
|
|
2522
|
+
}
|
|
2523
|
+
function isWatchTimeout(error) {
|
|
2524
|
+
return error instanceof Error && /^watch timed out after/.test(error.message);
|
|
2525
|
+
}
|
|
2526
|
+
function isReconnectableWatchError(error) {
|
|
2527
|
+
return error instanceof Error && !/^watch failed: [45]\d\d /.test(error.message);
|
|
2528
|
+
}
|
|
2529
|
+
async function sleep2(milliseconds) {
|
|
2530
|
+
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
2531
|
+
}
|
|
1468
2532
|
program.parseAsync(process.argv).catch((error) => {
|
|
1469
2533
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}
|
|
1470
2534
|
`);
|