prreviewbuddy 0.15.2 → 0.18.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/CHANGELOG.md +95 -0
- package/LICENSE +11 -0
- package/README.md +64 -0
- package/dist/main.js +834 -171
- package/dist/{relative_time-Dh7GvPvk.js → relative_time-Ckbb4Srd.js} +916 -483
- package/dist/server.js +121 -21
- package/package.json +3 -2
- package/static/reviews.js +1 -1
- package/static/shell.css +113 -3
package/dist/main.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { $ as
|
|
3
|
-
import { basename, join } from "node:path";
|
|
2
|
+
import { $ as agentIdOf, B as MANAGED_ROOT, C as LANDING_PAGE, Ct as agentById, D as updateReview, G as allJobs, H as removeWorktree, I as forgeResolver, J as loadJob, M as reanalyseReview, N as runJob, P as startJob, Q as git, R as clearClaim, St as AGENT_IDS, Tt as detectAgents, U as checkFreshness, V as readMarker, W as WORK_PHASES, X as resolveTarget, Z as displayRef, a as liveJobsFor, at as positionInLineage, bt as STORE_ROOT, dt as summarise, et as followedRefName, f as ensureServer, g as workspaceUrl, h as stopServer, it as loadWorkspace, l as record, lt as reviewedRepositories, m as reviewsUrl, nt as lineageKeyFor, o as runningJobs, p as readServerRecord, q as isTerminal, r as deleteReview$1, st as recentWorkspaces, t as relativeTime, tt as groupByLineage, u as bootstrapUrl, ut as saveWorkspace, v as BUILD_VERSION, y as PACKAGE_NAME, z as isClaimed } from "./relative_time-Ckbb4Srd.js";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { hostname } from "node:os";
|
|
6
7
|
import { createInterface } from "node:readline";
|
|
7
8
|
import { platform } from "node:process";
|
|
8
9
|
//#region src/args.ts
|
|
@@ -17,18 +18,26 @@ var VALUED = {
|
|
|
17
18
|
],
|
|
18
19
|
open: [],
|
|
19
20
|
agents: [],
|
|
20
|
-
config: []
|
|
21
|
+
config: [],
|
|
22
|
+
uninstall: []
|
|
21
23
|
};
|
|
22
24
|
/** Flags that stand alone, per command. */
|
|
23
25
|
var BARE = {
|
|
24
26
|
review: [
|
|
25
27
|
"--fresh",
|
|
26
28
|
"--update",
|
|
27
|
-
"--reanalyse"
|
|
29
|
+
"--reanalyse",
|
|
30
|
+
"--delete",
|
|
31
|
+
"--yes"
|
|
28
32
|
],
|
|
29
33
|
open: [],
|
|
30
34
|
agents: [],
|
|
31
|
-
config: []
|
|
35
|
+
config: [],
|
|
36
|
+
uninstall: [
|
|
37
|
+
"--yes",
|
|
38
|
+
"--force",
|
|
39
|
+
"--keep-reviews"
|
|
40
|
+
]
|
|
32
41
|
};
|
|
33
42
|
/**
|
|
34
43
|
* How many positionals each command takes.
|
|
@@ -44,7 +53,8 @@ var POSITIONALS = {
|
|
|
44
53
|
review: 0,
|
|
45
54
|
open: 1,
|
|
46
55
|
agents: 1,
|
|
47
|
-
config: 3
|
|
56
|
+
config: 3,
|
|
57
|
+
uninstall: 0
|
|
48
58
|
};
|
|
49
59
|
/** The three flags that say what is being acted on, and the field each becomes. */
|
|
50
60
|
var TARGETS = {
|
|
@@ -62,7 +72,8 @@ var TARGETS = {
|
|
|
62
72
|
var OPERATIONS = {
|
|
63
73
|
"--fresh": ["fresh", "reviews the target again, keeping the review that exists"],
|
|
64
74
|
"--update": ["update", "brings the existing review up to date with new commits"],
|
|
65
|
-
"--reanalyse": ["reanalyse", "analyses the same commit again, for a fresh judgement"]
|
|
75
|
+
"--reanalyse": ["reanalyse", "analyses the same commit again, for a fresh judgement"],
|
|
76
|
+
"--delete": ["delete", "removes the review and everything it was made in"]
|
|
66
77
|
};
|
|
67
78
|
function parse(argv) {
|
|
68
79
|
if (argv.length === 0) return { name: "help" };
|
|
@@ -107,6 +118,10 @@ function parse(argv) {
|
|
|
107
118
|
name: "error",
|
|
108
119
|
message: strayArgument(positional[0])
|
|
109
120
|
};
|
|
121
|
+
if (head === "uninstall" && positional[0]) return {
|
|
122
|
+
name: "error",
|
|
123
|
+
message: `\`uninstall\` removes everything on this machine, so it takes no target and \`${positional[0]}\` means nothing to it. It takes \`--keep-reviews\`, \`--yes\` and \`--force\`.`
|
|
124
|
+
};
|
|
110
125
|
const allowed = POSITIONALS[head] ?? 1;
|
|
111
126
|
if (positional.length > allowed) return {
|
|
112
127
|
name: "error",
|
|
@@ -126,9 +141,16 @@ function parse(argv) {
|
|
|
126
141
|
...target ? { [TARGETS[target]]: values[target] } : {},
|
|
127
142
|
...values["--base"] ? { base: values["--base"] } : {},
|
|
128
143
|
...values["--agent"] ? { agentId: values["--agent"] } : {},
|
|
129
|
-
...operation ? { operation: OPERATIONS[operation][0] } : {}
|
|
144
|
+
...operation ? { operation: OPERATIONS[operation][0] } : {},
|
|
145
|
+
...flags.has("--yes") ? { yes: true } : {}
|
|
130
146
|
};
|
|
131
147
|
}
|
|
148
|
+
if (head === "uninstall") return {
|
|
149
|
+
name: "uninstall",
|
|
150
|
+
yes: flags.has("--yes"),
|
|
151
|
+
force: flags.has("--force"),
|
|
152
|
+
keepReviews: flags.has("--keep-reviews")
|
|
153
|
+
};
|
|
132
154
|
if (head === "agents") return {
|
|
133
155
|
name: "agents",
|
|
134
156
|
...first ? { id: first } : {}
|
|
@@ -180,10 +202,18 @@ function refuseCombination(values, flags) {
|
|
|
180
202
|
return `\`${one}\` ${OPERATIONS[one][1]}, and \`${two}\` ${OPERATIONS[two][1]}. Ask for one of them.`;
|
|
181
203
|
}
|
|
182
204
|
if (values["--pr"] && values["--base"]) return "A pull request is compared against the branch it is asking to merge into, so `--base` would stop the diff being the pull request's. To compare its branch against something else, review the branch: `prreviewbuddy review --branch <branch> --base <ref>`.";
|
|
205
|
+
if (flags.has("--delete")) {
|
|
206
|
+
if (!values["--review"]) {
|
|
207
|
+
const named = values["--branch"] ?? values["--pr"];
|
|
208
|
+
return (named ? `\`--delete\` removes one review, and \`${named}\` can name several: reviewing a target twice with \`--fresh\` leaves two. ` : "`--delete` removes one review, and the branch you are standing on can have several: reviewing it twice with `--fresh` leaves two. ") + "Say which with `--review <review>`, which `prreviewbuddy open` prints beside each review.";
|
|
209
|
+
}
|
|
210
|
+
if (values["--agent"]) return "`--agent` chooses who analyses a change, and `--delete` analyses nothing. Drop it, or drop `--delete` if you meant to review something.";
|
|
211
|
+
}
|
|
212
|
+
if (flags.has("--yes") && !flags.has("--delete")) return "`--yes` answers the confirmation that `--delete` asks, and nothing here is asking one. Add `--delete` if you meant to remove a review.";
|
|
183
213
|
if (values["--review"]) {
|
|
184
214
|
if (values["--base"]) return "A review's base is settled when it is made and is part of what identifies it, so `--base` cannot move it. Review the branch against that base instead, and you will have both.";
|
|
185
215
|
if (flags.has("--fresh")) return `\`--fresh\` makes another review of a target, and \`--review ${values["--review"]}\` names a review rather than a target. Name the branch or the pull request you want reviewed again.`;
|
|
186
|
-
if (!flags.has("--update") && !flags.has("--reanalyse")) return `\`--review ${values["--review"]}\` says which review but not what to do with it. To read it, run \`prreviewbuddy open ${values["--review"]}\`; to act on it, add \`--update\` or \`--
|
|
216
|
+
if (!flags.has("--update") && !flags.has("--reanalyse") && !flags.has("--delete")) return `\`--review ${values["--review"]}\` says which review but not what to do with it. To read it, run \`prreviewbuddy open ${values["--review"]}\`; to act on it, add \`--update\`, \`--reanalyse\` or \`--delete\`.`;
|
|
187
217
|
}
|
|
188
218
|
return null;
|
|
189
219
|
}
|
|
@@ -203,7 +233,7 @@ function strayArgument(argument) {
|
|
|
203
233
|
}
|
|
204
234
|
function unknownCommand(head) {
|
|
205
235
|
if (head === "update") return "Updating a review is something you do to a target now, rather than a command of its own. Run `prreviewbuddy review --update` for the branch you are on, or `prreviewbuddy review --branch feat/foo --update` for one you are not.";
|
|
206
|
-
return `There is no \`${head}\` command. This does
|
|
236
|
+
return `There is no \`${head}\` command. This does five things: review, open, agents, config and uninstall. Run \`prreviewbuddy --help\` for what each one takes.`;
|
|
207
237
|
}
|
|
208
238
|
//#endregion
|
|
209
239
|
//#region ../../packages/review-harness/src/workspace/agent_preference.ts
|
|
@@ -554,167 +584,31 @@ function describeMovement(existing) {
|
|
|
554
584
|
return `${subject} has moved ${existing.commitsBehind} commit${existing.commitsBehind === 1 ? "" : "s"} since.`;
|
|
555
585
|
}
|
|
556
586
|
//#endregion
|
|
557
|
-
//#region src/
|
|
558
|
-
/**
|
|
559
|
-
* What the review is doing, while it does it.
|
|
560
|
-
*
|
|
561
|
-
* The job record is the source of truth, and it is the same one the workspace page polls over
|
|
562
|
-
* HTTP. Read here directly from the store rather than through the server, because this process is
|
|
563
|
-
* the one driving the job and a loopback request to ask itself what it is doing would be a strange
|
|
564
|
-
* way to find out.
|
|
565
|
-
*
|
|
566
|
-
* This used to print `job.progress ?? job.phase` whenever it changed, which meant the raw phase
|
|
567
|
-
* enum reached the terminal as a bare `preparing` before the first progress note arrived, and every
|
|
568
|
-
* later note appended a line to a growing log. A reviewer could not tell what had finished, what
|
|
569
|
-
* was running, or how much was left.
|
|
570
|
-
*
|
|
571
|
-
* It now renders one block: a header, then a line per phase with a mark for its state, and the
|
|
572
|
-
* agent's own notes indented under whichever phase is currently running. On a terminal the block is
|
|
573
|
-
* redrawn in place, so the review reads as one task progressing rather than as a transcript. Where
|
|
574
|
-
* stderr is not a terminal (a pipe, a CI log, a file) it falls back to appending only the lines
|
|
575
|
-
* that changed, because cursor movement written into a log is worse than a plain list.
|
|
576
|
-
*
|
|
577
|
-
* Progress goes to stderr and the review's link goes to stdout, so `prreviewbuddy review | pbcopy`
|
|
578
|
-
* copies a URL rather than a transcript.
|
|
579
|
-
*/
|
|
580
|
-
var POLL_INTERVAL_MS = 500;
|
|
581
|
-
/**
|
|
582
|
-
* Two forms per phase, because a finished step and a running one are different sentences.
|
|
583
|
-
*
|
|
584
|
-
* The page says these differently again (`job_view.ts`), and deliberately: it is describing a
|
|
585
|
-
* review to someone who arrived after the fact, while this is narrating one to someone watching it
|
|
586
|
-
* happen. Sharing the strings would force one of the two to read wrongly.
|
|
587
|
-
*/
|
|
588
|
-
var PHASES = {
|
|
589
|
-
preparing: {
|
|
590
|
-
doing: "Preparing isolated checkout",
|
|
591
|
-
done: "Prepared isolated checkout"
|
|
592
|
-
},
|
|
593
|
-
context: {
|
|
594
|
-
doing: "Reading what changed",
|
|
595
|
-
done: "Read what changed"
|
|
596
|
-
},
|
|
597
|
-
conversation: {
|
|
598
|
-
doing: "Reading pull request conversation",
|
|
599
|
-
done: "Read pull request conversation"
|
|
600
|
-
},
|
|
601
|
-
analysing: {
|
|
602
|
-
doing: "Analysing the change",
|
|
603
|
-
done: "Analysed the change"
|
|
604
|
-
},
|
|
605
|
-
done: {
|
|
606
|
-
doing: "Finishing",
|
|
607
|
-
done: "Finished"
|
|
608
|
-
},
|
|
609
|
-
failed: {
|
|
610
|
-
doing: "Stopped",
|
|
611
|
-
done: "Stopped"
|
|
612
|
-
}
|
|
613
|
-
};
|
|
587
|
+
//#region src/confirm.ts
|
|
614
588
|
/**
|
|
615
|
-
*
|
|
589
|
+
* A yes or no, asked only when there is somebody to answer.
|
|
616
590
|
*
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
return stream.isTTY === true && !process.env.NO_COLOR;
|
|
622
|
-
}
|
|
623
|
-
var DIM = "\x1B[2m";
|
|
624
|
-
var GREEN = "\x1B[32m";
|
|
625
|
-
var RED = "\x1B[31m";
|
|
626
|
-
var RESET = "\x1B[0m";
|
|
627
|
-
/** The header above the phases: what is being reviewed, and the facts about how. */
|
|
628
|
-
function header(target, colour) {
|
|
629
|
-
const meta = `${target.sha.slice(0, 7)} · isolated checkout · ${target.originRepoPath}`;
|
|
630
|
-
return [
|
|
631
|
-
`Reviewing ${target.branch}`,
|
|
632
|
-
colour ? `${DIM}${meta}${RESET}` : meta,
|
|
633
|
-
""
|
|
634
|
-
];
|
|
635
|
-
}
|
|
636
|
-
/**
|
|
637
|
-
* One line per phase, marked with its state, and the agent's note under the running one.
|
|
591
|
+
* The same shape as `chooseAgent` next door and for the same reasons: hand-rolled on
|
|
592
|
+
* `node:readline` because this binary has no runtime dependencies and one question is not worth
|
|
593
|
+
* acquiring one, and with its input and output injected so the decision is testable without a
|
|
594
|
+
* shell.
|
|
638
595
|
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
* transcript was made of.
|
|
596
|
+
* The default is no. Every caller of this is about to delete something, and a bare Return at a
|
|
597
|
+
* prompt somebody did not read must leave their machine as it was.
|
|
642
598
|
*/
|
|
643
|
-
function
|
|
644
|
-
|
|
645
|
-
const
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
const
|
|
650
|
-
|
|
651
|
-
const
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
continue;
|
|
656
|
-
}
|
|
657
|
-
lines.push(colour && paint ? `${paint}${mark}${RESET} ${label}` : `${mark} ${label}`);
|
|
658
|
-
if (current && job.progress && !restates(job.progress, label)) lines.push(colour ? `${DIM} ${job.progress}${RESET}` : ` ${job.progress}`);
|
|
599
|
+
async function confirm(options) {
|
|
600
|
+
if (!options.isTTY) return { kind: "unattended" };
|
|
601
|
+
const reader = createInterface({ input: options.input });
|
|
602
|
+
const lines = reader[Symbol.asyncIterator]();
|
|
603
|
+
try {
|
|
604
|
+
options.write(`${options.question} [y/N] `);
|
|
605
|
+
const next = await lines.next();
|
|
606
|
+
if (next.done) return { kind: "no" };
|
|
607
|
+
const answer = next.value.trim().toLowerCase();
|
|
608
|
+
return answer === "y" || answer === "yes" ? { kind: "yes" } : { kind: "no" };
|
|
609
|
+
} finally {
|
|
610
|
+
reader.close();
|
|
659
611
|
}
|
|
660
|
-
return lines;
|
|
661
|
-
}
|
|
662
|
-
/** Same words as the phase heading, give or take the articles and the trailing full stop. */
|
|
663
|
-
function restates(note, label) {
|
|
664
|
-
const bare = (text) => text.toLowerCase().replace(/\bthe\b/g, "").replace(/[^a-z]/g, "");
|
|
665
|
-
return bare(note) === bare(label);
|
|
666
|
-
}
|
|
667
|
-
/**
|
|
668
|
-
* How many rows this block will actually occupy, which is not how many strings it is.
|
|
669
|
-
*
|
|
670
|
-
* The redraw moves the cursor up by a count and clears from there, so that count has to be in the
|
|
671
|
-
* unit the terminal moves in: rows on screen, after wrapping. Counting strings instead lands the
|
|
672
|
-
* cursor short and clears output the block never drew. The header's second line carries a full
|
|
673
|
-
* repository path and wraps in any normal window, so this was not a corner case.
|
|
674
|
-
*
|
|
675
|
-
* Escape sequences are removed first because they occupy no columns. A resize between drawing and
|
|
676
|
-
* redrawing still misplaces the cursor; nothing short of tracking the resize can fix that, and the
|
|
677
|
-
* next repaint corrects itself.
|
|
678
|
-
*/
|
|
679
|
-
function rowsOccupied(lines, columns) {
|
|
680
|
-
if (!columns) return lines.length;
|
|
681
|
-
const visible = (line) => line.replace(/\u001b\[[0-9;]*m/g, "").length;
|
|
682
|
-
return lines.reduce((rows, line) => rows + Math.max(1, Math.ceil(visible(line) / columns)), 0);
|
|
683
|
-
}
|
|
684
|
-
/**
|
|
685
|
-
* Watch a job and keep one block on the screen describing it.
|
|
686
|
-
*
|
|
687
|
-
* Returns the stop function the caller must call: nothing here may outlive the command, and a
|
|
688
|
-
* stray interval would make `review` hang after the review finished.
|
|
689
|
-
*/
|
|
690
|
-
function followJob(jobId, target, options = {}) {
|
|
691
|
-
const stream = options.stream ?? process.stderr;
|
|
692
|
-
const read = options.read ?? loadJob;
|
|
693
|
-
const colour = styling(stream);
|
|
694
|
-
const inPlace = stream.isTTY === true;
|
|
695
|
-
let drawn = 0;
|
|
696
|
-
let last = "";
|
|
697
|
-
const paint = () => {
|
|
698
|
-
const job = read(jobId);
|
|
699
|
-
if (!job) return;
|
|
700
|
-
const lines = [...header(target, colour), ...phaseLines(job, colour)];
|
|
701
|
-
const block = lines.join("\n");
|
|
702
|
-
if (block === last) return;
|
|
703
|
-
if (inPlace && drawn > 0) stream.write(`\u001b[${drawn}A\u001b[0J`);
|
|
704
|
-
stream.write(`${block}\n`);
|
|
705
|
-
last = block;
|
|
706
|
-
drawn = rowsOccupied(lines, stream.columns ?? 0);
|
|
707
|
-
};
|
|
708
|
-
const timer = setInterval(paint, POLL_INTERVAL_MS);
|
|
709
|
-
timer.unref();
|
|
710
|
-
paint();
|
|
711
|
-
let stopped = false;
|
|
712
|
-
return () => {
|
|
713
|
-
if (stopped) return;
|
|
714
|
-
stopped = true;
|
|
715
|
-
clearInterval(timer);
|
|
716
|
-
paint();
|
|
717
|
-
};
|
|
718
612
|
}
|
|
719
613
|
//#endregion
|
|
720
614
|
//#region src/text.ts
|
|
@@ -761,6 +655,8 @@ your own checkout is never read or written while it works.
|
|
|
761
655
|
prreviewbuddy review --fresh review the target again, keeping the existing review
|
|
762
656
|
prreviewbuddy review --review <review> --update
|
|
763
657
|
act on one review by its review ID
|
|
658
|
+
prreviewbuddy review --review <review> --delete
|
|
659
|
+
delete one review and its isolated checkouts
|
|
764
660
|
|
|
765
661
|
prreviewbuddy open list your reviews and open the index
|
|
766
662
|
prreviewbuddy open <review> open a review by its review ID
|
|
@@ -770,11 +666,16 @@ your own checkout is never read or written while it works.
|
|
|
770
666
|
prreviewbuddy config get agent the coding agent used when you do not name one
|
|
771
667
|
prreviewbuddy config set agent <agent> remember ${AGENT_NAMES}
|
|
772
668
|
|
|
669
|
+
prreviewbuddy uninstall remove everything this put on your machine
|
|
670
|
+
prreviewbuddy uninstall --keep-reviews keep your reviews and settings, clear the rest
|
|
671
|
+
|
|
773
672
|
Anything in the first group says what to review. Anything in the second says what to do to it, and
|
|
774
673
|
they go together: \`prreviewbuddy review --branch feat/foo --update\` brings the review of feat/foo up
|
|
775
|
-
to date without checking it out.
|
|
776
|
-
|
|
777
|
-
|
|
674
|
+
to date without checking it out. \`prreviewbuddy open\` prints a review ID beside each review, and
|
|
675
|
+
you need one to tell two reviews of the same branch apart or to delete one. Deleting a review
|
|
676
|
+
removes its record and the checkouts it was made in, and never touches your branch or its commits.
|
|
677
|
+
|
|
678
|
+
Reviews can also be deleted from the list in your browser, one at a time or a whole branch at once.
|
|
778
679
|
|
|
779
680
|
There is no account and no API key. PR Review Buddy uses a coding agent installed on your machine
|
|
780
681
|
and that agent's existing authentication, and nothing here ever asks you for a credential.
|
|
@@ -1035,8 +936,19 @@ function noReviews() {
|
|
|
1035
936
|
* The position appears only where there is something to position against. "Review 1 of 1" on the
|
|
1036
937
|
* common case is noise dressed as precision.
|
|
1037
938
|
*/
|
|
939
|
+
/**
|
|
940
|
+
* The reviews, with the ID said out loud.
|
|
941
|
+
*
|
|
942
|
+
* The ID used to be visible only as a fragment of the URL below it, which was fine while it was
|
|
943
|
+
* optional: `--review` is a tiebreaker and `open` works without one. Deleting a review requires it,
|
|
944
|
+
* so a person now has to be able to read one off without dissecting a link, and this is where they
|
|
945
|
+
* are already standing when they want it.
|
|
946
|
+
*
|
|
947
|
+
* Above the URL rather than below, because the URL is the long line: a label sitting under it reads
|
|
948
|
+
* as belonging to whatever comes next.
|
|
949
|
+
*/
|
|
1038
950
|
function reviewList(reviews) {
|
|
1039
|
-
return reviews.map((review) => `${review.summary.branch} ${review.summary.title}\n ${describeListed(review)}\n ${review.url}`).join("\n\n");
|
|
951
|
+
return reviews.map((review) => `${review.summary.branch} ${review.summary.title}\n ${describeListed(review)}\n Review ID: ${review.summary.id}\n ${review.url}`).join("\n\n");
|
|
1040
952
|
}
|
|
1041
953
|
/**
|
|
1042
954
|
* Answered in the order the questions get asked, which is the order the browser's card answers
|
|
@@ -1103,6 +1015,155 @@ function updateFinished(outcome, url, acted) {
|
|
|
1103
1015
|
function count(value, noun) {
|
|
1104
1016
|
return `${value} ${noun}${value === 1 ? "" : "s"}`;
|
|
1105
1017
|
}
|
|
1018
|
+
/**
|
|
1019
|
+
* What `uninstall` says, and the one rule running through all of it: say what happened to the
|
|
1020
|
+
* machine, not what this program did internally.
|
|
1021
|
+
*
|
|
1022
|
+
* A reviewer removing a tool is at the least invested they will ever be in its vocabulary, so
|
|
1023
|
+
* nothing here mentions jobs, claims, drivers, the store or the daemon. It mentions their reviews,
|
|
1024
|
+
* their settings, the checkouts and the repositories, because those are things they own.
|
|
1025
|
+
*/
|
|
1026
|
+
/**
|
|
1027
|
+
* The refusal when a review is still being made.
|
|
1028
|
+
*
|
|
1029
|
+
* Names the branch, because "a job is running" sends somebody to a process list to find out which
|
|
1030
|
+
* of their afternoon's work is at stake. Both ways out are offered in the same breath: the one that
|
|
1031
|
+
* costs nothing is first, and the one that costs the analysis says plainly that it stops it.
|
|
1032
|
+
*/
|
|
1033
|
+
function uninstallRunning(running) {
|
|
1034
|
+
return [
|
|
1035
|
+
`${running.length === 1 ? `A review of ${running[0].branch} is still being made` : `${count(running.length, "review")} are still being made (${running.map((one) => one.branch).join(", ")})`}, so nothing was removed.`,
|
|
1036
|
+
"",
|
|
1037
|
+
"Wait for it to finish, or run `prreviewbuddy uninstall --force` to stop it and uninstall anyway."
|
|
1038
|
+
].join("\n");
|
|
1039
|
+
}
|
|
1040
|
+
/** What is about to go, in the reviewer's own terms, and the question. */
|
|
1041
|
+
function uninstallPlan(keepReviews) {
|
|
1042
|
+
return keepReviews ? [
|
|
1043
|
+
"This clears the isolated checkouts and the workspace server, and cleans up the git",
|
|
1044
|
+
"bookkeeping in the repositories PR Review Buddy has reviewed.",
|
|
1045
|
+
"",
|
|
1046
|
+
"Your reviews and settings stay in ~/.prreviewbuddy."
|
|
1047
|
+
].join("\n") : [
|
|
1048
|
+
"This removes every review on this machine, your settings, the isolated checkouts, and the",
|
|
1049
|
+
"git bookkeeping PR Review Buddy left in the repositories it has reviewed.",
|
|
1050
|
+
"",
|
|
1051
|
+
"It cannot be undone."
|
|
1052
|
+
].join("\n");
|
|
1053
|
+
}
|
|
1054
|
+
/** The question itself, kept separate so the prompt reads as one line whatever precedes it. */
|
|
1055
|
+
var UNINSTALL_QUESTION = "Continue?";
|
|
1056
|
+
/**
|
|
1057
|
+
* A review that is still being analysed, and cannot be deleted yet.
|
|
1058
|
+
*
|
|
1059
|
+
* Names the branch rather than a job or a pid, because that is the thing the person recognises, and
|
|
1060
|
+
* offers no way past it. Deleting a record and stopping an analysis are separate decisions and only
|
|
1061
|
+
* one of them was asked for: `uninstall --force` exists because tearing the whole runtime down is a
|
|
1062
|
+
* coherent thing to want, and there is no equivalent want here.
|
|
1063
|
+
*
|
|
1064
|
+
* The review would come back anyway. A driver holds the review in memory for the length of the
|
|
1065
|
+
* analysis and writes it back when it finishes, so a deletion now is a deletion the reviewer would
|
|
1066
|
+
* watch undo itself, which is worse than being told to wait.
|
|
1067
|
+
*/
|
|
1068
|
+
function deleteRunning(branch) {
|
|
1069
|
+
return [
|
|
1070
|
+
`A review of ${branch} is still being analysed, so nothing was deleted.`,
|
|
1071
|
+
"",
|
|
1072
|
+
"Wait for it to finish, then delete it. There is no way to delete a review out from under the",
|
|
1073
|
+
"analysis making it."
|
|
1074
|
+
].join("\n");
|
|
1075
|
+
}
|
|
1076
|
+
/** What is about to go, before the question. Said in the reviewer's terms, not the store's. */
|
|
1077
|
+
function deletePlan(summary) {
|
|
1078
|
+
return [
|
|
1079
|
+
`This deletes the review of ${summary.branch} from ${relativeTime(summary.createdAt)}, along`,
|
|
1080
|
+
"with the isolated checkouts it was made in.",
|
|
1081
|
+
"",
|
|
1082
|
+
"Your branch and its commits are untouched. It cannot be undone."
|
|
1083
|
+
].join("\n");
|
|
1084
|
+
}
|
|
1085
|
+
/** The question itself, kept separate so the prompt reads as one line whatever precedes it. */
|
|
1086
|
+
var DELETE_QUESTION = "Delete it?";
|
|
1087
|
+
function deleteUnattended(id) {
|
|
1088
|
+
return `Deleting a review asks for confirmation, and there is nothing here to ask. Run \`prreviewbuddy review --review ${id} --delete --yes\` if you meant it.`;
|
|
1089
|
+
}
|
|
1090
|
+
function deleteCancelled() {
|
|
1091
|
+
return "Nothing was deleted.";
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* What went.
|
|
1095
|
+
*
|
|
1096
|
+
* The checkouts are counted because they are the part with a size: a review's record is small and
|
|
1097
|
+
* the working trees beside it are the repository over again. A refusal from the removal guard is
|
|
1098
|
+
* printed verbatim, because it names a directory somebody has to look at.
|
|
1099
|
+
*/
|
|
1100
|
+
function deleteFinished(report) {
|
|
1101
|
+
const lines = [report.worktreesRemoved > 0 ? `Deleted, along with ${count(report.worktreesRemoved, "isolated checkout")}.` : "Deleted."];
|
|
1102
|
+
if (report.refusals.length > 0) lines.push("", ...report.refusals);
|
|
1103
|
+
return lines.join("\n");
|
|
1104
|
+
}
|
|
1105
|
+
/** Asked to delete something that is not there. Not a failure: it is the state they wanted. */
|
|
1106
|
+
function deleteAlreadyGone(id) {
|
|
1107
|
+
return `There is no review called ${id}, so there was nothing to delete.`;
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Piped, redirected, or in a script, where a question is a hang rather than a question.
|
|
1111
|
+
*
|
|
1112
|
+
* Says which flag, because the alternative is a person discovering the flag by reading `--help` for
|
|
1113
|
+
* a command they have already decided to run.
|
|
1114
|
+
*/
|
|
1115
|
+
function uninstallUnattended() {
|
|
1116
|
+
return "Uninstalling asks for confirmation, and there is nothing here to ask. Run `prreviewbuddy uninstall --yes` if you meant it.";
|
|
1117
|
+
}
|
|
1118
|
+
function uninstallCancelled() {
|
|
1119
|
+
return "Nothing was removed.";
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* What was done, and then the one thing left to do.
|
|
1123
|
+
*
|
|
1124
|
+
* The npm line is last and is not run for them. A binary that deletes its own package while running
|
|
1125
|
+
* from inside it is at the mercy of whichever package manager put it there, and the failure mode is
|
|
1126
|
+
* a half-removed install nobody can diagnose. Printing the line costs the reader one paste and
|
|
1127
|
+
* costs nobody a broken machine.
|
|
1128
|
+
*/
|
|
1129
|
+
function uninstallFinished(report, keepReviews, packageName) {
|
|
1130
|
+
const lines = [];
|
|
1131
|
+
const cleaned = [];
|
|
1132
|
+
if (report.worktreesRemoved > 0) cleaned.push(`removed ${count(report.worktreesRemoved, "isolated checkout")}`);
|
|
1133
|
+
if (report.repositoriesPruned.length > 0) {
|
|
1134
|
+
const repositories = report.repositoriesPruned.length;
|
|
1135
|
+
cleaned.push(`cleaned up ${repositories} ${repositories === 1 ? "repository" : "repositories"}`);
|
|
1136
|
+
}
|
|
1137
|
+
if (keepReviews) {
|
|
1138
|
+
lines.push(cleaned.length > 0 ? `${cap(cleaned.join(", and "))}.` : "There was no runtime state to clear.");
|
|
1139
|
+
lines.push("Your reviews and settings are still in ~/.prreviewbuddy.");
|
|
1140
|
+
} else if (report.storeRemoved) {
|
|
1141
|
+
lines.push(cleaned.length > 0 ? `${cap(cleaned.join(", and "))}.` : "PR Review Buddy is removed.");
|
|
1142
|
+
lines.push("~/.prreviewbuddy is gone.");
|
|
1143
|
+
} else lines.push("There was nothing to remove: PR Review Buddy is keeping nothing on this machine.");
|
|
1144
|
+
if (report.refusals.length > 0) {
|
|
1145
|
+
const left = report.refusals.length === 1 ? "One thing was" : `${count(report.refusals.length, "thing")} were`;
|
|
1146
|
+
lines.push("", `${left} left alone, and each says why:`, ...report.refusals.map((one) => ` ${one}`));
|
|
1147
|
+
}
|
|
1148
|
+
lines.push("", "To remove the command itself:", ` npm rm -g ${packageName}`);
|
|
1149
|
+
return lines.join("\n");
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* What somebody sees the first time they run this, above whatever they asked for.
|
|
1153
|
+
*
|
|
1154
|
+
* Short because they typed a command rather than asking to be onboarded. Two lines of it, both
|
|
1155
|
+
* pointing at somewhere fuller, and then out of the way for the rest of the install's life. It
|
|
1156
|
+
* names the version because the next thing this person does about a problem is quote a number.
|
|
1157
|
+
*/
|
|
1158
|
+
function firstRun(version) {
|
|
1159
|
+
return [
|
|
1160
|
+
`PR Review Buddy ${version}`,
|
|
1161
|
+
"",
|
|
1162
|
+
" prreviewbuddy agents which coding agents you have",
|
|
1163
|
+
" prreviewbuddy --help everything it does",
|
|
1164
|
+
""
|
|
1165
|
+
].join("\n");
|
|
1166
|
+
}
|
|
1106
1167
|
//#endregion
|
|
1107
1168
|
//#region src/write.ts
|
|
1108
1169
|
/**
|
|
@@ -1118,6 +1179,228 @@ function err(text) {
|
|
|
1118
1179
|
process.stderr.write(`${text}\n`);
|
|
1119
1180
|
}
|
|
1120
1181
|
//#endregion
|
|
1182
|
+
//#region src/commands/delete_review.ts
|
|
1183
|
+
/**
|
|
1184
|
+
* `prreviewbuddy review --review <review> --delete`.
|
|
1185
|
+
*
|
|
1186
|
+
* An operation on the review axis rather than a command of its own, because that is what it is:
|
|
1187
|
+
* one target, one operation, the same sentence shape as `--update` and `--reanalyse`. A top-level
|
|
1188
|
+
* `delete` would have needed its own way of saying which review, and there is already one.
|
|
1189
|
+
*
|
|
1190
|
+
* **The target must be an ID.** `--branch feat/foo --delete` is refused in the parser, and the
|
|
1191
|
+
* reason is `--fresh`: reviewing a branch twice on purpose leaves two reviews of it, so a branch
|
|
1192
|
+
* names a set rather than a review. Every other operation can pick the newest and be right, because
|
|
1193
|
+
* updating the wrong one of two is a wasted minute. Deleting the wrong one is the work.
|
|
1194
|
+
*
|
|
1195
|
+
* The order is the design, and it is `uninstall`'s: is anything running, then does the person mean
|
|
1196
|
+
* it, then act. Nothing on disk is touched before both answers are in.
|
|
1197
|
+
*/
|
|
1198
|
+
var REAL$1 = {
|
|
1199
|
+
load: (id) => loadWorkspace(id),
|
|
1200
|
+
liveJobs: (id) => liveJobsFor(id),
|
|
1201
|
+
ask: (question) => confirm({
|
|
1202
|
+
question,
|
|
1203
|
+
isTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
1204
|
+
input: process.stdin,
|
|
1205
|
+
write: (chunk) => process.stderr.write(chunk)
|
|
1206
|
+
}),
|
|
1207
|
+
remove: (id) => deleteReview$1(id)
|
|
1208
|
+
};
|
|
1209
|
+
async function deleteReview(command, deps = REAL$1) {
|
|
1210
|
+
const id = command.reviewId;
|
|
1211
|
+
const workspace = deps.load(id);
|
|
1212
|
+
if (!workspace) {
|
|
1213
|
+
out(deleteAlreadyGone(id));
|
|
1214
|
+
return 0;
|
|
1215
|
+
}
|
|
1216
|
+
const running = deps.liveJobs(id);
|
|
1217
|
+
if (running.length > 0) {
|
|
1218
|
+
err(deleteRunning(running[0]?.job.target.branch ?? workspace.changeSet.headRef));
|
|
1219
|
+
return 1;
|
|
1220
|
+
}
|
|
1221
|
+
if (!command.yes) {
|
|
1222
|
+
err(deletePlan({
|
|
1223
|
+
branch: workspace.changeSet.headRef,
|
|
1224
|
+
createdAt: workspace.createdAt
|
|
1225
|
+
}));
|
|
1226
|
+
err("");
|
|
1227
|
+
const answer = await deps.ask(DELETE_QUESTION);
|
|
1228
|
+
if (answer.kind === "unattended") {
|
|
1229
|
+
err(deleteUnattended(id));
|
|
1230
|
+
return 1;
|
|
1231
|
+
}
|
|
1232
|
+
if (answer.kind === "no") {
|
|
1233
|
+
err(deleteCancelled());
|
|
1234
|
+
return 1;
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
out(deleteFinished(await deps.remove(id)));
|
|
1238
|
+
return 0;
|
|
1239
|
+
}
|
|
1240
|
+
//#endregion
|
|
1241
|
+
//#region src/progress.ts
|
|
1242
|
+
/**
|
|
1243
|
+
* What the review is doing, while it does it.
|
|
1244
|
+
*
|
|
1245
|
+
* The job record is the source of truth, and it is the same one the workspace page polls over
|
|
1246
|
+
* HTTP. Read here directly from the store rather than through the server, because this process is
|
|
1247
|
+
* the one driving the job and a loopback request to ask itself what it is doing would be a strange
|
|
1248
|
+
* way to find out.
|
|
1249
|
+
*
|
|
1250
|
+
* This used to print `job.progress ?? job.phase` whenever it changed, which meant the raw phase
|
|
1251
|
+
* enum reached the terminal as a bare `preparing` before the first progress note arrived, and every
|
|
1252
|
+
* later note appended a line to a growing log. A reviewer could not tell what had finished, what
|
|
1253
|
+
* was running, or how much was left.
|
|
1254
|
+
*
|
|
1255
|
+
* It now renders one block: a header, then a line per phase with a mark for its state, and the
|
|
1256
|
+
* agent's own notes indented under whichever phase is currently running. On a terminal the block is
|
|
1257
|
+
* redrawn in place, so the review reads as one task progressing rather than as a transcript. Where
|
|
1258
|
+
* stderr is not a terminal (a pipe, a CI log, a file) it falls back to appending only the lines
|
|
1259
|
+
* that changed, because cursor movement written into a log is worse than a plain list.
|
|
1260
|
+
*
|
|
1261
|
+
* Progress goes to stderr and the review's link goes to stdout, so `prreviewbuddy review | pbcopy`
|
|
1262
|
+
* copies a URL rather than a transcript.
|
|
1263
|
+
*/
|
|
1264
|
+
var POLL_INTERVAL_MS = 500;
|
|
1265
|
+
/**
|
|
1266
|
+
* Two forms per phase, because a finished step and a running one are different sentences.
|
|
1267
|
+
*
|
|
1268
|
+
* The page says these differently again (`job_view.ts`), and deliberately: it is describing a
|
|
1269
|
+
* review to someone who arrived after the fact, while this is narrating one to someone watching it
|
|
1270
|
+
* happen. Sharing the strings would force one of the two to read wrongly.
|
|
1271
|
+
*/
|
|
1272
|
+
var PHASES = {
|
|
1273
|
+
preparing: {
|
|
1274
|
+
doing: "Preparing isolated checkout",
|
|
1275
|
+
done: "Prepared isolated checkout"
|
|
1276
|
+
},
|
|
1277
|
+
context: {
|
|
1278
|
+
doing: "Reading what changed",
|
|
1279
|
+
done: "Read what changed"
|
|
1280
|
+
},
|
|
1281
|
+
conversation: {
|
|
1282
|
+
doing: "Reading pull request conversation",
|
|
1283
|
+
done: "Read pull request conversation"
|
|
1284
|
+
},
|
|
1285
|
+
analysing: {
|
|
1286
|
+
doing: "Analysing the change",
|
|
1287
|
+
done: "Analysed the change"
|
|
1288
|
+
},
|
|
1289
|
+
done: {
|
|
1290
|
+
doing: "Finishing",
|
|
1291
|
+
done: "Finished"
|
|
1292
|
+
},
|
|
1293
|
+
failed: {
|
|
1294
|
+
doing: "Stopped",
|
|
1295
|
+
done: "Stopped"
|
|
1296
|
+
}
|
|
1297
|
+
};
|
|
1298
|
+
/**
|
|
1299
|
+
* Colour only where it means something, and never where it cannot be seen.
|
|
1300
|
+
*
|
|
1301
|
+
* `NO_COLOR` is honoured because this writes to stderr, which people redirect into files and CI
|
|
1302
|
+
* logs where escape codes are noise rather than emphasis.
|
|
1303
|
+
*/
|
|
1304
|
+
function styling(stream) {
|
|
1305
|
+
return stream.isTTY === true && !process.env.NO_COLOR;
|
|
1306
|
+
}
|
|
1307
|
+
var DIM = "\x1B[2m";
|
|
1308
|
+
var GREEN = "\x1B[32m";
|
|
1309
|
+
var RED = "\x1B[31m";
|
|
1310
|
+
var RESET = "\x1B[0m";
|
|
1311
|
+
/** The header above the phases: what is being reviewed, and the facts about how. */
|
|
1312
|
+
function header(target, colour) {
|
|
1313
|
+
const meta = `${target.sha.slice(0, 7)} · isolated checkout · ${target.originRepoPath}`;
|
|
1314
|
+
return [
|
|
1315
|
+
`Reviewing ${target.branch}`,
|
|
1316
|
+
colour ? `${DIM}${meta}${RESET}` : meta,
|
|
1317
|
+
""
|
|
1318
|
+
];
|
|
1319
|
+
}
|
|
1320
|
+
/**
|
|
1321
|
+
* One line per phase, marked with its state, and the agent's note under the running one.
|
|
1322
|
+
*
|
|
1323
|
+
* Completed phases stay visible because they are the part that says how far along this is. Their
|
|
1324
|
+
* detail does not: a note about a file read two phases ago is history, and history is what the
|
|
1325
|
+
* transcript was made of.
|
|
1326
|
+
*/
|
|
1327
|
+
function phaseLines(job, colour) {
|
|
1328
|
+
const lines = [];
|
|
1329
|
+
const failedAt = job.phase === "failed" ? job.failure?.phase : void 0;
|
|
1330
|
+
for (const phase of WORK_PHASES) {
|
|
1331
|
+
const complete = job.completed.includes(phase) || job.phase === "done";
|
|
1332
|
+
const failed = failedAt === phase;
|
|
1333
|
+
const current = !complete && !failed && job.phase === phase;
|
|
1334
|
+
const mark = failed ? "✕" : complete ? "✓" : current ? "⟳" : " ";
|
|
1335
|
+
const label = complete || failed ? PHASES[phase].done : PHASES[phase].doing;
|
|
1336
|
+
const paint = failed ? RED : complete ? GREEN : "";
|
|
1337
|
+
if (!complete && !current && !failed) {
|
|
1338
|
+
lines.push(colour ? `${DIM} ${mark} ${label}${RESET}` : ` ${mark} ${label}`);
|
|
1339
|
+
continue;
|
|
1340
|
+
}
|
|
1341
|
+
lines.push(colour && paint ? `${paint}${mark}${RESET} ${label}` : `${mark} ${label}`);
|
|
1342
|
+
if (current && job.progress && !restates(job.progress, label)) lines.push(colour ? `${DIM} ${job.progress}${RESET}` : ` ${job.progress}`);
|
|
1343
|
+
}
|
|
1344
|
+
return lines;
|
|
1345
|
+
}
|
|
1346
|
+
/** Same words as the phase heading, give or take the articles and the trailing full stop. */
|
|
1347
|
+
function restates(note, label) {
|
|
1348
|
+
const bare = (text) => text.toLowerCase().replace(/\bthe\b/g, "").replace(/[^a-z]/g, "");
|
|
1349
|
+
return bare(note) === bare(label);
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* How many rows this block will actually occupy, which is not how many strings it is.
|
|
1353
|
+
*
|
|
1354
|
+
* The redraw moves the cursor up by a count and clears from there, so that count has to be in the
|
|
1355
|
+
* unit the terminal moves in: rows on screen, after wrapping. Counting strings instead lands the
|
|
1356
|
+
* cursor short and clears output the block never drew. The header's second line carries a full
|
|
1357
|
+
* repository path and wraps in any normal window, so this was not a corner case.
|
|
1358
|
+
*
|
|
1359
|
+
* Escape sequences are removed first because they occupy no columns. A resize between drawing and
|
|
1360
|
+
* redrawing still misplaces the cursor; nothing short of tracking the resize can fix that, and the
|
|
1361
|
+
* next repaint corrects itself.
|
|
1362
|
+
*/
|
|
1363
|
+
function rowsOccupied(lines, columns) {
|
|
1364
|
+
if (!columns) return lines.length;
|
|
1365
|
+
const visible = (line) => line.replace(/\u001b\[[0-9;]*m/g, "").length;
|
|
1366
|
+
return lines.reduce((rows, line) => rows + Math.max(1, Math.ceil(visible(line) / columns)), 0);
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* Watch a job and keep one block on the screen describing it.
|
|
1370
|
+
*
|
|
1371
|
+
* Returns the stop function the caller must call: nothing here may outlive the command, and a
|
|
1372
|
+
* stray interval would make `review` hang after the review finished.
|
|
1373
|
+
*/
|
|
1374
|
+
function followJob(jobId, target, options = {}) {
|
|
1375
|
+
const stream = options.stream ?? process.stderr;
|
|
1376
|
+
const read = options.read ?? loadJob;
|
|
1377
|
+
const colour = styling(stream);
|
|
1378
|
+
const inPlace = stream.isTTY === true;
|
|
1379
|
+
let drawn = 0;
|
|
1380
|
+
let last = "";
|
|
1381
|
+
const paint = () => {
|
|
1382
|
+
const job = read(jobId);
|
|
1383
|
+
if (!job) return;
|
|
1384
|
+
const lines = [...header(target, colour), ...phaseLines(job, colour)];
|
|
1385
|
+
const block = lines.join("\n");
|
|
1386
|
+
if (block === last) return;
|
|
1387
|
+
if (inPlace && drawn > 0) stream.write(`\u001b[${drawn}A\u001b[0J`);
|
|
1388
|
+
stream.write(`${block}\n`);
|
|
1389
|
+
last = block;
|
|
1390
|
+
drawn = rowsOccupied(lines, stream.columns ?? 0);
|
|
1391
|
+
};
|
|
1392
|
+
const timer = setInterval(paint, POLL_INTERVAL_MS);
|
|
1393
|
+
timer.unref();
|
|
1394
|
+
paint();
|
|
1395
|
+
let stopped = false;
|
|
1396
|
+
return () => {
|
|
1397
|
+
if (stopped) return;
|
|
1398
|
+
stopped = true;
|
|
1399
|
+
clearInterval(timer);
|
|
1400
|
+
paint();
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
//#endregion
|
|
1121
1404
|
//#region src/commands/review.ts
|
|
1122
1405
|
/**
|
|
1123
1406
|
* `prreviewbuddy review`.
|
|
@@ -1141,6 +1424,7 @@ function err(text) {
|
|
|
1141
1424
|
* and it is the same `findExistingReview` the default operation already asked.
|
|
1142
1425
|
*/
|
|
1143
1426
|
async function review(command, repoPath) {
|
|
1427
|
+
if (command.operation === "delete") return deleteReview(command);
|
|
1144
1428
|
if (command.reviewId) {
|
|
1145
1429
|
const workspace = loadWorkspace(command.reviewId);
|
|
1146
1430
|
if (!workspace) {
|
|
@@ -1531,6 +1815,365 @@ function config(command) {
|
|
|
1531
1815
|
return 0;
|
|
1532
1816
|
}
|
|
1533
1817
|
//#endregion
|
|
1818
|
+
//#region ../../packages/review-harness/src/workspace/uninstall.ts
|
|
1819
|
+
/**
|
|
1820
|
+
* Undoing an install, which is more than deleting a directory.
|
|
1821
|
+
*
|
|
1822
|
+
* Everything this product keeps on a machine is under `STORE_ROOT`, with one exception, and the
|
|
1823
|
+
* exception is the whole reason this file exists: `git worktree add` writes an admin entry into the
|
|
1824
|
+
* repository being reviewed, so a reviewer who deletes `~/.prreviewbuddy` by hand is left with
|
|
1825
|
+
* entries pointing at nothing, in repositories nobody told them we had touched. They would have to
|
|
1826
|
+
* discover that from `git worktree list` in a repository they had no reason to suspect.
|
|
1827
|
+
*
|
|
1828
|
+
* So the order here is not decoration. Worktrees are removed while the records that name them still
|
|
1829
|
+
* exist, because those records are the only thing that says which repositories to clean and which
|
|
1830
|
+
* directory belonged to which. Delete the store first and the information needed to finish the job
|
|
1831
|
+
* is gone with it.
|
|
1832
|
+
*
|
|
1833
|
+
* **On deleting.** `worktree.ts` is the module that deletes, and it stays that: every managed
|
|
1834
|
+
* checkout here goes through `removeWorktree` and its guard, not through a recursive remove that
|
|
1835
|
+
* happens to know a path. The one removal this file performs itself is `STORE_ROOT`, and the
|
|
1836
|
+
* evidence it rests on is different in kind: that path is not derived from a record, a repository,
|
|
1837
|
+
* an argument or anything else that could be wrong. It is `homedir()` joined to a constant, it is
|
|
1838
|
+
* ours by construction, and removing it is the entire thing the reviewer asked for.
|
|
1839
|
+
*
|
|
1840
|
+
* Nothing here prints. The copy lives in the surface that calls it.
|
|
1841
|
+
*/
|
|
1842
|
+
/**
|
|
1843
|
+
* End the processes making those reviews. `--force` only, and never reached by default.
|
|
1844
|
+
*
|
|
1845
|
+
* A claim written on another host says nothing about a pid here, which is the same reasoning
|
|
1846
|
+
* `job_claim.ts:ownerAlive` states: `~/.prreviewbuddy` can sit on a synced home directory. Those are
|
|
1847
|
+
* left alone; the claim is cleared regardless, because the store it lives in is about to go.
|
|
1848
|
+
*
|
|
1849
|
+
* SIGTERM to the driver, and no further. The coding agent is that driver's child and may outlive it
|
|
1850
|
+
* by moments, with nothing left to write into; chasing a process group would buy a tidier `ps` at
|
|
1851
|
+
* the cost of a signal delivery this file cannot make portable.
|
|
1852
|
+
*/
|
|
1853
|
+
function stopDrivers(running) {
|
|
1854
|
+
let stopped = 0;
|
|
1855
|
+
for (const { job, claim } of running) {
|
|
1856
|
+
if (claim.host === hostname()) try {
|
|
1857
|
+
process.kill(claim.pid, "SIGTERM");
|
|
1858
|
+
stopped += 1;
|
|
1859
|
+
} catch {}
|
|
1860
|
+
clearClaim(job.id);
|
|
1861
|
+
}
|
|
1862
|
+
return stopped;
|
|
1863
|
+
}
|
|
1864
|
+
/**
|
|
1865
|
+
* Every repository this machine has a record of having reviewed.
|
|
1866
|
+
*
|
|
1867
|
+
* Three sources because they expire at different rates and any one of them can be the last witness.
|
|
1868
|
+
* A job record is deleted six hours after the review finishes; a worktree marker goes with its
|
|
1869
|
+
* directory; a review is kept for thirty days. The reviews are usually the only thing left, and a
|
|
1870
|
+
* repository we cannot name is a repository we cannot clean.
|
|
1871
|
+
*/
|
|
1872
|
+
function touchedRepositories() {
|
|
1873
|
+
const paths = [
|
|
1874
|
+
...allJobs().map((job) => job.target.originRepoPath),
|
|
1875
|
+
...managedDirectories().flatMap((path) => {
|
|
1876
|
+
const marker = readMarker(path);
|
|
1877
|
+
return marker ? [marker.originRepoPath] : [];
|
|
1878
|
+
}),
|
|
1879
|
+
...reviewedRepositories()
|
|
1880
|
+
];
|
|
1881
|
+
return [...new Set(paths.filter(Boolean).map((path) => resolve(path)))];
|
|
1882
|
+
}
|
|
1883
|
+
/** Directories under the managed root, or none if there is no managed root. */
|
|
1884
|
+
function managedDirectories() {
|
|
1885
|
+
try {
|
|
1886
|
+
return readdirSync(MANAGED_ROOT).map((name) => join(MANAGED_ROOT, name)).filter((path) => {
|
|
1887
|
+
try {
|
|
1888
|
+
return statSync(path).isDirectory();
|
|
1889
|
+
} catch {
|
|
1890
|
+
return false;
|
|
1891
|
+
}
|
|
1892
|
+
});
|
|
1893
|
+
} catch {
|
|
1894
|
+
return [];
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
/**
|
|
1898
|
+
* Remove every managed checkout, through the guard that owns removal.
|
|
1899
|
+
*
|
|
1900
|
+
* Both sources again: the job records name the directories that jobs still know about, and the
|
|
1901
|
+
* managed root holds the ones whose record has already gone. A directory reachable both ways is
|
|
1902
|
+
* removed once, and the second attempt would refuse rather than delete something else, so the
|
|
1903
|
+
* deduplication is for the report rather than for safety.
|
|
1904
|
+
*
|
|
1905
|
+
* `minAgeMs: 0` because the caller has established that nothing is running. See `removeWorktree`.
|
|
1906
|
+
*/
|
|
1907
|
+
async function removeAllWorktrees() {
|
|
1908
|
+
const fromJobs = allJobs().flatMap((job) => job.worktreePath ? [{
|
|
1909
|
+
path: job.worktreePath,
|
|
1910
|
+
repo: job.target.originRepoPath
|
|
1911
|
+
}] : []);
|
|
1912
|
+
const fromDisk = managedDirectories().flatMap((path) => {
|
|
1913
|
+
const marker = readMarker(path);
|
|
1914
|
+
return marker ? [{
|
|
1915
|
+
path,
|
|
1916
|
+
repo: marker.originRepoPath
|
|
1917
|
+
}] : [];
|
|
1918
|
+
});
|
|
1919
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1920
|
+
let removed = 0;
|
|
1921
|
+
const refusals = [];
|
|
1922
|
+
for (const candidate of [...fromJobs, ...fromDisk]) {
|
|
1923
|
+
const path = resolve(candidate.path);
|
|
1924
|
+
if (seen.has(path)) continue;
|
|
1925
|
+
seen.add(path);
|
|
1926
|
+
if (!existsSync(path)) continue;
|
|
1927
|
+
const outcome = await removeWorktree({
|
|
1928
|
+
originRepoPath: candidate.repo,
|
|
1929
|
+
worktreePath: path,
|
|
1930
|
+
minAgeMs: 0
|
|
1931
|
+
});
|
|
1932
|
+
if (outcome.removed) removed += 1;
|
|
1933
|
+
else refusals.push({
|
|
1934
|
+
path,
|
|
1935
|
+
message: outcome.message
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1938
|
+
return {
|
|
1939
|
+
removed,
|
|
1940
|
+
refusals
|
|
1941
|
+
};
|
|
1942
|
+
}
|
|
1943
|
+
/**
|
|
1944
|
+
* Clear the bookkeeping git keeps about worktrees of ours that no longer exist.
|
|
1945
|
+
*
|
|
1946
|
+
* Scoped by asking first. `git worktree prune` takes no path and drops every registered worktree
|
|
1947
|
+
* whose directory has gone, which in somebody else's repository may include one of theirs, so it is
|
|
1948
|
+
* run only where the listing shows a missing worktree inside our own managed root. A repository
|
|
1949
|
+
* with none of ours to clean is never touched at all.
|
|
1950
|
+
*
|
|
1951
|
+
* This is the step that clears a checkout which vanished without git being told: a retirement that
|
|
1952
|
+
* was interrupted, a directory somebody deleted themselves, a machine that lost power. What it
|
|
1953
|
+
* cannot do is help a reviewer who has already deleted the whole store, because the records naming
|
|
1954
|
+
* their repositories went with it and nothing left on the machine knows where to look. That is the
|
|
1955
|
+
* argument for this command existing rather than for a paragraph in a README.
|
|
1956
|
+
*/
|
|
1957
|
+
async function pruneStale(repositories) {
|
|
1958
|
+
const pruned = [];
|
|
1959
|
+
for (const repository of repositories) try {
|
|
1960
|
+
if (!(await git(repository, [
|
|
1961
|
+
"worktree",
|
|
1962
|
+
"list",
|
|
1963
|
+
"--porcelain"
|
|
1964
|
+
])).split("\n").some((line) => isOurMissingWorktree(line))) continue;
|
|
1965
|
+
await git(repository, ["worktree", "prune"]);
|
|
1966
|
+
pruned.push(repository);
|
|
1967
|
+
} catch {}
|
|
1968
|
+
return pruned;
|
|
1969
|
+
}
|
|
1970
|
+
/** A `worktree <path>` line from the porcelain listing, naming a directory of ours that is gone. */
|
|
1971
|
+
function isOurMissingWorktree(line) {
|
|
1972
|
+
if (!line.startsWith("worktree ")) return false;
|
|
1973
|
+
const path = resolve(line.slice(9).trim());
|
|
1974
|
+
return isInside(path, realManagedRoot()) && !existsSync(path);
|
|
1975
|
+
}
|
|
1976
|
+
/**
|
|
1977
|
+
* The managed root as git would have written it, whether or not it is still there.
|
|
1978
|
+
*
|
|
1979
|
+
* By the time this runs the store has usually been removed, so the root cannot simply be resolved:
|
|
1980
|
+
* `realpathSync` throws on a path that is gone, and falling back to the unresolved spelling is how
|
|
1981
|
+
* this quietly stopped matching anything at all. Resolving the deepest ancestor that does exist,
|
|
1982
|
+
* the home directory at worst, gives the same answer with the directory present or absent.
|
|
1983
|
+
*/
|
|
1984
|
+
function realManagedRoot() {
|
|
1985
|
+
const resolved = resolve(MANAGED_ROOT);
|
|
1986
|
+
let existing = resolved;
|
|
1987
|
+
const missing = [];
|
|
1988
|
+
while (!existsSync(existing)) {
|
|
1989
|
+
const parent = dirname(existing);
|
|
1990
|
+
if (parent === existing) return resolved;
|
|
1991
|
+
missing.unshift(basename(existing));
|
|
1992
|
+
existing = parent;
|
|
1993
|
+
}
|
|
1994
|
+
try {
|
|
1995
|
+
return join(realpathSync(existing), ...missing);
|
|
1996
|
+
} catch {
|
|
1997
|
+
return resolved;
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
function isInside(path, root) {
|
|
2001
|
+
return path === root || path.startsWith(`${root}/`) || path.startsWith(`${root}\\`);
|
|
2002
|
+
}
|
|
2003
|
+
/**
|
|
2004
|
+
* What a reviewer means by runtime state, which is what `--keep-reviews` removes.
|
|
2005
|
+
*
|
|
2006
|
+
* Named removals rather than a keep-list, and the direction is the point: a file some later version
|
|
2007
|
+
* of this product puts in the store is kept by a build that has never heard of it. The other way
|
|
2008
|
+
* round deletes somebody's data to satisfy a list written before the data existed.
|
|
2009
|
+
*
|
|
2010
|
+
* So what stays is everything else, and today that means their reviews (`workspaces`,
|
|
2011
|
+
* `reviews.html`), their settings (`config.json`, `prompts.json`) and the two records of having
|
|
2012
|
+
* been told something once (`onboarding-shown`, `telemetry-notice-shown`). Those last two are not
|
|
2013
|
+
* runtime state: it is the same person on the same machine, they have read both notices, and
|
|
2014
|
+
* showing either again would be this product forgetting a conversation rather than clearing one.
|
|
2015
|
+
* The usage log itself (`events.jsonl`) does go, because that is data about them rather than a note
|
|
2016
|
+
* to ourselves.
|
|
2017
|
+
*/
|
|
2018
|
+
var RUNTIME_ENTRIES = [
|
|
2019
|
+
"jobs",
|
|
2020
|
+
"claims",
|
|
2021
|
+
"worktrees",
|
|
2022
|
+
"no-hooks",
|
|
2023
|
+
"server.json",
|
|
2024
|
+
"server.lock",
|
|
2025
|
+
"events.jsonl"
|
|
2026
|
+
];
|
|
2027
|
+
/**
|
|
2028
|
+
* Remove the store, or the ephemeral half of it.
|
|
2029
|
+
*
|
|
2030
|
+
* The only unguarded deletion in the product, and the only path it will ever name. See the note at
|
|
2031
|
+
* the top of this file for what that rests on.
|
|
2032
|
+
*/
|
|
2033
|
+
function removeStore(options) {
|
|
2034
|
+
if (!existsSync(STORE_ROOT)) return {
|
|
2035
|
+
removed: false,
|
|
2036
|
+
kept: []
|
|
2037
|
+
};
|
|
2038
|
+
if (!options.keepReviews) {
|
|
2039
|
+
rmSync(STORE_ROOT, {
|
|
2040
|
+
recursive: true,
|
|
2041
|
+
force: true
|
|
2042
|
+
});
|
|
2043
|
+
return {
|
|
2044
|
+
removed: true,
|
|
2045
|
+
kept: []
|
|
2046
|
+
};
|
|
2047
|
+
}
|
|
2048
|
+
for (const entry of RUNTIME_ENTRIES) rmSync(join(STORE_ROOT, entry), {
|
|
2049
|
+
recursive: true,
|
|
2050
|
+
force: true
|
|
2051
|
+
});
|
|
2052
|
+
return {
|
|
2053
|
+
removed: false,
|
|
2054
|
+
kept: readdirSync(STORE_ROOT)
|
|
2055
|
+
};
|
|
2056
|
+
}
|
|
2057
|
+
/**
|
|
2058
|
+
* The removal itself, once the decision to do it has been taken.
|
|
2059
|
+
*
|
|
2060
|
+
* Stopping the server and stopping any driver happen before this and belong to the surface, because
|
|
2061
|
+
* they are what the reviewer was asked about. What is left is mechanical and ordered: clean the
|
|
2062
|
+
* repositories while the records still name them, then remove the records.
|
|
2063
|
+
*/
|
|
2064
|
+
async function cleanUp(options) {
|
|
2065
|
+
const repositories = touchedRepositories();
|
|
2066
|
+
const worktrees = await removeAllWorktrees();
|
|
2067
|
+
const store = removeStore({ keepReviews: options.keepReviews });
|
|
2068
|
+
const repositoriesPruned = await pruneStale(repositories);
|
|
2069
|
+
return {
|
|
2070
|
+
worktreesRemoved: worktrees.removed,
|
|
2071
|
+
repositoriesPruned,
|
|
2072
|
+
refusals: worktrees.refusals.filter((refusal) => existsSync(refusal.path)).map((refusal) => refusal.message),
|
|
2073
|
+
kept: store.kept,
|
|
2074
|
+
storeRemoved: store.removed
|
|
2075
|
+
};
|
|
2076
|
+
}
|
|
2077
|
+
//#endregion
|
|
2078
|
+
//#region src/commands/uninstall.ts
|
|
2079
|
+
/**
|
|
2080
|
+
* `prreviewbuddy uninstall`.
|
|
2081
|
+
*
|
|
2082
|
+
* The order is the design. Nothing is touched before the two questions have been answered: is any
|
|
2083
|
+
* work in progress, and does the person actually mean it. After that the sequence is fixed by what
|
|
2084
|
+
* each step needs from the one before, and `cleanUp` holds that half.
|
|
2085
|
+
*
|
|
2086
|
+
* It stops the workspace server, which the help screen says on purpose there is no command to do.
|
|
2087
|
+
* That is not a hole in the rule but the end of it: "no command to stop the server" exists so that
|
|
2088
|
+
* a review outlives the terminal that asked for it, and there are no reviews left to outlive
|
|
2089
|
+
* anything here.
|
|
2090
|
+
*
|
|
2091
|
+
* What it does not do is remove its own npm package. A running binary deleting the install it is
|
|
2092
|
+
* executing from depends on which package manager put it there and fails in ways that leave a
|
|
2093
|
+
* half-removed command behind, so the last thing printed is the line to type. See
|
|
2094
|
+
* `text.uninstallFinished`.
|
|
2095
|
+
*/
|
|
2096
|
+
var REAL = {
|
|
2097
|
+
runningJobs: () => runningJobs(),
|
|
2098
|
+
ask: (question) => confirm({
|
|
2099
|
+
question,
|
|
2100
|
+
isTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
2101
|
+
input: process.stdin,
|
|
2102
|
+
write: (chunk) => process.stderr.write(chunk)
|
|
2103
|
+
}),
|
|
2104
|
+
stopServer: () => {
|
|
2105
|
+
stopServer();
|
|
2106
|
+
},
|
|
2107
|
+
stopDrivers: (running) => {
|
|
2108
|
+
stopDrivers(running);
|
|
2109
|
+
},
|
|
2110
|
+
cleanUp: (options) => cleanUp(options)
|
|
2111
|
+
};
|
|
2112
|
+
async function uninstall(command, deps = REAL) {
|
|
2113
|
+
const running = deps.runningJobs();
|
|
2114
|
+
if (running.length > 0 && !command.force) {
|
|
2115
|
+
err(uninstallRunning(running.map((one) => ({ branch: one.job.target.branch }))));
|
|
2116
|
+
return 1;
|
|
2117
|
+
}
|
|
2118
|
+
if (!command.yes) {
|
|
2119
|
+
err(uninstallPlan(command.keepReviews));
|
|
2120
|
+
err("");
|
|
2121
|
+
const answer = await deps.ask(UNINSTALL_QUESTION);
|
|
2122
|
+
if (answer.kind === "unattended") {
|
|
2123
|
+
err(uninstallUnattended());
|
|
2124
|
+
return 1;
|
|
2125
|
+
}
|
|
2126
|
+
if (answer.kind === "no") {
|
|
2127
|
+
err(uninstallCancelled());
|
|
2128
|
+
return 1;
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
deps.stopServer();
|
|
2132
|
+
if (running.length > 0) deps.stopDrivers(running);
|
|
2133
|
+
out(uninstallFinished(await deps.cleanUp({ keepReviews: command.keepReviews }), command.keepReviews, PACKAGE_NAME));
|
|
2134
|
+
return 0;
|
|
2135
|
+
}
|
|
2136
|
+
//#endregion
|
|
2137
|
+
//#region src/first_run.ts
|
|
2138
|
+
/**
|
|
2139
|
+
* The two lines somebody needs the first time they run this, and never again.
|
|
2140
|
+
*
|
|
2141
|
+
* This began as a `postinstall` script, which is the obvious place for it and the wrong one: npm 11
|
|
2142
|
+
* does not run install scripts unless they are allow-listed, so the banner would have reached
|
|
2143
|
+
* almost nobody, and its mere presence in the manifest adds an install-scripts warning to every
|
|
2144
|
+
* install of a package that asks for nothing and holds no credential. That is a bad trade in both
|
|
2145
|
+
* directions.
|
|
2146
|
+
*
|
|
2147
|
+
* The first command is a better moment anyway. It reaches every user however they installed this,
|
|
2148
|
+
* and it arrives while they are using the product rather than while they are watching a package
|
|
2149
|
+
* manager count files.
|
|
2150
|
+
*
|
|
2151
|
+
* A marker file rather than a flag in `config.json`: that file holds settings a person may edit,
|
|
2152
|
+
* and this is not a setting. It is a fact about the machine, of the same kind and in the same place
|
|
2153
|
+
* as the telemetry notice next to it. A home directory that cannot be written to costs somebody a
|
|
2154
|
+
* repeated banner and never a review, which is why nothing here throws.
|
|
2155
|
+
*/
|
|
2156
|
+
var MARKER = join(STORE_ROOT, "onboarding-shown");
|
|
2157
|
+
/**
|
|
2158
|
+
* Whether this machine has been greeted.
|
|
2159
|
+
*
|
|
2160
|
+
* Unreadable reads as greeted. Getting this wrong in one direction repeats a banner, and in the
|
|
2161
|
+
* other it prints one over somebody's output every single time, so the failure goes quiet.
|
|
2162
|
+
*/
|
|
2163
|
+
function greeted() {
|
|
2164
|
+
try {
|
|
2165
|
+
return existsSync(MARKER);
|
|
2166
|
+
} catch {
|
|
2167
|
+
return true;
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
function recordGreeting() {
|
|
2171
|
+
try {
|
|
2172
|
+
mkdirSync(STORE_ROOT, { recursive: true });
|
|
2173
|
+
writeFileSync(MARKER, `${(/* @__PURE__ */ new Date()).toISOString()}\n`, { mode: 384 });
|
|
2174
|
+
} catch {}
|
|
2175
|
+
}
|
|
2176
|
+
//#endregion
|
|
1534
2177
|
//#region src/main.ts
|
|
1535
2178
|
/**
|
|
1536
2179
|
* PR Review Buddy at the command line.
|
|
@@ -1545,8 +2188,27 @@ function config(command) {
|
|
|
1545
2188
|
* asks for one. There is a little configuration now, and it holds a preference rather than a
|
|
1546
2189
|
* secret: which agent to use when you do not name one.
|
|
1547
2190
|
*/
|
|
2191
|
+
/**
|
|
2192
|
+
* The one-off hello, printed above the first command somebody runs and never again.
|
|
2193
|
+
*
|
|
2194
|
+
* Not for `--help`, which is the fuller version of what it says, and not for `uninstall`, where
|
|
2195
|
+
* greeting somebody on their way out would be a joke at their expense. To stderr, like every other
|
|
2196
|
+
* line that is not a result, so `prreviewbuddy review | pbcopy` still copies a URL.
|
|
2197
|
+
*/
|
|
2198
|
+
var GREETED = [
|
|
2199
|
+
"review",
|
|
2200
|
+
"open",
|
|
2201
|
+
"agents",
|
|
2202
|
+
"config"
|
|
2203
|
+
];
|
|
2204
|
+
function greet(command) {
|
|
2205
|
+
if (!GREETED.includes(command.name) || greeted()) return;
|
|
2206
|
+
err(firstRun(BUILD_VERSION));
|
|
2207
|
+
recordGreeting();
|
|
2208
|
+
}
|
|
1548
2209
|
async function main(argv) {
|
|
1549
2210
|
const command = parse(argv);
|
|
2211
|
+
greet(command);
|
|
1550
2212
|
switch (command.name) {
|
|
1551
2213
|
case "help":
|
|
1552
2214
|
out(HELP);
|
|
@@ -1561,6 +2223,7 @@ async function main(argv) {
|
|
|
1561
2223
|
case "open": return open(command);
|
|
1562
2224
|
case "agents": return agents(command);
|
|
1563
2225
|
case "config": return config(command);
|
|
2226
|
+
case "uninstall": return uninstall(command);
|
|
1564
2227
|
}
|
|
1565
2228
|
}
|
|
1566
2229
|
process.exitCode = await main(process.argv.slice(2)).catch((error) => {
|