prreviewbuddy 0.25.13 → 0.27.2
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 +78 -0
- package/dist/{relative_time-vn8Ex9E8.js → delete_review-B3xciJ03.js} +5479 -5173
- package/dist/main.js +861 -306
- package/dist/server.js +341 -68
- package/package.json +1 -1
- package/static/reviews.js +1 -1
- package/static/shell.css +8 -2
- package/static/workspace.js +10 -10
package/dist/main.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { A as
|
|
2
|
+
import { A as removeWorktree, B as workspaceUrl, Bt as resolveTarget, C as recordTelemetryUploadConsent, Dt as saveWorkspace, Et as reviewedRepositories, G as DEFAULT_WORKSPACE_PORT, H as BUILD_VERSION, Ht as git, I as ensureServer, J as parseWorkspacePort, Jt as CONFIG_PATH, K as MAX_WORKSPACE_PORT, Kt as readAgentPreference, M as relativeTime, N as readIndexToken, O as MANAGED_ROOT, Ot as summarise, P as bootstrapUrl, Q as forgeResolver, Qt as agentById, R as reviewsUrl, S as record, U as PACKAGE_NAME, Ut as resolveAgent, Vt as displayRef, Wt as resolveModel, X as statedWorkspacePort, Xt as AGENT_IDS, Yt as STORE_ROOT, Z as writePortPreference, Zt as DEFAULT_AGENT_ID, _t as lineageKeyFor, a as wasBlocked, at as isTerminal, b as startJob, bt as matchingWorkspaceIds, c as updateReview, ct as PHASES, dt as purposeOf, en as detectAgents, f as UpdateAlreadyRunningError, ft as clearClaim, g as recordKissRun, ht as groupByLineage, i as liveJobsFor, j as modelHelpLines, k as readMarker, m as runningJobs, mt as followedRefName, n as deleteReview$1, nt as allJobs, ot as loadJob, p as liveUpdateFor, pt as isClaimed, q as MIN_WORKSPACE_PORT, qt as writeAgentPreference, tt as checkFreshness, ut as progressSteps, v as reanalyseReview, w as telemetryUploadConsent, wt as recentWorkspaces, xt as positionInLineage, y as runJob, yt as loadWorkspace, z as stopServer } from "./delete_review-B3xciJ03.js";
|
|
3
3
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -12,11 +12,13 @@ var VALUED = {
|
|
|
12
12
|
review: [
|
|
13
13
|
"--base",
|
|
14
14
|
"--agent",
|
|
15
|
+
"--model",
|
|
15
16
|
"--branch",
|
|
16
17
|
"--pr",
|
|
17
18
|
"--review",
|
|
18
19
|
"--audience",
|
|
19
|
-
"--lens"
|
|
20
|
+
"--lens",
|
|
21
|
+
"--concurrency"
|
|
20
22
|
],
|
|
21
23
|
open: [],
|
|
22
24
|
agents: [],
|
|
@@ -113,6 +115,8 @@ function parse(argv) {
|
|
|
113
115
|
const values = {};
|
|
114
116
|
const flags = /* @__PURE__ */ new Set();
|
|
115
117
|
const positional = [];
|
|
118
|
+
/** Every URL `--pr` took. `review` has no positionals, so what follows `--pr` up to the next flag is its. */
|
|
119
|
+
const prs = [];
|
|
116
120
|
for (let index = 0; index < rest.length; index += 1) {
|
|
117
121
|
const argument = rest[index];
|
|
118
122
|
if (!argument.startsWith("--")) {
|
|
@@ -129,6 +133,13 @@ function parse(argv) {
|
|
|
129
133
|
};
|
|
130
134
|
values[name] = value;
|
|
131
135
|
if (equals === -1) index += 1;
|
|
136
|
+
if (head === "review" && name === "--pr") {
|
|
137
|
+
prs.push(value);
|
|
138
|
+
while (rest[index + 1] !== void 0 && !rest[index + 1].startsWith("--")) {
|
|
139
|
+
prs.push(rest[index + 1]);
|
|
140
|
+
index += 1;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
132
143
|
continue;
|
|
133
144
|
}
|
|
134
145
|
if (BARE[head]?.includes(name) && equals === -1) {
|
|
@@ -155,11 +166,24 @@ function parse(argv) {
|
|
|
155
166
|
};
|
|
156
167
|
const first = positional[0];
|
|
157
168
|
if (head === "review") {
|
|
169
|
+
const batch = refuseBatch(prs, values, flags);
|
|
170
|
+
if (batch) return {
|
|
171
|
+
name: "error",
|
|
172
|
+
message: batch
|
|
173
|
+
};
|
|
158
174
|
const refused = refuseCombination(values, flags);
|
|
159
175
|
if (refused) return {
|
|
160
176
|
name: "error",
|
|
161
177
|
message: refused
|
|
162
178
|
};
|
|
179
|
+
if (prs.length > 1) return {
|
|
180
|
+
name: "review",
|
|
181
|
+
prUrls: prs,
|
|
182
|
+
...values["--concurrency"] ? { concurrency: Number(values["--concurrency"]) } : {},
|
|
183
|
+
...values["--agent"] ? { agentId: values["--agent"] } : {},
|
|
184
|
+
...values["--model"] ? { requestedModel: values["--model"] } : {},
|
|
185
|
+
...flags.has("--fresh") ? { operation: "fresh" } : {}
|
|
186
|
+
};
|
|
163
187
|
const target = Object.keys(TARGETS).find((flag) => values[flag]);
|
|
164
188
|
const operation = Object.keys(OPERATIONS).find((flag) => flags.has(flag));
|
|
165
189
|
return {
|
|
@@ -167,6 +191,7 @@ function parse(argv) {
|
|
|
167
191
|
...target ? { [TARGETS[target]]: values[target] } : {},
|
|
168
192
|
...values["--base"] ? { base: values["--base"] } : {},
|
|
169
193
|
...values["--agent"] ? { agentId: values["--agent"] } : {},
|
|
194
|
+
...values["--model"] ? { requestedModel: values["--model"] } : {},
|
|
170
195
|
...operation ? { operation: OPERATIONS[operation][0] } : {},
|
|
171
196
|
...values["--lens"] ? { lens: values["--lens"] } : {},
|
|
172
197
|
...values["--audience"] ? { audience: values["--audience"] } : {},
|
|
@@ -214,6 +239,32 @@ function parse(argv) {
|
|
|
214
239
|
};
|
|
215
240
|
}
|
|
216
241
|
/**
|
|
242
|
+
* What several pull requests at once cannot be asked for, or null.
|
|
243
|
+
*
|
|
244
|
+
* A batch is the standard review of each, started together. An operation or a lens is a question
|
|
245
|
+
* about one review, and answering it for several at once is a feature of its own rather than a
|
|
246
|
+
* flag this can pass through.
|
|
247
|
+
*/
|
|
248
|
+
function refuseBatch(prs, values, flags) {
|
|
249
|
+
const concurrency = values["--concurrency"];
|
|
250
|
+
if (concurrency !== void 0) {
|
|
251
|
+
if (!/^\d+$/.test(concurrency) || Number(concurrency) < 1) return `\`--concurrency\` is how many pull requests are reviewed at once, so it takes a whole number of 1 or more, and \`${concurrency}\` is not one.`;
|
|
252
|
+
if (prs.length < 2) return "`--concurrency` says how many of several pull requests are reviewed at once, and this names one at most. Give `--pr` two or more URLs, or drop `--concurrency`.";
|
|
253
|
+
}
|
|
254
|
+
if (prs.length < 2) return null;
|
|
255
|
+
const repeated = prs.find((url, index) => prs.indexOf(url) !== index);
|
|
256
|
+
if (repeated) return `\`${repeated}\` is named twice. Each pull request is reviewed once, so name it once.`;
|
|
257
|
+
const operation = [
|
|
258
|
+
"--update",
|
|
259
|
+
"--reanalyse",
|
|
260
|
+
"--delete"
|
|
261
|
+
].find((flag) => flags.has(flag));
|
|
262
|
+
if (operation) return `\`${operation}\` ${OPERATIONS[operation][1]}, and it acts on one review. With several pull requests, run it once for each: \`prreviewbuddy review --pr <url> ` + operation + "`.";
|
|
263
|
+
const lensFlag = values["--lens"] ? "--lens" : values["--audience"] ? "--audience" : null;
|
|
264
|
+
if (lensFlag) return `Several pull requests at once get the standard review of each, and \`${lensFlag}\` asks for a different one. Run the lens once for each pull request: \`prreviewbuddy review --pr <url> --lens kiss\`.`;
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
217
268
|
* The combinations of individually legal flags that ask a question with no answer, or null.
|
|
218
269
|
*
|
|
219
270
|
* In this order, so the message is the most specific true one: how many targets, how many
|
|
@@ -257,6 +308,7 @@ function refuseCombination(values, flags) {
|
|
|
257
308
|
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.";
|
|
258
309
|
}
|
|
259
310
|
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.";
|
|
311
|
+
if (values["--model"]) return "`--model` chooses which model analyses a change, and `--delete` analyses nothing. Drop it, or drop `--delete` if you meant to review something.";
|
|
260
312
|
}
|
|
261
313
|
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.";
|
|
262
314
|
if (values["--review"]) {
|
|
@@ -285,62 +337,6 @@ function unknownCommand(head) {
|
|
|
285
337
|
return `There is no \`${head}\` command. This does five things: review, open, agents, config and uninstall. Run \`prreviewbuddy --help\` for what each one takes.`;
|
|
286
338
|
}
|
|
287
339
|
//#endregion
|
|
288
|
-
//#region ../../packages/review-harness/src/agents/resolve.ts
|
|
289
|
-
/**
|
|
290
|
-
* Which agent answers, decided in one place.
|
|
291
|
-
*
|
|
292
|
-
* A pure decision: nothing here prints, prompts or spawns. The CLI renders the outcome and the
|
|
293
|
-
* harness owns the rule, which is what lets `review`, `update` and the first-run picker all reach
|
|
294
|
-
* the same conclusion from different starting points instead of each carrying their own version of
|
|
295
|
-
* "well, if there is only one installed...".
|
|
296
|
-
*
|
|
297
|
-
* The precedence is the whole content of this module:
|
|
298
|
-
*
|
|
299
|
-
* 1. flag an explicit `--agent` always wins, and an unavailable one fails rather than
|
|
300
|
-
* quietly falling back. Somebody who named an agent wants that agent or an answer.
|
|
301
|
-
* 2. review the agent of this review's most recent analysis. A review is not owned by an
|
|
302
|
-
* agent, but an update with nothing said should continue the conversation it is
|
|
303
|
-
* part of rather than switch tools because a machine default changed last week.
|
|
304
|
-
* 3. preference the configured default.
|
|
305
|
-
* 4. only-one exactly one agent installed, so there is nothing to ask about.
|
|
306
|
-
*
|
|
307
|
-
* and otherwise there is no answer to give: several installed and nothing chosen is `ambiguous`,
|
|
308
|
-
* which the CLI turns into a question or a refusal depending on whether anyone is there to ask.
|
|
309
|
-
* None installed is `none`. Neither is an error, because neither is the reviewer's mistake.
|
|
310
|
-
*/
|
|
311
|
-
async function resolveAgent(request) {
|
|
312
|
-
if (request.flag) return named(request.flag, "flag");
|
|
313
|
-
const fromReview = request.review ? agentIdOf(request.review.session) : null;
|
|
314
|
-
if (fromReview && AGENT_IDS.includes(fromReview)) return named(fromReview, "review");
|
|
315
|
-
const preferred = readAgentPreference();
|
|
316
|
-
if (preferred) return named(preferred, "preference");
|
|
317
|
-
const installed = (await detectAgents()).filter((agent) => agent.state === "installed");
|
|
318
|
-
if (installed.length === 1) return named(installed[0].id, "only-one");
|
|
319
|
-
if (installed.length === 0) return { kind: "none" };
|
|
320
|
-
return {
|
|
321
|
-
kind: "ambiguous",
|
|
322
|
-
candidates: installed
|
|
323
|
-
};
|
|
324
|
-
}
|
|
325
|
-
async function named(id, because) {
|
|
326
|
-
if (!AGENT_IDS.includes(id)) return {
|
|
327
|
-
kind: "unknown-id",
|
|
328
|
-
id
|
|
329
|
-
};
|
|
330
|
-
const agent = agentById(id);
|
|
331
|
-
const availability = await agent.available(agent.captureEnv());
|
|
332
|
-
if (!availability.ok) return {
|
|
333
|
-
kind: "unavailable",
|
|
334
|
-
agent,
|
|
335
|
-
reason: availability.reason
|
|
336
|
-
};
|
|
337
|
-
return {
|
|
338
|
-
kind: "resolved",
|
|
339
|
-
agent,
|
|
340
|
-
because
|
|
341
|
-
};
|
|
342
|
-
}
|
|
343
|
-
//#endregion
|
|
344
340
|
//#region src/select.ts
|
|
345
341
|
/**
|
|
346
342
|
* Choosing an agent the first time, and only when there is somebody to ask.
|
|
@@ -428,6 +424,68 @@ async function pick$1(ask, options, candidates) {
|
|
|
428
424
|
return null;
|
|
429
425
|
}
|
|
430
426
|
//#endregion
|
|
427
|
+
//#region src/selection_flags.ts
|
|
428
|
+
/**
|
|
429
|
+
* The flags that choose how a run executes, and what to say when a branch cannot consume them.
|
|
430
|
+
*
|
|
431
|
+
* `--agent` and `--model` select an execution rather than describe a subject: they say which tool
|
|
432
|
+
* and which model answer, not what is being reviewed. So every branch that hands back an analysis
|
|
433
|
+
* somebody else's run produced has the same problem, and they kept solving it separately. `--agent`
|
|
434
|
+
* was answered on a resume and not on an update; `--model` on neither; then on a resume and beside
|
|
435
|
+
* `--update`, but not beside a review that already exists. One mistake, four escapes, because the
|
|
436
|
+
* decision lived in four places.
|
|
437
|
+
*
|
|
438
|
+
* It lives here now. A branch asks what it may not consume and prints the answer; it does not get
|
|
439
|
+
* to decide which half of the question to ask. The copy table is total over both dimensions, so a
|
|
440
|
+
* third selection flag or a fourth branch is a type error at every cell it has not written yet
|
|
441
|
+
* rather than a flag that goes quiet in one place.
|
|
442
|
+
*
|
|
443
|
+
* What this never does is obey. Each of these branches continues work that was already begun, and
|
|
444
|
+
* changing the engine halfway leaves a review whose provenance and whose execution disagree. So
|
|
445
|
+
* the flag is answered and the command that would honour it is named.
|
|
446
|
+
*/
|
|
447
|
+
/** The execution-selection flags, in the order they are answered. */
|
|
448
|
+
var SELECTION_FLAGS = ["agent", "model"];
|
|
449
|
+
/**
|
|
450
|
+
* One sentence per branch per flag.
|
|
451
|
+
*
|
|
452
|
+
* Written out rather than generated from a pattern: the reason each branch cannot use the flag is
|
|
453
|
+
* different, and a reviewer is owed the actual reason rather than a template with the noun swapped.
|
|
454
|
+
* Totality is what the type gives; the wording is deliberately hand-set.
|
|
455
|
+
*
|
|
456
|
+
* They share a tense on purpose. Both lines for one branch can print in the same breath, and a
|
|
457
|
+
* tense that shifts between two adjacent lines reads as two different voices.
|
|
458
|
+
*/
|
|
459
|
+
var SENTENCE = {
|
|
460
|
+
update: {
|
|
461
|
+
agent: (value) => `An update continues the review with the agent it was made by, so \`--agent ${value}\` changes nothing here. To analyse the same commit again with another agent, run \`prreviewbuddy review --reanalyse --agent ${value}\`.`,
|
|
462
|
+
model: (value) => `An update continues the review with the model it was made with, so \`--model ${value}\` changes nothing here. To analyse the same commit with that model, run \`prreviewbuddy review --reanalyse --model ${value}\`.`
|
|
463
|
+
},
|
|
464
|
+
resume: {
|
|
465
|
+
agent: (value) => `A resume continues the job the review already started, so \`--agent ${value}\` changes nothing here: the phases it has finished were run by the agent it was started under. To analyse the same commit with another agent, run \`prreviewbuddy review --reanalyse --agent ${value}\`.`,
|
|
466
|
+
model: (value) => `A resume continues with the model the review was started with, so \`--model ${value}\` changes nothing here. To analyse the same commit with that model, run \`prreviewbuddy review --reanalyse --model ${value}\`.`
|
|
467
|
+
},
|
|
468
|
+
existing: {
|
|
469
|
+
agent: (value) => `This is the review that already exists rather than a new one, so \`--agent ${value}\` changes nothing here. To analyse the same commit again with that agent, run \`prreviewbuddy review --reanalyse --agent ${value}\`.`,
|
|
470
|
+
model: (value) => `This is the review that already exists rather than a new one, so \`--model ${value}\` changes nothing here. To analyse the same commit again with that model, run \`prreviewbuddy review --reanalyse --model ${value}\`.`
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
/**
|
|
474
|
+
* Which selection flags this branch will not consume, said out loud.
|
|
475
|
+
*
|
|
476
|
+
* `running` is what the branch will actually use, when it knows: a resume carries the agent and
|
|
477
|
+
* model its job was created with, and a flag naming one of those asked for what is already
|
|
478
|
+
* happening. Answering it would tell a reviewer their correct command was ignored. A branch that
|
|
479
|
+
* cannot know passes nothing and every named flag is answered.
|
|
480
|
+
*/
|
|
481
|
+
function unusedSelection(continuation, asked, running = {}) {
|
|
482
|
+
return SELECTION_FLAGS.flatMap((flag) => {
|
|
483
|
+
const value = asked[flag];
|
|
484
|
+
if (!value || value === running[flag]) return [];
|
|
485
|
+
return [SENTENCE[continuation][flag](value)];
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
//#endregion
|
|
431
489
|
//#region src/audience.ts
|
|
432
490
|
/**
|
|
433
491
|
* Asking whose code this is, when the command did not say.
|
|
@@ -560,13 +618,19 @@ async function pick(ask, options) {
|
|
|
560
618
|
* this branch against some other base is a different comparison wearing the right branch name. A
|
|
561
619
|
* base nobody named narrows nothing, because the command claimed nothing about it: any review of
|
|
562
620
|
* the branch is a truthful answer to "give me the review of this branch".
|
|
621
|
+
*
|
|
622
|
+
* **A pull request number narrows it too.** A request target has no ref, so its lineage key is the
|
|
623
|
+
* head branch name, and two fork requests both called `patch-1` share one. Only a review recording
|
|
624
|
+
* the same number answers a named request. One recording no number is unknown rather than a
|
|
625
|
+
* match, the same strictness as an empty base: the cost is one redundant review, never the wrong one.
|
|
563
626
|
*/
|
|
564
627
|
async function findExistingReview(target) {
|
|
565
628
|
const groups = groupByLineage(recentWorkspaces(Number.MAX_SAFE_INTEGER));
|
|
566
629
|
const wanted = lineageKeyFor(target.originRepoPath, target.branch, target.ref);
|
|
567
630
|
const group = groups.find((entry) => entry.key === wanted);
|
|
568
631
|
if (!group) return null;
|
|
569
|
-
const
|
|
632
|
+
const requested = target.pullRequest?.number;
|
|
633
|
+
const history = group.history.filter((review) => target.base ? madeAgainst(target.base)(review) : true).filter((review) => requested ? review.prNumber === requested : true);
|
|
570
634
|
if (history.length === 0) return null;
|
|
571
635
|
const summary = history[0];
|
|
572
636
|
const found = {
|
|
@@ -665,58 +729,161 @@ function describeMovement(existing) {
|
|
|
665
729
|
return `${subject} has moved ${existing.commitsBehind} commit${existing.commitsBehind === 1 ? "" : "s"} since.`;
|
|
666
730
|
}
|
|
667
731
|
//#endregion
|
|
668
|
-
//#region src/
|
|
732
|
+
//#region src/batch_schedule.ts
|
|
669
733
|
/**
|
|
670
|
-
*
|
|
671
|
-
*
|
|
672
|
-
* A review id is a UUID, and a UUID is not a thing anyone retypes. `open` prints them in full and
|
|
673
|
-
* every other command took nothing else, so `--review a8a9a058` answered "there is no review called
|
|
674
|
-
* a8a9a058" about a review that was right there. Every tool that hands out identifiers this long
|
|
675
|
-
* takes an unambiguous prefix instead, and so does this one now.
|
|
734
|
+
* Run `run` over every item, at most `concurrency` at once, and report each one's ending.
|
|
676
735
|
*
|
|
677
|
-
*
|
|
678
|
-
*
|
|
679
|
-
*
|
|
680
|
-
* shorthand back has the full id in the refusal.
|
|
736
|
+
* Never throws for an item: one that rejects is recorded and the rest carry on. Once `signal` is
|
|
737
|
+
* aborted nothing more starts, and what is already running is waited for, because it is the work's
|
|
738
|
+
* own job to notice the abort and stop.
|
|
681
739
|
*/
|
|
682
|
-
function
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
740
|
+
async function runBatch(items, options) {
|
|
741
|
+
const outcomes = items.map(() => ({ kind: "not-started" }));
|
|
742
|
+
let next = 0;
|
|
743
|
+
const lane = async () => {
|
|
744
|
+
while (next < items.length && !options.signal?.aborted) {
|
|
745
|
+
const index = next;
|
|
746
|
+
next += 1;
|
|
747
|
+
try {
|
|
748
|
+
outcomes[index] = {
|
|
749
|
+
kind: "done",
|
|
750
|
+
value: await options.run(items[index], index)
|
|
751
|
+
};
|
|
752
|
+
} catch (error) {
|
|
753
|
+
outcomes[index] = {
|
|
754
|
+
kind: "threw",
|
|
755
|
+
error
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
}
|
|
692
759
|
};
|
|
760
|
+
const lanes = Math.max(1, Math.min(options.concurrency, items.length));
|
|
761
|
+
await Promise.all(Array.from({ length: lanes }, lane));
|
|
762
|
+
return outcomes;
|
|
693
763
|
}
|
|
694
764
|
//#endregion
|
|
695
|
-
//#region src/
|
|
765
|
+
//#region src/progress.ts
|
|
696
766
|
/**
|
|
697
|
-
*
|
|
767
|
+
* What the review is doing, while it does it.
|
|
698
768
|
*
|
|
699
|
-
* The
|
|
700
|
-
*
|
|
701
|
-
*
|
|
702
|
-
*
|
|
769
|
+
* The job record is the source of truth, and it is the same one the workspace page polls over
|
|
770
|
+
* HTTP. Read here directly from the store rather than through the server, because this process is
|
|
771
|
+
* the one driving the job and a loopback request to ask itself what it is doing would be a strange
|
|
772
|
+
* way to find out.
|
|
703
773
|
*
|
|
704
|
-
*
|
|
705
|
-
*
|
|
774
|
+
* This used to print `job.progress ?? job.phase` whenever it changed, which meant the raw phase
|
|
775
|
+
* enum reached the terminal as a bare `preparing` before the first progress note arrived, and every
|
|
776
|
+
* later note appended a line to a growing log. A reviewer could not tell what had finished, what
|
|
777
|
+
* was running, or how much was left.
|
|
778
|
+
*
|
|
779
|
+
* It now renders one block: a header, then a line per phase with a mark for its state, and the
|
|
780
|
+
* agent's own notes indented under whichever phase is currently running. On a terminal the block is
|
|
781
|
+
* redrawn in place, so the review reads as one task progressing rather than as a transcript. Where
|
|
782
|
+
* stderr is not a terminal (a pipe, a CI log, a file) it falls back to appending only the lines
|
|
783
|
+
* that changed, because cursor movement written into a log is worse than a plain list.
|
|
784
|
+
*
|
|
785
|
+
* Progress goes to stderr and the review's link goes to stdout, so `prreviewbuddy review | pbcopy`
|
|
786
|
+
* copies a URL rather than a transcript.
|
|
706
787
|
*/
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
788
|
+
var POLL_INTERVAL_MS$1 = 500;
|
|
789
|
+
/**
|
|
790
|
+
* Colour only where it means something, and never where it cannot be seen.
|
|
791
|
+
*
|
|
792
|
+
* `NO_COLOR` is honoured because this writes to stderr, which people redirect into files and CI
|
|
793
|
+
* logs where escape codes are noise rather than emphasis.
|
|
794
|
+
*/
|
|
795
|
+
function styling(stream) {
|
|
796
|
+
return stream.isTTY === true && !process.env.NO_COLOR;
|
|
797
|
+
}
|
|
798
|
+
var DIM = "\x1B[2m";
|
|
799
|
+
var GREEN = "\x1B[32m";
|
|
800
|
+
var RED = "\x1B[31m";
|
|
801
|
+
var RESET = "\x1B[0m";
|
|
802
|
+
/** The header above the phases: what is being reviewed, and the facts about how. */
|
|
803
|
+
function header(target, colour, purpose = "review") {
|
|
804
|
+
const meta = `${target.sha.slice(0, 7)} · isolated checkout · ${target.originRepoPath}`;
|
|
805
|
+
return [
|
|
806
|
+
`${purpose === "update" ? "Updating" : "Reviewing"} ${target.branch}`,
|
|
807
|
+
colour ? `${DIM}${meta}${RESET}` : meta,
|
|
808
|
+
""
|
|
809
|
+
];
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* One line per phase, marked with its state, and the agent's note under the running one.
|
|
813
|
+
*
|
|
814
|
+
* Completed phases stay visible because they are the part that says how far along this is. Their
|
|
815
|
+
* detail does not: a note about a file read two phases ago is history, and history is what the
|
|
816
|
+
* transcript was made of.
|
|
817
|
+
*
|
|
818
|
+
* Which phases there are, what they are called and what has become of each is `progressSteps`,
|
|
819
|
+
* shared with the workspace page so the browser and this block never name one phase two things.
|
|
820
|
+
* What is left here is how a terminal draws that: a mark, a colour, and an indent.
|
|
821
|
+
*/
|
|
822
|
+
function phaseLines(job, colour) {
|
|
823
|
+
const lines = [];
|
|
824
|
+
for (const step of progressSteps(job)) {
|
|
825
|
+
const mark = step.state === "failed" ? "✕" : step.state === "stopped" ? "–" : step.state === "complete" ? "✓" : step.state === "active" ? "⟳" : " ";
|
|
826
|
+
const paint = step.state === "failed" ? RED : step.state === "complete" ? GREEN : "";
|
|
827
|
+
if (step.state === "pending") {
|
|
828
|
+
lines.push(colour ? `${DIM} ${mark} ${step.label}${RESET}` : ` ${mark} ${step.label}`);
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
lines.push(colour && paint ? `${paint}${mark}${RESET} ${step.label}` : `${mark} ${step.label}`);
|
|
832
|
+
if (step.note) lines.push(colour ? `${DIM} ${step.note}${RESET}` : ` ${step.note}`);
|
|
719
833
|
}
|
|
834
|
+
return lines;
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* How many rows this block will actually occupy, which is not how many strings it is.
|
|
838
|
+
*
|
|
839
|
+
* The redraw moves the cursor up by a count and clears from there, so that count has to be in the
|
|
840
|
+
* unit the terminal moves in: rows on screen, after wrapping. Counting strings instead lands the
|
|
841
|
+
* cursor short and clears output the block never drew. The header's second line carries a full
|
|
842
|
+
* repository path and wraps in any normal window, so this was not a corner case.
|
|
843
|
+
*
|
|
844
|
+
* Escape sequences are removed first because they occupy no columns. A resize between drawing and
|
|
845
|
+
* redrawing still misplaces the cursor; nothing short of tracking the resize can fix that, and the
|
|
846
|
+
* next repaint corrects itself.
|
|
847
|
+
*/
|
|
848
|
+
function rowsOccupied(lines, columns) {
|
|
849
|
+
if (!columns) return lines.length;
|
|
850
|
+
const visible = (line) => line.replace(/\u001b\[[0-9;]*m/g, "").length;
|
|
851
|
+
return lines.reduce((rows, line) => rows + Math.max(1, Math.ceil(visible(line) / columns)), 0);
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Watch a job and keep one block on the screen describing it.
|
|
855
|
+
*
|
|
856
|
+
* Returns the stop function the caller must call: nothing here may outlive the command, and a
|
|
857
|
+
* stray interval would make `review` hang after the review finished.
|
|
858
|
+
*/
|
|
859
|
+
function followJob(jobId, target, options = {}) {
|
|
860
|
+
const stream = options.stream ?? process.stderr;
|
|
861
|
+
const read = options.read ?? loadJob;
|
|
862
|
+
const colour = styling(stream);
|
|
863
|
+
const inPlace = stream.isTTY === true;
|
|
864
|
+
let drawn = 0;
|
|
865
|
+
let last = "";
|
|
866
|
+
const paint = () => {
|
|
867
|
+
const job = read(jobId);
|
|
868
|
+
if (!job) return;
|
|
869
|
+
const lines = [...header(job.target ?? target, colour, purposeOf(job)), ...phaseLines(job, colour)];
|
|
870
|
+
const block = lines.join("\n");
|
|
871
|
+
if (block === last) return;
|
|
872
|
+
if (inPlace && drawn > 0) stream.write(`\u001b[${drawn}A\u001b[0J`);
|
|
873
|
+
stream.write(`${block}\n`);
|
|
874
|
+
last = block;
|
|
875
|
+
drawn = rowsOccupied(lines, stream.columns ?? 0);
|
|
876
|
+
};
|
|
877
|
+
const timer = setInterval(paint, POLL_INTERVAL_MS$1);
|
|
878
|
+
timer.unref();
|
|
879
|
+
paint();
|
|
880
|
+
let stopped = false;
|
|
881
|
+
return () => {
|
|
882
|
+
if (stopped) return;
|
|
883
|
+
stopped = true;
|
|
884
|
+
clearInterval(timer);
|
|
885
|
+
paint();
|
|
886
|
+
};
|
|
720
887
|
}
|
|
721
888
|
//#endregion
|
|
722
889
|
//#region src/text.ts
|
|
@@ -748,6 +915,33 @@ function orList(items) {
|
|
|
748
915
|
return `${items.slice(0, -1).join(", ")} or ${items[items.length - 1]}`;
|
|
749
916
|
}
|
|
750
917
|
var AGENT_NAMES = orList([...AGENT_IDS].sort());
|
|
918
|
+
/**
|
|
919
|
+
* Where model names come from, laid out from what each adapter says about its own tool.
|
|
920
|
+
*
|
|
921
|
+
* Built from the registry for the same reason `AGENT_NAMES` is, and one step further: this file
|
|
922
|
+
* names neither the agents nor the way each is asked. An adapter that learns a better answer
|
|
923
|
+
* changes it in one place, and a fourth adapter appears here without this string being touched.
|
|
924
|
+
*
|
|
925
|
+
* No model is named anywhere in it, deliberately. Which models exist belongs to the vendor, and a
|
|
926
|
+
* copy of it here would be wrong by the next release.
|
|
927
|
+
*/
|
|
928
|
+
/**
|
|
929
|
+
* The adapters, skipping any this build cannot produce.
|
|
930
|
+
*
|
|
931
|
+
* Guarded for the reason `review_job.ts` guards its own registry lookups: an id the registry cannot
|
|
932
|
+
* answer for is not a reason to fail, and here it would take the whole help text down with it at
|
|
933
|
+
* import time. Help is the last thing that should need a working registry.
|
|
934
|
+
*/
|
|
935
|
+
function knownAgents() {
|
|
936
|
+
return [...AGENT_IDS].sort().flatMap((id) => {
|
|
937
|
+
try {
|
|
938
|
+
const agent = agentById(id);
|
|
939
|
+
return agent ? [agent] : [];
|
|
940
|
+
} catch {
|
|
941
|
+
return [];
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
}
|
|
751
945
|
var HELP = `PR Review Buddy reviews a branch in an isolated checkout and opens the result
|
|
752
946
|
in your browser. The review runs in a separate process against a worktree pinned to one commit, so
|
|
753
947
|
your own checkout is never read or written while it works.
|
|
@@ -755,8 +949,19 @@ your own checkout is never read or written while it works.
|
|
|
755
949
|
prreviewbuddy review review the current branch
|
|
756
950
|
prreviewbuddy review --branch <branch> review another branch, without checking it out
|
|
757
951
|
prreviewbuddy review --pr <url> review a pull request
|
|
952
|
+
prreviewbuddy review --pr <url> <url> ...
|
|
953
|
+
review several pull requests, each on its own, two at a
|
|
954
|
+
time; --concurrency <n> changes how many
|
|
758
955
|
prreviewbuddy review --base <ref> compare against a base of your choosing
|
|
759
956
|
prreviewbuddy review --agent <agent> review with ${AGENT_NAMES}
|
|
957
|
+
prreviewbuddy review --model <model> ask that agent for a particular model. Reviews keep
|
|
958
|
+
their requested model when reanalysed; use
|
|
959
|
+
--reanalyse --model <model> to change it.${((rows) => {
|
|
960
|
+
if (rows.length === 0) return "";
|
|
961
|
+
const width = Math.max(...rows.map(([name]) => name.length));
|
|
962
|
+
const table = rows.map(([name, how]) => `${" ".repeat(44)}${name.padEnd(width + 2)}${how}`).join("\n");
|
|
963
|
+
return `\n${" ".repeat(42)}To find the names:\n${table}`;
|
|
964
|
+
})(modelHelpLines(knownAgents()))}
|
|
760
965
|
|
|
761
966
|
prreviewbuddy review --lens <name> run a specialised review lens: ${orList([...LENS_NAMES])}
|
|
762
967
|
prreviewbuddy review --lens kiss --audience author
|
|
@@ -806,6 +1011,10 @@ have been made first. \`--audience\` is required with \`--lens kiss\` and is nev
|
|
|
806
1011
|
you are reviewing, because a reviewer checks out somebody else's branch and an author reviews their
|
|
807
1012
|
own pull request.
|
|
808
1013
|
|
|
1014
|
+
Several pull requests at once are several ordinary reviews, each with its own link, and the command
|
|
1015
|
+
prints them all when it ends. One that fails does not stop the others. Ctrl-C stops them all, and
|
|
1016
|
+
running the same command again carries each one on from where it got to.
|
|
1017
|
+
|
|
809
1018
|
Reviews can also be deleted from the list in your browser, one at a time or a whole branch at once.
|
|
810
1019
|
|
|
811
1020
|
There is no account and no API key. PR Review Buddy uses a coding agent installed on your machine
|
|
@@ -1142,8 +1351,40 @@ function reviewFailed(message, url) {
|
|
|
1142
1351
|
* be the paused review of `feat/foo`. Named through the same subject every other sentence about an
|
|
1143
1352
|
* existing review uses, so a review that follows `origin/feat/foo` is resumed under that name.
|
|
1144
1353
|
*/
|
|
1145
|
-
function resumingReview(failure, existing) {
|
|
1146
|
-
|
|
1354
|
+
function resumingReview(failure, existing, stopped = false) {
|
|
1355
|
+
const subject = followedRefName(existing.branch, existing.headResolvedRef) ?? existing.branch;
|
|
1356
|
+
if (stopped) return `The review of ${subject} was stopped.\nCarrying on from where it stopped.`;
|
|
1357
|
+
return `The review of ${subject} is paused: ${failure}\nCarrying on from where it stopped.`;
|
|
1358
|
+
}
|
|
1359
|
+
var BATCH_WORDS = {
|
|
1360
|
+
ready: "Ready",
|
|
1361
|
+
existing: "Already reviewed",
|
|
1362
|
+
elsewhere: "Already being made",
|
|
1363
|
+
duplicate: "Duplicate",
|
|
1364
|
+
failed: "Failed",
|
|
1365
|
+
stopped: "Stopped",
|
|
1366
|
+
"not-started": "Not started"
|
|
1367
|
+
};
|
|
1368
|
+
function batchOutcomeWord(outcome) {
|
|
1369
|
+
return BATCH_WORDS[outcome];
|
|
1370
|
+
}
|
|
1371
|
+
var BATCH_QUEUED = "Queued";
|
|
1372
|
+
var BATCH_LOOKING_UP = "Looking up the pull request";
|
|
1373
|
+
var BATCH_STOPPING = "Stopping. Reviews already running are being stopped, and the rest will not start.";
|
|
1374
|
+
/** The end of a batch: one line per pull request, with its link, and the index last. */
|
|
1375
|
+
function batchFinished(rows, index) {
|
|
1376
|
+
const width = Math.max(...rows.map((row) => row.label.length));
|
|
1377
|
+
const tally = Object.keys(BATCH_WORDS).map((outcome) => [outcome, rows.filter((row) => row.outcome === outcome).length]).filter(([, n]) => n > 0).map(([outcome, n]) => `${n} ${BATCH_WORDS[outcome].toLowerCase()}`);
|
|
1378
|
+
const lines = ["", `${count(rows.length, "pull request")}: ${tally.join(", ")}`];
|
|
1379
|
+
for (const row of rows) {
|
|
1380
|
+
const lead = ` ${row.label.padEnd(width)} ${BATCH_WORDS[row.outcome].padEnd(18)} `;
|
|
1381
|
+
lines.push(`${lead}${row.detail ?? row.url ?? ""}`.trimEnd());
|
|
1382
|
+
if (row.detail && row.url) lines.push(`${" ".repeat(lead.length)}${row.url}`);
|
|
1383
|
+
}
|
|
1384
|
+
if (rows.some((row) => row.outcome === "stopped" || row.outcome === "not-started" || row.outcome === "failed" && row.url)) lines.push("", "Run the same command again to carry on with the reviews that did not finish, from where each got to.");
|
|
1385
|
+
const all = indexLinks(index);
|
|
1386
|
+
if (all) lines.push("", all);
|
|
1387
|
+
return lines.join("\n");
|
|
1147
1388
|
}
|
|
1148
1389
|
function noReviews() {
|
|
1149
1390
|
return "No reviews stored yet. Run `prreviewbuddy review` in a repository to make one.";
|
|
@@ -1239,30 +1480,6 @@ function noReviewOfTarget(name, make) {
|
|
|
1239
1480
|
return `There is no review of ${name} to act on. Run \`${make}\` to make one.`;
|
|
1240
1481
|
}
|
|
1241
1482
|
/**
|
|
1242
|
-
* `--agent` was named alongside `--update`, where it changes nothing.
|
|
1243
|
-
*
|
|
1244
|
-
* An update continues an analysis rather than starting one, and `updateReview` runs the agent
|
|
1245
|
-
* recorded in the review's own session. Said out loud rather than resolved and thrown away, which
|
|
1246
|
-
* is what the old `update` command did: a flag that is accepted and quietly ignored is worse than
|
|
1247
|
-
* one that is refused, because the reviewer walks away believing they changed something.
|
|
1248
|
-
*/
|
|
1249
|
-
function agentUnusedByUpdate(agentId) {
|
|
1250
|
-
return `An update continues the review with the agent it was made by, so \`--agent ${agentId}\` changes nothing here. To analyse the same commit again with another agent, run \`prreviewbuddy review --reanalyse --agent ${agentId}\`.`;
|
|
1251
|
-
}
|
|
1252
|
-
/**
|
|
1253
|
-
* `--agent` was named alongside a resume, where it changes nothing either.
|
|
1254
|
-
*
|
|
1255
|
-
* A paused job carries the agent it was started under, and its finished phases were done by that
|
|
1256
|
-
* agent. Switching engines halfway would leave one review whose provenance and whose execution
|
|
1257
|
-
* disagree, so the flag does not mutate the job; it is said instead, for the reason above.
|
|
1258
|
-
*
|
|
1259
|
-
* Only when the two differ. Resuming a Codex job with `--agent codex` is the agent the reviewer
|
|
1260
|
-
* asked for, and telling them it was ignored would be true and useless.
|
|
1261
|
-
*/
|
|
1262
|
-
function agentUnusedByResume(agentId) {
|
|
1263
|
-
return `A resume continues with the agent the review was started by, so \`--agent ${agentId}\` changes nothing here. To analyse the same commit with it instead, run \`prreviewbuddy review --reanalyse --agent ${agentId}\`.`;
|
|
1264
|
-
}
|
|
1265
|
-
/**
|
|
1266
1483
|
* The update's own sentence, which the harness already wrote for the workspace strip, plus the
|
|
1267
1484
|
* link. Not rephrased here: an update that stopped early has a specific reason and the reviewer
|
|
1268
1485
|
* gets the same reason whichever surface asked for it.
|
|
@@ -1447,18 +1664,391 @@ function firstRun(version) {
|
|
|
1447
1664
|
].join("\n");
|
|
1448
1665
|
}
|
|
1449
1666
|
//#endregion
|
|
1450
|
-
//#region src/
|
|
1667
|
+
//#region src/batch_progress.ts
|
|
1451
1668
|
/**
|
|
1452
|
-
*
|
|
1669
|
+
* What a batch is doing, while it does it: one row per pull request.
|
|
1453
1670
|
*
|
|
1454
|
-
*
|
|
1455
|
-
*
|
|
1671
|
+
* The mechanics are `followJob`'s: poll, redraw in place on a terminal, append only on change
|
|
1672
|
+
* anywhere else, and stderr so the links on stdout still pipe. The words for a running review are
|
|
1673
|
+
* `progressSteps`' active step, so a batch row, the single-review block and the page never name one
|
|
1674
|
+
* phase two things.
|
|
1456
1675
|
*/
|
|
1457
|
-
|
|
1458
|
-
|
|
1676
|
+
var POLL_INTERVAL_MS = 500;
|
|
1677
|
+
function runningWords(job) {
|
|
1678
|
+
if (!job) return "Starting the review";
|
|
1679
|
+
return progressSteps(job).find((step) => step.state === "active")?.label ?? PHASES[job.phase].doing;
|
|
1680
|
+
}
|
|
1681
|
+
/** A row's mark, its colour, and what it says. */
|
|
1682
|
+
function describe$1(state, read) {
|
|
1683
|
+
switch (state.kind) {
|
|
1684
|
+
case "queued": return {
|
|
1685
|
+
mark: " ",
|
|
1686
|
+
paint: "",
|
|
1687
|
+
words: BATCH_QUEUED,
|
|
1688
|
+
faint: true
|
|
1689
|
+
};
|
|
1690
|
+
case "looking-up": return {
|
|
1691
|
+
mark: "⟳",
|
|
1692
|
+
paint: "",
|
|
1693
|
+
words: BATCH_LOOKING_UP
|
|
1694
|
+
};
|
|
1695
|
+
case "running": return {
|
|
1696
|
+
mark: "⟳",
|
|
1697
|
+
paint: "",
|
|
1698
|
+
words: runningWords(read(state.jobId))
|
|
1699
|
+
};
|
|
1700
|
+
case "ready": return {
|
|
1701
|
+
mark: "✓",
|
|
1702
|
+
paint: GREEN,
|
|
1703
|
+
words: batchOutcomeWord("ready")
|
|
1704
|
+
};
|
|
1705
|
+
case "existing": return {
|
|
1706
|
+
mark: "✓",
|
|
1707
|
+
paint: GREEN,
|
|
1708
|
+
words: batchOutcomeWord(state.inProgress ? "elsewhere" : "existing")
|
|
1709
|
+
};
|
|
1710
|
+
case "duplicate": return {
|
|
1711
|
+
mark: "✓",
|
|
1712
|
+
paint: GREEN,
|
|
1713
|
+
words: `${batchOutcomeWord("duplicate")}, same pull request as ${state.of}`
|
|
1714
|
+
};
|
|
1715
|
+
case "failed": return {
|
|
1716
|
+
mark: "✕",
|
|
1717
|
+
paint: RED,
|
|
1718
|
+
words: batchOutcomeWord("failed")
|
|
1719
|
+
};
|
|
1720
|
+
case "stopped": return {
|
|
1721
|
+
mark: "–",
|
|
1722
|
+
paint: "",
|
|
1723
|
+
words: batchOutcomeWord("stopped")
|
|
1724
|
+
};
|
|
1725
|
+
case "not-started": return {
|
|
1726
|
+
mark: "–",
|
|
1727
|
+
paint: "",
|
|
1728
|
+
words: batchOutcomeWord("not-started"),
|
|
1729
|
+
faint: true
|
|
1730
|
+
};
|
|
1731
|
+
}
|
|
1459
1732
|
}
|
|
1460
|
-
|
|
1461
|
-
|
|
1733
|
+
/** Which count in the footer a row adds to. */
|
|
1734
|
+
var FOOTER = {
|
|
1735
|
+
queued: "queued",
|
|
1736
|
+
"looking-up": "running",
|
|
1737
|
+
running: "running",
|
|
1738
|
+
ready: "ready",
|
|
1739
|
+
existing: "ready",
|
|
1740
|
+
duplicate: "ready",
|
|
1741
|
+
failed: "failed",
|
|
1742
|
+
stopped: "stopped",
|
|
1743
|
+
"not-started": "not started"
|
|
1744
|
+
};
|
|
1745
|
+
var FOOTER_ORDER = [
|
|
1746
|
+
"ready",
|
|
1747
|
+
"running",
|
|
1748
|
+
"queued",
|
|
1749
|
+
"failed",
|
|
1750
|
+
"stopped",
|
|
1751
|
+
"not started"
|
|
1752
|
+
];
|
|
1753
|
+
/** The rows and the footer counting them. Pure. */
|
|
1754
|
+
function batchLines(rows, colour, read = loadJob, stopping = false) {
|
|
1755
|
+
const width = Math.max(...rows.map((row) => row.label.length));
|
|
1756
|
+
const lines = rows.map((row) => {
|
|
1757
|
+
const { mark, paint, words, faint } = describe$1(row.state, read);
|
|
1758
|
+
const body = `${row.label.padEnd(width)} ${words}`;
|
|
1759
|
+
if (!colour) return `${mark} ${body}`;
|
|
1760
|
+
if (faint) return `${DIM}${mark} ${body}${RESET}`;
|
|
1761
|
+
return paint ? `${paint}${mark}${RESET} ${body}` : `${mark} ${body}`;
|
|
1762
|
+
});
|
|
1763
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1764
|
+
for (const row of rows) counts.set(FOOTER[row.state.kind], (counts.get(FOOTER[row.state.kind]) ?? 0) + 1);
|
|
1765
|
+
const footer = FOOTER_ORDER.filter((name) => counts.has(name)).map((name) => `${counts.get(name)} ${name}`).join(" · ");
|
|
1766
|
+
lines.push("", colour ? `${DIM}${footer}${RESET}` : footer);
|
|
1767
|
+
if (stopping) lines.push(BATCH_STOPPING);
|
|
1768
|
+
return lines;
|
|
1769
|
+
}
|
|
1770
|
+
/** Keep one block on the screen describing the batch. Returns the stop function the caller must call. */
|
|
1771
|
+
function followBatch(rows, options = {}) {
|
|
1772
|
+
const stream = options.stream ?? process.stderr;
|
|
1773
|
+
const read = options.read ?? loadJob;
|
|
1774
|
+
const colour = styling(stream);
|
|
1775
|
+
const inPlace = stream.isTTY === true;
|
|
1776
|
+
let drawn = 0;
|
|
1777
|
+
let last = "";
|
|
1778
|
+
const all = indexLinks(options.index ?? null);
|
|
1779
|
+
const header = all ? [
|
|
1780
|
+
`Reviewing ${rows.length} pull requests`,
|
|
1781
|
+
"",
|
|
1782
|
+
all
|
|
1783
|
+
] : [`Reviewing ${rows.length} pull requests`];
|
|
1784
|
+
const paint = () => {
|
|
1785
|
+
const lines = [
|
|
1786
|
+
...header,
|
|
1787
|
+
"",
|
|
1788
|
+
...batchLines(rows, colour, read, options.stopping?.() ?? false)
|
|
1789
|
+
];
|
|
1790
|
+
const block = lines.join("\n");
|
|
1791
|
+
if (block === last) return;
|
|
1792
|
+
if (inPlace && drawn > 0) stream.write(`\u001b[${drawn}A\u001b[0J`);
|
|
1793
|
+
stream.write(`${block}\n`);
|
|
1794
|
+
last = block;
|
|
1795
|
+
drawn = rowsOccupied(lines, stream.columns ?? 0);
|
|
1796
|
+
};
|
|
1797
|
+
const timer = setInterval(paint, POLL_INTERVAL_MS);
|
|
1798
|
+
timer.unref();
|
|
1799
|
+
paint();
|
|
1800
|
+
let stopped = false;
|
|
1801
|
+
return () => {
|
|
1802
|
+
if (stopped) return;
|
|
1803
|
+
stopped = true;
|
|
1804
|
+
clearInterval(timer);
|
|
1805
|
+
paint();
|
|
1806
|
+
};
|
|
1807
|
+
}
|
|
1808
|
+
//#endregion
|
|
1809
|
+
//#region src/write.ts
|
|
1810
|
+
/**
|
|
1811
|
+
* Where the two kinds of output go, decided once.
|
|
1812
|
+
*
|
|
1813
|
+
* Links and results to stdout, everything else to stderr, so `prreviewbuddy review | pbcopy`
|
|
1814
|
+
* copies a URL and progress still reaches the terminal it was meant for.
|
|
1815
|
+
*/
|
|
1816
|
+
function out(text) {
|
|
1817
|
+
process.stdout.write(`${text}\n`);
|
|
1818
|
+
}
|
|
1819
|
+
function err(text) {
|
|
1820
|
+
process.stderr.write(`${text}\n`);
|
|
1821
|
+
}
|
|
1822
|
+
/**
|
|
1823
|
+
* What to call each pull request on screen: `#128`, or `!45` for a merge request. Two from
|
|
1824
|
+
* different repositories with one number are given the repository's name too.
|
|
1825
|
+
*/
|
|
1826
|
+
function labelsFor(urls) {
|
|
1827
|
+
const parts = urls.map((url) => /\/([^/]+)\/(?:-\/)?(pull|pull-requests|merge_requests)\/(\d+)/.exec(url));
|
|
1828
|
+
const short = parts.map((match, index) => match ? `${match[2] === "merge_requests" ? "!" : "#"}${match[3]}` : urls[index]);
|
|
1829
|
+
return short.map((label, index) => {
|
|
1830
|
+
const match = parts[index];
|
|
1831
|
+
return short.some((other, j) => other === label && parts[j]?.[1] !== match?.[1]) && match ? `${match[1]}${label}` : label;
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
async function reviewBatch(command, repoPath, deps = realDeps()) {
|
|
1835
|
+
const urls = command.prUrls ?? [];
|
|
1836
|
+
const agent = await deps.chooseAgent(command);
|
|
1837
|
+
if (!agent) return 1;
|
|
1838
|
+
const rows = labelsFor(urls).map((label) => ({
|
|
1839
|
+
label,
|
|
1840
|
+
state: { kind: "queued" }
|
|
1841
|
+
}));
|
|
1842
|
+
const controller = new AbortController();
|
|
1843
|
+
const signal = controller.signal;
|
|
1844
|
+
/** Which row claimed each pull request, by what it resolved to rather than how it was spelled. */
|
|
1845
|
+
const claimed = /* @__PURE__ */ new Map();
|
|
1846
|
+
const reviewOne = async (url, index) => {
|
|
1847
|
+
const row = rows[index];
|
|
1848
|
+
try {
|
|
1849
|
+
await attempt(url, index, row);
|
|
1850
|
+
} catch (error) {
|
|
1851
|
+
row.state = signal.aborted ? { kind: "not-started" } : {
|
|
1852
|
+
kind: "failed",
|
|
1853
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
1854
|
+
};
|
|
1855
|
+
}
|
|
1856
|
+
};
|
|
1857
|
+
const attempt = async (url, index, row) => {
|
|
1858
|
+
row.state = { kind: "looking-up" };
|
|
1859
|
+
const target = await deps.resolve(url, repoPath);
|
|
1860
|
+
if (signal.aborted) {
|
|
1861
|
+
row.state = { kind: "not-started" };
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
const identity = `${target.originRepoPath}\n${target.pullRequest?.number ?? target.sha}`;
|
|
1865
|
+
const first = claimed.get(identity);
|
|
1866
|
+
if (first !== void 0) {
|
|
1867
|
+
row.state = {
|
|
1868
|
+
kind: "duplicate",
|
|
1869
|
+
of: rows[first].label
|
|
1870
|
+
};
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
claimed.set(identity, index);
|
|
1874
|
+
const prepared = await deps.prepare(target, agent.id, command.operation === "fresh", command.requestedModel);
|
|
1875
|
+
if (prepared.kind === "existing") {
|
|
1876
|
+
row.workspaceId = prepared.existing.id;
|
|
1877
|
+
row.state = {
|
|
1878
|
+
kind: "existing",
|
|
1879
|
+
inProgress: prepared.existing.inProgress
|
|
1880
|
+
};
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
const workspaceId = prepared.kind === "resume" ? prepared.existing.id : prepared.workspaceId;
|
|
1884
|
+
row.workspaceId = workspaceId;
|
|
1885
|
+
row.state = {
|
|
1886
|
+
kind: "running",
|
|
1887
|
+
jobId: deps.jobIdOf(workspaceId) ?? ""
|
|
1888
|
+
};
|
|
1889
|
+
const settled = await deps.settle(workspaceId, signal);
|
|
1890
|
+
row.state = settled.ok ? { kind: "ready" } : settled.stopped ? { kind: "stopped" } : {
|
|
1891
|
+
kind: "failed",
|
|
1892
|
+
reason: settled.reason
|
|
1893
|
+
};
|
|
1894
|
+
};
|
|
1895
|
+
const links = await deps.links();
|
|
1896
|
+
const stopListening = deps.onInterrupt(() => controller.abort());
|
|
1897
|
+
const stopDrawing = deps.follow(rows, () => signal.aborted, links.index);
|
|
1898
|
+
try {
|
|
1899
|
+
(await runBatch(urls, {
|
|
1900
|
+
concurrency: command.concurrency ?? 2,
|
|
1901
|
+
signal,
|
|
1902
|
+
run: reviewOne
|
|
1903
|
+
})).forEach((outcome, index) => {
|
|
1904
|
+
if (outcome.kind === "not-started") rows[index].state = { kind: "not-started" };
|
|
1905
|
+
});
|
|
1906
|
+
} finally {
|
|
1907
|
+
stopDrawing();
|
|
1908
|
+
stopListening();
|
|
1909
|
+
}
|
|
1910
|
+
deps.out(batchFinished(rows.map((row) => summaryRow(row, links.review)), links.index));
|
|
1911
|
+
if (signal.aborted) return 130;
|
|
1912
|
+
return rows.some((row) => row.state.kind === "failed") ? 1 : 0;
|
|
1913
|
+
}
|
|
1914
|
+
function summaryRow(row, link) {
|
|
1915
|
+
const url = row.workspaceId ? link(row.workspaceId) : void 0;
|
|
1916
|
+
const withUrl = url ? { url } : {};
|
|
1917
|
+
switch (row.state.kind) {
|
|
1918
|
+
case "ready": return {
|
|
1919
|
+
label: row.label,
|
|
1920
|
+
outcome: "ready",
|
|
1921
|
+
...withUrl
|
|
1922
|
+
};
|
|
1923
|
+
case "existing": return {
|
|
1924
|
+
label: row.label,
|
|
1925
|
+
outcome: row.state.inProgress ? "elsewhere" : "existing",
|
|
1926
|
+
...withUrl
|
|
1927
|
+
};
|
|
1928
|
+
case "duplicate": return {
|
|
1929
|
+
label: row.label,
|
|
1930
|
+
outcome: "duplicate",
|
|
1931
|
+
detail: `same pull request as ${row.state.of}`
|
|
1932
|
+
};
|
|
1933
|
+
case "failed": return {
|
|
1934
|
+
label: row.label,
|
|
1935
|
+
outcome: "failed",
|
|
1936
|
+
detail: row.state.reason,
|
|
1937
|
+
...withUrl
|
|
1938
|
+
};
|
|
1939
|
+
case "stopped": return {
|
|
1940
|
+
label: row.label,
|
|
1941
|
+
outcome: "stopped",
|
|
1942
|
+
...withUrl
|
|
1943
|
+
};
|
|
1944
|
+
default: return {
|
|
1945
|
+
label: row.label,
|
|
1946
|
+
outcome: "not-started"
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
function realDeps() {
|
|
1951
|
+
return {
|
|
1952
|
+
chooseAgent: (command) => chooseFor(command),
|
|
1953
|
+
resolve: (prUrl, repoPath) => resolveTarget({
|
|
1954
|
+
repoPath,
|
|
1955
|
+
prUrl,
|
|
1956
|
+
resolveRequest: forgeResolver
|
|
1957
|
+
}),
|
|
1958
|
+
prepare: prepareReview,
|
|
1959
|
+
settle: (workspaceId, signal) => settleReview(workspaceId, "standard", signal),
|
|
1960
|
+
jobIdOf: (workspaceId) => loadWorkspace(workspaceId)?.jobId,
|
|
1961
|
+
links: async () => {
|
|
1962
|
+
try {
|
|
1963
|
+
const port = await ensureServer();
|
|
1964
|
+
return {
|
|
1965
|
+
review: (workspaceId) => {
|
|
1966
|
+
const workspace = loadWorkspace(workspaceId);
|
|
1967
|
+
return workspace ? linkTo(port, workspaceId, workspace.token) : void 0;
|
|
1968
|
+
},
|
|
1969
|
+
index: reviewsUrl(port, readIndexToken())
|
|
1970
|
+
};
|
|
1971
|
+
} catch {
|
|
1972
|
+
return {
|
|
1973
|
+
review: () => void 0,
|
|
1974
|
+
index: null
|
|
1975
|
+
};
|
|
1976
|
+
}
|
|
1977
|
+
},
|
|
1978
|
+
follow: (rows, stopping, index) => followBatch(rows, {
|
|
1979
|
+
stopping,
|
|
1980
|
+
index
|
|
1981
|
+
}),
|
|
1982
|
+
onInterrupt: (handler) => {
|
|
1983
|
+
let pressed = false;
|
|
1984
|
+
const listener = () => {
|
|
1985
|
+
if (pressed) {
|
|
1986
|
+
process.off("SIGINT", listener);
|
|
1987
|
+
process.kill(process.pid, "SIGINT");
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
pressed = true;
|
|
1991
|
+
handler();
|
|
1992
|
+
};
|
|
1993
|
+
process.on("SIGINT", listener);
|
|
1994
|
+
return () => process.off("SIGINT", listener);
|
|
1995
|
+
},
|
|
1996
|
+
out
|
|
1997
|
+
};
|
|
1998
|
+
}
|
|
1999
|
+
//#endregion
|
|
2000
|
+
//#region src/review_id.ts
|
|
2001
|
+
/**
|
|
2002
|
+
* Which review a `--review` value names.
|
|
2003
|
+
*
|
|
2004
|
+
* A review id is a UUID, and a UUID is not a thing anyone retypes. `open` prints them in full and
|
|
2005
|
+
* every other command took nothing else, so `--review a8a9a058` answered "there is no review called
|
|
2006
|
+
* a8a9a058" about a review that was right there. Every tool that hands out identifiers this long
|
|
2007
|
+
* takes an unambiguous prefix instead, and so does this one now.
|
|
2008
|
+
*
|
|
2009
|
+
* A prefix that matches two reviews is refused rather than resolved to the newer or the first. It
|
|
2010
|
+
* is the same rule the base resolver keeps one directory over: acting on the wrong one of two is
|
|
2011
|
+
* not a wasted minute when the operation is `--delete`, it is the work. Anyone who wants the
|
|
2012
|
+
* shorthand back has the full id in the refusal.
|
|
2013
|
+
*/
|
|
2014
|
+
function lookUpReviewId(value, matches = matchingWorkspaceIds) {
|
|
2015
|
+
const found = matches(value);
|
|
2016
|
+
if (found.length === 0) return { kind: "none" };
|
|
2017
|
+
if (found.length === 1) return {
|
|
2018
|
+
kind: "one",
|
|
2019
|
+
id: found[0]
|
|
2020
|
+
};
|
|
2021
|
+
return {
|
|
2022
|
+
kind: "ambiguous",
|
|
2023
|
+
ids: found
|
|
2024
|
+
};
|
|
2025
|
+
}
|
|
2026
|
+
//#endregion
|
|
2027
|
+
//#region src/confirm.ts
|
|
2028
|
+
/**
|
|
2029
|
+
* A yes or no, asked only when there is somebody to answer.
|
|
2030
|
+
*
|
|
2031
|
+
* The same shape as `chooseAgent` next door and for the same reasons: hand-rolled on
|
|
2032
|
+
* `node:readline` because this binary has no runtime dependencies and one question is not worth
|
|
2033
|
+
* acquiring one, and with its input and output injected so the decision is testable without a
|
|
2034
|
+
* shell.
|
|
2035
|
+
*
|
|
2036
|
+
* The default is no. Every caller of this is about to delete something, and a bare Return at a
|
|
2037
|
+
* prompt somebody did not read must leave their machine as it was.
|
|
2038
|
+
*/
|
|
2039
|
+
async function confirm(options) {
|
|
2040
|
+
if (!options.isTTY) return { kind: "unattended" };
|
|
2041
|
+
const reader = createInterface({ input: options.input });
|
|
2042
|
+
const lines = reader[Symbol.asyncIterator]();
|
|
2043
|
+
try {
|
|
2044
|
+
options.write(`${options.question} [y/N] `);
|
|
2045
|
+
const next = await lines.next();
|
|
2046
|
+
if (next.done) return { kind: "no" };
|
|
2047
|
+
const answer = next.value.trim().toLowerCase();
|
|
2048
|
+
return answer === "y" || answer === "yes" ? { kind: "yes" } : { kind: "no" };
|
|
2049
|
+
} finally {
|
|
2050
|
+
reader.close();
|
|
2051
|
+
}
|
|
1462
2052
|
}
|
|
1463
2053
|
//#endregion
|
|
1464
2054
|
//#region src/commands/delete_review.ts
|
|
@@ -1547,130 +2137,6 @@ function describe(deps, id) {
|
|
|
1547
2137
|
};
|
|
1548
2138
|
}
|
|
1549
2139
|
//#endregion
|
|
1550
|
-
//#region src/progress.ts
|
|
1551
|
-
/**
|
|
1552
|
-
* What the review is doing, while it does it.
|
|
1553
|
-
*
|
|
1554
|
-
* The job record is the source of truth, and it is the same one the workspace page polls over
|
|
1555
|
-
* HTTP. Read here directly from the store rather than through the server, because this process is
|
|
1556
|
-
* the one driving the job and a loopback request to ask itself what it is doing would be a strange
|
|
1557
|
-
* way to find out.
|
|
1558
|
-
*
|
|
1559
|
-
* This used to print `job.progress ?? job.phase` whenever it changed, which meant the raw phase
|
|
1560
|
-
* enum reached the terminal as a bare `preparing` before the first progress note arrived, and every
|
|
1561
|
-
* later note appended a line to a growing log. A reviewer could not tell what had finished, what
|
|
1562
|
-
* was running, or how much was left.
|
|
1563
|
-
*
|
|
1564
|
-
* It now renders one block: a header, then a line per phase with a mark for its state, and the
|
|
1565
|
-
* agent's own notes indented under whichever phase is currently running. On a terminal the block is
|
|
1566
|
-
* redrawn in place, so the review reads as one task progressing rather than as a transcript. Where
|
|
1567
|
-
* stderr is not a terminal (a pipe, a CI log, a file) it falls back to appending only the lines
|
|
1568
|
-
* that changed, because cursor movement written into a log is worse than a plain list.
|
|
1569
|
-
*
|
|
1570
|
-
* Progress goes to stderr and the review's link goes to stdout, so `prreviewbuddy review | pbcopy`
|
|
1571
|
-
* copies a URL rather than a transcript.
|
|
1572
|
-
*/
|
|
1573
|
-
var POLL_INTERVAL_MS = 500;
|
|
1574
|
-
/**
|
|
1575
|
-
* Colour only where it means something, and never where it cannot be seen.
|
|
1576
|
-
*
|
|
1577
|
-
* `NO_COLOR` is honoured because this writes to stderr, which people redirect into files and CI
|
|
1578
|
-
* logs where escape codes are noise rather than emphasis.
|
|
1579
|
-
*/
|
|
1580
|
-
function styling(stream) {
|
|
1581
|
-
return stream.isTTY === true && !process.env.NO_COLOR;
|
|
1582
|
-
}
|
|
1583
|
-
var DIM = "\x1B[2m";
|
|
1584
|
-
var GREEN = "\x1B[32m";
|
|
1585
|
-
var RED = "\x1B[31m";
|
|
1586
|
-
var RESET = "\x1B[0m";
|
|
1587
|
-
/** The header above the phases: what is being reviewed, and the facts about how. */
|
|
1588
|
-
function header(target, colour, purpose = "review") {
|
|
1589
|
-
const meta = `${target.sha.slice(0, 7)} · isolated checkout · ${target.originRepoPath}`;
|
|
1590
|
-
return [
|
|
1591
|
-
`${purpose === "update" ? "Updating" : "Reviewing"} ${target.branch}`,
|
|
1592
|
-
colour ? `${DIM}${meta}${RESET}` : meta,
|
|
1593
|
-
""
|
|
1594
|
-
];
|
|
1595
|
-
}
|
|
1596
|
-
/**
|
|
1597
|
-
* One line per phase, marked with its state, and the agent's note under the running one.
|
|
1598
|
-
*
|
|
1599
|
-
* Completed phases stay visible because they are the part that says how far along this is. Their
|
|
1600
|
-
* detail does not: a note about a file read two phases ago is history, and history is what the
|
|
1601
|
-
* transcript was made of.
|
|
1602
|
-
*
|
|
1603
|
-
* Which phases there are, what they are called and what has become of each is `progressSteps`,
|
|
1604
|
-
* shared with the workspace page so the browser and this block never name one phase two things.
|
|
1605
|
-
* What is left here is how a terminal draws that: a mark, a colour, and an indent.
|
|
1606
|
-
*/
|
|
1607
|
-
function phaseLines(job, colour) {
|
|
1608
|
-
const lines = [];
|
|
1609
|
-
for (const step of progressSteps(job)) {
|
|
1610
|
-
const mark = step.state === "failed" ? "✕" : step.state === "complete" ? "✓" : step.state === "active" ? "⟳" : " ";
|
|
1611
|
-
const paint = step.state === "failed" ? RED : step.state === "complete" ? GREEN : "";
|
|
1612
|
-
if (step.state === "pending") {
|
|
1613
|
-
lines.push(colour ? `${DIM} ${mark} ${step.label}${RESET}` : ` ${mark} ${step.label}`);
|
|
1614
|
-
continue;
|
|
1615
|
-
}
|
|
1616
|
-
lines.push(colour && paint ? `${paint}${mark}${RESET} ${step.label}` : `${mark} ${step.label}`);
|
|
1617
|
-
if (step.note) lines.push(colour ? `${DIM} ${step.note}${RESET}` : ` ${step.note}`);
|
|
1618
|
-
}
|
|
1619
|
-
return lines;
|
|
1620
|
-
}
|
|
1621
|
-
/**
|
|
1622
|
-
* How many rows this block will actually occupy, which is not how many strings it is.
|
|
1623
|
-
*
|
|
1624
|
-
* The redraw moves the cursor up by a count and clears from there, so that count has to be in the
|
|
1625
|
-
* unit the terminal moves in: rows on screen, after wrapping. Counting strings instead lands the
|
|
1626
|
-
* cursor short and clears output the block never drew. The header's second line carries a full
|
|
1627
|
-
* repository path and wraps in any normal window, so this was not a corner case.
|
|
1628
|
-
*
|
|
1629
|
-
* Escape sequences are removed first because they occupy no columns. A resize between drawing and
|
|
1630
|
-
* redrawing still misplaces the cursor; nothing short of tracking the resize can fix that, and the
|
|
1631
|
-
* next repaint corrects itself.
|
|
1632
|
-
*/
|
|
1633
|
-
function rowsOccupied(lines, columns) {
|
|
1634
|
-
if (!columns) return lines.length;
|
|
1635
|
-
const visible = (line) => line.replace(/\u001b\[[0-9;]*m/g, "").length;
|
|
1636
|
-
return lines.reduce((rows, line) => rows + Math.max(1, Math.ceil(visible(line) / columns)), 0);
|
|
1637
|
-
}
|
|
1638
|
-
/**
|
|
1639
|
-
* Watch a job and keep one block on the screen describing it.
|
|
1640
|
-
*
|
|
1641
|
-
* Returns the stop function the caller must call: nothing here may outlive the command, and a
|
|
1642
|
-
* stray interval would make `review` hang after the review finished.
|
|
1643
|
-
*/
|
|
1644
|
-
function followJob(jobId, target, options = {}) {
|
|
1645
|
-
const stream = options.stream ?? process.stderr;
|
|
1646
|
-
const read = options.read ?? loadJob;
|
|
1647
|
-
const colour = styling(stream);
|
|
1648
|
-
const inPlace = stream.isTTY === true;
|
|
1649
|
-
let drawn = 0;
|
|
1650
|
-
let last = "";
|
|
1651
|
-
const paint = () => {
|
|
1652
|
-
const job = read(jobId);
|
|
1653
|
-
if (!job) return;
|
|
1654
|
-
const lines = [...header(job.target ?? target, colour, purposeOf(job)), ...phaseLines(job, colour)];
|
|
1655
|
-
const block = lines.join("\n");
|
|
1656
|
-
if (block === last) return;
|
|
1657
|
-
if (inPlace && drawn > 0) stream.write(`\u001b[${drawn}A\u001b[0J`);
|
|
1658
|
-
stream.write(`${block}\n`);
|
|
1659
|
-
last = block;
|
|
1660
|
-
drawn = rowsOccupied(lines, stream.columns ?? 0);
|
|
1661
|
-
};
|
|
1662
|
-
const timer = setInterval(paint, POLL_INTERVAL_MS);
|
|
1663
|
-
timer.unref();
|
|
1664
|
-
paint();
|
|
1665
|
-
let stopped = false;
|
|
1666
|
-
return () => {
|
|
1667
|
-
if (stopped) return;
|
|
1668
|
-
stopped = true;
|
|
1669
|
-
clearInterval(timer);
|
|
1670
|
-
paint();
|
|
1671
|
-
};
|
|
1672
|
-
}
|
|
1673
|
-
//#endregion
|
|
1674
2140
|
//#region src/commands/review.ts
|
|
1675
2141
|
/**
|
|
1676
2142
|
* `prreviewbuddy review`.
|
|
@@ -1726,6 +2192,7 @@ function describeReview(id) {
|
|
|
1726
2192
|
}
|
|
1727
2193
|
async function review(command, repoPath) {
|
|
1728
2194
|
if (command.operation === "delete") return deleteReview(command);
|
|
2195
|
+
if (command.prUrls) return reviewBatch(command, repoPath);
|
|
1729
2196
|
if (command.reviewId) {
|
|
1730
2197
|
const workspace = byId(command.reviewId);
|
|
1731
2198
|
if (typeof workspace === "number") return workspace;
|
|
@@ -1750,24 +2217,25 @@ async function review(command, repoPath) {
|
|
|
1750
2217
|
if (command.lens === "kiss") return kiss(command, target);
|
|
1751
2218
|
const agent = await chooseFor(command);
|
|
1752
2219
|
if (!agent) return 1;
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
out(existingReview(existing, url));
|
|
1760
|
-
return 0;
|
|
1761
|
-
}
|
|
1762
|
-
err(resumingReview(failed.message, existing));
|
|
1763
|
-
if (command.agentId && command.agentId !== failed.agentId) err(agentUnusedByResume(command.agentId));
|
|
1764
|
-
return drive(existing.id, url);
|
|
1765
|
-
}
|
|
2220
|
+
const prepared = await prepareReview(target, agent.id, command.operation === "fresh", command.requestedModel);
|
|
2221
|
+
if (prepared.kind === "existing") {
|
|
2222
|
+
const url = linkTo(await ensureServer(), prepared.existing.id, prepared.existing.token);
|
|
2223
|
+
out(existingReview(prepared.existing, url));
|
|
2224
|
+
for (const line of unusedSelection("existing", selectionOf(command))) err(line);
|
|
2225
|
+
return 0;
|
|
1766
2226
|
}
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
2227
|
+
if (prepared.kind === "resume") {
|
|
2228
|
+
const { existing, failure } = prepared;
|
|
2229
|
+
const url = linkTo(await ensureServer(), existing.id, existing.token);
|
|
2230
|
+
err(resumingReview(failure.message, existing, failure.stopped));
|
|
2231
|
+
const running = {
|
|
2232
|
+
...failure.agentId ? { agent: failure.agentId } : {},
|
|
2233
|
+
...failure.requestedModel ? { model: failure.requestedModel } : {}
|
|
2234
|
+
};
|
|
2235
|
+
for (const line of unusedSelection("resume", selectionOf(command), running)) err(line);
|
|
2236
|
+
return drive(existing.id, url);
|
|
2237
|
+
}
|
|
2238
|
+
const { workspaceId } = prepared;
|
|
1771
2239
|
const url = linkTo(await ensureServer(), workspaceId, loadWorkspace(workspaceId).token);
|
|
1772
2240
|
out(startedReview(url, target));
|
|
1773
2241
|
return drive(workspaceId, url);
|
|
@@ -1799,11 +2267,16 @@ async function kiss(command, target) {
|
|
|
1799
2267
|
const workspace = existing ? loadWorkspace(existing.id) : null;
|
|
1800
2268
|
const agent = await chooseFor(command, workspace ?? void 0);
|
|
1801
2269
|
if (!agent) return 1;
|
|
2270
|
+
const requestedModel = resolveModel({
|
|
2271
|
+
...command.requestedModel ? { flag: command.requestedModel } : {},
|
|
2272
|
+
...workspace ? { review: { session: workspace.session } } : {}
|
|
2273
|
+
}) ?? void 0;
|
|
1802
2274
|
if (workspace) {
|
|
1803
2275
|
const job = await recordKissRun({
|
|
1804
2276
|
workspaceId: workspace.id,
|
|
1805
2277
|
audience,
|
|
1806
|
-
agentId: agent.id
|
|
2278
|
+
agentId: agent.id,
|
|
2279
|
+
...requestedModel ? { requestedModel } : {}
|
|
1807
2280
|
});
|
|
1808
2281
|
const url = linkTo(await ensureServer(), workspace.id, workspace.token);
|
|
1809
2282
|
out(startedReview(url, target));
|
|
@@ -1815,7 +2288,8 @@ async function kiss(command, target) {
|
|
|
1815
2288
|
type: "kiss",
|
|
1816
2289
|
audience
|
|
1817
2290
|
},
|
|
1818
|
-
agentId: agent.id
|
|
2291
|
+
agentId: agent.id,
|
|
2292
|
+
...requestedModel ? { requestedModel } : {}
|
|
1819
2293
|
});
|
|
1820
2294
|
const fresh = loadWorkspace(workspaceId);
|
|
1821
2295
|
const run = (fresh.kissRuns ?? [])[0];
|
|
@@ -1847,6 +2321,18 @@ async function audienceFor(command) {
|
|
|
1847
2321
|
return choice.audience;
|
|
1848
2322
|
}
|
|
1849
2323
|
/**
|
|
2324
|
+
* The command's execution-selection flags, in the shape the answering rule takes.
|
|
2325
|
+
*
|
|
2326
|
+
* One conversion for every branch that has to answer them, so a third flag is added here and in
|
|
2327
|
+
* `selection_flags.ts` rather than at each call site that might forget it.
|
|
2328
|
+
*/
|
|
2329
|
+
function selectionOf(command) {
|
|
2330
|
+
return {
|
|
2331
|
+
...command.agentId ? { agent: command.agentId } : {},
|
|
2332
|
+
...command.requestedModel ? { model: command.requestedModel } : {}
|
|
2333
|
+
};
|
|
2334
|
+
}
|
|
2335
|
+
/**
|
|
1850
2336
|
* Doing something to a review that already exists.
|
|
1851
2337
|
*
|
|
1852
2338
|
* Two operations rather than a flag and its modifier, which is what `update.ts` had and what its
|
|
@@ -1868,7 +2354,7 @@ async function operate(command, workspace) {
|
|
|
1868
2354
|
try {
|
|
1869
2355
|
await reanalyseReview(workspace.id, agent.id, (jobId) => {
|
|
1870
2356
|
stopReanalysis = follow(jobId);
|
|
1871
|
-
});
|
|
2357
|
+
}, command.requestedModel);
|
|
1872
2358
|
} finally {
|
|
1873
2359
|
stopReanalysis?.();
|
|
1874
2360
|
}
|
|
@@ -1876,7 +2362,7 @@ async function operate(command, workspace) {
|
|
|
1876
2362
|
out(reanalysed(agent.displayName, linkTo(port, workspace.id, workspace.token), actedOn(workspace)));
|
|
1877
2363
|
return 0;
|
|
1878
2364
|
}
|
|
1879
|
-
|
|
2365
|
+
for (const line of unusedSelection("update", selectionOf(command))) err(line);
|
|
1880
2366
|
const url = linkTo(await ensureServer(), workspace.id, workspace.token);
|
|
1881
2367
|
if (liveUpdateFor(workspace.id)) {
|
|
1882
2368
|
out(updateAlreadyRunning(url, actedOn(workspace)));
|
|
@@ -1955,16 +2441,6 @@ function makeCommandFor(command) {
|
|
|
1955
2441
|
if (command.prUrl) return `prreviewbuddy review --pr ${command.prUrl}`;
|
|
1956
2442
|
return "prreviewbuddy review";
|
|
1957
2443
|
}
|
|
1958
|
-
/**
|
|
1959
|
-
* Why this review's job stopped, if it stopped badly. Null for a review that finished, and for one
|
|
1960
|
-
* still being made.
|
|
1961
|
-
*
|
|
1962
|
-
* A job can read as failed and still be driven: the sweep calls a job stopped after half an hour
|
|
1963
|
-
* without a write, and an analysis that streams nothing is silent while entirely alive. Resuming
|
|
1964
|
-
* that one would start a second driver against the worktree the first is still standing in, so the
|
|
1965
|
-
* claim is asked as well as the phase. `runJob` would refuse anyway, but by then this command has
|
|
1966
|
-
* printed that it is picking the review up, and would report the refusal as a failed review.
|
|
1967
|
-
*/
|
|
1968
2444
|
function failureOf(workspaceId) {
|
|
1969
2445
|
const workspace = loadWorkspace(workspaceId);
|
|
1970
2446
|
if (!workspace?.jobId) return null;
|
|
@@ -1973,7 +2449,95 @@ function failureOf(workspaceId) {
|
|
|
1973
2449
|
if (isClaimed(job.id)) return null;
|
|
1974
2450
|
return {
|
|
1975
2451
|
message: job.failure?.message ?? "The reason was not recorded.",
|
|
1976
|
-
|
|
2452
|
+
stopped: job.failure?.stopped === true,
|
|
2453
|
+
agentId: job.env.agentId ?? DEFAULT_AGENT_ID,
|
|
2454
|
+
...job.requestedModel ? { requestedModel: job.requestedModel } : {}
|
|
2455
|
+
};
|
|
2456
|
+
}
|
|
2457
|
+
/**
|
|
2458
|
+
* What to run for a resolved target, decided and started, with nothing printed.
|
|
2459
|
+
*
|
|
2460
|
+
* The first half of `review`, and the half a batch shares: a batch says all of it in one summary
|
|
2461
|
+
* at the end rather than as it goes, so the deciding and the saying have to be apart.
|
|
2462
|
+
*
|
|
2463
|
+
* A review that exists is usually a review that finished, and handing it back rather than making a
|
|
2464
|
+
* second one is the whole rule. A review whose job failed is the exception, and handing that one
|
|
2465
|
+
* back is worse than useless: the reviewer typed a command and would get a link to a page reporting
|
|
2466
|
+
* the failure they just caused, with the way out being a button in a browser. It is the same resume
|
|
2467
|
+
* the workspace's Retry offers, taken here because this is where the person is.
|
|
2468
|
+
*
|
|
2469
|
+
* The target is the one already resolved, not re-derived. A request reference is resolved by
|
|
2470
|
+
* spawning an agent, and resolving it twice risks pinning the job to a different commit than the one
|
|
2471
|
+
* the existing-review check just looked at. It carries the base too, which is why none is passed
|
|
2472
|
+
* alongside it: two copies of one answer make one of them the authority without saying which.
|
|
2473
|
+
*
|
|
2474
|
+
* The agent is the resolved one, not the flag. They are the same string only when somebody typed
|
|
2475
|
+
* one: a machine default, a review's own agent and the first-run picker all reach a decision the
|
|
2476
|
+
* flag never held, and passing the flag threw all three away. The harness reads an absent id as
|
|
2477
|
+
* "whatever the default is", so a configured default of Codex announced Codex and then analysed
|
|
2478
|
+
* with Claude, and nothing said so until the failure named the wrong tool.
|
|
2479
|
+
*/
|
|
2480
|
+
async function prepareReview(target, agentId, fresh, requestedModel) {
|
|
2481
|
+
if (!fresh) {
|
|
2482
|
+
const existing = await findExistingReview(target);
|
|
2483
|
+
if (existing) {
|
|
2484
|
+
const failure = failureOf(existing.id);
|
|
2485
|
+
return failure ? {
|
|
2486
|
+
kind: "resume",
|
|
2487
|
+
existing,
|
|
2488
|
+
failure
|
|
2489
|
+
} : {
|
|
2490
|
+
kind: "existing",
|
|
2491
|
+
existing
|
|
2492
|
+
};
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
return {
|
|
2496
|
+
kind: "new",
|
|
2497
|
+
workspaceId: await startJob({
|
|
2498
|
+
target,
|
|
2499
|
+
agentId,
|
|
2500
|
+
...requestedModel ? { requestedModel } : {}
|
|
2501
|
+
})
|
|
2502
|
+
};
|
|
2503
|
+
}
|
|
2504
|
+
/**
|
|
2505
|
+
* Run a review's job to its end and say whether a review came out of it, with nothing printed.
|
|
2506
|
+
*
|
|
2507
|
+
* Any ending that is not `done`, not only the one that says `failed`. A run that stopped without
|
|
2508
|
+
* recording why still stopped, and reading "not failed" as "finished" is what let a job left at
|
|
2509
|
+
* `analysing` print `Review ready` over a review that does not exist.
|
|
2510
|
+
*
|
|
2511
|
+
* The standard review is also checked for a result. A finished analysis always leaves one -- a
|
|
2512
|
+
* reply that could not be read is a failure and never reaches here -- so a null one is a bug, and
|
|
2513
|
+
* `Review ready . Risk not rated . 0 findings` is the one sentence it must never be reported as:
|
|
2514
|
+
* that reads as a clean bill of health, which is the opposite of what happened. A KISS run's
|
|
2515
|
+
* answer is checked by `drive`, which knows which run it is.
|
|
2516
|
+
*/
|
|
2517
|
+
async function settleReview(workspaceId, reviewType = "standard", signal) {
|
|
2518
|
+
let job;
|
|
2519
|
+
try {
|
|
2520
|
+
job = await runJob(workspaceId, {}, reviewType, signal ? { signal } : {});
|
|
2521
|
+
} catch (error) {
|
|
2522
|
+
return {
|
|
2523
|
+
ok: false,
|
|
2524
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
2525
|
+
stopped: false
|
|
2526
|
+
};
|
|
2527
|
+
}
|
|
2528
|
+
if (job.phase !== "done") return {
|
|
2529
|
+
ok: false,
|
|
2530
|
+
reason: failureMessage(job),
|
|
2531
|
+
stopped: job.failure?.stopped === true
|
|
2532
|
+
};
|
|
2533
|
+
if (reviewType === "standard" && !loadWorkspace(workspaceId)?.session?.result) return {
|
|
2534
|
+
ok: false,
|
|
2535
|
+
reason: "The analysis finished without recording a review.",
|
|
2536
|
+
stopped: false
|
|
2537
|
+
};
|
|
2538
|
+
return {
|
|
2539
|
+
ok: true,
|
|
2540
|
+
job
|
|
1977
2541
|
};
|
|
1978
2542
|
}
|
|
1979
2543
|
/**
|
|
@@ -1998,22 +2562,17 @@ async function drive(workspaceId, url, kissRun) {
|
|
|
1998
2562
|
sha: "",
|
|
1999
2563
|
originRepoPath: ""
|
|
2000
2564
|
});
|
|
2001
|
-
let
|
|
2565
|
+
let settled;
|
|
2002
2566
|
try {
|
|
2003
|
-
|
|
2004
|
-
if (finished.phase !== "done") {
|
|
2005
|
-
stop();
|
|
2006
|
-
err(reviewFailed(failureMessage(finished), await linkNow(workspace, url)));
|
|
2007
|
-
return 1;
|
|
2008
|
-
}
|
|
2009
|
-
} catch (error) {
|
|
2010
|
-
stop();
|
|
2011
|
-
err(reviewFailed(error instanceof Error ? error.message : String(error), await linkNow(workspace, url)));
|
|
2012
|
-
return 1;
|
|
2567
|
+
settled = await settleReview(workspaceId, kissRun ? "kiss" : "standard");
|
|
2013
2568
|
} finally {
|
|
2014
2569
|
stop();
|
|
2015
2570
|
}
|
|
2016
2571
|
const link = await linkNow(workspace, url);
|
|
2572
|
+
if (!settled.ok) {
|
|
2573
|
+
err(reviewFailed(settled.reason, link));
|
|
2574
|
+
return 1;
|
|
2575
|
+
}
|
|
2017
2576
|
const done = loadWorkspace(workspaceId);
|
|
2018
2577
|
if (kissRun) {
|
|
2019
2578
|
const answer = (done.kissRuns ?? []).find((candidate) => candidate.jobId === kissRun.jobId)?.result;
|
|
@@ -2024,11 +2583,7 @@ async function drive(workspaceId, url, kissRun) {
|
|
|
2024
2583
|
out(kissFinished(link, answer.recommendations.length));
|
|
2025
2584
|
return 0;
|
|
2026
2585
|
}
|
|
2027
|
-
|
|
2028
|
-
err(reviewFailed("The analysis finished without recording a review.", link));
|
|
2029
|
-
return 1;
|
|
2030
|
-
}
|
|
2031
|
-
out(reviewFinished(link, summarise(done), finished.conversation));
|
|
2586
|
+
out(reviewFinished(link, summarise(done), settled.job.conversation));
|
|
2032
2587
|
return 0;
|
|
2033
2588
|
}
|
|
2034
2589
|
/**
|