thurview 0.8.3 → 0.10.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 +64 -15
- package/dist/cli.js +453 -82
- 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/graph.js +32 -2
- package/dist/graph.js.map +1 -1
- package/dist/store.js.map +1 -1
- package/package.json +1 -1
- package/skills/review-fix/SKILL.md +165 -0
- package/skills/review-fix/references/forges.md +58 -0
- package/skills/thurview/SKILL.md +5 -1
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";
|
|
@@ -18,6 +18,8 @@ import { replyThread, setThreadStatus, needsAgent } from "./threads.js";
|
|
|
18
18
|
import { attach } from "./presence.js";
|
|
19
19
|
import { startServer } from "./server/server.js";
|
|
20
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";
|
|
21
23
|
import { VERSION } from "./version.js";
|
|
22
24
|
const execFileP = promisify(execFile);
|
|
23
25
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -46,10 +48,27 @@ function targetLabel(t) {
|
|
|
46
48
|
return `map ${t.node}`;
|
|
47
49
|
return "review";
|
|
48
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
|
+
}
|
|
49
57
|
function lastMessage(t) {
|
|
50
58
|
const m = t.messages[t.messages.length - 1];
|
|
51
59
|
return m ? `${m.role}: ${truncate(m.body.replace(/\s+/g, " "), 120)}` : "";
|
|
52
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
|
+
}
|
|
53
72
|
async function worktreeOf(cwd) {
|
|
54
73
|
try {
|
|
55
74
|
return await g.repoRoot(cwd);
|
|
@@ -84,6 +103,33 @@ async function resolveReview(idOpt) {
|
|
|
84
103
|
"Run `thurview scaffold` to create one",
|
|
85
104
|
]);
|
|
86
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
|
+
}
|
|
87
133
|
async function reviewRow(r, fields) {
|
|
88
134
|
const t = await readThreads(r.id);
|
|
89
135
|
const row = {
|
|
@@ -96,7 +142,7 @@ async function reviewRow(r, fields) {
|
|
|
96
142
|
needsAgent: t.threads.filter(needsAgent).length,
|
|
97
143
|
};
|
|
98
144
|
if (fields.has("all") || fields.has("binding"))
|
|
99
|
-
row["binding"] = r.binding
|
|
145
|
+
row["binding"] = bindingLabel(r.binding);
|
|
100
146
|
if (fields.has("all") || fields.has("pins"))
|
|
101
147
|
row["pins"] =
|
|
102
148
|
kindOf(r) === "explainer"
|
|
@@ -169,22 +215,43 @@ async function ensureServer() {
|
|
|
169
215
|
function reviewUrl(base, id, view) {
|
|
170
216
|
return `${base}/review/${id}${view ? `#/${view}` : ""}`;
|
|
171
217
|
}
|
|
218
|
+
function pinnedOf(review) {
|
|
219
|
+
return { worktree: review.worktree, pins: review.pins, dir: reviewDir(review.id) };
|
|
220
|
+
}
|
|
172
221
|
/**
|
|
173
|
-
*
|
|
174
|
-
*
|
|
222
|
+
* Base and head as `--base` and `--head` name them. Head defaults to HEAD and
|
|
223
|
+
* base to where head forked from trunk, so a branch is diffed against what it
|
|
224
|
+
* branched from rather than against wherever trunk has moved since.
|
|
225
|
+
*/
|
|
226
|
+
async function pinRange(worktree, base, head, usage) {
|
|
227
|
+
try {
|
|
228
|
+
const h = await g.revParse(worktree, head ?? "HEAD");
|
|
229
|
+
const b = base
|
|
230
|
+
? await g.revParse(worktree, base)
|
|
231
|
+
: await g.mergeBase(worktree, await g.trunkRef(worktree), h);
|
|
232
|
+
return { base: b, head: h };
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
throw new AxiError(e.message, "VALIDATION_ERROR", [
|
|
236
|
+
`Pass resolvable refs: \`${usage}\``,
|
|
237
|
+
]);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* The interface delta at two pinned commits. The graphs are cached per commit
|
|
242
|
+
* under `at.dir`, so publish and `graph interfaces` build them once between
|
|
175
243
|
* them; both modules load lazily to keep tree-sitter off every other command's path.
|
|
176
244
|
*/
|
|
177
|
-
async function deltaFor(
|
|
245
|
+
async function deltaFor(at, base, head) {
|
|
178
246
|
const graph = await import("./graph.js");
|
|
179
247
|
const { interfaceDelta } = await import("./interfaces.js");
|
|
180
|
-
const
|
|
181
|
-
const
|
|
182
|
-
const
|
|
183
|
-
const
|
|
184
|
-
const changed = await g.changedFiles(review.worktree, review.pins.base, review.pins.head);
|
|
248
|
+
const b = base ?? (await graph.graphAt(at.worktree, at.pins.base, at.dir));
|
|
249
|
+
const h = head ?? (await graph.graphAt(at.worktree, at.pins.head, at.dir));
|
|
250
|
+
const changes = await g.lineChanges(at.worktree, at.pins.base, at.pins.head);
|
|
251
|
+
const changed = await g.changedFiles(at.worktree, at.pins.base, at.pins.head);
|
|
185
252
|
return interfaceDelta({
|
|
186
|
-
cwd:
|
|
187
|
-
pins:
|
|
253
|
+
cwd: at.worktree,
|
|
254
|
+
pins: at.pins,
|
|
188
255
|
base: b,
|
|
189
256
|
head: h,
|
|
190
257
|
impact: graph.impact(b, h, changes, 1),
|
|
@@ -272,13 +339,21 @@ const SPECS = {
|
|
|
272
339
|
scaffold: {
|
|
273
340
|
description: "Create a review pinned to exact base and head commits, or re-pin one",
|
|
274
341
|
flags: {
|
|
275
|
-
pr: {
|
|
342
|
+
pr: {
|
|
343
|
+
kind: "string",
|
|
344
|
+
help: "review a pull or merge request (number or URL, needs gh or glab)",
|
|
345
|
+
},
|
|
276
346
|
base: { kind: "string", help: "base revision (default: trunk fork point)" },
|
|
277
347
|
head: { kind: "string", help: "head revision (default: current branch)" },
|
|
278
348
|
title: { kind: "string", help: "initial title" },
|
|
279
349
|
new: { kind: "boolean", help: "create another review even if one matches the binding" },
|
|
280
350
|
update: { kind: "boolean", help: "re-pin an existing review from its binding" },
|
|
281
351
|
review: { kind: "string", help: "review to update (id prefix)" },
|
|
352
|
+
forge: {
|
|
353
|
+
kind: "string",
|
|
354
|
+
help: "github or gitlab, when the host is not one of the two known ones",
|
|
355
|
+
},
|
|
356
|
+
repo: { kind: "string", help: "host/path, when `origin` is not the repository to post to" },
|
|
282
357
|
},
|
|
283
358
|
examples: [
|
|
284
359
|
"thurview scaffold",
|
|
@@ -384,17 +459,55 @@ const SPECS = {
|
|
|
384
459
|
args: "interfaces|impact|callers <name>|tests-for <name>|architecture",
|
|
385
460
|
flags: {
|
|
386
461
|
review: { kind: "string", help: "review id prefix" },
|
|
462
|
+
base: {
|
|
463
|
+
kind: "string",
|
|
464
|
+
help: "instead of --review: base revision (default: trunk fork point)",
|
|
465
|
+
},
|
|
466
|
+
head: { kind: "string", help: "instead of --review: head revision (default: HEAD)" },
|
|
387
467
|
graph: { kind: "string", help: "callers, tests-for: head or base", default: "head" },
|
|
388
468
|
depth: { kind: "string", help: "how many caller hops to follow", default: "2" },
|
|
389
469
|
},
|
|
390
470
|
examples: [
|
|
391
471
|
"thurview graph interfaces",
|
|
392
472
|
"thurview graph impact",
|
|
473
|
+
"thurview graph impact --base main --head HEAD",
|
|
393
474
|
"thurview graph callers login",
|
|
394
475
|
"thurview graph tests-for login --graph base",
|
|
395
476
|
"thurview graph architecture",
|
|
396
477
|
],
|
|
397
478
|
},
|
|
479
|
+
forge: {
|
|
480
|
+
description: "Read a pull or merge request through its forge, and post the review back to it",
|
|
481
|
+
args: 'status|prior|submit --file <path>|reply <threadId> --body "<text>"',
|
|
482
|
+
flags: {
|
|
483
|
+
review: { kind: "string", help: "review id prefix; its binding names the change request" },
|
|
484
|
+
change: { kind: "string", help: "change request number or URL, instead of a review binding" },
|
|
485
|
+
forge: {
|
|
486
|
+
kind: "string",
|
|
487
|
+
help: "github or gitlab, when the host is not one of the two known ones",
|
|
488
|
+
},
|
|
489
|
+
repo: { kind: "string", help: "host/path, when `origin` is not the repository to post to" },
|
|
490
|
+
file: { kind: "string", help: "submit: the JSON submission to post" },
|
|
491
|
+
body: { kind: "string", help: "reply: the answer text" },
|
|
492
|
+
resolve: { kind: "boolean", help: "reply: resolve the thread as well as answering it" },
|
|
493
|
+
at: { kind: "string", help: "reply --resolve: the commit the point was verified at" },
|
|
494
|
+
confirm: { kind: "boolean", help: "submit: required to post an approve" },
|
|
495
|
+
"dry-run": { kind: "boolean", help: "submit: validate and report, post nothing" },
|
|
496
|
+
"max-lines": {
|
|
497
|
+
kind: "string",
|
|
498
|
+
help: "submit: warn above this many lines per comment",
|
|
499
|
+
default: "5",
|
|
500
|
+
},
|
|
501
|
+
mine: { kind: "boolean", help: "prior: only threads this account wrote" },
|
|
502
|
+
full: { kind: "boolean", help: "prior, status: do not truncate or filter" },
|
|
503
|
+
},
|
|
504
|
+
examples: [
|
|
505
|
+
"thurview forge status --change 123",
|
|
506
|
+
"thurview forge prior --change 123 --mine",
|
|
507
|
+
"thurview forge submit --file pass.json --dry-run",
|
|
508
|
+
'thurview forge reply <threadId> --body "<answer>" --resolve --at <sha>',
|
|
509
|
+
],
|
|
510
|
+
},
|
|
398
511
|
delete: {
|
|
399
512
|
description: "Delete a review and everything stored for it (the code is untouched)",
|
|
400
513
|
flags: { review: { kind: "string", help: "review id prefix (required)" } },
|
|
@@ -480,47 +593,31 @@ const commands = {
|
|
|
480
593
|
const pr = str(p, "pr");
|
|
481
594
|
if (pr || b?.kind === "pr") {
|
|
482
595
|
const ref = pr ?? b.name;
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
catch (e) {
|
|
489
|
-
throw new AxiError(`could not read pull request ${ref}: ${e.message.split("\n")[0]}`, "PR_ERROR", [
|
|
490
|
-
"Check `gh auth status` and the PR number",
|
|
491
|
-
"Or run `thurview scaffold --base <ref> --head <ref>`",
|
|
492
|
-
]);
|
|
493
|
-
}
|
|
596
|
+
const repo = await repoOf(worktree, str(p, "repo"));
|
|
597
|
+
const forge = await forgeFor(repo.host, str(p, "forge") ?? b?.forge);
|
|
598
|
+
const cr = await forge.get(repo, ref);
|
|
599
|
+
// A fork's head is not a branch in this checkout, so fetch it by the
|
|
600
|
+
// ref the forge publishes it under before anything tries to resolve it.
|
|
494
601
|
await g
|
|
495
|
-
.fetch(worktree, "origin",
|
|
602
|
+
.fetch(worktree, "origin", forge.fetchRef(cr), `refs/heads/${cr.baseBranch}`)
|
|
496
603
|
.catch(() => { });
|
|
497
|
-
head =
|
|
498
|
-
let baseRef = `origin/${
|
|
604
|
+
head = cr.head;
|
|
605
|
+
let baseRef = `origin/${cr.baseBranch}`;
|
|
499
606
|
try {
|
|
500
607
|
await g.revParse(worktree, baseRef);
|
|
501
608
|
}
|
|
502
609
|
catch {
|
|
503
|
-
baseRef =
|
|
610
|
+
baseRef = cr.baseBranch;
|
|
504
611
|
}
|
|
505
612
|
base = await g.mergeBase(worktree, baseRef, head);
|
|
506
|
-
binding = { kind: "pr", name:
|
|
507
|
-
title ||=
|
|
613
|
+
binding = { kind: "pr", name: cr.number, url: cr.url, forge: forge.id };
|
|
614
|
+
title ||= cr.title;
|
|
508
615
|
}
|
|
509
616
|
else if (str(p, "base") || str(p, "head") || b?.kind === "range") {
|
|
510
617
|
const [bb, hh] = b?.kind === "range" && !str(p, "base") && !str(p, "head")
|
|
511
618
|
? b.name.split("..")
|
|
512
619
|
: [str(p, "base"), str(p, "head")];
|
|
513
|
-
|
|
514
|
-
head = await g.revParse(worktree, hh ?? "HEAD");
|
|
515
|
-
base = bb
|
|
516
|
-
? await g.revParse(worktree, bb)
|
|
517
|
-
: await g.mergeBase(worktree, await g.trunkRef(worktree), head);
|
|
518
|
-
}
|
|
519
|
-
catch (e) {
|
|
520
|
-
throw new AxiError(e.message, "VALIDATION_ERROR", [
|
|
521
|
-
"Pass resolvable refs: `thurview scaffold --base <ref> --head <ref>`",
|
|
522
|
-
]);
|
|
523
|
-
}
|
|
620
|
+
({ base, head } = await pinRange(worktree, bb, hh, "thurview scaffold --base <ref> --head <ref>"));
|
|
524
621
|
binding = { kind: "range", name: `${base.slice(0, 12)}..${head.slice(0, 12)}` };
|
|
525
622
|
}
|
|
526
623
|
else {
|
|
@@ -602,7 +699,7 @@ const commands = {
|
|
|
602
699
|
title: review.title,
|
|
603
700
|
status: review.status,
|
|
604
701
|
rev: review.revision,
|
|
605
|
-
binding: binding
|
|
702
|
+
binding: bindingLabel(binding),
|
|
606
703
|
base,
|
|
607
704
|
head,
|
|
608
705
|
worktree,
|
|
@@ -816,7 +913,7 @@ const commands = {
|
|
|
816
913
|
let interfaces = null;
|
|
817
914
|
if (kind === "review") {
|
|
818
915
|
try {
|
|
819
|
-
interfaces = await deltaFor(review);
|
|
916
|
+
interfaces = await deltaFor(pinnedOf(review));
|
|
820
917
|
}
|
|
821
918
|
catch (e) {
|
|
822
919
|
diags.push({
|
|
@@ -1149,17 +1246,37 @@ const commands = {
|
|
|
1149
1246
|
const side = str(p, "graph") ?? "head";
|
|
1150
1247
|
if (side !== "head" && side !== "base")
|
|
1151
1248
|
throw new AxiError("--graph must be head or base", "VALIDATION_ERROR", help);
|
|
1249
|
+
const baseRef = str(p, "base");
|
|
1250
|
+
const headRef = str(p, "head");
|
|
1251
|
+
const commits = baseRef !== undefined || headRef !== undefined;
|
|
1252
|
+
if (commits && str(p, "review"))
|
|
1253
|
+
throw new AxiError("pass --review or --base/--head, not both", "VALIDATION_ERROR", help);
|
|
1152
1254
|
const graph = await import("./graph.js");
|
|
1153
|
-
const review = await resolveReview(str(p, "review"));
|
|
1154
|
-
|
|
1255
|
+
const review = commits ? null : await resolveReview(str(p, "review"));
|
|
1256
|
+
let t;
|
|
1257
|
+
if (review)
|
|
1258
|
+
t = pinnedOf(review);
|
|
1259
|
+
else {
|
|
1260
|
+
const worktree = await worktreeOf(process.cwd());
|
|
1261
|
+
if (!worktree)
|
|
1262
|
+
throw new AxiError("not inside a git repository", "VALIDATION_ERROR", [
|
|
1263
|
+
"Run inside the source worktree, or pass --review <id>",
|
|
1264
|
+
]);
|
|
1265
|
+
// No review directory owns these graphs, and a commit's graph is the same
|
|
1266
|
+
// whoever asks, so they share one cache under the thurview home.
|
|
1267
|
+
const pins = await pinRange(worktree, baseRef, headRef, "thurview graph impact --base <ref>");
|
|
1268
|
+
t = { worktree, pins, dir: home() };
|
|
1269
|
+
}
|
|
1270
|
+
// A next step has to name the same commits, or it answers about another change.
|
|
1271
|
+
const again = review ? "" : ` --base ${short(t.pins.base)} --head ${short(t.pins.head)}`;
|
|
1272
|
+
if (review && kindOf(review) === "explainer" && (sub === "interfaces" || sub === "impact"))
|
|
1155
1273
|
throw new AxiError(`graph ${sub} compares two commits; an explainer is pinned to one`, "VALIDATION_ERROR", [
|
|
1156
1274
|
`Run \`thurview graph architecture --review ${short(review.id)}\` for the structure at that commit`,
|
|
1157
1275
|
`Run \`thurview graph callers <name> --review ${short(review.id)}\` to follow one symbol`,
|
|
1158
1276
|
]);
|
|
1159
|
-
const
|
|
1160
|
-
const at = (commit) => graph.graphAt(review.worktree, commit, dir);
|
|
1277
|
+
const at = (commit) => graph.graphAt(t.worktree, commit, t.dir);
|
|
1161
1278
|
if (sub === "callers" || sub === "tests-for") {
|
|
1162
|
-
const g = await at(side === "base" ?
|
|
1279
|
+
const g = await at(side === "base" ? t.pins.base : t.pins.head);
|
|
1163
1280
|
const pins = {
|
|
1164
1281
|
graph: side,
|
|
1165
1282
|
commit: short(g.commit),
|
|
@@ -1171,25 +1288,25 @@ const commands = {
|
|
|
1171
1288
|
...pins,
|
|
1172
1289
|
symbol: name,
|
|
1173
1290
|
callers: graph.callers(g, name),
|
|
1174
|
-
help: [`Run \`thurview graph tests-for ${name}\` to see what exercises it`],
|
|
1291
|
+
help: [`Run \`thurview graph tests-for ${name}${again}\` to see what exercises it`],
|
|
1175
1292
|
};
|
|
1176
1293
|
return {
|
|
1177
1294
|
...pins,
|
|
1178
1295
|
symbol: name,
|
|
1179
1296
|
depth,
|
|
1180
1297
|
tests: graph.testsFor(g, name, depth),
|
|
1181
|
-
help: [`Run \`thurview graph callers ${name}\` for every reference`],
|
|
1298
|
+
help: [`Run \`thurview graph callers ${name}${again}\` for every reference`],
|
|
1182
1299
|
};
|
|
1183
1300
|
}
|
|
1184
|
-
const base = await at(
|
|
1185
|
-
const head = await at(
|
|
1301
|
+
const base = await at(t.pins.base);
|
|
1302
|
+
const head = await at(t.pins.head);
|
|
1186
1303
|
const pins = {
|
|
1187
1304
|
base: short(base.commit),
|
|
1188
1305
|
head: short(head.commit),
|
|
1189
1306
|
languages: graph.LANGUAGES.join(","),
|
|
1190
1307
|
};
|
|
1191
1308
|
if (sub === "interfaces") {
|
|
1192
|
-
const delta = await deltaFor(
|
|
1309
|
+
const delta = await deltaFor(t, base, head);
|
|
1193
1310
|
return {
|
|
1194
1311
|
...pins,
|
|
1195
1312
|
verdict: delta.verdict,
|
|
@@ -1207,29 +1324,31 @@ const commands = {
|
|
|
1207
1324
|
unreadable: delta.unreadable,
|
|
1208
1325
|
truncated: delta.truncated,
|
|
1209
1326
|
help: [
|
|
1210
|
-
|
|
1211
|
-
|
|
1327
|
+
...(review
|
|
1328
|
+
? ["Write one capability line per entry in data.yaml under `interfaces`, keyed by id"]
|
|
1329
|
+
: []),
|
|
1330
|
+
`Run \`thurview graph callers <name>${again}\` to see who a removed or changed interface reached`,
|
|
1212
1331
|
],
|
|
1213
1332
|
};
|
|
1214
1333
|
}
|
|
1215
1334
|
if (sub === "impact") {
|
|
1216
|
-
const changes = await g.lineChanges(
|
|
1335
|
+
const changes = await g.lineChanges(t.worktree, t.pins.base, t.pins.head);
|
|
1217
1336
|
return {
|
|
1218
1337
|
...pins,
|
|
1219
1338
|
depth,
|
|
1220
1339
|
...graph.impact(base, head, changes, depth),
|
|
1221
1340
|
help: [
|
|
1222
|
-
|
|
1223
|
-
|
|
1341
|
+
`Run \`thurview graph callers <name>${again}\` to follow one symbol`,
|
|
1342
|
+
`Run \`thurview graph architecture${again}\` for the module structure and its diff`,
|
|
1224
1343
|
],
|
|
1225
1344
|
};
|
|
1226
1345
|
}
|
|
1227
1346
|
// An explainer is scoped to a path, so its structure is that path's, not the
|
|
1228
1347
|
// repository's: the same bound the Coverage tab accounts for.
|
|
1229
|
-
if (kindOf(review) === "explainer") {
|
|
1348
|
+
if (review && kindOf(review) === "explainer") {
|
|
1230
1349
|
const scope = review.binding.name;
|
|
1231
1350
|
const g0 = scopeGraph(head, scope);
|
|
1232
|
-
const allFiles = await g.listFiles(
|
|
1351
|
+
const allFiles = await g.listFiles(t.worktree, t.pins.head);
|
|
1233
1352
|
const { diff: _diff, truncated: _truncated, ...rest } = graph.architecture(g0, g0);
|
|
1234
1353
|
return {
|
|
1235
1354
|
commit: short(head.commit),
|
|
@@ -1375,6 +1494,247 @@ const commands = {
|
|
|
1375
1494
|
],
|
|
1376
1495
|
};
|
|
1377
1496
|
},
|
|
1497
|
+
async forge(args) {
|
|
1498
|
+
const sub = args[0];
|
|
1499
|
+
const s = spec("forge").flags;
|
|
1500
|
+
const usage = [
|
|
1501
|
+
"thurview forge status [--change <ref>] [--review <id>]",
|
|
1502
|
+
"thurview forge prior [--change <ref>] [--mine] [--full]",
|
|
1503
|
+
"thurview forge submit --file <path> [--dry-run] [--confirm]",
|
|
1504
|
+
'thurview forge reply <threadId> --body "<text>" [--resolve --at <sha>]',
|
|
1505
|
+
];
|
|
1506
|
+
if (!sub || !["status", "prior", "submit", "reply"].includes(sub))
|
|
1507
|
+
throw new AxiError(`unknown forge command${sub ? ` ${sub}` : ""}`, "VALIDATION_ERROR", usage);
|
|
1508
|
+
const common = {
|
|
1509
|
+
review: s["review"],
|
|
1510
|
+
change: s["change"],
|
|
1511
|
+
forge: s["forge"],
|
|
1512
|
+
repo: s["repo"],
|
|
1513
|
+
};
|
|
1514
|
+
const rest = args.slice(1);
|
|
1515
|
+
if (sub === "status") {
|
|
1516
|
+
const p = parseFlags("forge status", rest, { ...common, full: s["full"] });
|
|
1517
|
+
const ctx = await forgeContext(p);
|
|
1518
|
+
const checks = await ctx.forge.checks(ctx.repo, ctx.cr);
|
|
1519
|
+
const baseline = await ctx.forge.baseline(ctx.repo, ctx.cr.baseBranch).catch(() => null);
|
|
1520
|
+
const ci = summariseCi(checks, baseline, ctx.cr.baseBranch);
|
|
1521
|
+
const shown = bool(p, "full") ? checks : checks.filter((c) => c.state !== "passed");
|
|
1522
|
+
const hidden = checks.length - shown.length;
|
|
1523
|
+
const help = [
|
|
1524
|
+
`Quote \`ci.verdict\` in the review; do not read "nothing failed" as "the tests passed"`,
|
|
1525
|
+
`Run \`thurview forge prior --change ${ctx.cr.number}\` to read the previous pass before writing a new one`,
|
|
1526
|
+
];
|
|
1527
|
+
if (hidden && !bool(p, "full"))
|
|
1528
|
+
help.push(`${hidden} passing checks hidden; pass --full to list them`);
|
|
1529
|
+
if (ctx.review && ctx.review.pins.head !== ctx.cr.head)
|
|
1530
|
+
help.unshift(`The head moved since this review was pinned; run \`thurview scaffold --update --review ${short(ctx.review.id)}\` and diff only what moved`);
|
|
1531
|
+
return {
|
|
1532
|
+
change: {
|
|
1533
|
+
forge: ctx.forge.id,
|
|
1534
|
+
repo: `${ctx.repo.host}/${ctx.repo.path}`,
|
|
1535
|
+
number: ctx.cr.number,
|
|
1536
|
+
title: ctx.cr.title,
|
|
1537
|
+
url: ctx.cr.url,
|
|
1538
|
+
state: ctx.cr.state,
|
|
1539
|
+
author: ctx.cr.author,
|
|
1540
|
+
draft: ctx.cr.draft,
|
|
1541
|
+
fromFork: ctx.cr.fromFork,
|
|
1542
|
+
head: ctx.cr.head,
|
|
1543
|
+
headBranch: ctx.cr.headBranch,
|
|
1544
|
+
baseBranch: ctx.cr.baseBranch,
|
|
1545
|
+
},
|
|
1546
|
+
ci,
|
|
1547
|
+
checks: shown.length
|
|
1548
|
+
? shown.map((c) => ({ name: c.name, state: c.state, raw: c.raw }))
|
|
1549
|
+
: `0 of ${checks.length} checks need attention`,
|
|
1550
|
+
...(ctx.review
|
|
1551
|
+
? {
|
|
1552
|
+
review: {
|
|
1553
|
+
id: short(ctx.review.id),
|
|
1554
|
+
pinnedHead: ctx.review.pins.head,
|
|
1555
|
+
movedSincePin: ctx.review.pins.head !== ctx.cr.head,
|
|
1556
|
+
},
|
|
1557
|
+
}
|
|
1558
|
+
: {}),
|
|
1559
|
+
permalink: ctx.forge.permalink(ctx.repo, ctx.cr.head, "<path>", 10, 20),
|
|
1560
|
+
help,
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
if (sub === "prior") {
|
|
1564
|
+
const p = parseFlags("forge prior", rest, {
|
|
1565
|
+
...common,
|
|
1566
|
+
mine: s["mine"],
|
|
1567
|
+
full: s["full"],
|
|
1568
|
+
});
|
|
1569
|
+
const ctx = await forgeContext(p);
|
|
1570
|
+
const prior = await ctx.forge.prior(ctx.repo, ctx.cr);
|
|
1571
|
+
const { threads } = prior;
|
|
1572
|
+
// A forge wraps inline comments in a pass of its own with an empty
|
|
1573
|
+
// body. That envelope is not something to answer; the thread under it
|
|
1574
|
+
// is, and it is already in `threads`.
|
|
1575
|
+
const passes = prior.passes.filter((x) => x.body.trim() !== "" || x.verdict !== "commented");
|
|
1576
|
+
const me = await ctx.forge.whoami(ctx.repo).catch(() => "");
|
|
1577
|
+
const full = bool(p, "full");
|
|
1578
|
+
const rows = (bool(p, "mine") ? threads.filter((t) => t.author === me) : threads).map((t) => {
|
|
1579
|
+
const last = t.messages[t.messages.length - 1];
|
|
1580
|
+
return {
|
|
1581
|
+
id: t.id,
|
|
1582
|
+
author: t.author,
|
|
1583
|
+
at: t.path ? `${t.path}${t.line ? `:${t.line}` : ""}` : "(change request)",
|
|
1584
|
+
resolved: t.resolved,
|
|
1585
|
+
outdated: t.outdated,
|
|
1586
|
+
atHead: t.commit ? t.commit === ctx.cr.head : null,
|
|
1587
|
+
messages: t.messages.length,
|
|
1588
|
+
last: last ? (full ? last.body : truncate(last.body.replace(/\s+/g, " "), 160)) : "",
|
|
1589
|
+
};
|
|
1590
|
+
});
|
|
1591
|
+
const open = rows.filter((r) => !r.resolved).length;
|
|
1592
|
+
return {
|
|
1593
|
+
summary: {
|
|
1594
|
+
passes: passes.length,
|
|
1595
|
+
threads: rows.length,
|
|
1596
|
+
open,
|
|
1597
|
+
resolved: rows.length - open,
|
|
1598
|
+
mine: me ? threads.filter((t) => t.author === me).length : null,
|
|
1599
|
+
notAtHead: rows.filter((r) => r.atHead === false).length,
|
|
1600
|
+
head: ctx.cr.head,
|
|
1601
|
+
},
|
|
1602
|
+
passes: passes.length
|
|
1603
|
+
? passes.map((x) => ({
|
|
1604
|
+
author: x.author,
|
|
1605
|
+
verdict: x.verdict,
|
|
1606
|
+
at: x.at,
|
|
1607
|
+
commit: x.commit ?? "",
|
|
1608
|
+
body: full ? x.body : truncate(x.body.replace(/\s+/g, " "), 200),
|
|
1609
|
+
}))
|
|
1610
|
+
: "0 (nobody has reviewed this change request yet)",
|
|
1611
|
+
threads: rows.length ? rows : "0 (no review thread on this change request)",
|
|
1612
|
+
help: passes.length || rows.length
|
|
1613
|
+
? [
|
|
1614
|
+
"Go through every open thread point by point: addressed, partially addressed, or untouched",
|
|
1615
|
+
'Answer one with `thurview forge reply <threadId> --body "<text>"`, and add --resolve --at <sha> only once you verified the point at that head',
|
|
1616
|
+
full ? "" : "Pass --full for the untruncated bodies",
|
|
1617
|
+
].filter(Boolean)
|
|
1618
|
+
: ["This is the first pass; there is no prior review to answer"],
|
|
1619
|
+
};
|
|
1620
|
+
}
|
|
1621
|
+
if (sub === "submit") {
|
|
1622
|
+
const p = parseFlags("forge submit", rest, {
|
|
1623
|
+
...common,
|
|
1624
|
+
file: s["file"],
|
|
1625
|
+
confirm: s["confirm"],
|
|
1626
|
+
"dry-run": s["dry-run"],
|
|
1627
|
+
"max-lines": s["max-lines"],
|
|
1628
|
+
});
|
|
1629
|
+
const file = str(p, "file");
|
|
1630
|
+
if (!file)
|
|
1631
|
+
throw new AxiError("--file is required", "VALIDATION_ERROR", [
|
|
1632
|
+
"thurview forge submit --file <path>",
|
|
1633
|
+
]);
|
|
1634
|
+
const text = await readText(resolve(process.cwd(), file));
|
|
1635
|
+
if (text === null)
|
|
1636
|
+
throw new AxiError(`${file} not found`, "NOT_FOUND", [
|
|
1637
|
+
'Write the pass as JSON: {"verdict": "comment", "body": "<summary>", "comments": []}',
|
|
1638
|
+
]);
|
|
1639
|
+
const submission = parseSubmission(text, file);
|
|
1640
|
+
const max = Number(str(p, "max-lines"));
|
|
1641
|
+
const warnings = longComments(submission, Number.isFinite(max) && max > 0 ? max : 5);
|
|
1642
|
+
const consequences = submission.verdict === "approve"
|
|
1643
|
+
? [
|
|
1644
|
+
"Approving dismisses any standing request for changes, which is what makes this mergeable",
|
|
1645
|
+
"Where auto-merge is armed, approving merges the code with no further human read",
|
|
1646
|
+
"Say that to the user before you pass --confirm",
|
|
1647
|
+
]
|
|
1648
|
+
: [];
|
|
1649
|
+
const dry = bool(p, "dry-run");
|
|
1650
|
+
if (submission.verdict === "approve" && !bool(p, "confirm") && !dry)
|
|
1651
|
+
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"]);
|
|
1652
|
+
const ctx = await forgeContext(p);
|
|
1653
|
+
if (dry)
|
|
1654
|
+
return {
|
|
1655
|
+
dryRun: {
|
|
1656
|
+
forge: ctx.forge.id,
|
|
1657
|
+
change: `${ctx.repo.path}#${ctx.cr.number}`,
|
|
1658
|
+
head: ctx.cr.head,
|
|
1659
|
+
verdict: submission.verdict,
|
|
1660
|
+
comments: submission.comments.length,
|
|
1661
|
+
bodyLines: submission.body.trimEnd().split("\n").length,
|
|
1662
|
+
},
|
|
1663
|
+
comments: submission.comments.length
|
|
1664
|
+
? submission.comments.map((c) => ({
|
|
1665
|
+
at: `${c.path}:${c.startLine && c.startLine < c.line ? `${c.startLine}-${c.line}` : c.line}`,
|
|
1666
|
+
side: c.side ?? "head",
|
|
1667
|
+
lines: c.body.trimEnd().split("\n").length,
|
|
1668
|
+
}))
|
|
1669
|
+
: "0 (a summary-only pass)",
|
|
1670
|
+
warnings: warnings.length ? warnings : "0 (every comment is within the line budget)",
|
|
1671
|
+
...(consequences.length ? { consequences } : {}),
|
|
1672
|
+
help: [
|
|
1673
|
+
"Nothing was posted; re-run without --dry-run to post it",
|
|
1674
|
+
"Check every anchor is a line the diff actually touches, or the forge refuses the comment",
|
|
1675
|
+
],
|
|
1676
|
+
};
|
|
1677
|
+
const posted = await ctx.forge.submit(ctx.repo, ctx.cr, submission);
|
|
1678
|
+
return {
|
|
1679
|
+
submitted: {
|
|
1680
|
+
forge: ctx.forge.id,
|
|
1681
|
+
change: `${ctx.repo.path}#${ctx.cr.number}`,
|
|
1682
|
+
head: ctx.cr.head,
|
|
1683
|
+
verdict: posted.verdict,
|
|
1684
|
+
comments: posted.posted,
|
|
1685
|
+
url: posted.url ?? ctx.cr.url,
|
|
1686
|
+
},
|
|
1687
|
+
warnings: warnings.length ? warnings : "0 (every comment is within the line budget)",
|
|
1688
|
+
notes: posted.notes.length ? posted.notes : "0 (the forge did exactly what was asked)",
|
|
1689
|
+
help: [
|
|
1690
|
+
`Record ${ctx.cr.head.slice(0, 12)} as the head you reviewed; a later pass diffs against it`,
|
|
1691
|
+
"Never merge, close or push to the change request; that decision is the maintainer's",
|
|
1692
|
+
],
|
|
1693
|
+
};
|
|
1694
|
+
}
|
|
1695
|
+
const p = parseFlags("forge reply", rest, { ...common, body: s["body"], resolve: s["resolve"], at: s["at"] }, 1);
|
|
1696
|
+
const threadId = p.positional[0];
|
|
1697
|
+
if (!threadId)
|
|
1698
|
+
throw new AxiError("forge reply needs a thread id", "VALIDATION_ERROR", [
|
|
1699
|
+
'thurview forge reply <threadId> --body "<text>"',
|
|
1700
|
+
"Run `thurview forge prior` for the thread ids",
|
|
1701
|
+
]);
|
|
1702
|
+
const body = str(p, "body");
|
|
1703
|
+
const wantResolve = bool(p, "resolve");
|
|
1704
|
+
if (!body && !wantResolve)
|
|
1705
|
+
throw new AxiError("forge reply needs --body, --resolve, or both", "VALIDATION_ERROR", [
|
|
1706
|
+
'thurview forge reply <threadId> --body "<text>" --resolve --at <sha>',
|
|
1707
|
+
]);
|
|
1708
|
+
const ctx = await forgeContext(p);
|
|
1709
|
+
if (wantResolve) {
|
|
1710
|
+
const at = str(p, "at");
|
|
1711
|
+
if (!at)
|
|
1712
|
+
throw new AxiError("--resolve needs --at <sha>", "VALIDATION_ERROR", [
|
|
1713
|
+
"Resolving tells the author the point is verified; --at is the commit you verified it at",
|
|
1714
|
+
`The current head is ${ctx.cr.head}`,
|
|
1715
|
+
]);
|
|
1716
|
+
if (at.length < 7 || !ctx.cr.head.startsWith(at))
|
|
1717
|
+
throw new AxiError(`--at ${at} is not the current head of ${ctx.repo.path}#${ctx.cr.number}`, "CONFLICT", [
|
|
1718
|
+
`The head is ${ctx.cr.head}`,
|
|
1719
|
+
"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",
|
|
1720
|
+
]);
|
|
1721
|
+
}
|
|
1722
|
+
const done = await ctx.forge.reply(ctx.repo, ctx.cr, threadId, body, wantResolve);
|
|
1723
|
+
return {
|
|
1724
|
+
thread: {
|
|
1725
|
+
id: threadId,
|
|
1726
|
+
change: `${ctx.repo.path}#${ctx.cr.number}`,
|
|
1727
|
+
replied: done.replied,
|
|
1728
|
+
resolved: done.resolved,
|
|
1729
|
+
verifiedAt: wantResolve ? ctx.cr.head : "",
|
|
1730
|
+
},
|
|
1731
|
+
notes: done.notes.length ? done.notes : "0 (the forge did exactly what was asked)",
|
|
1732
|
+
help: [
|
|
1733
|
+
"Leave a thread open when the point is only partially addressed, and say which part",
|
|
1734
|
+
`Run \`thurview forge prior --change ${ctx.cr.number}\` to see what is still open`,
|
|
1735
|
+
],
|
|
1736
|
+
};
|
|
1737
|
+
},
|
|
1378
1738
|
async delete(args) {
|
|
1379
1739
|
const p = parseFlags("delete", args, spec("delete").flags);
|
|
1380
1740
|
const idOpt = str(p, "review");
|
|
@@ -1427,7 +1787,7 @@ const commands = {
|
|
|
1427
1787
|
}
|
|
1428
1788
|
if (sub === "skill") {
|
|
1429
1789
|
const p = parseFlags("setup skill", args.slice(1), { targets: s["targets"] });
|
|
1430
|
-
const
|
|
1790
|
+
const names = await bundledSkills();
|
|
1431
1791
|
const dirs = {
|
|
1432
1792
|
claude: join(homedir(), ".claude", "skills"),
|
|
1433
1793
|
agents: join(homedir(), ".agents", "skills"),
|
|
@@ -1444,27 +1804,29 @@ const commands = {
|
|
|
1444
1804
|
"thurview setup skill --targets claude,agents,cursor",
|
|
1445
1805
|
]);
|
|
1446
1806
|
await mkdir(d, { recursive: true });
|
|
1447
|
-
const
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1807
|
+
for (const name of names) {
|
|
1808
|
+
const dst = join(d, name);
|
|
1809
|
+
try {
|
|
1810
|
+
const stt = await lstat(dst);
|
|
1811
|
+
if (stt.isSymbolicLink())
|
|
1812
|
+
await rm(dst);
|
|
1813
|
+
else
|
|
1814
|
+
throw new AxiError(`${dst} exists and is not a symlink`, "CONFLICT", [
|
|
1815
|
+
`Remove ${dst} and run \`thurview setup skill\` again`,
|
|
1816
|
+
]);
|
|
1817
|
+
}
|
|
1818
|
+
catch (e) {
|
|
1819
|
+
if (e instanceof AxiError)
|
|
1820
|
+
throw e;
|
|
1821
|
+
}
|
|
1822
|
+
await symlink(join(skillsRoot(), name), dst, "dir");
|
|
1460
1823
|
}
|
|
1461
|
-
|
|
1462
|
-
installed[t] = dst;
|
|
1824
|
+
installed[t] = `${join(d, `{${names.join(",")}}`)}`;
|
|
1463
1825
|
}
|
|
1464
1826
|
return {
|
|
1465
1827
|
skill: installed,
|
|
1466
1828
|
help: [
|
|
1467
|
-
"Invoke
|
|
1829
|
+
"Invoke them as /thurview and /review-fix in Claude Code, or by name in other agents",
|
|
1468
1830
|
"Run `thurview setup hooks` for ambient context at session start",
|
|
1469
1831
|
],
|
|
1470
1832
|
};
|
|
@@ -1473,10 +1835,12 @@ const commands = {
|
|
|
1473
1835
|
const st = sessionStartHookStatus({ ...identity, scope: "user" });
|
|
1474
1836
|
const skill = {};
|
|
1475
1837
|
for (const [t, d] of Object.entries({
|
|
1476
|
-
claude: join(homedir(), ".claude", "skills"
|
|
1477
|
-
agents: join(homedir(), ".agents", "skills"
|
|
1478
|
-
}))
|
|
1479
|
-
|
|
1838
|
+
claude: join(homedir(), ".claude", "skills"),
|
|
1839
|
+
agents: join(homedir(), ".agents", "skills"),
|
|
1840
|
+
})) {
|
|
1841
|
+
const there = (await bundledSkills()).filter((n) => existsSync(join(d, n)));
|
|
1842
|
+
skill[t] = there.length ? there.map((n) => join(d, n)).join(", ") : "not installed";
|
|
1843
|
+
}
|
|
1480
1844
|
return {
|
|
1481
1845
|
hooks: {
|
|
1482
1846
|
claude: st.claude.installed ? st.claude.path : "not installed",
|
|
@@ -1489,7 +1853,14 @@ const commands = {
|
|
|
1489
1853
|
},
|
|
1490
1854
|
async skill(args) {
|
|
1491
1855
|
parseFlags("skill", args, {});
|
|
1492
|
-
|
|
1856
|
+
const skills = {};
|
|
1857
|
+
for (const name of await bundledSkills())
|
|
1858
|
+
skills[name] = join(skillsRoot(), name, "SKILL.md");
|
|
1859
|
+
return {
|
|
1860
|
+
skill: skills["thurview"],
|
|
1861
|
+
skills,
|
|
1862
|
+
help: ["Read the SKILL.md of the one that matches the request, and its references beside it"],
|
|
1863
|
+
};
|
|
1493
1864
|
},
|
|
1494
1865
|
};
|
|
1495
1866
|
function topLevelHelp() {
|