getgloss 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -18
- package/dist/cli/index.js +1009 -220
- package/dist/cli/index.js.map +1 -1
- package/dist/server/daemon.js +1126 -188
- package/dist/server/daemon.js.map +1 -1
- package/dist/web/assets/index-DBK7WjlV.css +1 -0
- package/dist/web/assets/index-DkylW082.js +244 -0
- package/dist/web/assets/syntax-DdLzhglo.js +13 -0
- package/dist/web/index.html +2 -2
- package/dist/web/prompt.md +14 -6
- package/dist/web/setup.md +24 -8
- package/package.json +4 -1
- package/skill/SKILL.md +32 -11
- package/dist/web/assets/index-Dj8yXNw3.css +0 -1
- package/dist/web/assets/index-mwzx-OHz.js +0 -189
package/dist/cli/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import path from "path";
|
|
|
12
12
|
// package.json
|
|
13
13
|
var package_default = {
|
|
14
14
|
name: "getgloss",
|
|
15
|
-
version: "0.
|
|
15
|
+
version: "0.8.0",
|
|
16
16
|
description: "Local browser-based diff review for coding-agent loops.",
|
|
17
17
|
type: "module",
|
|
18
18
|
packageManager: "pnpm@10.33.2",
|
|
@@ -43,6 +43,8 @@ var package_default = {
|
|
|
43
43
|
},
|
|
44
44
|
dependencies: {
|
|
45
45
|
"@hono/node-server": "1.19.14",
|
|
46
|
+
"@shikijs/langs": "4.1.0",
|
|
47
|
+
"@shikijs/themes": "4.1.0",
|
|
46
48
|
"@tailwindcss/vite": "4.3.0",
|
|
47
49
|
commander: "14.0.3",
|
|
48
50
|
execa: "9.6.1",
|
|
@@ -52,6 +54,7 @@ var package_default = {
|
|
|
52
54
|
open: "10.2.0",
|
|
53
55
|
react: "19.2.6",
|
|
54
56
|
"react-dom": "19.2.6",
|
|
57
|
+
shiki: "4.1.0",
|
|
55
58
|
ulid: "3.0.2",
|
|
56
59
|
zustand: "5.0.13"
|
|
57
60
|
},
|
|
@@ -117,6 +120,27 @@ function globalReviewsDir() {
|
|
|
117
120
|
function globalReviewDir(reviewId) {
|
|
118
121
|
return path.join(globalReviewsDir(), reviewId);
|
|
119
122
|
}
|
|
123
|
+
function globalReviewTurnsDir(reviewId) {
|
|
124
|
+
return path.join(globalReviewDir(reviewId), "turns");
|
|
125
|
+
}
|
|
126
|
+
function globalReviewTurnDir(reviewId, turnId) {
|
|
127
|
+
return path.join(globalReviewTurnsDir(reviewId), turnId);
|
|
128
|
+
}
|
|
129
|
+
function globalReviewTurnMetaFile(reviewId, turnId) {
|
|
130
|
+
return path.join(globalReviewTurnDir(reviewId, turnId), "turn.json");
|
|
131
|
+
}
|
|
132
|
+
function globalReviewTurnDiffFile(reviewId, turnId) {
|
|
133
|
+
return path.join(globalReviewTurnDir(reviewId, turnId), "diff.json");
|
|
134
|
+
}
|
|
135
|
+
function globalReviewTurnFeedbackFile(reviewId, turnId) {
|
|
136
|
+
return path.join(globalReviewTurnDir(reviewId, turnId), "feedback.json");
|
|
137
|
+
}
|
|
138
|
+
function globalReviewTurnMarkdownFile(reviewId, turnId) {
|
|
139
|
+
return path.join(globalReviewTurnDir(reviewId, turnId), "feedback.md");
|
|
140
|
+
}
|
|
141
|
+
function globalReviewTurnResolvedFile(reviewId, turnId) {
|
|
142
|
+
return path.join(globalReviewTurnDir(reviewId, turnId), "resolved.json");
|
|
143
|
+
}
|
|
120
144
|
function globalReviewMetaFile(reviewId) {
|
|
121
145
|
return path.join(globalReviewDir(reviewId), "meta.json");
|
|
122
146
|
}
|
|
@@ -169,7 +193,7 @@ function languageForPath(filePath) {
|
|
|
169
193
|
return languageByExtension[ext] ?? ext;
|
|
170
194
|
}
|
|
171
195
|
|
|
172
|
-
// src/
|
|
196
|
+
// src/shared/diff-parser.ts
|
|
173
197
|
var hunkHeaderPattern = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/;
|
|
174
198
|
function stripGitPath(input) {
|
|
175
199
|
return input.replace(/^[ab]\//, "");
|
|
@@ -297,6 +321,18 @@ function parseUnifiedDiff(diffText) {
|
|
|
297
321
|
return files;
|
|
298
322
|
}
|
|
299
323
|
|
|
324
|
+
// src/shared/diff-stats.ts
|
|
325
|
+
function summarizeDiffFiles(files) {
|
|
326
|
+
return files.reduce(
|
|
327
|
+
(stats, file) => ({
|
|
328
|
+
files: stats.files + 1,
|
|
329
|
+
additions: stats.additions + file.additions,
|
|
330
|
+
deletions: stats.deletions + file.deletions
|
|
331
|
+
}),
|
|
332
|
+
{ files: 0, additions: 0, deletions: 0 }
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
300
336
|
// src/cli/git.ts
|
|
301
337
|
var DIFF_ARGS = ["diff", "--no-color", "--find-renames", "--find-copies"];
|
|
302
338
|
async function git(args, cwd = process.cwd()) {
|
|
@@ -317,16 +353,6 @@ async function gitLenient(args, cwd) {
|
|
|
317
353
|
async function getRepoRoot(cwd = process.cwd()) {
|
|
318
354
|
return git(["rev-parse", "--show-toplevel"], cwd);
|
|
319
355
|
}
|
|
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
356
|
function buildPayload({
|
|
331
357
|
repoRoot,
|
|
332
358
|
branch,
|
|
@@ -335,7 +361,8 @@ function buildPayload({
|
|
|
335
361
|
mode,
|
|
336
362
|
requestedBase,
|
|
337
363
|
comparison,
|
|
338
|
-
fallbackReason
|
|
364
|
+
fallbackReason,
|
|
365
|
+
commitDiffs
|
|
339
366
|
}) {
|
|
340
367
|
const files = parseUnifiedDiff(rawDiff);
|
|
341
368
|
return {
|
|
@@ -349,9 +376,10 @@ function buildPayload({
|
|
|
349
376
|
comparison,
|
|
350
377
|
fallbackReason
|
|
351
378
|
},
|
|
352
|
-
stats:
|
|
379
|
+
stats: summarizeDiffFiles(files),
|
|
353
380
|
rawDiff,
|
|
354
381
|
files,
|
|
382
|
+
...commitDiffs ? { commitDiffs } : {},
|
|
355
383
|
capturedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
356
384
|
};
|
|
357
385
|
}
|
|
@@ -407,6 +435,52 @@ async function resolveBranchBase(repoRoot) {
|
|
|
407
435
|
}
|
|
408
436
|
return null;
|
|
409
437
|
}
|
|
438
|
+
async function captureCommitDiffs(baseSha, comparisonRef, repoRoot) {
|
|
439
|
+
const rawLog = await gitMaybe(
|
|
440
|
+
[
|
|
441
|
+
"log",
|
|
442
|
+
"--reverse",
|
|
443
|
+
"--format=%H%x00%h%x00%an%x00%ae%x00%aI%x00%cI%x00%s%x1e",
|
|
444
|
+
`${baseSha}..${comparisonRef}`
|
|
445
|
+
],
|
|
446
|
+
repoRoot
|
|
447
|
+
);
|
|
448
|
+
if (!rawLog) {
|
|
449
|
+
return [];
|
|
450
|
+
}
|
|
451
|
+
const commits = rawLog.split("").map((entry) => entry.trim()).filter(Boolean).map((entry) => {
|
|
452
|
+
const [
|
|
453
|
+
sha = "",
|
|
454
|
+
shortSha2 = "",
|
|
455
|
+
authorName = "",
|
|
456
|
+
authorEmail = "",
|
|
457
|
+
authoredAt = "",
|
|
458
|
+
committedAt = "",
|
|
459
|
+
...subjectParts
|
|
460
|
+
] = entry.split("\0");
|
|
461
|
+
return {
|
|
462
|
+
sha,
|
|
463
|
+
shortSha: shortSha2,
|
|
464
|
+
subject: subjectParts.join("\0"),
|
|
465
|
+
authorName,
|
|
466
|
+
authorEmail,
|
|
467
|
+
authoredAt,
|
|
468
|
+
committedAt
|
|
469
|
+
};
|
|
470
|
+
}).filter((commit) => commit.sha && commit.shortSha);
|
|
471
|
+
const commitDiffs = [];
|
|
472
|
+
for (const commit of commits) {
|
|
473
|
+
const rawDiff = await git([...DIFF_ARGS, `${commit.sha}^`, commit.sha, "--"], repoRoot);
|
|
474
|
+
const files = parseUnifiedDiff(rawDiff);
|
|
475
|
+
commitDiffs.push({
|
|
476
|
+
commit,
|
|
477
|
+
stats: summarizeDiffFiles(files),
|
|
478
|
+
rawDiff,
|
|
479
|
+
files
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
return commitDiffs;
|
|
483
|
+
}
|
|
410
484
|
async function captureDiff(baseRef, cwd = process.cwd()) {
|
|
411
485
|
const repoRoot = await getRepoRoot(cwd);
|
|
412
486
|
const [headSha, branch] = await Promise.all([
|
|
@@ -455,7 +529,10 @@ async function captureDiff(baseRef, cwd = process.cwd()) {
|
|
|
455
529
|
fallbackReason: "missing-branch-base"
|
|
456
530
|
});
|
|
457
531
|
}
|
|
458
|
-
const rawDiff = await
|
|
532
|
+
const [rawDiff, commitDiffs] = await Promise.all([
|
|
533
|
+
git([...DIFF_ARGS, branchBase.mergeBaseSha, "HEAD", "--"], repoRoot),
|
|
534
|
+
captureCommitDiffs(branchBase.mergeBaseSha, "HEAD", repoRoot)
|
|
535
|
+
]);
|
|
459
536
|
return buildPayload({
|
|
460
537
|
repoRoot,
|
|
461
538
|
branch,
|
|
@@ -464,7 +541,8 @@ async function captureDiff(baseRef, cwd = process.cwd()) {
|
|
|
464
541
|
mode: "branch",
|
|
465
542
|
requestedBase: null,
|
|
466
543
|
comparison: { ref: "HEAD", sha: headSha },
|
|
467
|
-
fallbackReason: "working-tree-clean"
|
|
544
|
+
fallbackReason: "working-tree-clean",
|
|
545
|
+
commitDiffs
|
|
468
546
|
});
|
|
469
547
|
}
|
|
470
548
|
async function assertGitAvailable() {
|
|
@@ -472,33 +550,66 @@ async function assertGitAvailable() {
|
|
|
472
550
|
}
|
|
473
551
|
|
|
474
552
|
// src/cli/lifecycle.ts
|
|
475
|
-
import { spawn } from "child_process";
|
|
476
|
-
import { existsSync, openSync } from "fs";
|
|
477
|
-
import { rm } from "fs/promises";
|
|
553
|
+
import { execFile, spawn } from "child_process";
|
|
554
|
+
import { closeSync, existsSync, openSync } from "fs";
|
|
555
|
+
import { rm as rm2 } from "fs/promises";
|
|
556
|
+
import { userInfo } from "os";
|
|
478
557
|
import { fileURLToPath } from "url";
|
|
558
|
+
import { promisify } from "util";
|
|
479
559
|
import getPort from "get-port";
|
|
480
560
|
|
|
481
561
|
// src/shared/server-info.ts
|
|
482
562
|
import { readFile } from "fs/promises";
|
|
483
563
|
|
|
564
|
+
// src/shared/errors.ts
|
|
565
|
+
function formatError(error) {
|
|
566
|
+
return error instanceof Error ? error.message : String(error);
|
|
567
|
+
}
|
|
568
|
+
function isFileNotFound(error) {
|
|
569
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
570
|
+
}
|
|
571
|
+
|
|
484
572
|
// src/shared/json.ts
|
|
485
|
-
import {
|
|
573
|
+
import { randomUUID } from "crypto";
|
|
574
|
+
import { rename, rm, writeFile } from "fs/promises";
|
|
575
|
+
import path3 from "path";
|
|
486
576
|
function serializeJson(value) {
|
|
487
577
|
return `${JSON.stringify(value, null, 2)}
|
|
488
578
|
`;
|
|
489
579
|
}
|
|
490
580
|
async function writeJsonFile(filePath, value) {
|
|
491
|
-
await
|
|
581
|
+
await writeTextFile(filePath, serializeJson(value));
|
|
582
|
+
}
|
|
583
|
+
async function writeTextFile(filePath, value) {
|
|
584
|
+
const tempPath = path3.join(
|
|
585
|
+
path3.dirname(filePath),
|
|
586
|
+
`.${path3.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`
|
|
587
|
+
);
|
|
588
|
+
try {
|
|
589
|
+
await writeFile(tempPath, value);
|
|
590
|
+
await rename(tempPath, filePath);
|
|
591
|
+
} catch (error) {
|
|
592
|
+
await rm(tempPath, { force: true }).catch(() => void 0);
|
|
593
|
+
throw error;
|
|
594
|
+
}
|
|
492
595
|
}
|
|
493
596
|
|
|
597
|
+
// src/shared/types.ts
|
|
598
|
+
var SIDES = ["L", "R"];
|
|
599
|
+
var REVIEW_STATUSES = ["pending", "submitted", "cancelled", "resolved"];
|
|
600
|
+
var DIFF_LINE_TYPES = ["context", "add", "delete"];
|
|
601
|
+
var DIFF_SCOPE_MODES = ["working", "branch", "explicit"];
|
|
602
|
+
var DIFF_FALLBACK_REASONS = ["working-tree-clean", "missing-branch-base"];
|
|
603
|
+
var REVIEW_SCOPE_MODES = ["all", "single", "range"];
|
|
604
|
+
var RESOLUTION_STATUSES = ["partial", "resolved"];
|
|
605
|
+
var REVIEW_UPDATE_REASONS = [
|
|
606
|
+
"review-resolved",
|
|
607
|
+
"comment-resolved",
|
|
608
|
+
"comment-reopened",
|
|
609
|
+
"turn-created"
|
|
610
|
+
];
|
|
611
|
+
|
|
494
612
|
// 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
613
|
function parseJson(raw, guard, label) {
|
|
503
614
|
const parsed = JSON.parse(raw);
|
|
504
615
|
return parseJsonValue(parsed, guard, label);
|
|
@@ -516,34 +627,46 @@ function isHealthResponse(value) {
|
|
|
516
627
|
return isRecord(value) && isBoolean(value.ok) && isString(value.version) && isNumber(value.activeReviews);
|
|
517
628
|
}
|
|
518
629
|
function isCreateReviewResponse(value) {
|
|
519
|
-
return isRecord(value) &&
|
|
630
|
+
return isRecord(value) && hasReviewRegistrationFields(value) && isOptional(value.turn, isReviewTurnSummary);
|
|
631
|
+
}
|
|
632
|
+
function isCreateReviewTurnResponse(value) {
|
|
633
|
+
return isRecord(value) && hasReviewRegistrationFields(value) && isReviewTurnSummary(value.turn) && isBoolean(value.reused);
|
|
520
634
|
}
|
|
521
635
|
function isListReviewsResponse(value) {
|
|
522
636
|
return isRecord(value) && isArrayOf(value.reviews, isReviewMeta);
|
|
523
637
|
}
|
|
524
638
|
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);
|
|
639
|
+
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);
|
|
526
640
|
}
|
|
527
641
|
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);
|
|
642
|
+
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);
|
|
529
643
|
}
|
|
530
644
|
function isReviewRecord(value) {
|
|
531
|
-
return isRecord(value) && isReviewMeta(value.meta) && isDiffPayload(value.diff) && isOptional(value.feedback, isFeedbackBundle) && isOptional(value.resolution, isResolutionBundle);
|
|
645
|
+
return isRecord(value) && isReviewMeta(value.meta) && isArrayOf(value.turns, isReviewTurn) && isDiffPayload(value.diff) && isOptional(value.feedback, isFeedbackBundle) && isOptional(value.resolution, isResolutionBundle);
|
|
532
646
|
}
|
|
533
647
|
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.
|
|
648
|
+
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(
|
|
649
|
+
value.turns,
|
|
650
|
+
(turns) => isArrayOf(turns, isReviewTurnSummary)
|
|
651
|
+
) && isOptionalString(value.feedbackPath) && isOptionalString(value.markdownPath);
|
|
535
652
|
}
|
|
536
653
|
function isReviewMeta(value) {
|
|
537
654
|
return isStoredReviewMeta(value) && isString(value.artifactDir);
|
|
538
655
|
}
|
|
656
|
+
function hasReviewRegistrationFields(value) {
|
|
657
|
+
return isReviewMeta(value.meta) && isString(value.url);
|
|
658
|
+
}
|
|
539
659
|
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) &&
|
|
660
|
+
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(
|
|
661
|
+
value.commitDiffs,
|
|
662
|
+
(commitDiffs) => isArrayOf(commitDiffs, isCommitDiff)
|
|
663
|
+
) && isString(value.capturedAt);
|
|
541
664
|
}
|
|
542
665
|
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);
|
|
666
|
+
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);
|
|
544
667
|
}
|
|
545
668
|
function isResolutionBundle(value) {
|
|
546
|
-
return isRecord(value) && isString(value.reviewId) && isResolutionStatus(value.status) && isNullableString(value.summary) && isNullableString(value.resolvedAt) && isArrayOf(value.comments, isResolvedComment);
|
|
669
|
+
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);
|
|
547
670
|
}
|
|
548
671
|
function isReviewEvent(value) {
|
|
549
672
|
if (!isRecord(value) || !isString(value.reviewId) || !isString(value.type)) {
|
|
@@ -553,16 +676,33 @@ function isReviewEvent(value) {
|
|
|
553
676
|
case "review.opened":
|
|
554
677
|
case "review.cancelled":
|
|
555
678
|
return true;
|
|
679
|
+
case "review.turn.created":
|
|
680
|
+
return isString(value.turnId) && isNumber(value.turnIndex) && isBoolean(value.reused);
|
|
556
681
|
case "review.submitted":
|
|
557
|
-
return isRecord(value.counts) && isNumber(value.counts.files) && isNumber(value.counts.comments);
|
|
682
|
+
return isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isRecord(value.counts) && isNumber(value.counts.files) && isNumber(value.counts.comments);
|
|
558
683
|
case "review.updated":
|
|
559
|
-
return isReviewUpdateReason(value.reason) && isReviewStatus(value.status) && isResolutionStatus(value.resolutionStatus) && isResolutionCounts(value.counts);
|
|
684
|
+
return isOptionalString(value.turnId) && isOptionalNumber(value.turnIndex) && isReviewUpdateReason(value.reason) && isReviewStatus(value.status) && isResolutionStatus(value.resolutionStatus) && isResolutionCounts(value.counts);
|
|
560
685
|
default:
|
|
561
686
|
return false;
|
|
562
687
|
}
|
|
563
688
|
}
|
|
689
|
+
function isReviewTurnMeta(value) {
|
|
690
|
+
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);
|
|
691
|
+
}
|
|
692
|
+
function isReviewTurnSummary(value) {
|
|
693
|
+
if (!isRecord(value) || !isReviewTurnMeta(value)) {
|
|
694
|
+
return false;
|
|
695
|
+
}
|
|
696
|
+
return isString(value.capturedAt) && isDiffStats(value.stats) && isResolutionCounts(value.comments);
|
|
697
|
+
}
|
|
698
|
+
function isReviewTurn(value) {
|
|
699
|
+
if (!isRecord(value) || !isReviewTurnMeta(value)) {
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
return isDiffPayload(value.diff) && isOptional(value.feedback, isFeedbackBundle) && isOptional(value.resolution, isResolutionBundle);
|
|
703
|
+
}
|
|
564
704
|
function isDiffScope(value) {
|
|
565
|
-
return isRecord(value) && isOneOf(value.mode,
|
|
705
|
+
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));
|
|
566
706
|
}
|
|
567
707
|
function isDiffRef(value) {
|
|
568
708
|
return isRecord(value) && isString(value.ref) && isNullableString(value.sha);
|
|
@@ -573,6 +713,25 @@ function isBaseRef(value) {
|
|
|
573
713
|
function isDiffStats(value) {
|
|
574
714
|
return isRecord(value) && isNumber(value.files) && isNumber(value.additions) && isNumber(value.deletions);
|
|
575
715
|
}
|
|
716
|
+
function isDiffCommit(value) {
|
|
717
|
+
return isRecord(value) && isString(value.sha) && isString(value.shortSha) && isString(value.subject) && isString(value.authorName) && isString(value.authorEmail) && isString(value.authoredAt) && isString(value.committedAt);
|
|
718
|
+
}
|
|
719
|
+
function isCommitDiff(value) {
|
|
720
|
+
return isRecord(value) && isDiffCommit(value.commit) && isDiffStats(value.stats) && isString(value.rawDiff) && isArrayOf(value.files, isDiffFile);
|
|
721
|
+
}
|
|
722
|
+
function isReviewScope(value) {
|
|
723
|
+
if (!isRecord(value) || !isOneOf(value.mode, REVIEW_SCOPE_MODES)) {
|
|
724
|
+
return false;
|
|
725
|
+
}
|
|
726
|
+
switch (value.mode) {
|
|
727
|
+
case "all":
|
|
728
|
+
return true;
|
|
729
|
+
case "single":
|
|
730
|
+
return isString(value.sha);
|
|
731
|
+
case "range":
|
|
732
|
+
return isString(value.fromSha) && isString(value.toSha);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
576
735
|
function isDiffFile(value) {
|
|
577
736
|
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
737
|
}
|
|
@@ -580,10 +739,10 @@ function isDiffHunk(value) {
|
|
|
580
739
|
return isRecord(value) && isNumber(value.oldStart) && isNumber(value.oldLines) && isNumber(value.newStart) && isNumber(value.newLines) && isString(value.header) && isArrayOf(value.lines, isDiffLine);
|
|
581
740
|
}
|
|
582
741
|
function isDiffLine(value) {
|
|
583
|
-
return isRecord(value) && isOneOf(value.type,
|
|
742
|
+
return isRecord(value) && isOneOf(value.type, DIFF_LINE_TYPES) && isNullableNumber(value.oldLine) && isNullableNumber(value.newLine) && isString(value.content);
|
|
584
743
|
}
|
|
585
744
|
function isComment(value) {
|
|
586
|
-
return isRecord(value) && isString(value.id) && isString(value.filePath) && isNumber(value.startLine) && isNumber(value.endLine) && isOneOf(value.side,
|
|
745
|
+
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
746
|
}
|
|
588
747
|
function isResolvedComment(value) {
|
|
589
748
|
return isRecord(value) && isString(value.commentId) && value.status === "resolved" && isOptionalString(value.summary) && isString(value.resolvedAt);
|
|
@@ -592,13 +751,13 @@ function isResolutionCounts(value) {
|
|
|
592
751
|
return isRecord(value) && isNumber(value.total) && isNumber(value.resolved) && isNumber(value.open);
|
|
593
752
|
}
|
|
594
753
|
function isReviewStatus(value) {
|
|
595
|
-
return isOneOf(value,
|
|
754
|
+
return isOneOf(value, REVIEW_STATUSES);
|
|
596
755
|
}
|
|
597
756
|
function isResolutionStatus(value) {
|
|
598
|
-
return isOneOf(value,
|
|
757
|
+
return isOneOf(value, RESOLUTION_STATUSES);
|
|
599
758
|
}
|
|
600
759
|
function isReviewUpdateReason(value) {
|
|
601
|
-
return isOneOf(value,
|
|
760
|
+
return isOneOf(value, REVIEW_UPDATE_REASONS);
|
|
602
761
|
}
|
|
603
762
|
function isRecord(value) {
|
|
604
763
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -659,12 +818,6 @@ async function writeServerInfo(info) {
|
|
|
659
818
|
await ensureDir(globalStateDir());
|
|
660
819
|
await writeJsonFile(globalServerFile(), info);
|
|
661
820
|
}
|
|
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
821
|
|
|
669
822
|
// src/cli/server-client.ts
|
|
670
823
|
var ServerClient = class {
|
|
@@ -678,6 +831,14 @@ var ServerClient = class {
|
|
|
678
831
|
async createReview(diff) {
|
|
679
832
|
return this.post("/api/reviews", diff, isCreateReviewResponse, "create review response");
|
|
680
833
|
}
|
|
834
|
+
async appendReviewTurn(reviewId, diff) {
|
|
835
|
+
return this.post(
|
|
836
|
+
`/api/reviews/${reviewId}/turns`,
|
|
837
|
+
diff,
|
|
838
|
+
isCreateReviewTurnResponse,
|
|
839
|
+
"create review turn response"
|
|
840
|
+
);
|
|
841
|
+
}
|
|
681
842
|
async getReview(reviewId) {
|
|
682
843
|
return this.get(`/api/reviews/${reviewId}`, isReviewRecord, "review response");
|
|
683
844
|
}
|
|
@@ -687,8 +848,8 @@ var ServerClient = class {
|
|
|
687
848
|
async getFeedback(reviewId) {
|
|
688
849
|
return this.get(`/api/reviews/${reviewId}/feedback`, isFeedbackBundle, "feedback response");
|
|
689
850
|
}
|
|
690
|
-
async markResolved(reviewId, summary) {
|
|
691
|
-
const request = { summary };
|
|
851
|
+
async markResolved(reviewId, summary, turn) {
|
|
852
|
+
const request = { summary, turn };
|
|
692
853
|
return this.post(
|
|
693
854
|
`/api/reviews/${reviewId}/resolved`,
|
|
694
855
|
request,
|
|
@@ -778,20 +939,20 @@ var ServerClient = class {
|
|
|
778
939
|
}
|
|
779
940
|
}
|
|
780
941
|
}
|
|
781
|
-
async get(
|
|
782
|
-
const response = await fetch(`${this.baseUrl}${
|
|
942
|
+
async get(path5, guard, label) {
|
|
943
|
+
const response = await fetch(`${this.baseUrl}${path5}`);
|
|
783
944
|
return parseResponse(response, guard, label);
|
|
784
945
|
}
|
|
785
|
-
async post(
|
|
786
|
-
const response = await fetch(`${this.baseUrl}${
|
|
946
|
+
async post(path5, body, guard, label) {
|
|
947
|
+
const response = await fetch(`${this.baseUrl}${path5}`, {
|
|
787
948
|
method: "POST",
|
|
788
949
|
headers: { "content-type": "application/json" },
|
|
789
950
|
body: JSON.stringify(body)
|
|
790
951
|
});
|
|
791
952
|
return parseResponse(response, guard, label);
|
|
792
953
|
}
|
|
793
|
-
async delete(
|
|
794
|
-
const response = await fetch(`${this.baseUrl}${
|
|
954
|
+
async delete(path5, guard, label) {
|
|
955
|
+
const response = await fetch(`${this.baseUrl}${path5}`, { method: "DELETE" });
|
|
795
956
|
return parseResponse(response, guard, label);
|
|
796
957
|
}
|
|
797
958
|
};
|
|
@@ -806,13 +967,16 @@ function isPrematureWatchEnd(error) {
|
|
|
806
967
|
return error instanceof Error && error.message === "watch stream ended before completion";
|
|
807
968
|
}
|
|
808
969
|
function isAbortError(error) {
|
|
809
|
-
return
|
|
970
|
+
return error instanceof Error && error.name === "AbortError";
|
|
810
971
|
}
|
|
811
972
|
async function sleep(milliseconds) {
|
|
812
973
|
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
813
974
|
}
|
|
814
975
|
|
|
815
976
|
// src/cli/lifecycle.ts
|
|
977
|
+
var execFileAsync = promisify(execFile);
|
|
978
|
+
var gracefulShutdownTimeoutMs = 2e3;
|
|
979
|
+
var forceShutdownTimeoutMs = 1e3;
|
|
816
980
|
function serverUrl(info) {
|
|
817
981
|
return `http://localhost:${info.port}`;
|
|
818
982
|
}
|
|
@@ -839,9 +1003,23 @@ async function startServer(options = {}) {
|
|
|
839
1003
|
if (existing && await isServerResponsive(existing)) {
|
|
840
1004
|
return existing;
|
|
841
1005
|
}
|
|
1006
|
+
if (existing) {
|
|
1007
|
+
await retireServer(existing);
|
|
1008
|
+
}
|
|
1009
|
+
const preferredPort = options.port ?? existing?.port ?? await getPort();
|
|
1010
|
+
try {
|
|
1011
|
+
return await launchServer(preferredPort);
|
|
1012
|
+
} catch (error) {
|
|
1013
|
+
if (options.port || !existing?.port) {
|
|
1014
|
+
throw error;
|
|
1015
|
+
}
|
|
1016
|
+
await removeServerInfoForPid(existing.pid);
|
|
1017
|
+
return launchServer(await getPort());
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
async function launchServer(port) {
|
|
842
1021
|
await ensureDir(globalStateDir());
|
|
843
1022
|
await ensureDir(globalLogDir());
|
|
844
|
-
const port = options.port ?? await getPort();
|
|
845
1023
|
const daemonPath = fileURLToPath(new URL("../server/daemon.js", import.meta.url));
|
|
846
1024
|
if (!existsSync(daemonPath)) {
|
|
847
1025
|
throw new Error(`Cannot find server daemon at ${daemonPath}. Run pnpm build first.`);
|
|
@@ -856,6 +1034,7 @@ async function startServer(options = {}) {
|
|
|
856
1034
|
},
|
|
857
1035
|
stdio: ["ignore", logFd, logFd]
|
|
858
1036
|
});
|
|
1037
|
+
closeSync(logFd);
|
|
859
1038
|
child.unref();
|
|
860
1039
|
const info = {
|
|
861
1040
|
pid: child.pid ?? -1,
|
|
@@ -872,18 +1051,40 @@ async function startServer(options = {}) {
|
|
|
872
1051
|
}
|
|
873
1052
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
874
1053
|
}
|
|
1054
|
+
await terminatePid(info.pid);
|
|
1055
|
+
await removeServerInfoForPid(info.pid);
|
|
875
1056
|
throw new Error(`Server did not become responsive. See ${globalServerLogFile()}`);
|
|
876
1057
|
}
|
|
877
|
-
async function stopServer() {
|
|
1058
|
+
async function stopServer(options = {}) {
|
|
1059
|
+
if (options.all) {
|
|
1060
|
+
const info2 = await readServerInfo();
|
|
1061
|
+
const daemonPids = await listGlossDaemonPids();
|
|
1062
|
+
const stoppedPids = [];
|
|
1063
|
+
for (const pid of daemonPids) {
|
|
1064
|
+
if (await terminatePid(pid)) {
|
|
1065
|
+
stoppedPids.push(pid);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
await rm2(globalServerFile(), { force: true });
|
|
1069
|
+
return { stopped: stoppedPids.length > 0, info: info2, stoppedPids };
|
|
1070
|
+
}
|
|
878
1071
|
const info = await readServerInfo();
|
|
879
1072
|
if (!info) {
|
|
880
1073
|
return { stopped: false, info: null };
|
|
881
1074
|
}
|
|
882
|
-
if (isPidAlive(info.pid)) {
|
|
883
|
-
|
|
1075
|
+
if (!isPidAlive(info.pid)) {
|
|
1076
|
+
await removeServerInfoForPid(info.pid);
|
|
1077
|
+
return { stopped: false, info };
|
|
1078
|
+
}
|
|
1079
|
+
if (!await isGlossDaemonPid(info.pid)) {
|
|
1080
|
+
await removeServerInfoForPid(info.pid);
|
|
1081
|
+
return { stopped: false, info };
|
|
1082
|
+
}
|
|
1083
|
+
const stopped = await terminatePid(info.pid);
|
|
1084
|
+
if (stopped) {
|
|
1085
|
+
await removeServerInfoForPid(info.pid);
|
|
884
1086
|
}
|
|
885
|
-
|
|
886
|
-
return { stopped: true, info };
|
|
1087
|
+
return { stopped, info };
|
|
887
1088
|
}
|
|
888
1089
|
function isPidAlive(pid) {
|
|
889
1090
|
if (pid <= 0) {
|
|
@@ -896,9 +1097,86 @@ function isPidAlive(pid) {
|
|
|
896
1097
|
return false;
|
|
897
1098
|
}
|
|
898
1099
|
}
|
|
1100
|
+
async function retireServer(info) {
|
|
1101
|
+
if (isPidAlive(info.pid) && await isGlossDaemonPid(info.pid)) {
|
|
1102
|
+
await terminatePid(info.pid);
|
|
1103
|
+
}
|
|
1104
|
+
await removeServerInfoForPid(info.pid);
|
|
1105
|
+
}
|
|
1106
|
+
async function terminatePid(pid) {
|
|
1107
|
+
if (!isPidAlive(pid)) {
|
|
1108
|
+
return true;
|
|
1109
|
+
}
|
|
1110
|
+
try {
|
|
1111
|
+
process.kill(pid, "SIGTERM");
|
|
1112
|
+
} catch {
|
|
1113
|
+
return !isPidAlive(pid);
|
|
1114
|
+
}
|
|
1115
|
+
if (await waitForPidExit(pid, gracefulShutdownTimeoutMs)) {
|
|
1116
|
+
return true;
|
|
1117
|
+
}
|
|
1118
|
+
try {
|
|
1119
|
+
process.kill(pid, "SIGKILL");
|
|
1120
|
+
} catch {
|
|
1121
|
+
return !isPidAlive(pid);
|
|
1122
|
+
}
|
|
1123
|
+
return waitForPidExit(pid, forceShutdownTimeoutMs);
|
|
1124
|
+
}
|
|
1125
|
+
async function waitForPidExit(pid, timeoutMs) {
|
|
1126
|
+
const deadline = Date.now() + timeoutMs;
|
|
1127
|
+
while (Date.now() < deadline) {
|
|
1128
|
+
if (!isPidAlive(pid)) {
|
|
1129
|
+
return true;
|
|
1130
|
+
}
|
|
1131
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1132
|
+
}
|
|
1133
|
+
return !isPidAlive(pid);
|
|
1134
|
+
}
|
|
1135
|
+
async function removeServerInfoForPid(pid) {
|
|
1136
|
+
const current = await readServerInfo().catch(() => null);
|
|
1137
|
+
if (!current || current.pid === pid) {
|
|
1138
|
+
await rm2(globalServerFile(), { force: true });
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
async function isGlossDaemonPid(pid) {
|
|
1142
|
+
const command = await readProcessCommand(pid);
|
|
1143
|
+
return command ? isGlossDaemonCommand(command) : false;
|
|
1144
|
+
}
|
|
1145
|
+
async function readProcessCommand(pid) {
|
|
1146
|
+
try {
|
|
1147
|
+
const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(pid), "-ww"]);
|
|
1148
|
+
return stdout.trim() || null;
|
|
1149
|
+
} catch {
|
|
1150
|
+
return null;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
async function listGlossDaemonPids() {
|
|
1154
|
+
let stdout;
|
|
1155
|
+
try {
|
|
1156
|
+
({ stdout } = await execFileAsync("ps", ["-axo", "pid=,user=,command=", "-ww"]));
|
|
1157
|
+
} catch {
|
|
1158
|
+
return [];
|
|
1159
|
+
}
|
|
1160
|
+
const currentUser = userInfo().username;
|
|
1161
|
+
return parseGlossDaemonPids(stdout, currentUser, process.pid);
|
|
1162
|
+
}
|
|
1163
|
+
function parseGlossDaemonPids(stdout, currentUser, currentPid = process.pid) {
|
|
1164
|
+
return stdout.split("\n").map((line) => /^\s*(\d+)\s+(\S+)\s+(.+)$/.exec(line)).filter((match) => Boolean(match)).map((match) => ({
|
|
1165
|
+
pid: Number(match[1]),
|
|
1166
|
+
user: match[2],
|
|
1167
|
+
command: match[3]
|
|
1168
|
+
})).filter(
|
|
1169
|
+
({ pid, user, command }) => pid !== currentPid && user === currentUser && isGlossDaemonCommand(command)
|
|
1170
|
+
).map(({ pid }) => pid);
|
|
1171
|
+
}
|
|
1172
|
+
function isGlossDaemonCommand(command) {
|
|
1173
|
+
return /(?:^|\s)(?:\S*\/)?node\s+\S*dist\/server\/daemon\.js(?:\s|$)/.test(command);
|
|
1174
|
+
}
|
|
899
1175
|
|
|
900
1176
|
// src/server/store.ts
|
|
901
|
-
import {
|
|
1177
|
+
import { createHash } from "crypto";
|
|
1178
|
+
import { readdir, readFile as readFile2 } from "fs/promises";
|
|
1179
|
+
import path4 from "path";
|
|
902
1180
|
import { ulid } from "ulid";
|
|
903
1181
|
|
|
904
1182
|
// src/shared/comments.ts
|
|
@@ -928,6 +1206,60 @@ function resolutionCounts(feedback, resolvedComments = []) {
|
|
|
928
1206
|
};
|
|
929
1207
|
}
|
|
930
1208
|
|
|
1209
|
+
// src/shared/review-scope.ts
|
|
1210
|
+
var ALL_REVIEW_SCOPE = { mode: "all" };
|
|
1211
|
+
function normalizeReviewScope(diff, scope = ALL_REVIEW_SCOPE) {
|
|
1212
|
+
if (scope.mode === "all") {
|
|
1213
|
+
return ALL_REVIEW_SCOPE;
|
|
1214
|
+
}
|
|
1215
|
+
const commitDiffs = diff.commitDiffs ?? [];
|
|
1216
|
+
if (commitDiffs.length === 0) {
|
|
1217
|
+
throw new Error("Review scope requires a review with per-commit diffs");
|
|
1218
|
+
}
|
|
1219
|
+
if (scope.mode === "single") {
|
|
1220
|
+
const commit = commitDiffs.find((commitDiff) => commitDiff.commit.sha === scope.sha);
|
|
1221
|
+
if (!commit) {
|
|
1222
|
+
throw new Error("Review scope must use commits from this review");
|
|
1223
|
+
}
|
|
1224
|
+
return { mode: "single", sha: commit.commit.sha };
|
|
1225
|
+
}
|
|
1226
|
+
const fromIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.fromSha);
|
|
1227
|
+
const toIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.toSha);
|
|
1228
|
+
if (fromIndex < 0 || toIndex < 0) {
|
|
1229
|
+
throw new Error("Review scope must use commits from this review");
|
|
1230
|
+
}
|
|
1231
|
+
if (fromIndex > toIndex) {
|
|
1232
|
+
throw new Error("Review scope range must be in review order");
|
|
1233
|
+
}
|
|
1234
|
+
return {
|
|
1235
|
+
mode: "range",
|
|
1236
|
+
fromSha: commitDiffs[fromIndex].commit.sha,
|
|
1237
|
+
toSha: commitDiffs[toIndex].commit.sha
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
function sameReviewScope(left, right) {
|
|
1241
|
+
return JSON.stringify(left ?? ALL_REVIEW_SCOPE) === JSON.stringify(right ?? ALL_REVIEW_SCOPE);
|
|
1242
|
+
}
|
|
1243
|
+
function reviewScopeLabel(scope = ALL_REVIEW_SCOPE, commitDiffs = []) {
|
|
1244
|
+
if (scope.mode === "all") {
|
|
1245
|
+
return "All commits";
|
|
1246
|
+
}
|
|
1247
|
+
if (scope.mode === "single") {
|
|
1248
|
+
const commit = commitDiffs.find((commitDiff) => commitDiff.commit.sha === scope.sha);
|
|
1249
|
+
return commit ? `${commit.commit.shortSha} ${commit.commit.subject}` : `Commit ${shortSha(scope.sha)}`;
|
|
1250
|
+
}
|
|
1251
|
+
const fromIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.fromSha);
|
|
1252
|
+
const toIndex = commitDiffs.findIndex((commitDiff) => commitDiff.commit.sha === scope.toSha);
|
|
1253
|
+
if (fromIndex >= 0 && toIndex >= fromIndex) {
|
|
1254
|
+
const count = toIndex - fromIndex + 1;
|
|
1255
|
+
return `${count} commits \xB7 ${commitDiffs[fromIndex].commit.shortSha} to ${commitDiffs[toIndex].commit.shortSha}`;
|
|
1256
|
+
}
|
|
1257
|
+
return `Commit range ${shortSha(scope.fromSha)} to ${shortSha(scope.toSha)}`;
|
|
1258
|
+
}
|
|
1259
|
+
function shortSha(sha) {
|
|
1260
|
+
return sha.slice(0, 7);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
931
1263
|
// src/shared/markdown.ts
|
|
932
1264
|
function fenceFor(snippet) {
|
|
933
1265
|
let fence = "```";
|
|
@@ -942,20 +1274,32 @@ function languageForSnippet(filePath, snippet) {
|
|
|
942
1274
|
return looksLikeUnifiedDiff ? "diff" : languageForPath(filePath) ?? "";
|
|
943
1275
|
}
|
|
944
1276
|
function serializeFeedbackMarkdown(bundle) {
|
|
945
|
-
const comments =
|
|
946
|
-
const
|
|
1277
|
+
const comments = bundle.comments.toSorted(compareCommentsByLocation);
|
|
1278
|
+
const commentsByFile = /* @__PURE__ */ new Map();
|
|
1279
|
+
const files = [];
|
|
1280
|
+
for (const comment of comments) {
|
|
1281
|
+
const fileComments = commentsByFile.get(comment.filePath);
|
|
1282
|
+
if (fileComments) {
|
|
1283
|
+
fileComments.push(comment);
|
|
1284
|
+
} else {
|
|
1285
|
+
commentsByFile.set(comment.filePath, [comment]);
|
|
1286
|
+
files.push(comment.filePath);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
947
1289
|
const lines = [
|
|
948
1290
|
`# Gloss feedback - ${bundle.timestamp}`,
|
|
949
1291
|
`Review: ${bundle.reviewId}`,
|
|
1292
|
+
...bundle.turnIndex ? [`Turn: ${bundle.turnIndex} (${bundle.turnId ?? "unknown"})`] : [],
|
|
1293
|
+
...bundle.reviewScope ? [`Review scope: ${reviewScopeLabel(bundle.reviewScope)}`] : [],
|
|
950
1294
|
`Base: ${bundle.base.ref} (${bundle.base.sha.slice(0, 7)}) Branch: ${bundle.branch ?? "(detached)"}`,
|
|
951
1295
|
`Files: ${files.length} Comments: ${comments.length}`,
|
|
952
1296
|
""
|
|
953
1297
|
];
|
|
954
1298
|
for (const filePath of files) {
|
|
955
1299
|
lines.push(`## ${filePath}`, "");
|
|
956
|
-
for (const comment of
|
|
1300
|
+
for (const comment of commentsByFile.get(filePath) ?? []) {
|
|
957
1301
|
const snippet = comment.originalSnippet.trimEnd();
|
|
958
|
-
const firstSnippetLine = snippet
|
|
1302
|
+
const firstSnippetLine = firstNonEmptyLine(snippet);
|
|
959
1303
|
const heading = comment.startLine === comment.endLine && firstSnippetLine ? `### ${formatLineRange(comment)} - \`${firstSnippetLine.trim().slice(0, 80)}\`` : `### ${formatLineRange(comment)}`;
|
|
960
1304
|
lines.push(heading, comment.body.trim(), "");
|
|
961
1305
|
if (snippet) {
|
|
@@ -967,6 +1311,14 @@ function serializeFeedbackMarkdown(bundle) {
|
|
|
967
1311
|
return `${lines.join("\n").trimEnd()}
|
|
968
1312
|
`;
|
|
969
1313
|
}
|
|
1314
|
+
function firstNonEmptyLine(text) {
|
|
1315
|
+
for (const line of text.split("\n")) {
|
|
1316
|
+
if (line.trim().length > 0) {
|
|
1317
|
+
return line;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
return void 0;
|
|
1321
|
+
}
|
|
970
1322
|
|
|
971
1323
|
// src/shared/reviews.ts
|
|
972
1324
|
function isResolvableReviewStatus(status) {
|
|
@@ -980,6 +1332,7 @@ var ReviewStore = class {
|
|
|
980
1332
|
async create(diff) {
|
|
981
1333
|
const id = ulid();
|
|
982
1334
|
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1335
|
+
const turn = createTurn(id, 1, diff, createdAt);
|
|
983
1336
|
const meta = {
|
|
984
1337
|
id,
|
|
985
1338
|
cwd: diff.cwd,
|
|
@@ -987,108 +1340,188 @@ var ReviewStore = class {
|
|
|
987
1340
|
branch: diff.branch,
|
|
988
1341
|
status: "pending",
|
|
989
1342
|
createdAt,
|
|
990
|
-
artifactDir: globalReviewDir(id)
|
|
1343
|
+
artifactDir: globalReviewDir(id),
|
|
1344
|
+
activeTurnId: turn.id
|
|
991
1345
|
};
|
|
992
|
-
const record = { meta, diff };
|
|
1346
|
+
const record = normalizeRecord({ meta, turns: [turn], diff: turn.diff });
|
|
993
1347
|
this.reviews.set(id, record);
|
|
994
|
-
await this.persistInitial(record);
|
|
1348
|
+
await this.persistInitial(record, turn);
|
|
995
1349
|
this.emit({ type: "review.opened", reviewId: id });
|
|
996
1350
|
return record;
|
|
997
1351
|
}
|
|
1352
|
+
async appendTurn(id, diff) {
|
|
1353
|
+
const record = await this.get(id);
|
|
1354
|
+
if (!record) {
|
|
1355
|
+
throw new Error(`Review ${id} not found`);
|
|
1356
|
+
}
|
|
1357
|
+
if (record.meta.cwd !== diff.cwd) {
|
|
1358
|
+
throw new Error(`Review ${id} belongs to ${record.meta.cwd}, not ${diff.cwd}`);
|
|
1359
|
+
}
|
|
1360
|
+
const latest = latestTurn(record);
|
|
1361
|
+
if (latest.status === "pending") {
|
|
1362
|
+
if (diffFingerprint(latest.diff) === diffFingerprint(diff)) {
|
|
1363
|
+
this.emit({
|
|
1364
|
+
type: "review.turn.created",
|
|
1365
|
+
reviewId: id,
|
|
1366
|
+
turnId: latest.id,
|
|
1367
|
+
turnIndex: latest.index,
|
|
1368
|
+
reused: true
|
|
1369
|
+
});
|
|
1370
|
+
return { record, turn: latest, reused: true };
|
|
1371
|
+
}
|
|
1372
|
+
throw new Error(`Review ${id} already has a pending turn`);
|
|
1373
|
+
}
|
|
1374
|
+
if (latest.status === "cancelled") {
|
|
1375
|
+
throw new Error(`Review ${id} is cancelled and cannot be continued`);
|
|
1376
|
+
}
|
|
1377
|
+
const turn = createTurn(id, latest.index + 1, diff, (/* @__PURE__ */ new Date()).toISOString());
|
|
1378
|
+
const nextRecord = normalizeRecord({
|
|
1379
|
+
...record,
|
|
1380
|
+
meta: { ...record.meta, activeTurnId: turn.id },
|
|
1381
|
+
turns: [...record.turns, turn]
|
|
1382
|
+
});
|
|
1383
|
+
this.reviews.set(id, nextRecord);
|
|
1384
|
+
await this.persistInitial(nextRecord, turn);
|
|
1385
|
+
this.emit({
|
|
1386
|
+
type: "review.turn.created",
|
|
1387
|
+
reviewId: id,
|
|
1388
|
+
turnId: turn.id,
|
|
1389
|
+
turnIndex: turn.index,
|
|
1390
|
+
reused: false
|
|
1391
|
+
});
|
|
1392
|
+
return { record: nextRecord, turn, reused: false };
|
|
1393
|
+
}
|
|
998
1394
|
async list() {
|
|
999
1395
|
await this.loadAllReviews();
|
|
1000
|
-
return
|
|
1396
|
+
return Array.from(this.reviews.values()).map((record) => record.meta).toSorted((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
1001
1397
|
}
|
|
1002
1398
|
async get(id) {
|
|
1003
1399
|
return this.reviews.get(id) ?? await this.loadKnownReview(id);
|
|
1004
1400
|
}
|
|
1005
|
-
async
|
|
1401
|
+
async getTurn(id, turnId) {
|
|
1402
|
+
const record = await this.get(id);
|
|
1403
|
+
return record?.turns.find((turn) => turn.id === turnId) ?? null;
|
|
1404
|
+
}
|
|
1405
|
+
async submit(id, comments, reviewScope) {
|
|
1006
1406
|
const record = await this.get(id);
|
|
1007
1407
|
if (!record) {
|
|
1008
1408
|
throw new Error(`Review ${id} not found`);
|
|
1009
1409
|
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1410
|
+
const turn = activeTurn(record);
|
|
1411
|
+
const sortedComments = comments.toSorted(compareCommentsByLocation);
|
|
1412
|
+
const normalizedReviewScope = normalizeReviewScope(turn.diff, reviewScope);
|
|
1413
|
+
if (turn.status !== "pending") {
|
|
1414
|
+
if (turn.feedback && sameComments(turn.feedback.comments, sortedComments) && sameReviewScope(turn.feedback.reviewScope, normalizedReviewScope)) {
|
|
1415
|
+
return {
|
|
1416
|
+
record,
|
|
1417
|
+
feedbackPath: requiredPath(turn.feedbackPath, "feedback path"),
|
|
1418
|
+
markdownPath: requiredPath(turn.markdownPath, "markdown path"),
|
|
1419
|
+
turn
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
throw new Error(`Review ${id} turn ${turn.index} is ${turn.status} and cannot be submitted`);
|
|
1012
1423
|
}
|
|
1013
1424
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
1425
|
+
const feedbackPath = globalReviewTurnFeedbackFile(id, turn.id);
|
|
1426
|
+
const markdownPath = globalReviewTurnMarkdownFile(id, turn.id);
|
|
1014
1427
|
const feedback = {
|
|
1015
1428
|
version: 1,
|
|
1016
1429
|
reviewId: id,
|
|
1430
|
+
turnId: turn.id,
|
|
1431
|
+
turnIndex: turn.index,
|
|
1017
1432
|
timestamp,
|
|
1018
|
-
base:
|
|
1019
|
-
branch:
|
|
1020
|
-
|
|
1433
|
+
base: turn.diff.base,
|
|
1434
|
+
branch: turn.diff.branch,
|
|
1435
|
+
reviewScope: normalizedReviewScope,
|
|
1436
|
+
comments: sortedComments
|
|
1021
1437
|
};
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
const feedbackPath = globalReviewFeedbackFile(id);
|
|
1027
|
-
const markdownPath = globalReviewMarkdownFile(id);
|
|
1028
|
-
record.meta = {
|
|
1029
|
-
...record.meta,
|
|
1030
|
-
artifactDir,
|
|
1438
|
+
const nextTurn = {
|
|
1439
|
+
...turn,
|
|
1440
|
+
status: "submitted",
|
|
1441
|
+
submittedAt: timestamp,
|
|
1031
1442
|
feedbackPath,
|
|
1032
|
-
markdownPath
|
|
1443
|
+
markdownPath,
|
|
1444
|
+
feedback
|
|
1033
1445
|
};
|
|
1034
|
-
|
|
1446
|
+
const nextRecord = normalizeRecord(replaceTurn(record, nextTurn));
|
|
1447
|
+
this.reviews.set(id, nextRecord);
|
|
1448
|
+
await ensureDir(globalReviewTurnDir(id, nextTurn.id));
|
|
1035
1449
|
await Promise.all([
|
|
1036
|
-
writeJsonFile(
|
|
1450
|
+
writeJsonFile(globalReviewTurnMetaFile(id, nextTurn.id), turnMeta(nextTurn)),
|
|
1037
1451
|
writeJsonFile(feedbackPath, feedback),
|
|
1038
|
-
|
|
1452
|
+
writeTextFile(markdownPath, serializeFeedbackMarkdown(feedback))
|
|
1039
1453
|
]);
|
|
1454
|
+
await this.persistMeta(nextRecord);
|
|
1040
1455
|
this.emit({
|
|
1041
1456
|
type: "review.submitted",
|
|
1042
1457
|
reviewId: id,
|
|
1458
|
+
turnId: nextTurn.id,
|
|
1459
|
+
turnIndex: nextTurn.index,
|
|
1043
1460
|
counts: {
|
|
1044
1461
|
files: countCommentFiles(feedback.comments),
|
|
1045
1462
|
comments: feedback.comments.length
|
|
1046
1463
|
}
|
|
1047
1464
|
});
|
|
1048
|
-
return { record, feedbackPath, markdownPath };
|
|
1465
|
+
return { record: nextRecord, feedbackPath, markdownPath, turn: nextTurn };
|
|
1049
1466
|
}
|
|
1050
1467
|
async feedback(id) {
|
|
1051
1468
|
const record = await this.get(id);
|
|
1052
1469
|
return record?.feedback ?? null;
|
|
1053
1470
|
}
|
|
1054
|
-
async markResolved(id, summary) {
|
|
1471
|
+
async markResolved(id, summary, turnSelector) {
|
|
1055
1472
|
const record = await this.get(id);
|
|
1056
1473
|
if (!record) {
|
|
1057
1474
|
throw new Error(`Review ${id} not found`);
|
|
1058
1475
|
}
|
|
1059
|
-
this.
|
|
1476
|
+
const turn = this.resolveTurnSelector(record, turnSelector);
|
|
1477
|
+
this.assertResolvable(turn, id);
|
|
1060
1478
|
const resolvedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1061
1479
|
const existingById = new Map(
|
|
1062
|
-
(
|
|
1480
|
+
(turn.resolution?.comments ?? []).map((comment) => [comment.commentId, comment])
|
|
1063
1481
|
);
|
|
1064
1482
|
const comments = this.sortResolvedComments(
|
|
1065
|
-
(
|
|
1483
|
+
(turn.feedback?.comments ?? []).map((comment) => ({
|
|
1066
1484
|
...existingById.get(comment.id),
|
|
1067
1485
|
commentId: comment.id,
|
|
1068
1486
|
status: "resolved",
|
|
1069
1487
|
resolvedAt: existingById.get(comment.id)?.resolvedAt ?? resolvedAt
|
|
1070
1488
|
})),
|
|
1071
|
-
|
|
1489
|
+
turn
|
|
1072
1490
|
);
|
|
1073
1491
|
const resolution = {
|
|
1074
1492
|
reviewId: id,
|
|
1493
|
+
turnId: turn.id,
|
|
1494
|
+
turnIndex: turn.index,
|
|
1075
1495
|
status: "resolved",
|
|
1076
|
-
summary: summary ??
|
|
1496
|
+
summary: summary ?? turn.resolution?.summary ?? null,
|
|
1077
1497
|
resolvedAt,
|
|
1078
1498
|
comments
|
|
1079
1499
|
};
|
|
1080
|
-
|
|
1081
|
-
|
|
1500
|
+
const nextTurn = {
|
|
1501
|
+
...turn,
|
|
1502
|
+
status: "resolved",
|
|
1503
|
+
resolvedAt
|
|
1504
|
+
};
|
|
1505
|
+
return this.persistResolution(record, nextTurn, resolution, "review-resolved");
|
|
1082
1506
|
}
|
|
1083
1507
|
async resolveComment(id, commentId, summary) {
|
|
1084
1508
|
const record = await this.get(id);
|
|
1085
1509
|
if (!record) {
|
|
1086
1510
|
throw new Error(`Review ${id} not found`);
|
|
1087
1511
|
}
|
|
1088
|
-
this.
|
|
1089
|
-
|
|
1512
|
+
const turn = this.findTurnForComment(record, commentId);
|
|
1513
|
+
if (!turn) {
|
|
1514
|
+
const currentTurn = activeTurn(record);
|
|
1515
|
+
if (!isResolvableReviewStatus(currentTurn.status)) {
|
|
1516
|
+
throw new Error(
|
|
1517
|
+
`Review ${id} turn ${currentTurn.index} is ${currentTurn.status} and cannot be resolved`
|
|
1518
|
+
);
|
|
1519
|
+
}
|
|
1520
|
+
throw new Error(`Comment ${commentId} not found`);
|
|
1521
|
+
}
|
|
1522
|
+
this.assertResolvable(turn, id);
|
|
1090
1523
|
const resolvedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1091
|
-
const previous =
|
|
1524
|
+
const previous = turn.resolution?.comments.find((comment) => comment.commentId === commentId);
|
|
1092
1525
|
const nextSummary = summary ?? previous?.summary;
|
|
1093
1526
|
const nextComment = {
|
|
1094
1527
|
commentId,
|
|
@@ -1098,46 +1531,59 @@ var ReviewStore = class {
|
|
|
1098
1531
|
};
|
|
1099
1532
|
const comments = this.sortResolvedComments(
|
|
1100
1533
|
[
|
|
1101
|
-
...(
|
|
1534
|
+
...(turn.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
|
|
1102
1535
|
nextComment
|
|
1103
1536
|
],
|
|
1104
|
-
|
|
1537
|
+
turn
|
|
1105
1538
|
);
|
|
1106
|
-
const counts = resolutionCounts(
|
|
1539
|
+
const counts = resolutionCounts(turn.feedback, comments);
|
|
1107
1540
|
const fullyResolved = counts.total === counts.resolved;
|
|
1108
1541
|
const resolution = {
|
|
1109
1542
|
reviewId: id,
|
|
1543
|
+
turnId: turn.id,
|
|
1544
|
+
turnIndex: turn.index,
|
|
1110
1545
|
status: fullyResolved ? "resolved" : "partial",
|
|
1111
|
-
summary: fullyResolved ?
|
|
1546
|
+
summary: fullyResolved ? turn.resolution?.summary ?? null : null,
|
|
1112
1547
|
resolvedAt: fullyResolved ? resolvedAt : null,
|
|
1113
1548
|
comments
|
|
1114
1549
|
};
|
|
1115
|
-
|
|
1116
|
-
return this.persistResolution(record, resolution, "comment-resolved");
|
|
1550
|
+
const nextTurn = fullyResolved ? { ...turn, status: "resolved", resolvedAt } : { ...turn, status: "submitted", resolvedAt: void 0 };
|
|
1551
|
+
return this.persistResolution(record, nextTurn, resolution, "comment-resolved");
|
|
1117
1552
|
}
|
|
1118
1553
|
async reopenComment(id, commentId) {
|
|
1119
1554
|
const record = await this.get(id);
|
|
1120
1555
|
if (!record) {
|
|
1121
1556
|
throw new Error(`Review ${id} not found`);
|
|
1122
1557
|
}
|
|
1123
|
-
this.
|
|
1124
|
-
|
|
1558
|
+
const turn = this.findTurnForComment(record, commentId);
|
|
1559
|
+
if (!turn) {
|
|
1560
|
+
const currentTurn = activeTurn(record);
|
|
1561
|
+
if (!isResolvableReviewStatus(currentTurn.status)) {
|
|
1562
|
+
throw new Error(
|
|
1563
|
+
`Review ${id} turn ${currentTurn.index} is ${currentTurn.status} and cannot be resolved`
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1566
|
+
throw new Error(`Comment ${commentId} not found`);
|
|
1567
|
+
}
|
|
1568
|
+
this.assertResolvable(turn, id);
|
|
1125
1569
|
const comments = this.sortResolvedComments(
|
|
1126
|
-
(
|
|
1127
|
-
|
|
1570
|
+
(turn.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
|
|
1571
|
+
turn
|
|
1128
1572
|
);
|
|
1129
|
-
const counts = resolutionCounts(
|
|
1573
|
+
const counts = resolutionCounts(turn.feedback, comments);
|
|
1130
1574
|
const fullyResolved = counts.total > 0 && counts.total === counts.resolved;
|
|
1131
1575
|
const resolvedAt = fullyResolved ? (/* @__PURE__ */ new Date()).toISOString() : null;
|
|
1132
1576
|
const resolution = {
|
|
1133
1577
|
reviewId: id,
|
|
1578
|
+
turnId: turn.id,
|
|
1579
|
+
turnIndex: turn.index,
|
|
1134
1580
|
status: fullyResolved ? "resolved" : "partial",
|
|
1135
|
-
summary: fullyResolved ?
|
|
1581
|
+
summary: fullyResolved ? turn.resolution?.summary ?? null : null,
|
|
1136
1582
|
resolvedAt,
|
|
1137
1583
|
comments
|
|
1138
1584
|
};
|
|
1139
|
-
|
|
1140
|
-
return this.persistResolution(record, resolution, "comment-reopened");
|
|
1585
|
+
const nextTurn = fullyResolved ? { ...turn, status: "resolved", resolvedAt: resolvedAt ?? void 0 } : { ...turn, status: "submitted", resolvedAt: void 0 };
|
|
1586
|
+
return this.persistResolution(record, nextTurn, resolution, "comment-reopened");
|
|
1141
1587
|
}
|
|
1142
1588
|
subscribe(reviewId, listener) {
|
|
1143
1589
|
const listeners = this.listeners.get(reviewId) ?? /* @__PURE__ */ new Set();
|
|
@@ -1155,13 +1601,17 @@ var ReviewStore = class {
|
|
|
1155
1601
|
listener(event);
|
|
1156
1602
|
}
|
|
1157
1603
|
}
|
|
1158
|
-
async persistInitial(record) {
|
|
1159
|
-
|
|
1160
|
-
await ensureDir(dir);
|
|
1604
|
+
async persistInitial(record, turn) {
|
|
1605
|
+
await ensureDir(turn.artifactDir);
|
|
1161
1606
|
await Promise.all([
|
|
1162
|
-
writeJsonFile(
|
|
1163
|
-
writeJsonFile(
|
|
1607
|
+
writeJsonFile(globalReviewTurnMetaFile(record.meta.id, turn.id), turnMeta(turn)),
|
|
1608
|
+
writeJsonFile(turn.diffPath, turn.diff)
|
|
1164
1609
|
]);
|
|
1610
|
+
await this.persistMeta(record);
|
|
1611
|
+
}
|
|
1612
|
+
async persistMeta(record) {
|
|
1613
|
+
await ensureDir(globalReviewDir(record.meta.id));
|
|
1614
|
+
await writeJsonFile(globalReviewMetaFile(record.meta.id), record.meta);
|
|
1165
1615
|
}
|
|
1166
1616
|
async loadKnownReview(id) {
|
|
1167
1617
|
const existing = this.reviews.get(id);
|
|
@@ -1175,23 +1625,106 @@ var ReviewStore = class {
|
|
|
1175
1625
|
try {
|
|
1176
1626
|
entries = await readdir(globalReviewsDir(), { withFileTypes: true });
|
|
1177
1627
|
} catch (error) {
|
|
1178
|
-
if (
|
|
1628
|
+
if (isFileNotFound(error)) {
|
|
1179
1629
|
return;
|
|
1180
1630
|
}
|
|
1181
1631
|
throw new Error(
|
|
1182
|
-
`Could not read reviews directory at ${globalReviewsDir()}: ${
|
|
1632
|
+
`Could not read reviews directory at ${globalReviewsDir()}: ${formatError(error)}`,
|
|
1183
1633
|
{
|
|
1184
1634
|
cause: error
|
|
1185
1635
|
}
|
|
1186
1636
|
);
|
|
1187
1637
|
}
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1638
|
+
const reviewLoads = [];
|
|
1639
|
+
for (const entry of entries) {
|
|
1640
|
+
if (entry.isDirectory()) {
|
|
1641
|
+
reviewLoads.push(this.loadReview(entry.name));
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
await Promise.all(reviewLoads);
|
|
1191
1645
|
}
|
|
1192
1646
|
async loadReview(id) {
|
|
1193
1647
|
const metaPath = globalReviewMetaFile(id);
|
|
1194
|
-
|
|
1648
|
+
let metaRaw;
|
|
1649
|
+
try {
|
|
1650
|
+
metaRaw = await readFile2(metaPath, "utf8");
|
|
1651
|
+
} catch (error) {
|
|
1652
|
+
if (isFileNotFound(error)) {
|
|
1653
|
+
return this.loadReviewFromTurnsOnly(id);
|
|
1654
|
+
}
|
|
1655
|
+
throw new Error(`Could not load review ${id}: ${formatError(error)}`, { cause: error });
|
|
1656
|
+
}
|
|
1657
|
+
const storedMeta = parseJsonFile(metaRaw, isStoredReviewMeta, "review metadata", metaPath);
|
|
1658
|
+
const persistedTurns = await this.loadPersistedTurns(id);
|
|
1659
|
+
const legacyTurn = await this.loadLegacyTurn(id, storedMeta);
|
|
1660
|
+
const turns = mergeRecoveredTurns(legacyTurn, persistedTurns);
|
|
1661
|
+
if (turns.length === 0) {
|
|
1662
|
+
throw new Error(`Review ${id} has no recoverable turns`);
|
|
1663
|
+
}
|
|
1664
|
+
const latest = latestTurn({ turns });
|
|
1665
|
+
const record = normalizeRecord({
|
|
1666
|
+
meta: {
|
|
1667
|
+
...storedMeta,
|
|
1668
|
+
artifactDir: storedMeta.artifactDir ?? globalReviewDir(id),
|
|
1669
|
+
activeTurnId: latest.id
|
|
1670
|
+
},
|
|
1671
|
+
turns,
|
|
1672
|
+
diff: latest.diff
|
|
1673
|
+
});
|
|
1674
|
+
this.reviews.set(id, record);
|
|
1675
|
+
return record;
|
|
1676
|
+
}
|
|
1677
|
+
async loadReviewFromTurnsOnly(id) {
|
|
1678
|
+
const turns = await this.loadPersistedTurns(id);
|
|
1679
|
+
if (turns.length === 0) {
|
|
1680
|
+
return null;
|
|
1681
|
+
}
|
|
1682
|
+
const latest = latestTurn({ turns });
|
|
1683
|
+
const record = normalizeRecord({
|
|
1684
|
+
meta: {
|
|
1685
|
+
id,
|
|
1686
|
+
cwd: latest.diff.cwd,
|
|
1687
|
+
base: latest.diff.base,
|
|
1688
|
+
branch: latest.diff.branch,
|
|
1689
|
+
status: latest.status,
|
|
1690
|
+
createdAt: turns[0]?.createdAt ?? latest.createdAt,
|
|
1691
|
+
artifactDir: globalReviewDir(id),
|
|
1692
|
+
activeTurnId: latest.id
|
|
1693
|
+
},
|
|
1694
|
+
turns,
|
|
1695
|
+
diff: latest.diff
|
|
1696
|
+
});
|
|
1697
|
+
this.reviews.set(id, record);
|
|
1698
|
+
await this.persistMeta(record);
|
|
1699
|
+
return record;
|
|
1700
|
+
}
|
|
1701
|
+
async loadPersistedTurns(id) {
|
|
1702
|
+
let entries;
|
|
1703
|
+
try {
|
|
1704
|
+
entries = await readdir(globalReviewTurnsDir(id), { withFileTypes: true });
|
|
1705
|
+
} catch (error) {
|
|
1706
|
+
if (isFileNotFound(error)) {
|
|
1707
|
+
return [];
|
|
1708
|
+
}
|
|
1709
|
+
throw new Error(`Could not read review turns for ${id}: ${formatError(error)}`, {
|
|
1710
|
+
cause: error
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
const turns = [];
|
|
1714
|
+
for (const entry of entries) {
|
|
1715
|
+
if (!entry.isDirectory()) {
|
|
1716
|
+
continue;
|
|
1717
|
+
}
|
|
1718
|
+
const turn = await this.loadPersistedTurn(id, entry.name);
|
|
1719
|
+
if (turn) {
|
|
1720
|
+
turns.push(turn);
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
return turns.toSorted((a, b) => a.index - b.index);
|
|
1724
|
+
}
|
|
1725
|
+
async loadPersistedTurn(id, turnId) {
|
|
1726
|
+
const metaPath = globalReviewTurnMetaFile(id, turnId);
|
|
1727
|
+
const diffPath = globalReviewTurnDiffFile(id, turnId);
|
|
1195
1728
|
let metaRaw;
|
|
1196
1729
|
let diffRaw;
|
|
1197
1730
|
try {
|
|
@@ -1200,71 +1733,119 @@ var ReviewStore = class {
|
|
|
1200
1733
|
readFile2(diffPath, "utf8")
|
|
1201
1734
|
]);
|
|
1202
1735
|
} catch (error) {
|
|
1203
|
-
if (
|
|
1736
|
+
if (isFileNotFound(error)) {
|
|
1737
|
+
return null;
|
|
1738
|
+
}
|
|
1739
|
+
throw new Error(`Could not load review ${id} turn ${turnId}: ${formatError(error)}`, {
|
|
1740
|
+
cause: error
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
const meta = parseJsonFile(metaRaw, isReviewTurnMeta, "review turn metadata", metaPath);
|
|
1744
|
+
const diff = parseJsonFile(diffRaw, isDiffPayload, "review turn diff", diffPath);
|
|
1745
|
+
const [feedback, resolution] = await Promise.all([
|
|
1746
|
+
readOptionalJsonFile(
|
|
1747
|
+
globalReviewTurnFeedbackFile(id, turnId),
|
|
1748
|
+
isFeedbackBundle,
|
|
1749
|
+
"review feedback"
|
|
1750
|
+
),
|
|
1751
|
+
readOptionalJsonFile(
|
|
1752
|
+
globalReviewTurnResolvedFile(id, turnId),
|
|
1753
|
+
isResolutionBundle,
|
|
1754
|
+
"review resolution"
|
|
1755
|
+
)
|
|
1756
|
+
]);
|
|
1757
|
+
return reconcileTurn(meta, diff, feedback, resolution);
|
|
1758
|
+
}
|
|
1759
|
+
async loadLegacyTurn(id, storedMeta) {
|
|
1760
|
+
const diffPath = globalReviewDiffFile(id);
|
|
1761
|
+
let diffRaw;
|
|
1762
|
+
try {
|
|
1763
|
+
diffRaw = await readFile2(diffPath, "utf8");
|
|
1764
|
+
} catch (error) {
|
|
1765
|
+
if (isFileNotFound(error)) {
|
|
1204
1766
|
return null;
|
|
1205
1767
|
}
|
|
1206
|
-
throw new Error(`Could not load review ${id}: ${
|
|
1768
|
+
throw new Error(`Could not load legacy review ${id}: ${formatError(error)}`, {
|
|
1769
|
+
cause: error
|
|
1770
|
+
});
|
|
1207
1771
|
}
|
|
1208
|
-
const meta = parseJsonFile(metaRaw, isStoredReviewMeta, "review metadata", metaPath);
|
|
1209
1772
|
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
|
|
1773
|
+
const [feedback, resolution] = await Promise.all([
|
|
1774
|
+
readOptionalJsonFile(globalReviewFeedbackFile(id), isFeedbackBundle, "review feedback"),
|
|
1775
|
+
readOptionalJsonFile(globalReviewResolvedFile(id), isResolutionBundle, "review resolution")
|
|
1776
|
+
]);
|
|
1777
|
+
const artifactDir = storedMeta.artifactDir ?? globalReviewDir(id);
|
|
1778
|
+
const legacySummary = storedMeta.turns?.find(
|
|
1779
|
+
(turn) => turn.artifactDir === artifactDir || turn.diffPath === diffPath
|
|
1780
|
+
) ?? storedMeta.turns?.find((turn) => turn.index === 1) ?? storedMeta.turns?.[0];
|
|
1781
|
+
const meta = {
|
|
1782
|
+
id: legacySummary?.id ?? storedMeta.activeTurnId ?? "turn-1",
|
|
1783
|
+
index: legacySummary?.index ?? 1,
|
|
1784
|
+
status: legacySummary?.status ?? storedMeta.status,
|
|
1785
|
+
createdAt: legacySummary?.createdAt ?? storedMeta.createdAt,
|
|
1786
|
+
submittedAt: legacySummary?.submittedAt ?? storedMeta.submittedAt,
|
|
1787
|
+
resolvedAt: legacySummary?.resolvedAt ?? storedMeta.resolvedAt,
|
|
1788
|
+
artifactDir: legacySummary?.artifactDir ?? artifactDir,
|
|
1789
|
+
diffPath,
|
|
1790
|
+
...feedback ? { feedbackPath: globalReviewFeedbackFile(id), markdownPath: globalReviewMarkdownFile(id) } : {},
|
|
1791
|
+
...resolution ? { resolvedPath: globalReviewResolvedFile(id) } : {}
|
|
1230
1792
|
};
|
|
1231
|
-
|
|
1232
|
-
return record;
|
|
1793
|
+
return reconcileTurn(meta, diff, feedback, resolution);
|
|
1233
1794
|
}
|
|
1234
|
-
assertResolvable(
|
|
1235
|
-
if (!isResolvableReviewStatus(
|
|
1236
|
-
throw new Error(`Review ${id} is ${
|
|
1795
|
+
assertResolvable(turn, id) {
|
|
1796
|
+
if (!isResolvableReviewStatus(turn.status)) {
|
|
1797
|
+
throw new Error(`Review ${id} turn ${turn.index} is ${turn.status} and cannot be resolved`);
|
|
1237
1798
|
}
|
|
1238
|
-
if (!
|
|
1239
|
-
throw new Error(`Review ${id} has no submitted feedback`);
|
|
1799
|
+
if (!turn.feedback) {
|
|
1800
|
+
throw new Error(`Review ${id} turn ${turn.index} has no submitted feedback`);
|
|
1240
1801
|
}
|
|
1241
1802
|
}
|
|
1242
|
-
|
|
1243
|
-
if (!
|
|
1244
|
-
|
|
1803
|
+
resolveTurnSelector(record, selector) {
|
|
1804
|
+
if (!selector) {
|
|
1805
|
+
return activeTurn(record);
|
|
1245
1806
|
}
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1807
|
+
const turn = record.turns.find((candidate) => candidate.id === selector) ?? record.turns.find((candidate) => String(candidate.index) === selector);
|
|
1808
|
+
if (!turn) {
|
|
1809
|
+
throw new Error(`Turn ${selector} not found in review ${record.meta.id}`);
|
|
1810
|
+
}
|
|
1811
|
+
return turn;
|
|
1812
|
+
}
|
|
1813
|
+
findTurnForComment(record, commentId) {
|
|
1814
|
+
return [...record.turns].reverse().find(
|
|
1815
|
+
(candidate) => candidate.feedback?.comments.some((comment) => comment.id === commentId)
|
|
1816
|
+
) ?? null;
|
|
1817
|
+
}
|
|
1818
|
+
async persistResolution(record, turn, resolution, reason) {
|
|
1819
|
+
const resolvedPath = globalReviewTurnResolvedFile(record.meta.id, turn.id);
|
|
1820
|
+
const nextTurn = {
|
|
1821
|
+
...turn,
|
|
1822
|
+
resolvedPath,
|
|
1823
|
+
resolution
|
|
1824
|
+
};
|
|
1825
|
+
const nextRecord = normalizeRecord(replaceTurn(record, nextTurn));
|
|
1826
|
+
this.reviews.set(record.meta.id, nextRecord);
|
|
1827
|
+
await ensureDir(globalReviewTurnDir(record.meta.id, nextTurn.id));
|
|
1252
1828
|
await Promise.all([
|
|
1253
1829
|
writeJsonFile(resolvedPath, resolution),
|
|
1254
|
-
writeJsonFile(
|
|
1830
|
+
writeJsonFile(globalReviewTurnMetaFile(record.meta.id, nextTurn.id), turnMeta(nextTurn))
|
|
1255
1831
|
]);
|
|
1832
|
+
await this.persistMeta(nextRecord);
|
|
1256
1833
|
const result = {
|
|
1257
1834
|
ok: true,
|
|
1258
1835
|
reviewId: record.meta.id,
|
|
1259
|
-
|
|
1836
|
+
turnId: nextTurn.id,
|
|
1837
|
+
turnIndex: nextTurn.index,
|
|
1838
|
+
status: nextTurn.status,
|
|
1260
1839
|
resolutionStatus: resolution.status,
|
|
1261
|
-
comments: resolutionCounts(
|
|
1840
|
+
comments: resolutionCounts(nextTurn.feedback, resolution.comments),
|
|
1262
1841
|
path: resolvedPath,
|
|
1263
1842
|
resolution
|
|
1264
1843
|
};
|
|
1265
1844
|
this.emit({
|
|
1266
1845
|
type: "review.updated",
|
|
1267
1846
|
reviewId: record.meta.id,
|
|
1847
|
+
turnId: nextTurn.id,
|
|
1848
|
+
turnIndex: nextTurn.index,
|
|
1268
1849
|
reason,
|
|
1269
1850
|
status: result.status,
|
|
1270
1851
|
resolutionStatus: result.resolutionStatus,
|
|
@@ -1272,24 +1853,134 @@ var ReviewStore = class {
|
|
|
1272
1853
|
});
|
|
1273
1854
|
return result;
|
|
1274
1855
|
}
|
|
1275
|
-
sortResolvedComments(comments,
|
|
1856
|
+
sortResolvedComments(comments, turn) {
|
|
1276
1857
|
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)
|
|
1858
|
+
turn.feedback.comments.map((comment, index) => [comment.id, index])
|
|
1281
1859
|
);
|
|
1860
|
+
return comments.map((comment) => ({ comment, index: feedbackIndex.get(comment.commentId) })).filter(
|
|
1861
|
+
(entry) => entry.index !== void 0
|
|
1862
|
+
).sort((a, b) => a.index - b.index).map(({ comment }) => comment);
|
|
1282
1863
|
}
|
|
1283
1864
|
};
|
|
1865
|
+
function createTurn(reviewId, index, diff, createdAt) {
|
|
1866
|
+
const id = ulid();
|
|
1867
|
+
return {
|
|
1868
|
+
id,
|
|
1869
|
+
index,
|
|
1870
|
+
status: "pending",
|
|
1871
|
+
createdAt,
|
|
1872
|
+
artifactDir: globalReviewTurnDir(reviewId, id),
|
|
1873
|
+
diffPath: globalReviewTurnDiffFile(reviewId, id),
|
|
1874
|
+
diff
|
|
1875
|
+
};
|
|
1876
|
+
}
|
|
1877
|
+
function normalizeRecord(record) {
|
|
1878
|
+
const turns = record.turns.toSorted((a, b) => a.index - b.index);
|
|
1879
|
+
const active = turns.find((turn) => turn.id === record.meta.activeTurnId) ?? turns[turns.length - 1];
|
|
1880
|
+
const meta = {
|
|
1881
|
+
...record.meta,
|
|
1882
|
+
base: active.diff.base,
|
|
1883
|
+
branch: active.diff.branch,
|
|
1884
|
+
status: active.status,
|
|
1885
|
+
submittedAt: active.submittedAt,
|
|
1886
|
+
resolvedAt: active.resolvedAt,
|
|
1887
|
+
artifactDir: record.meta.artifactDir ?? globalReviewDir(record.meta.id),
|
|
1888
|
+
activeTurnId: active.id,
|
|
1889
|
+
turns: turns.map(turnSummary),
|
|
1890
|
+
feedbackPath: active.feedbackPath,
|
|
1891
|
+
markdownPath: active.markdownPath
|
|
1892
|
+
};
|
|
1893
|
+
return {
|
|
1894
|
+
meta,
|
|
1895
|
+
turns,
|
|
1896
|
+
diff: active.diff,
|
|
1897
|
+
...active.feedback ? { feedback: active.feedback } : {},
|
|
1898
|
+
...active.resolution ? { resolution: active.resolution } : {}
|
|
1899
|
+
};
|
|
1900
|
+
}
|
|
1901
|
+
function replaceTurn(record, nextTurn) {
|
|
1902
|
+
return {
|
|
1903
|
+
...record,
|
|
1904
|
+
turns: record.turns.map((turn) => turn.id === nextTurn.id ? nextTurn : turn)
|
|
1905
|
+
};
|
|
1906
|
+
}
|
|
1907
|
+
function activeTurn(record) {
|
|
1908
|
+
return record.turns.find((turn) => turn.id === record.meta.activeTurnId) ?? record.turns[record.turns.length - 1];
|
|
1909
|
+
}
|
|
1910
|
+
function latestTurn(record) {
|
|
1911
|
+
return record.turns.toSorted((a, b) => a.index - b.index)[record.turns.length - 1];
|
|
1912
|
+
}
|
|
1913
|
+
function turnMeta(turn) {
|
|
1914
|
+
return {
|
|
1915
|
+
id: turn.id,
|
|
1916
|
+
index: turn.index,
|
|
1917
|
+
status: turn.status,
|
|
1918
|
+
createdAt: turn.createdAt,
|
|
1919
|
+
submittedAt: turn.submittedAt,
|
|
1920
|
+
resolvedAt: turn.resolvedAt,
|
|
1921
|
+
artifactDir: turn.artifactDir,
|
|
1922
|
+
diffPath: turn.diffPath,
|
|
1923
|
+
feedbackPath: turn.feedbackPath,
|
|
1924
|
+
markdownPath: turn.markdownPath,
|
|
1925
|
+
resolvedPath: turn.resolvedPath
|
|
1926
|
+
};
|
|
1927
|
+
}
|
|
1928
|
+
function turnSummary(turn) {
|
|
1929
|
+
return {
|
|
1930
|
+
...turnMeta(turn),
|
|
1931
|
+
capturedAt: turn.diff.capturedAt,
|
|
1932
|
+
stats: turn.diff.stats,
|
|
1933
|
+
comments: resolutionCounts(turn.feedback, turn.resolution?.comments ?? [])
|
|
1934
|
+
};
|
|
1935
|
+
}
|
|
1936
|
+
function reconcileTurn(meta, diff, feedback, resolution) {
|
|
1937
|
+
const status = resolution?.status === "resolved" ? "resolved" : feedback ? "submitted" : "pending";
|
|
1938
|
+
return {
|
|
1939
|
+
...meta,
|
|
1940
|
+
status,
|
|
1941
|
+
submittedAt: feedback?.timestamp ?? meta.submittedAt,
|
|
1942
|
+
resolvedAt: status === "resolved" ? resolution?.resolvedAt ?? meta.resolvedAt : void 0,
|
|
1943
|
+
feedbackPath: feedback ? meta.feedbackPath ?? path4.join(meta.artifactDir, "feedback.json") : void 0,
|
|
1944
|
+
markdownPath: feedback ? meta.markdownPath ?? path4.join(meta.artifactDir, "feedback.md") : void 0,
|
|
1945
|
+
resolvedPath: resolution ? meta.resolvedPath ?? path4.join(meta.artifactDir, "resolved.json") : void 0,
|
|
1946
|
+
diff,
|
|
1947
|
+
...feedback ? { feedback } : {},
|
|
1948
|
+
...resolution ? { resolution } : {}
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
function mergeRecoveredTurns(legacyTurn, persistedTurns) {
|
|
1952
|
+
const turns = legacyTurn && !persistedTurns.some((turn) => turn.id === legacyTurn.id || turn.index === legacyTurn.index) ? [legacyTurn, ...persistedTurns] : persistedTurns;
|
|
1953
|
+
return turns.toSorted((a, b) => a.index - b.index);
|
|
1954
|
+
}
|
|
1955
|
+
function diffFingerprint(diff) {
|
|
1956
|
+
return createHash("sha256").update(
|
|
1957
|
+
JSON.stringify({
|
|
1958
|
+
base: diff.base,
|
|
1959
|
+
branch: diff.branch,
|
|
1960
|
+
cwd: diff.cwd,
|
|
1961
|
+
scope: diff.scope,
|
|
1962
|
+
rawDiff: diff.rawDiff
|
|
1963
|
+
})
|
|
1964
|
+
).digest("hex");
|
|
1965
|
+
}
|
|
1966
|
+
function sameComments(left, right) {
|
|
1967
|
+
return JSON.stringify(left.toSorted(compareCommentsByLocation)) === JSON.stringify(right.toSorted(compareCommentsByLocation));
|
|
1968
|
+
}
|
|
1969
|
+
function requiredPath(value, label) {
|
|
1970
|
+
if (!value) {
|
|
1971
|
+
throw new Error(`Submitted review is missing ${label}`);
|
|
1972
|
+
}
|
|
1973
|
+
return value;
|
|
1974
|
+
}
|
|
1284
1975
|
async function readOptionalJsonFile(filePath, guard, label) {
|
|
1285
1976
|
let raw;
|
|
1286
1977
|
try {
|
|
1287
1978
|
raw = await readFile2(filePath, "utf8");
|
|
1288
1979
|
} catch (error) {
|
|
1289
|
-
if (
|
|
1980
|
+
if (isFileNotFound(error)) {
|
|
1290
1981
|
return void 0;
|
|
1291
1982
|
}
|
|
1292
|
-
throw new Error(`Could not read ${label} at ${filePath}: ${
|
|
1983
|
+
throw new Error(`Could not read ${label} at ${filePath}: ${formatError(error)}`, {
|
|
1293
1984
|
cause: error
|
|
1294
1985
|
});
|
|
1295
1986
|
}
|
|
@@ -1299,15 +1990,9 @@ function parseJsonFile(raw, guard, label, filePath) {
|
|
|
1299
1990
|
try {
|
|
1300
1991
|
return parseJson(raw, guard, label);
|
|
1301
1992
|
} catch (error) {
|
|
1302
|
-
throw new Error(`Invalid ${label} at ${filePath}: ${
|
|
1993
|
+
throw new Error(`Invalid ${label} at ${filePath}: ${formatError(error)}`, { cause: error });
|
|
1303
1994
|
}
|
|
1304
1995
|
}
|
|
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
1996
|
var reviewStore = new ReviewStore();
|
|
1312
1997
|
|
|
1313
1998
|
// src/cli/status.ts
|
|
@@ -1335,31 +2020,61 @@ function printPlain(value) {
|
|
|
1335
2020
|
}
|
|
1336
2021
|
var program = new Command();
|
|
1337
2022
|
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(
|
|
2023
|
+
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
2024
|
async (options) => {
|
|
1340
2025
|
const globals = program.opts();
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
const
|
|
1344
|
-
const
|
|
2026
|
+
let info = await ensureServer();
|
|
2027
|
+
let client = new ServerClient(serverUrl(info));
|
|
2028
|
+
const inheritedBase = options.review && !options.base ? await baseForExistingReview(client, options.review) : null;
|
|
2029
|
+
const diff = await captureDiff(options.base ?? inheritedBase ?? void 0);
|
|
2030
|
+
const created = options.review ? await client.appendReviewTurn(options.review, diff) : await client.createReview(diff);
|
|
2031
|
+
const meta = created.meta;
|
|
2032
|
+
const turn = created.turn ?? meta.turns?.find((summary) => summary.id === meta.activeTurnId);
|
|
2033
|
+
if (!turn) {
|
|
2034
|
+
throw new Error(`Review ${meta.id} has no active turn`);
|
|
2035
|
+
}
|
|
2036
|
+
const reused = "reused" in created ? created.reused === true : false;
|
|
2037
|
+
let url = created.url;
|
|
2038
|
+
const shouldWatch = options.watch !== false;
|
|
1345
2039
|
if (options.printUrl) {
|
|
1346
2040
|
printPlain(url);
|
|
1347
2041
|
}
|
|
1348
2042
|
if (options.open !== false) {
|
|
1349
2043
|
await openBrowser(url);
|
|
1350
2044
|
}
|
|
1351
|
-
if (
|
|
2045
|
+
if (!shouldWatch) {
|
|
1352
2046
|
const result2 = {
|
|
1353
2047
|
reviewId: meta.id,
|
|
2048
|
+
turnId: turn.id,
|
|
2049
|
+
turnIndex: turn.index,
|
|
1354
2050
|
url,
|
|
1355
2051
|
files: diff.files.length,
|
|
1356
2052
|
scope: diff.scope.mode,
|
|
1357
|
-
artifactDir:
|
|
2053
|
+
artifactDir: turn.artifactDir,
|
|
2054
|
+
reused
|
|
1358
2055
|
};
|
|
1359
2056
|
globals.json ? printJson(result2) : printPlain(`Review ${meta.id}: ${url}`);
|
|
1360
2057
|
return;
|
|
1361
2058
|
}
|
|
1362
|
-
const
|
|
2059
|
+
const watched = await watchReviewWithReconnect(
|
|
2060
|
+
meta.id,
|
|
2061
|
+
info,
|
|
2062
|
+
options.timeout,
|
|
2063
|
+
async (nextInfo) => {
|
|
2064
|
+
info = nextInfo;
|
|
2065
|
+
client = new ServerClient(serverUrl(info));
|
|
2066
|
+
url = `${serverUrl(info)}/review/${meta.id}`;
|
|
2067
|
+
if (options.printUrl) {
|
|
2068
|
+
printPlain(url);
|
|
2069
|
+
}
|
|
2070
|
+
if (options.open !== false) {
|
|
2071
|
+
await openBrowser(url);
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
);
|
|
2075
|
+
info = watched.info;
|
|
2076
|
+
client = new ServerClient(serverUrl(info));
|
|
2077
|
+
const event = watched.event;
|
|
1363
2078
|
if (event.type === "review.cancelled") {
|
|
1364
2079
|
process.exitCode = 2;
|
|
1365
2080
|
globals.json ? printJson(event) : printPlain(`Review ${meta.id} cancelled`);
|
|
@@ -1368,16 +2083,26 @@ program.command("open").description("Capture local changes and open them for rev
|
|
|
1368
2083
|
if (event.type !== "review.submitted") {
|
|
1369
2084
|
throw new Error(`Unexpected review event ${event.type}`);
|
|
1370
2085
|
}
|
|
1371
|
-
const feedback = await
|
|
2086
|
+
const [feedback, submittedRecord] = await Promise.all([
|
|
2087
|
+
client.getFeedback(meta.id),
|
|
2088
|
+
client.getReview(meta.id)
|
|
2089
|
+
]);
|
|
2090
|
+
const submittedTurn = submittedRecord.meta.turns?.find((summary) => summary.id === (event.turnId ?? turn.id)) ?? turn;
|
|
2091
|
+
if (!submittedTurn.feedbackPath || !submittedTurn.markdownPath) {
|
|
2092
|
+
throw new Error(`Review ${meta.id} turn ${submittedTurn.index} is missing feedback paths`);
|
|
2093
|
+
}
|
|
1372
2094
|
const result = {
|
|
1373
2095
|
reviewId: meta.id,
|
|
2096
|
+
turnId: submittedTurn.id,
|
|
2097
|
+
turnIndex: submittedTurn.index,
|
|
1374
2098
|
url,
|
|
1375
2099
|
files: event.counts.files,
|
|
1376
2100
|
comments: event.counts.comments,
|
|
1377
|
-
feedbackPath:
|
|
1378
|
-
markdownPath:
|
|
1379
|
-
artifactDir:
|
|
1380
|
-
feedback
|
|
2101
|
+
feedbackPath: submittedTurn.feedbackPath,
|
|
2102
|
+
markdownPath: submittedTurn.markdownPath,
|
|
2103
|
+
artifactDir: submittedTurn.artifactDir,
|
|
2104
|
+
feedback,
|
|
2105
|
+
reused
|
|
1381
2106
|
};
|
|
1382
2107
|
globals.json ? printJson(result) : printPlain(`Review ${meta.id} submitted with ${event.counts.comments} comments`);
|
|
1383
2108
|
}
|
|
@@ -1385,8 +2110,12 @@ program.command("open").description("Capture local changes and open them for rev
|
|
|
1385
2110
|
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
2111
|
const globals = program.opts();
|
|
1387
2112
|
const info = await ensureServer();
|
|
1388
|
-
const
|
|
1389
|
-
|
|
2113
|
+
const { event } = await watchReviewWithReconnect(
|
|
2114
|
+
reviewId,
|
|
2115
|
+
info,
|
|
2116
|
+
options.timeout,
|
|
2117
|
+
async () => void 0
|
|
2118
|
+
);
|
|
1390
2119
|
globals.json ? printJson(event) : printPlain(`${event.type} ${event.reviewId}`);
|
|
1391
2120
|
});
|
|
1392
2121
|
program.command("start").description("Start or reuse the background server").option("--port <port>", "port to bind", Number).action(async (options) => {
|
|
@@ -1404,28 +2133,33 @@ program.command("status").description("Show server and active reviews").action(a
|
|
|
1404
2133
|
responsive && info ? `Gloss server running at ${serverUrl(info)} with ${reviews.length} active review(s)` : "Gloss server is not running"
|
|
1405
2134
|
);
|
|
1406
2135
|
});
|
|
1407
|
-
program.command("stop").description("Stop the managed background server").action(async () => {
|
|
1408
|
-
const globals = program.opts();
|
|
1409
|
-
const result = await stopServer();
|
|
1410
|
-
globals.json ? printJson(result) : printPlain(result.stopped ? "Gloss server stopped" : "Gloss server was not running");
|
|
1411
|
-
});
|
|
1412
|
-
program.command("resolve").argument("<reviewId>", "review id").description("Mark a submitted review or one feedback comment as resolved").option("--comment <commentId>", "resolve one submitted feedback comment").option("--summary <text>", "brief summary of the fixes applied").action(async (reviewId, options) => {
|
|
2136
|
+
program.command("stop").description("Stop the managed background server").option("--all", "stop all Gloss daemon processes for the current user").action(async (options) => {
|
|
1413
2137
|
const globals = program.opts();
|
|
1414
|
-
const
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
if (globals.json) {
|
|
1418
|
-
printJson({
|
|
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`
|
|
2138
|
+
const result = await stopServer({ all: options.all });
|
|
2139
|
+
globals.json ? printJson(result) : printPlain(
|
|
2140
|
+
options.all && result.stoppedPids ? `Stopped ${result.stoppedPids.length} Gloss daemon(s)` : result.stopped ? "Gloss server stopped" : "Gloss server was not running"
|
|
1427
2141
|
);
|
|
1428
2142
|
});
|
|
2143
|
+
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(
|
|
2144
|
+
async (reviewId, options) => {
|
|
2145
|
+
const globals = program.opts();
|
|
2146
|
+
const info = await ensureServer();
|
|
2147
|
+
const client = new ServerClient(serverUrl(info));
|
|
2148
|
+
const result = options.comment ? await client.resolveComment(reviewId, options.comment, options.summary) : await client.markResolved(reviewId, options.summary, options.turn);
|
|
2149
|
+
if (globals.json) {
|
|
2150
|
+
printJson({
|
|
2151
|
+
commentId: options.comment ?? null,
|
|
2152
|
+
summary: options.summary ?? null,
|
|
2153
|
+
turn: options.turn ?? null,
|
|
2154
|
+
...result
|
|
2155
|
+
});
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
printPlain(
|
|
2159
|
+
options.comment ? `Comment ${options.comment} resolved in review ${reviewId}` : `Review ${reviewId} resolved`
|
|
2160
|
+
);
|
|
2161
|
+
}
|
|
2162
|
+
);
|
|
1429
2163
|
program.command("doctor").description("Diagnose setup and validate git/state").action(async () => {
|
|
1430
2164
|
const globals = program.opts();
|
|
1431
2165
|
const checks = [];
|
|
@@ -1455,6 +2189,21 @@ program.command("doctor").description("Diagnose setup and validate git/state").a
|
|
|
1455
2189
|
ok: info ? await isServerResponsive(info) : false,
|
|
1456
2190
|
detail: info ? serverUrl(info) : "not started"
|
|
1457
2191
|
});
|
|
2192
|
+
try {
|
|
2193
|
+
const daemonPids = await listGlossDaemonPids();
|
|
2194
|
+
const unmanagedDaemonPids = daemonPids.filter((pid) => pid !== info?.pid);
|
|
2195
|
+
checks.push({
|
|
2196
|
+
name: "daemon-processes",
|
|
2197
|
+
ok: unmanagedDaemonPids.length === 0,
|
|
2198
|
+
detail: daemonPids.length === 0 ? "none" : `${daemonPids.length} found${unmanagedDaemonPids.length > 0 ? `; unmanaged pids ${unmanagedDaemonPids.join(", ")}` : ""}`
|
|
2199
|
+
});
|
|
2200
|
+
} catch (error) {
|
|
2201
|
+
checks.push({
|
|
2202
|
+
name: "daemon-processes",
|
|
2203
|
+
ok: false,
|
|
2204
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
2205
|
+
});
|
|
2206
|
+
}
|
|
1458
2207
|
if (globals.json) {
|
|
1459
2208
|
printJson({ checks });
|
|
1460
2209
|
} else {
|
|
@@ -1465,6 +2214,46 @@ program.command("doctor").description("Diagnose setup and validate git/state").a
|
|
|
1465
2214
|
}
|
|
1466
2215
|
}
|
|
1467
2216
|
});
|
|
2217
|
+
async function watchReviewWithReconnect(reviewId, initialInfo, timeoutSeconds, onServerChanged) {
|
|
2218
|
+
const startedAt = Date.now();
|
|
2219
|
+
let info = initialInfo;
|
|
2220
|
+
while (true) {
|
|
2221
|
+
const remainingSeconds = timeoutSeconds && timeoutSeconds > 0 ? timeoutSeconds - (Date.now() - startedAt) / 1e3 : void 0;
|
|
2222
|
+
if (remainingSeconds !== void 0 && remainingSeconds <= 0) {
|
|
2223
|
+
throw new Error(`watch timed out after ${timeoutSeconds} seconds`);
|
|
2224
|
+
}
|
|
2225
|
+
try {
|
|
2226
|
+
const event = await new ServerClient(serverUrl(info)).watchReview(reviewId, remainingSeconds);
|
|
2227
|
+
return { event, info };
|
|
2228
|
+
} catch (error) {
|
|
2229
|
+
if (isWatchTimeout(error)) {
|
|
2230
|
+
throw error;
|
|
2231
|
+
}
|
|
2232
|
+
if (!isReconnectableWatchError(error)) {
|
|
2233
|
+
throw error;
|
|
2234
|
+
}
|
|
2235
|
+
await sleep2(500);
|
|
2236
|
+
const nextInfo = await ensureServer();
|
|
2237
|
+
if (nextInfo.port !== info.port) {
|
|
2238
|
+
await onServerChanged(nextInfo);
|
|
2239
|
+
}
|
|
2240
|
+
info = nextInfo;
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
async function baseForExistingReview(client, reviewId) {
|
|
2245
|
+
const record = await client.getReview(reviewId);
|
|
2246
|
+
return record.diff.scope.mode === "explicit" ? record.diff.scope.requestedBase ?? record.diff.base.ref : null;
|
|
2247
|
+
}
|
|
2248
|
+
function isWatchTimeout(error) {
|
|
2249
|
+
return error instanceof Error && /^watch timed out after/.test(error.message);
|
|
2250
|
+
}
|
|
2251
|
+
function isReconnectableWatchError(error) {
|
|
2252
|
+
return error instanceof Error && !/^watch failed: [45]\d\d /.test(error.message);
|
|
2253
|
+
}
|
|
2254
|
+
async function sleep2(milliseconds) {
|
|
2255
|
+
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
2256
|
+
}
|
|
1468
2257
|
program.parseAsync(process.argv).catch((error) => {
|
|
1469
2258
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}
|
|
1470
2259
|
`);
|