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/dist/main.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { A as liveUpdateFor, B as recordTelemetryUploadConsent, Bt as displayRef, C as writePortPreference, Ct as recentWorkspaces, Dt as summarise, Et as saveWorkspace, F as reanalyseReview, Gt as writeAgentPreference, H as BUILD_VERSION, Ht as agentIdOf, I as runJob, Jt as AGENT_IDS, Kt as CONFIG_PATH, L as startJob, N as recordKissRun, Q as removeWorktree, Qt as detectAgents, S as statedWorkspacePort, T as updateReview, Tt as reviewedRepositories, U as PACKAGE_NAME, V as telemetryUploadConsent, Vt as git, Wt as readAgentPreference, X as MANAGED_ROOT, Xt as agentById, Yt as DEFAULT_AGENT_ID, Z as readMarker, _ as DEFAULT_WORKSPACE_PORT, a as liveJobsFor, at as loadJob, b as parseWorkspacePort, bt as positionInLineage, c as readIndexToken, d as ensureServer, dt as clearClaim, et as checkFreshness, ft as isClaimed, gt as lineageKeyFor, h as workspaceUrl, it as isTerminal, j as runningJobs, k as UpdateAlreadyRunningError, l as bootstrapUrl, lt as progressSteps, m as stopServer, mt as groupByLineage, o as wasBlocked, p as reviewsUrl, pt as followedRefName, q as forgeResolver, qt as STORE_ROOT, r as deleteReview$1, st as PHASES, t as relativeTime, tt as allJobs, ut as purposeOf, v as MAX_WORKSPACE_PORT, vt as loadWorkspace, y as MIN_WORKSPACE_PORT, yt as matchingWorkspaceIds, z as record, zt as resolveTarget } from "./relative_time-vn8Ex9E8.js";
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 history = target.base ? group.history.filter(madeAgainst(target.base)) : group.history;
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/review_id.ts
732
+ //#region src/batch_schedule.ts
669
733
  /**
670
- * Which review a `--review` value names.
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
- * A prefix that matches two reviews is refused rather than resolved to the newer or the first. It
678
- * is the same rule the base resolver keeps one directory over: acting on the wrong one of two is
679
- * not a wasted minute when the operation is `--delete`, it is the work. Anyone who wants the
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 lookUpReviewId(value, matches = matchingWorkspaceIds) {
683
- const found = matches(value);
684
- if (found.length === 0) return { kind: "none" };
685
- if (found.length === 1) return {
686
- kind: "one",
687
- id: found[0]
688
- };
689
- return {
690
- kind: "ambiguous",
691
- ids: found
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/confirm.ts
765
+ //#region src/progress.ts
696
766
  /**
697
- * A yes or no, asked only when there is somebody to answer.
767
+ * What the review is doing, while it does it.
698
768
  *
699
- * The same shape as `chooseAgent` next door and for the same reasons: hand-rolled on
700
- * `node:readline` because this binary has no runtime dependencies and one question is not worth
701
- * acquiring one, and with its input and output injected so the decision is testable without a
702
- * shell.
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
- * The default is no. Every caller of this is about to delete something, and a bare Return at a
705
- * prompt somebody did not read must leave their machine as it was.
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
- async function confirm(options) {
708
- if (!options.isTTY) return { kind: "unattended" };
709
- const reader = createInterface({ input: options.input });
710
- const lines = reader[Symbol.asyncIterator]();
711
- try {
712
- options.write(`${options.question} [y/N] `);
713
- const next = await lines.next();
714
- if (next.done) return { kind: "no" };
715
- const answer = next.value.trim().toLowerCase();
716
- return answer === "y" || answer === "yes" ? { kind: "yes" } : { kind: "no" };
717
- } finally {
718
- reader.close();
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
- return `The review of ${followedRefName(existing.branch, existing.headResolvedRef) ?? existing.branch} is paused: ${failure}\nCarrying on from where it stopped.`;
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/write.ts
1667
+ //#region src/batch_progress.ts
1451
1668
  /**
1452
- * Where the two kinds of output go, decided once.
1669
+ * What a batch is doing, while it does it: one row per pull request.
1453
1670
  *
1454
- * Links and results to stdout, everything else to stderr, so `prreviewbuddy review | pbcopy`
1455
- * copies a URL and progress still reaches the terminal it was meant for.
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
- function out(text) {
1458
- process.stdout.write(`${text}\n`);
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
- function err(text) {
1461
- process.stderr.write(`${text}\n`);
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
- if (command.operation !== "fresh") {
1754
- const existing = await findExistingReview(target);
1755
- if (existing) {
1756
- const url = linkTo(await ensureServer(), existing.id, existing.token);
1757
- const failed = failureOf(existing.id);
1758
- if (!failed) {
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
- const workspaceId = await startJob({
1768
- target,
1769
- agentId: agent.id
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
- if (command.agentId) err(agentUnusedByUpdate(command.agentId));
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
- agentId: job.env.agentId ?? DEFAULT_AGENT_ID
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 finished;
2565
+ let settled;
2002
2566
  try {
2003
- finished = await runJob(workspaceId, {}, kissRun ? "kiss" : "standard");
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
- if (!done.session.result) {
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
  /**