thurview 0.8.2 → 0.9.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 +56 -19
- package/dist/cli.js +447 -106
- package/dist/cli.js.map +1 -1
- package/dist/forge/github.js +245 -0
- package/dist/forge/github.js.map +1 -0
- package/dist/forge/gitlab.js +226 -0
- package/dist/forge/gitlab.js.map +1 -0
- package/dist/forge/index.js +110 -0
- package/dist/forge/index.js.map +1 -0
- package/dist/forge/run.js +55 -0
- package/dist/forge/run.js.map +1 -0
- package/dist/forge/submission.js +55 -0
- package/dist/forge/submission.js.map +1 -0
- package/dist/forge/types.js +37 -0
- package/dist/forge/types.js.map +1 -0
- package/dist/presence.js +34 -0
- package/dist/presence.js.map +1 -0
- package/dist/server/server.js +4 -0
- package/dist/server/server.js.map +1 -1
- package/dist/store.js +10 -0
- package/dist/store.js.map +1 -1
- package/dist/thread-state.js +17 -0
- package/dist/thread-state.js.map +1 -0
- package/dist/threads.js +10 -9
- package/dist/threads.js.map +1 -1
- package/dist/ui/app.css +18 -0
- package/dist/ui/app.js +82 -24
- package/dist/ui/app.js.map +4 -4
- package/package.json +1 -1
- package/skills/forge-review/SKILL.md +276 -0
- package/skills/forge-review/references/forges.md +58 -0
- package/skills/forge-review/references/security-surfaces.md +54 -0
- package/skills/thurview/SKILL.md +16 -3
- package/skills/thurview/references/lifecycle.md +54 -7
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@ import { runAxiCli, AxiError, installSessionStartHooks, sessionStartHookStatus,
|
|
|
2
2
|
import { encode } from "@toon-format/toon";
|
|
3
3
|
import { spawn, execFile } from "node:child_process";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
|
-
import { cp, mkdir, symlink, lstat, rm } from "node:fs/promises";
|
|
5
|
+
import { cp, mkdir, symlink, lstat, rm, readdir } from "node:fs/promises";
|
|
6
6
|
import { existsSync } from "node:fs";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { join, dirname, resolve } from "node:path";
|
|
@@ -15,8 +15,11 @@ import { computeCoverage, scopeGlob, scopeGraph, scopeTruncated, } from "./cover
|
|
|
15
15
|
import { parseTheme, compileTheme } from "./theme.js";
|
|
16
16
|
import { registerTheme } from "./highlight.js";
|
|
17
17
|
import { replyThread, setThreadStatus, needsAgent } from "./threads.js";
|
|
18
|
+
import { attach } from "./presence.js";
|
|
18
19
|
import { startServer } from "./server/server.js";
|
|
19
20
|
import { parseFlags, helpFor, str, bool } from "./flags.js";
|
|
21
|
+
import { forgeFor, repoOf, summariseCi, } from "./forge/index.js";
|
|
22
|
+
import { parseSubmission, longComments } from "./forge/submission.js";
|
|
20
23
|
import { VERSION } from "./version.js";
|
|
21
24
|
const execFileP = promisify(execFile);
|
|
22
25
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -45,10 +48,27 @@ function targetLabel(t) {
|
|
|
45
48
|
return `map ${t.node}`;
|
|
46
49
|
return "review";
|
|
47
50
|
}
|
|
51
|
+
/** `PR #12` or `MR !12`, because the forge's own word is what the reader knows. */
|
|
52
|
+
function bindingLabel(b) {
|
|
53
|
+
if (b.kind !== "pr")
|
|
54
|
+
return b.name;
|
|
55
|
+
return b.forge === "gitlab" ? `MR !${b.name}` : `PR #${b.name}`;
|
|
56
|
+
}
|
|
48
57
|
function lastMessage(t) {
|
|
49
58
|
const m = t.messages[t.messages.length - 1];
|
|
50
59
|
return m ? `${m.role}: ${truncate(m.body.replace(/\s+/g, " "), 120)}` : "";
|
|
51
60
|
}
|
|
61
|
+
function skillsRoot() {
|
|
62
|
+
return resolve(HERE, "..", "skills");
|
|
63
|
+
}
|
|
64
|
+
/** Every skill this package ships, so a new one installs without a code change. */
|
|
65
|
+
async function bundledSkills() {
|
|
66
|
+
const entries = await readdir(skillsRoot(), { withFileTypes: true });
|
|
67
|
+
return entries
|
|
68
|
+
.filter((e) => e.isDirectory())
|
|
69
|
+
.map((e) => e.name)
|
|
70
|
+
.sort();
|
|
71
|
+
}
|
|
52
72
|
async function worktreeOf(cwd) {
|
|
53
73
|
try {
|
|
54
74
|
return await g.repoRoot(cwd);
|
|
@@ -83,6 +103,33 @@ async function resolveReview(idOpt) {
|
|
|
83
103
|
"Run `thurview scaffold` to create one",
|
|
84
104
|
]);
|
|
85
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Which change request, on which forge, read once. `--change` names it
|
|
108
|
+
* outright; otherwise the active review's binding does, which is what keeps a
|
|
109
|
+
* pass anchored to the commits the document was written against.
|
|
110
|
+
*/
|
|
111
|
+
async function forgeContext(p) {
|
|
112
|
+
const explicit = str(p, "change");
|
|
113
|
+
const review = !explicit || str(p, "review") ? await resolveReview(str(p, "review")) : null;
|
|
114
|
+
let ref = explicit;
|
|
115
|
+
if (!ref) {
|
|
116
|
+
if (review.binding.kind !== "pr")
|
|
117
|
+
throw new AxiError(`review ${short(review.id)} is bound to ${review.binding.name}, not to a change request`, "VALIDATION_ERROR", [
|
|
118
|
+
"Pass --change <number|url>",
|
|
119
|
+
`Or re-pin it: \`thurview scaffold --pr <ref> --update --review ${short(review.id)}\``,
|
|
120
|
+
]);
|
|
121
|
+
ref = review.binding.name;
|
|
122
|
+
}
|
|
123
|
+
const repoFlag = str(p, "repo");
|
|
124
|
+
const worktree = review?.worktree ?? (await worktreeOf(process.cwd()));
|
|
125
|
+
if (!worktree && !repoFlag)
|
|
126
|
+
throw new AxiError("not inside a git repository", "VALIDATION_ERROR", [
|
|
127
|
+
"Run inside the source worktree, or pass --repo host/path",
|
|
128
|
+
]);
|
|
129
|
+
const repo = await repoOf(worktree ?? process.cwd(), repoFlag);
|
|
130
|
+
const forge = await forgeFor(repo.host, str(p, "forge"));
|
|
131
|
+
return { forge, repo, cr: await forge.get(repo, ref), review };
|
|
132
|
+
}
|
|
86
133
|
async function reviewRow(r, fields) {
|
|
87
134
|
const t = await readThreads(r.id);
|
|
88
135
|
const row = {
|
|
@@ -95,7 +142,7 @@ async function reviewRow(r, fields) {
|
|
|
95
142
|
needsAgent: t.threads.filter(needsAgent).length,
|
|
96
143
|
};
|
|
97
144
|
if (fields.has("all") || fields.has("binding"))
|
|
98
|
-
row["binding"] = r.binding
|
|
145
|
+
row["binding"] = bindingLabel(r.binding);
|
|
99
146
|
if (fields.has("all") || fields.has("pins"))
|
|
100
147
|
row["pins"] =
|
|
101
148
|
kindOf(r) === "explainer"
|
|
@@ -271,13 +318,21 @@ const SPECS = {
|
|
|
271
318
|
scaffold: {
|
|
272
319
|
description: "Create a review pinned to exact base and head commits, or re-pin one",
|
|
273
320
|
flags: {
|
|
274
|
-
pr: {
|
|
321
|
+
pr: {
|
|
322
|
+
kind: "string",
|
|
323
|
+
help: "review a pull or merge request (number or URL, needs gh or glab)",
|
|
324
|
+
},
|
|
275
325
|
base: { kind: "string", help: "base revision (default: trunk fork point)" },
|
|
276
326
|
head: { kind: "string", help: "head revision (default: current branch)" },
|
|
277
327
|
title: { kind: "string", help: "initial title" },
|
|
278
328
|
new: { kind: "boolean", help: "create another review even if one matches the binding" },
|
|
279
329
|
update: { kind: "boolean", help: "re-pin an existing review from its binding" },
|
|
280
330
|
review: { kind: "string", help: "review to update (id prefix)" },
|
|
331
|
+
forge: {
|
|
332
|
+
kind: "string",
|
|
333
|
+
help: "github or gitlab, when the host is not one of the two known ones",
|
|
334
|
+
},
|
|
335
|
+
repo: { kind: "string", help: "host/path, when `origin` is not the repository to post to" },
|
|
281
336
|
},
|
|
282
337
|
examples: [
|
|
283
338
|
"thurview scaffold",
|
|
@@ -394,6 +449,38 @@ const SPECS = {
|
|
|
394
449
|
"thurview graph architecture",
|
|
395
450
|
],
|
|
396
451
|
},
|
|
452
|
+
forge: {
|
|
453
|
+
description: "Read a pull or merge request through its forge, and post the review back to it",
|
|
454
|
+
args: 'status|prior|submit --file <path>|reply <threadId> --body "<text>"',
|
|
455
|
+
flags: {
|
|
456
|
+
review: { kind: "string", help: "review id prefix; its binding names the change request" },
|
|
457
|
+
change: { kind: "string", help: "change request number or URL, instead of a review binding" },
|
|
458
|
+
forge: {
|
|
459
|
+
kind: "string",
|
|
460
|
+
help: "github or gitlab, when the host is not one of the two known ones",
|
|
461
|
+
},
|
|
462
|
+
repo: { kind: "string", help: "host/path, when `origin` is not the repository to post to" },
|
|
463
|
+
file: { kind: "string", help: "submit: the JSON submission to post" },
|
|
464
|
+
body: { kind: "string", help: "reply: the answer text" },
|
|
465
|
+
resolve: { kind: "boolean", help: "reply: resolve the thread as well as answering it" },
|
|
466
|
+
at: { kind: "string", help: "reply --resolve: the commit the point was verified at" },
|
|
467
|
+
confirm: { kind: "boolean", help: "submit: required to post an approve" },
|
|
468
|
+
"dry-run": { kind: "boolean", help: "submit: validate and report, post nothing" },
|
|
469
|
+
"max-lines": {
|
|
470
|
+
kind: "string",
|
|
471
|
+
help: "submit: warn above this many lines per comment",
|
|
472
|
+
default: "5",
|
|
473
|
+
},
|
|
474
|
+
mine: { kind: "boolean", help: "prior: only threads this account wrote" },
|
|
475
|
+
full: { kind: "boolean", help: "prior, status: do not truncate or filter" },
|
|
476
|
+
},
|
|
477
|
+
examples: [
|
|
478
|
+
"thurview forge status --change 123",
|
|
479
|
+
"thurview forge prior --change 123 --mine",
|
|
480
|
+
"thurview forge submit --file pass.json --dry-run",
|
|
481
|
+
'thurview forge reply <threadId> --body "<answer>" --resolve --at <sha>',
|
|
482
|
+
],
|
|
483
|
+
},
|
|
397
484
|
delete: {
|
|
398
485
|
description: "Delete a review and everything stored for it (the code is untouched)",
|
|
399
486
|
flags: { review: { kind: "string", help: "review id prefix (required)" } },
|
|
@@ -479,31 +566,25 @@ const commands = {
|
|
|
479
566
|
const pr = str(p, "pr");
|
|
480
567
|
if (pr || b?.kind === "pr") {
|
|
481
568
|
const ref = pr ?? b.name;
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
catch (e) {
|
|
488
|
-
throw new AxiError(`could not read pull request ${ref}: ${e.message.split("\n")[0]}`, "PR_ERROR", [
|
|
489
|
-
"Check `gh auth status` and the PR number",
|
|
490
|
-
"Or run `thurview scaffold --base <ref> --head <ref>`",
|
|
491
|
-
]);
|
|
492
|
-
}
|
|
569
|
+
const repo = await repoOf(worktree, str(p, "repo"));
|
|
570
|
+
const forge = await forgeFor(repo.host, str(p, "forge") ?? b?.forge);
|
|
571
|
+
const cr = await forge.get(repo, ref);
|
|
572
|
+
// A fork's head is not a branch in this checkout, so fetch it by the
|
|
573
|
+
// ref the forge publishes it under before anything tries to resolve it.
|
|
493
574
|
await g
|
|
494
|
-
.fetch(worktree, "origin",
|
|
575
|
+
.fetch(worktree, "origin", forge.fetchRef(cr), `refs/heads/${cr.baseBranch}`)
|
|
495
576
|
.catch(() => { });
|
|
496
|
-
head =
|
|
497
|
-
let baseRef = `origin/${
|
|
577
|
+
head = cr.head;
|
|
578
|
+
let baseRef = `origin/${cr.baseBranch}`;
|
|
498
579
|
try {
|
|
499
580
|
await g.revParse(worktree, baseRef);
|
|
500
581
|
}
|
|
501
582
|
catch {
|
|
502
|
-
baseRef =
|
|
583
|
+
baseRef = cr.baseBranch;
|
|
503
584
|
}
|
|
504
585
|
base = await g.mergeBase(worktree, baseRef, head);
|
|
505
|
-
binding = { kind: "pr", name:
|
|
506
|
-
title ||=
|
|
586
|
+
binding = { kind: "pr", name: cr.number, url: cr.url, forge: forge.id };
|
|
587
|
+
title ||= cr.title;
|
|
507
588
|
}
|
|
508
589
|
else if (str(p, "base") || str(p, "head") || b?.kind === "range") {
|
|
509
590
|
const [bb, hh] = b?.kind === "range" && !str(p, "base") && !str(p, "head")
|
|
@@ -601,7 +682,7 @@ const commands = {
|
|
|
601
682
|
title: review.title,
|
|
602
683
|
status: review.status,
|
|
603
684
|
rev: review.revision,
|
|
604
|
-
binding: binding
|
|
685
|
+
binding: bindingLabel(binding),
|
|
605
686
|
base,
|
|
606
687
|
head,
|
|
607
688
|
worktree,
|
|
@@ -1044,77 +1125,85 @@ const commands = {
|
|
|
1044
1125
|
]);
|
|
1045
1126
|
const deadline = Date.now() + seconds * 1000;
|
|
1046
1127
|
const id = short(review.id);
|
|
1128
|
+
// While this loop runs the reader is told an agent is listening, and told
|
|
1129
|
+
// the opposite the moment it stops.
|
|
1130
|
+
const listening = attach(review.id);
|
|
1047
1131
|
const rows = (ts) => ts.map((t) => ({
|
|
1048
1132
|
id: t.id,
|
|
1049
1133
|
kind: t.kind,
|
|
1050
1134
|
target: targetLabel(t.target),
|
|
1051
1135
|
last: lastMessage(t),
|
|
1052
1136
|
}));
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1137
|
+
try {
|
|
1138
|
+
while (Date.now() < deadline) {
|
|
1139
|
+
const r = await readReview(review.id);
|
|
1140
|
+
if (!r)
|
|
1141
|
+
return {
|
|
1142
|
+
wait: { reason: "review-deleted", id },
|
|
1143
|
+
help: ["Stop the loop; the review no longer exists"],
|
|
1144
|
+
};
|
|
1145
|
+
const t = await readThreads(review.id);
|
|
1146
|
+
const last = t.decisions[t.decisions.length - 1];
|
|
1147
|
+
if (r.status === "awaiting-agent-updates") {
|
|
1148
|
+
const need = t.threads.filter(needsAgent);
|
|
1149
|
+
return {
|
|
1150
|
+
wait: {
|
|
1151
|
+
reason: "awaiting-agent-updates",
|
|
1152
|
+
id,
|
|
1153
|
+
status: r.status,
|
|
1154
|
+
decision: last
|
|
1155
|
+
? `${last.decision}${last.body ? `: ${truncate(last.body, 300)}` : ""}`
|
|
1156
|
+
: "",
|
|
1157
|
+
},
|
|
1158
|
+
count: need.length,
|
|
1159
|
+
threads: rows(need),
|
|
1160
|
+
help: [
|
|
1161
|
+
`Run \`thurview threads get <threadId> --review ${id}\` for the full thread`,
|
|
1162
|
+
`Run \`thurview threads resolve <threadId> --review ${id}\` after addressing each`,
|
|
1163
|
+
`Run \`thurview publish --review ${id}\` when every open comment is resolved`,
|
|
1164
|
+
],
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
if (r.status === "accepted" || r.status === "closed")
|
|
1168
|
+
return {
|
|
1169
|
+
wait: {
|
|
1170
|
+
reason: r.status,
|
|
1171
|
+
id,
|
|
1172
|
+
status: r.status,
|
|
1173
|
+
decision: last
|
|
1174
|
+
? `${last.decision}${last.body ? `: ${truncate(last.body, 300)}` : ""}`
|
|
1175
|
+
: "",
|
|
1176
|
+
},
|
|
1177
|
+
help: ["The review is complete; report it and stop the loop"],
|
|
1178
|
+
};
|
|
1179
|
+
if (r.dismissed)
|
|
1180
|
+
return {
|
|
1181
|
+
wait: { reason: "review-dismissed", id, status: r.status },
|
|
1182
|
+
help: ["Stop the loop; the reader dismissed the review"],
|
|
1183
|
+
};
|
|
1184
|
+
const asks = t.threads.filter((x) => needsAgent(x) && x.mode === "ask");
|
|
1185
|
+
if (asks.length)
|
|
1186
|
+
return {
|
|
1187
|
+
wait: { reason: "question", id, status: r.status },
|
|
1188
|
+
count: asks.length,
|
|
1189
|
+
threads: rows(asks),
|
|
1190
|
+
help: [
|
|
1191
|
+
`Run \`thurview threads reply <threadId> --review ${id} --body "<answer>"\``,
|
|
1192
|
+
`Run \`thurview wait --review ${id}\` again afterwards`,
|
|
1193
|
+
],
|
|
1194
|
+
};
|
|
1195
|
+
await new Promise((res) => setTimeout(res, 700));
|
|
1081
1196
|
}
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
},
|
|
1092
|
-
help: ["The review is complete; report it and stop the loop"],
|
|
1093
|
-
};
|
|
1094
|
-
if (r.dismissed)
|
|
1095
|
-
return {
|
|
1096
|
-
wait: { reason: "review-dismissed", id, status: r.status },
|
|
1097
|
-
help: ["Stop the loop; the reader dismissed the review"],
|
|
1098
|
-
};
|
|
1099
|
-
const asks = t.threads.filter((x) => needsAgent(x) && x.mode === "ask");
|
|
1100
|
-
if (asks.length)
|
|
1101
|
-
return {
|
|
1102
|
-
wait: { reason: "question", id, status: r.status },
|
|
1103
|
-
count: asks.length,
|
|
1104
|
-
threads: rows(asks),
|
|
1105
|
-
help: [
|
|
1106
|
-
`Run \`thurview threads reply <threadId> --review ${id} --body "<answer>"\``,
|
|
1107
|
-
`Run \`thurview wait --review ${id}\` again afterwards`,
|
|
1108
|
-
],
|
|
1109
|
-
};
|
|
1110
|
-
await new Promise((res) => setTimeout(res, 700));
|
|
1197
|
+
return {
|
|
1198
|
+
wait: { reason: "timeout", id, status: review.status, seconds },
|
|
1199
|
+
help: [
|
|
1200
|
+
`Run \`thurview wait --review ${id}\` again, or report that the reader has not responded`,
|
|
1201
|
+
],
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
finally {
|
|
1205
|
+
await listening.stop();
|
|
1111
1206
|
}
|
|
1112
|
-
return {
|
|
1113
|
-
wait: { reason: "timeout", id, status: review.status, seconds },
|
|
1114
|
-
help: [
|
|
1115
|
-
`Run \`thurview wait --review ${id}\` again, or report that the reader has not responded`,
|
|
1116
|
-
],
|
|
1117
|
-
};
|
|
1118
1207
|
},
|
|
1119
1208
|
async graph(args) {
|
|
1120
1209
|
const sub = args[0];
|
|
@@ -1366,6 +1455,247 @@ const commands = {
|
|
|
1366
1455
|
],
|
|
1367
1456
|
};
|
|
1368
1457
|
},
|
|
1458
|
+
async forge(args) {
|
|
1459
|
+
const sub = args[0];
|
|
1460
|
+
const s = spec("forge").flags;
|
|
1461
|
+
const usage = [
|
|
1462
|
+
"thurview forge status [--change <ref>] [--review <id>]",
|
|
1463
|
+
"thurview forge prior [--change <ref>] [--mine] [--full]",
|
|
1464
|
+
"thurview forge submit --file <path> [--dry-run] [--confirm]",
|
|
1465
|
+
'thurview forge reply <threadId> --body "<text>" [--resolve --at <sha>]',
|
|
1466
|
+
];
|
|
1467
|
+
if (!sub || !["status", "prior", "submit", "reply"].includes(sub))
|
|
1468
|
+
throw new AxiError(`unknown forge command${sub ? ` ${sub}` : ""}`, "VALIDATION_ERROR", usage);
|
|
1469
|
+
const common = {
|
|
1470
|
+
review: s["review"],
|
|
1471
|
+
change: s["change"],
|
|
1472
|
+
forge: s["forge"],
|
|
1473
|
+
repo: s["repo"],
|
|
1474
|
+
};
|
|
1475
|
+
const rest = args.slice(1);
|
|
1476
|
+
if (sub === "status") {
|
|
1477
|
+
const p = parseFlags("forge status", rest, { ...common, full: s["full"] });
|
|
1478
|
+
const ctx = await forgeContext(p);
|
|
1479
|
+
const checks = await ctx.forge.checks(ctx.repo, ctx.cr);
|
|
1480
|
+
const baseline = await ctx.forge.baseline(ctx.repo, ctx.cr.baseBranch).catch(() => null);
|
|
1481
|
+
const ci = summariseCi(checks, baseline, ctx.cr.baseBranch);
|
|
1482
|
+
const shown = bool(p, "full") ? checks : checks.filter((c) => c.state !== "passed");
|
|
1483
|
+
const hidden = checks.length - shown.length;
|
|
1484
|
+
const help = [
|
|
1485
|
+
`Quote \`ci.verdict\` in the review; do not read "nothing failed" as "the tests passed"`,
|
|
1486
|
+
`Run \`thurview forge prior --change ${ctx.cr.number}\` to read the previous pass before writing a new one`,
|
|
1487
|
+
];
|
|
1488
|
+
if (hidden && !bool(p, "full"))
|
|
1489
|
+
help.push(`${hidden} passing checks hidden; pass --full to list them`);
|
|
1490
|
+
if (ctx.review && ctx.review.pins.head !== ctx.cr.head)
|
|
1491
|
+
help.unshift(`The head moved since this review was pinned; run \`thurview scaffold --update --review ${short(ctx.review.id)}\` and diff only what moved`);
|
|
1492
|
+
return {
|
|
1493
|
+
change: {
|
|
1494
|
+
forge: ctx.forge.id,
|
|
1495
|
+
repo: `${ctx.repo.host}/${ctx.repo.path}`,
|
|
1496
|
+
number: ctx.cr.number,
|
|
1497
|
+
title: ctx.cr.title,
|
|
1498
|
+
url: ctx.cr.url,
|
|
1499
|
+
state: ctx.cr.state,
|
|
1500
|
+
author: ctx.cr.author,
|
|
1501
|
+
draft: ctx.cr.draft,
|
|
1502
|
+
fromFork: ctx.cr.fromFork,
|
|
1503
|
+
head: ctx.cr.head,
|
|
1504
|
+
headBranch: ctx.cr.headBranch,
|
|
1505
|
+
baseBranch: ctx.cr.baseBranch,
|
|
1506
|
+
},
|
|
1507
|
+
ci,
|
|
1508
|
+
checks: shown.length
|
|
1509
|
+
? shown.map((c) => ({ name: c.name, state: c.state, raw: c.raw }))
|
|
1510
|
+
: `0 of ${checks.length} checks need attention`,
|
|
1511
|
+
...(ctx.review
|
|
1512
|
+
? {
|
|
1513
|
+
review: {
|
|
1514
|
+
id: short(ctx.review.id),
|
|
1515
|
+
pinnedHead: ctx.review.pins.head,
|
|
1516
|
+
movedSincePin: ctx.review.pins.head !== ctx.cr.head,
|
|
1517
|
+
},
|
|
1518
|
+
}
|
|
1519
|
+
: {}),
|
|
1520
|
+
permalink: ctx.forge.permalink(ctx.repo, ctx.cr.head, "<path>", 10, 20),
|
|
1521
|
+
help,
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
if (sub === "prior") {
|
|
1525
|
+
const p = parseFlags("forge prior", rest, {
|
|
1526
|
+
...common,
|
|
1527
|
+
mine: s["mine"],
|
|
1528
|
+
full: s["full"],
|
|
1529
|
+
});
|
|
1530
|
+
const ctx = await forgeContext(p);
|
|
1531
|
+
const prior = await ctx.forge.prior(ctx.repo, ctx.cr);
|
|
1532
|
+
const { threads } = prior;
|
|
1533
|
+
// A forge wraps inline comments in a pass of its own with an empty
|
|
1534
|
+
// body. That envelope is not something to answer; the thread under it
|
|
1535
|
+
// is, and it is already in `threads`.
|
|
1536
|
+
const passes = prior.passes.filter((x) => x.body.trim() !== "" || x.verdict !== "commented");
|
|
1537
|
+
const me = await ctx.forge.whoami(ctx.repo).catch(() => "");
|
|
1538
|
+
const full = bool(p, "full");
|
|
1539
|
+
const rows = (bool(p, "mine") ? threads.filter((t) => t.author === me) : threads).map((t) => {
|
|
1540
|
+
const last = t.messages[t.messages.length - 1];
|
|
1541
|
+
return {
|
|
1542
|
+
id: t.id,
|
|
1543
|
+
author: t.author,
|
|
1544
|
+
at: t.path ? `${t.path}${t.line ? `:${t.line}` : ""}` : "(change request)",
|
|
1545
|
+
resolved: t.resolved,
|
|
1546
|
+
outdated: t.outdated,
|
|
1547
|
+
atHead: t.commit ? t.commit === ctx.cr.head : null,
|
|
1548
|
+
messages: t.messages.length,
|
|
1549
|
+
last: last ? (full ? last.body : truncate(last.body.replace(/\s+/g, " "), 160)) : "",
|
|
1550
|
+
};
|
|
1551
|
+
});
|
|
1552
|
+
const open = rows.filter((r) => !r.resolved).length;
|
|
1553
|
+
return {
|
|
1554
|
+
summary: {
|
|
1555
|
+
passes: passes.length,
|
|
1556
|
+
threads: rows.length,
|
|
1557
|
+
open,
|
|
1558
|
+
resolved: rows.length - open,
|
|
1559
|
+
mine: me ? threads.filter((t) => t.author === me).length : null,
|
|
1560
|
+
notAtHead: rows.filter((r) => r.atHead === false).length,
|
|
1561
|
+
head: ctx.cr.head,
|
|
1562
|
+
},
|
|
1563
|
+
passes: passes.length
|
|
1564
|
+
? passes.map((x) => ({
|
|
1565
|
+
author: x.author,
|
|
1566
|
+
verdict: x.verdict,
|
|
1567
|
+
at: x.at,
|
|
1568
|
+
commit: x.commit ?? "",
|
|
1569
|
+
body: full ? x.body : truncate(x.body.replace(/\s+/g, " "), 200),
|
|
1570
|
+
}))
|
|
1571
|
+
: "0 (nobody has reviewed this change request yet)",
|
|
1572
|
+
threads: rows.length ? rows : "0 (no review thread on this change request)",
|
|
1573
|
+
help: passes.length || rows.length
|
|
1574
|
+
? [
|
|
1575
|
+
"Go through every open thread point by point: addressed, partially addressed, or untouched",
|
|
1576
|
+
'Answer one with `thurview forge reply <threadId> --body "<text>"`, and add --resolve --at <sha> only once you verified the point at that head',
|
|
1577
|
+
full ? "" : "Pass --full for the untruncated bodies",
|
|
1578
|
+
].filter(Boolean)
|
|
1579
|
+
: ["This is the first pass; there is no prior review to answer"],
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
if (sub === "submit") {
|
|
1583
|
+
const p = parseFlags("forge submit", rest, {
|
|
1584
|
+
...common,
|
|
1585
|
+
file: s["file"],
|
|
1586
|
+
confirm: s["confirm"],
|
|
1587
|
+
"dry-run": s["dry-run"],
|
|
1588
|
+
"max-lines": s["max-lines"],
|
|
1589
|
+
});
|
|
1590
|
+
const file = str(p, "file");
|
|
1591
|
+
if (!file)
|
|
1592
|
+
throw new AxiError("--file is required", "VALIDATION_ERROR", [
|
|
1593
|
+
"thurview forge submit --file <path>",
|
|
1594
|
+
]);
|
|
1595
|
+
const text = await readText(resolve(process.cwd(), file));
|
|
1596
|
+
if (text === null)
|
|
1597
|
+
throw new AxiError(`${file} not found`, "NOT_FOUND", [
|
|
1598
|
+
'Write the pass as JSON: {"verdict": "comment", "body": "<summary>", "comments": []}',
|
|
1599
|
+
]);
|
|
1600
|
+
const submission = parseSubmission(text, file);
|
|
1601
|
+
const max = Number(str(p, "max-lines"));
|
|
1602
|
+
const warnings = longComments(submission, Number.isFinite(max) && max > 0 ? max : 5);
|
|
1603
|
+
const consequences = submission.verdict === "approve"
|
|
1604
|
+
? [
|
|
1605
|
+
"Approving dismisses any standing request for changes, which is what makes this mergeable",
|
|
1606
|
+
"Where auto-merge is armed, approving merges the code with no further human read",
|
|
1607
|
+
"Say that to the user before you pass --confirm",
|
|
1608
|
+
]
|
|
1609
|
+
: [];
|
|
1610
|
+
const dry = bool(p, "dry-run");
|
|
1611
|
+
if (submission.verdict === "approve" && !bool(p, "confirm") && !dry)
|
|
1612
|
+
throw new AxiError("approving is a state change, so it needs --confirm", "VALIDATION_ERROR", [...consequences, "Re-run with --confirm, or submit with verdict comment instead"]);
|
|
1613
|
+
const ctx = await forgeContext(p);
|
|
1614
|
+
if (dry)
|
|
1615
|
+
return {
|
|
1616
|
+
dryRun: {
|
|
1617
|
+
forge: ctx.forge.id,
|
|
1618
|
+
change: `${ctx.repo.path}#${ctx.cr.number}`,
|
|
1619
|
+
head: ctx.cr.head,
|
|
1620
|
+
verdict: submission.verdict,
|
|
1621
|
+
comments: submission.comments.length,
|
|
1622
|
+
bodyLines: submission.body.trimEnd().split("\n").length,
|
|
1623
|
+
},
|
|
1624
|
+
comments: submission.comments.length
|
|
1625
|
+
? submission.comments.map((c) => ({
|
|
1626
|
+
at: `${c.path}:${c.startLine && c.startLine < c.line ? `${c.startLine}-${c.line}` : c.line}`,
|
|
1627
|
+
side: c.side ?? "head",
|
|
1628
|
+
lines: c.body.trimEnd().split("\n").length,
|
|
1629
|
+
}))
|
|
1630
|
+
: "0 (a summary-only pass)",
|
|
1631
|
+
warnings: warnings.length ? warnings : "0 (every comment is within the line budget)",
|
|
1632
|
+
...(consequences.length ? { consequences } : {}),
|
|
1633
|
+
help: [
|
|
1634
|
+
"Nothing was posted; re-run without --dry-run to post it",
|
|
1635
|
+
"Check every anchor is a line the diff actually touches, or the forge refuses the comment",
|
|
1636
|
+
],
|
|
1637
|
+
};
|
|
1638
|
+
const posted = await ctx.forge.submit(ctx.repo, ctx.cr, submission);
|
|
1639
|
+
return {
|
|
1640
|
+
submitted: {
|
|
1641
|
+
forge: ctx.forge.id,
|
|
1642
|
+
change: `${ctx.repo.path}#${ctx.cr.number}`,
|
|
1643
|
+
head: ctx.cr.head,
|
|
1644
|
+
verdict: posted.verdict,
|
|
1645
|
+
comments: posted.posted,
|
|
1646
|
+
url: posted.url ?? ctx.cr.url,
|
|
1647
|
+
},
|
|
1648
|
+
warnings: warnings.length ? warnings : "0 (every comment is within the line budget)",
|
|
1649
|
+
notes: posted.notes.length ? posted.notes : "0 (the forge did exactly what was asked)",
|
|
1650
|
+
help: [
|
|
1651
|
+
`Record ${ctx.cr.head.slice(0, 12)} as the head you reviewed; a later pass diffs against it`,
|
|
1652
|
+
"Never merge, close or push to the change request; that decision is the maintainer's",
|
|
1653
|
+
],
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
const p = parseFlags("forge reply", rest, { ...common, body: s["body"], resolve: s["resolve"], at: s["at"] }, 1);
|
|
1657
|
+
const threadId = p.positional[0];
|
|
1658
|
+
if (!threadId)
|
|
1659
|
+
throw new AxiError("forge reply needs a thread id", "VALIDATION_ERROR", [
|
|
1660
|
+
'thurview forge reply <threadId> --body "<text>"',
|
|
1661
|
+
"Run `thurview forge prior` for the thread ids",
|
|
1662
|
+
]);
|
|
1663
|
+
const body = str(p, "body");
|
|
1664
|
+
const wantResolve = bool(p, "resolve");
|
|
1665
|
+
if (!body && !wantResolve)
|
|
1666
|
+
throw new AxiError("forge reply needs --body, --resolve, or both", "VALIDATION_ERROR", [
|
|
1667
|
+
'thurview forge reply <threadId> --body "<text>" --resolve --at <sha>',
|
|
1668
|
+
]);
|
|
1669
|
+
const ctx = await forgeContext(p);
|
|
1670
|
+
if (wantResolve) {
|
|
1671
|
+
const at = str(p, "at");
|
|
1672
|
+
if (!at)
|
|
1673
|
+
throw new AxiError("--resolve needs --at <sha>", "VALIDATION_ERROR", [
|
|
1674
|
+
"Resolving tells the author the point is verified; --at is the commit you verified it at",
|
|
1675
|
+
`The current head is ${ctx.cr.head}`,
|
|
1676
|
+
]);
|
|
1677
|
+
if (at.length < 7 || !ctx.cr.head.startsWith(at))
|
|
1678
|
+
throw new AxiError(`--at ${at} is not the current head of ${ctx.repo.path}#${ctx.cr.number}`, "CONFLICT", [
|
|
1679
|
+
`The head is ${ctx.cr.head}`,
|
|
1680
|
+
"Re-read the point at that head before resolving; a thread resolved against an older head tells the author a point was accepted that nobody checked",
|
|
1681
|
+
]);
|
|
1682
|
+
}
|
|
1683
|
+
const done = await ctx.forge.reply(ctx.repo, ctx.cr, threadId, body, wantResolve);
|
|
1684
|
+
return {
|
|
1685
|
+
thread: {
|
|
1686
|
+
id: threadId,
|
|
1687
|
+
change: `${ctx.repo.path}#${ctx.cr.number}`,
|
|
1688
|
+
replied: done.replied,
|
|
1689
|
+
resolved: done.resolved,
|
|
1690
|
+
verifiedAt: wantResolve ? ctx.cr.head : "",
|
|
1691
|
+
},
|
|
1692
|
+
notes: done.notes.length ? done.notes : "0 (the forge did exactly what was asked)",
|
|
1693
|
+
help: [
|
|
1694
|
+
"Leave a thread open when the point is only partially addressed, and say which part",
|
|
1695
|
+
`Run \`thurview forge prior --change ${ctx.cr.number}\` to see what is still open`,
|
|
1696
|
+
],
|
|
1697
|
+
};
|
|
1698
|
+
},
|
|
1369
1699
|
async delete(args) {
|
|
1370
1700
|
const p = parseFlags("delete", args, spec("delete").flags);
|
|
1371
1701
|
const idOpt = str(p, "review");
|
|
@@ -1418,7 +1748,7 @@ const commands = {
|
|
|
1418
1748
|
}
|
|
1419
1749
|
if (sub === "skill") {
|
|
1420
1750
|
const p = parseFlags("setup skill", args.slice(1), { targets: s["targets"] });
|
|
1421
|
-
const
|
|
1751
|
+
const names = await bundledSkills();
|
|
1422
1752
|
const dirs = {
|
|
1423
1753
|
claude: join(homedir(), ".claude", "skills"),
|
|
1424
1754
|
agents: join(homedir(), ".agents", "skills"),
|
|
@@ -1435,27 +1765,29 @@ const commands = {
|
|
|
1435
1765
|
"thurview setup skill --targets claude,agents,cursor",
|
|
1436
1766
|
]);
|
|
1437
1767
|
await mkdir(d, { recursive: true });
|
|
1438
|
-
const
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1768
|
+
for (const name of names) {
|
|
1769
|
+
const dst = join(d, name);
|
|
1770
|
+
try {
|
|
1771
|
+
const stt = await lstat(dst);
|
|
1772
|
+
if (stt.isSymbolicLink())
|
|
1773
|
+
await rm(dst);
|
|
1774
|
+
else
|
|
1775
|
+
throw new AxiError(`${dst} exists and is not a symlink`, "CONFLICT", [
|
|
1776
|
+
`Remove ${dst} and run \`thurview setup skill\` again`,
|
|
1777
|
+
]);
|
|
1778
|
+
}
|
|
1779
|
+
catch (e) {
|
|
1780
|
+
if (e instanceof AxiError)
|
|
1781
|
+
throw e;
|
|
1782
|
+
}
|
|
1783
|
+
await symlink(join(skillsRoot(), name), dst, "dir");
|
|
1451
1784
|
}
|
|
1452
|
-
|
|
1453
|
-
installed[t] = dst;
|
|
1785
|
+
installed[t] = `${join(d, `{${names.join(",")}}`)}`;
|
|
1454
1786
|
}
|
|
1455
1787
|
return {
|
|
1456
1788
|
skill: installed,
|
|
1457
1789
|
help: [
|
|
1458
|
-
"Invoke
|
|
1790
|
+
"Invoke them as /thurview and /forge-review in Claude Code, or by name in other agents",
|
|
1459
1791
|
"Run `thurview setup hooks` for ambient context at session start",
|
|
1460
1792
|
],
|
|
1461
1793
|
};
|
|
@@ -1464,10 +1796,12 @@ const commands = {
|
|
|
1464
1796
|
const st = sessionStartHookStatus({ ...identity, scope: "user" });
|
|
1465
1797
|
const skill = {};
|
|
1466
1798
|
for (const [t, d] of Object.entries({
|
|
1467
|
-
claude: join(homedir(), ".claude", "skills"
|
|
1468
|
-
agents: join(homedir(), ".agents", "skills"
|
|
1469
|
-
}))
|
|
1470
|
-
|
|
1799
|
+
claude: join(homedir(), ".claude", "skills"),
|
|
1800
|
+
agents: join(homedir(), ".agents", "skills"),
|
|
1801
|
+
})) {
|
|
1802
|
+
const there = (await bundledSkills()).filter((n) => existsSync(join(d, n)));
|
|
1803
|
+
skill[t] = there.length ? there.map((n) => join(d, n)).join(", ") : "not installed";
|
|
1804
|
+
}
|
|
1471
1805
|
return {
|
|
1472
1806
|
hooks: {
|
|
1473
1807
|
claude: st.claude.installed ? st.claude.path : "not installed",
|
|
@@ -1480,7 +1814,14 @@ const commands = {
|
|
|
1480
1814
|
},
|
|
1481
1815
|
async skill(args) {
|
|
1482
1816
|
parseFlags("skill", args, {});
|
|
1483
|
-
|
|
1817
|
+
const skills = {};
|
|
1818
|
+
for (const name of await bundledSkills())
|
|
1819
|
+
skills[name] = join(skillsRoot(), name, "SKILL.md");
|
|
1820
|
+
return {
|
|
1821
|
+
skill: skills["thurview"],
|
|
1822
|
+
skills,
|
|
1823
|
+
help: ["Read the SKILL.md of the one that matches the request, and its references beside it"],
|
|
1824
|
+
};
|
|
1484
1825
|
},
|
|
1485
1826
|
};
|
|
1486
1827
|
function topLevelHelp() {
|