prreviewbuddy 0.92.0 → 0.95.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,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { $ as readAskRequest, $a as runnableAgents, $i as explanationOf, Aa as TargetError, Ar as attemptIdOf, At as UpdateAlreadyRunningError, Ba as agentIdOf, Bt as append, Da as resolveTarget, Dt as parkedUpdateRefusal, E as relativeTime, Ea as kissOutcome, Er as forgeResolver, F as spawnRunner, Ft as prepareReview, G as reviewTag, Ga as STORE_ROOT, Gr as isClaimed, Gt as recordTelemetryUploadConsent, H as findingRef, Ha as readAgentPreference, Hr as purposeOf, I as applyUpdate, Ia as git, It as settleReview, J as forgetAskRequest, Ji as reviewerDispositions, Jr as followedRefName, Jt as BUILD_VERSION, Ka as AGENT_IDS, Kt as telemetryUploadConsent, L as modelProvenanceOf, Lr as PHASES, Lt as reanalyseReview, Mt as runningJobs, Na as AmbiguousRefError, Nr as isTerminal$1, Nt as failureOf, P as runDetached, Pr as loadJob, Qa as parkedReason, Ra as resolveAgent, T as runBatch, Ta as WITHHELD_REASON, Ua as writeAgentPreference, Ui as isIssueOutstanding, Ur as clearClaim, Ut as stamp, Vr as progressSteps, Wa as CONFIG_PATH, Wi as isQuestionOutstanding, Wr as driverGone, Wt as deliver, Xa as detectAgents, Y as interruption, Ya as agentIdentityOf, Yi as verdictClosure, Yt as PACKAGE_NAME, Z as isTerminalAsk, Za as isAgentId, _t as parseWorkspacePort, at as healthyPort, br as findExistingReview, bt as writePortPreference, ca as findingAssessment, di as reviewedRepositories, dr as MANAGED_ROOT, dt as stopServer, et as recordAskAccepted, fi as summarise, ft as workspaceUrl, gr as removeWorktree, gt as MIN_WORKSPACE_PORT, hr as readMarker, ht as MAX_WORKSPACE_PORT, i as deleteReview$1, ii as matchingWorkspaceIds, it as ensureServer, j as RUNNER_KINDS, jt as liveUpdateFor, k as queueForReview, kr as allJobs, li as recentWorkspaces, mi as workspaceRevision, mt as DEFAULT_WORKSPACE_PORT, ni as lineagePosition, nt as bootstrapUrl, o as liveJobsFor, oi as positionInLineage, ot as readServerRecord, q as endAskRequest, qa as agentById, qt as telemetryUploadEnabled, ri as loadWorkspace, s as wasBlocked, sr as modelHelpLines, st as reviewsUrl, t as MCP_SERVER_INFO_NAME, tt as readIndexToken, u as recordKissRun, ua as findingStatus, ui as reviewedCommit, vr as describeMovement, x as batchIdentity, xt as COMMIT_REVIEW_CANNOT_UPDATE, yr as describeSubject, yt as statedWorkspacePort, za as resolveModel, zt as startJob } from "./mcp_tools-BKPpd9dN.js";
3
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
- import { spawn } from "node:child_process";
2
+ import { $a as TargetError, $r as findExistingReview, $t as statedWorkspacePort, An as BUILD_VERSION, Br as modelHelpLines, Bt as readServerRecord, Ci as driverGone, Di as followedRefName, Dn as recordTelemetryUploadConsent, Dt as endAskRequest, E as relativeTime, En as deliver$1, Eo as cleanName, Fi as loadWorkspace, Fo as STORE_ROOT, Ft as readIndexToken, Gt as stopServer, Hi as reviewedCommit, I as collectionHash, Ii as matchingWorkspaceIds, It as bootstrapUrl, Ja as WITHHELD_REASON, Jr as readMarker, Jt as DEFAULT_WORKSPACE_PORT, Ki as workspaceRevision, Kt as workspaceUrl, Na as findingStatus, Nt as readAskRequest, On as telemetryUploadConsent, Ot as forgetAskRequest, Pi as lineagePosition, Pt as recordAskAccepted, Qr as describeSubject, Ri as positionInLineage, Rt as ensureServer, Sa as explanationOf, Si as clearClaim, Sn as append, St as findingRef, T as runBatch, Tn as stamp, Tt as reviewTag, Ui as reviewedRepositories, Vi as recentWorkspaces, Vt as reviewsUrl, Wi as summarise, Wr as MANAGED_ROOT, Xa as resolveTarget, Xt as MIN_WORKSPACE_PORT, Ya as kissOutcome, Yr as removeWorktree, Yt as MAX_WORKSPACE_PORT, Zr as describeMovement, Zt as parseWorkspacePort, _a as reviewerDispositions, _n as prepareReview, _o as agentIdentityOf, _t as modelProvenanceOf, ai as forgeResolver, ao as git, bi as progressSteps, bo as parkedReason, ci as allJobs, co as resolveModel, do as readAgentPreference, dt as RUNNER_KINDS, en as writePortPreference, et as deliver, fa as isIssueOutstanding, fi as isTerminal$1, fn as UpdateAlreadyRunningError, fo as writeAgentPreference, gi as PHASES, gt as applyUpdate, hn as failureOf, ho as agentById, ht as spawnRunner, i as deleteReview$1, ja as findingAssessment, jn as PACKAGE_NAME, jt as isTerminalAsk, kn as telemetryUploadEnabled, kt as interruption, li as attemptIdOf, lo as agentIdOf, lt as queueForReview, mn as runningJobs, mo as AGENT_IDS, mt as runDetached, no as AmbiguousRefError, nt as missedDelivery, o as liveJobsFor, pa as isQuestionOutstanding, pi as loadJob, pn as liveUpdateFor, po as CONFIG_PATH, rt as openDestination, s as wasBlocked, sn as parkedUpdateRefusal, so as resolveAgent, t as MCP_SERVER_INFO_NAME, tn as COMMIT_REVIEW_CANNOT_UPDATE, tt as isDelivered, u as recordKissRun, va as verdictClosure, vn as settleReview, vo as detectAgents, wi as isClaimed, x as batchIdentity, xi as purposeOf, xn as startJob, xo as runnableAgents, yn as reanalyseReview, yo as isAgentId, zt as healthyPort } from "./mcp_tools-DDbcXys2.js";
3
+ import { createHash, randomUUID } from "node:crypto";
5
4
  import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
5
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
6
6
  import { hostname } from "node:os";
7
- import { createHash, randomUUID } from "node:crypto";
7
+ import { spawn } from "node:child_process";
8
8
  import { createInterface } from "node:readline";
9
9
  import process$1, { platform } from "node:process";
10
10
  //#region \0rolldown/runtime.js
@@ -61,7 +61,9 @@ var VALUED = {
61
61
  "--review",
62
62
  "--audience",
63
63
  "--lens",
64
- "--concurrency"
64
+ "--concurrency",
65
+ "--collection",
66
+ "--new-collection"
65
67
  ],
66
68
  open: [],
67
69
  agents: [],
@@ -94,6 +96,7 @@ var BARE = {
94
96
  "--resume",
95
97
  "--yes",
96
98
  "--queue",
99
+ "--also-queue",
97
100
  "--json",
98
101
  "--no-wait"
99
102
  ],
@@ -292,6 +295,11 @@ function parse$2(argv) {
292
295
  };
293
296
  const first = positional[0];
294
297
  if (head === "review") {
298
+ const destination = refuseDestination(prs, values, flags);
299
+ if (destination) return {
300
+ name: "error",
301
+ message: destination
302
+ };
295
303
  const batch = refuseBatch(prs, values, flags);
296
304
  if (batch) return {
297
305
  name: "error",
@@ -309,7 +317,9 @@ function parse$2(argv) {
309
317
  ...values["--agent"] ? { agentId: values["--agent"] } : {},
310
318
  ...values["--model"] ? { requestedModel: values["--model"] } : {},
311
319
  ...flags.has("--fresh") ? { operation: "fresh" } : {},
312
- ...flags.has("--queue") ? { queue: true } : {}
320
+ ...flags.has("--queue") ? { queue: true } : {},
321
+ ...collectionOf(values, flags),
322
+ ...flags.has("--json") && (values["--collection"] !== void 0 || values["--new-collection"] !== void 0) ? { json: true } : {}
313
323
  };
314
324
  const target = Object.keys(TARGETS).find((flag) => values[flag]);
315
325
  const operation = Object.keys(OPERATIONS).find((flag) => flags.has(flag));
@@ -324,6 +334,7 @@ function parse$2(argv) {
324
334
  ...values["--audience"] ? { audience: values["--audience"] } : {},
325
335
  ...flags.has("--yes") ? { yes: true } : {},
326
336
  ...flags.has("--queue") ? { queue: true } : {},
337
+ ...collectionOf(values, flags),
327
338
  ...flags.has("--json") ? { json: true } : {},
328
339
  ...flags.has("--no-wait") ? { noWait: true } : {}
329
340
  };
@@ -368,6 +379,48 @@ function parse$2(argv) {
368
379
  ...first ? { id: first } : {}
369
380
  };
370
381
  }
382
+ /** The collection fields, present only when a collection option was typed. */
383
+ function collectionOf(values, flags) {
384
+ const name = values["--collection"];
385
+ const create = values["--new-collection"];
386
+ if (name === void 0 && create === void 0) return {};
387
+ return {
388
+ collection: name !== void 0 ? { name: cleanName(name) } : { create: cleanName(create) },
389
+ ...flags.has("--also-queue") ? { alsoQueue: true } : {}
390
+ };
391
+ }
392
+ /**
393
+ * What a collection option cannot be asked with, or null. Everything that needs no store is
394
+ * checked here, so a refusal never follows a write: whether a name exists, or is taken, is the one
395
+ * thing left to `openDestination`, and it too runs before any pull request is touched.
396
+ *
397
+ * A collection holds the reviews a batch of pull requests starts or finds, so it goes with `--pr`
398
+ * and with nothing that acts on one review, asks a different question of it, or answers in JSON.
399
+ */
400
+ function refuseDestination(prs, values, flags) {
401
+ const name = values["--collection"];
402
+ const create = values["--new-collection"];
403
+ const option = name !== void 0 ? "--collection" : create !== void 0 ? "--new-collection" : null;
404
+ if (name !== void 0 && create !== void 0) return "`--collection` adds the reviews to a collection you have, and `--new-collection` makes a new one for them. Keep the one you meant.";
405
+ if (!option) {
406
+ if (flags.has("--also-queue")) return "`--also-queue` puts the reviews on your review queue as well as in a collection, and no collection is named. Add `--collection <name>` or `--new-collection <name>`, or use `--queue` to put them on the queue alone.";
407
+ return null;
408
+ }
409
+ const typed = name ?? create;
410
+ if (flags.has("--queue")) return `With \`${option}\`, the reviews go into the collection only, and the queue is asked for with \`--also-queue\`. Use \`--also-queue\` in place of \`--queue\`.`;
411
+ if (cleanName(typed) === "") return `\`${option}\` needs a collection name after it.`;
412
+ if (create !== void 0 && cleanName(create).length > 60) return `\`--new-collection\` takes a name of 60 characters at most, and this one has ${cleanName(create).length}.`;
413
+ if (prs.length === 0) return `\`${option}\` collects the reviews of pull requests named with \`--pr\`, and this names none. Name them: \`prreviewbuddy review --pr <url> <url> ${option} "${cleanName(typed)}"\`.`;
414
+ const operation = [
415
+ "--update",
416
+ "--reanalyse",
417
+ "--delete",
418
+ "--resume"
419
+ ].find((flag) => flags.has(flag));
420
+ if (operation) return `\`${option}\` collects the reviews a command starts or finds, and \`${operation}\` acts on one review that already exists. Run them as separate commands.`;
421
+ if (values["--lens"] || values["--audience"]) return `\`${option}\` collects standard reviews, and \`${values["--lens"] ? "--lens" : "--audience"}\` asks for a different one. Drop one of them.`;
422
+ return null;
423
+ }
371
424
  /**
372
425
  * What several pull requests at once cannot be asked for, or null.
373
426
  *
@@ -382,6 +435,7 @@ function refuseBatch(prs, values, flags) {
382
435
  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`.";
383
436
  }
384
437
  if (prs.length < 2) return null;
438
+ if (flags.has("--no-wait")) return "`--no-wait` hands one review to a background runner, and several pull requests are run here until every review has ended. Drop `--no-wait`, or run each pull request on its own with `prreviewbuddy review --pr <url> --no-wait`.";
385
439
  const repeated = prs.find((url, index) => prs.indexOf(url) !== index);
386
440
  if (repeated) return `\`${repeated}\` is named twice. Each pull request is reviewed once, so name it once.`;
387
441
  const operation = [
@@ -461,9 +515,15 @@ function refuseCombination(values, flags) {
461
515
  * date, and resume one that stopped. The rest are refused by name rather than half-supported: a
462
516
  * lens and a batch answer in shapes the contract does not have yet, `--delete` asks a question, and
463
517
  * `--reanalyse` replaces the analysis an agent's finding ids belong to.
518
+ *
519
+ * `--no-wait` says whether to wait and `--json` how to answer, so each goes without the other. It
520
+ * covers the same operations, because those are the ones the background runner drives.
464
521
  */
465
522
  function refuseMachineFlags(values, flags) {
466
- if (flags.has("--no-wait") && !flags.has("--json")) return "`--no-wait` hands the work to a background runner and answers with its job, which only makes sense with `--json`. Add `--json`, or drop `--no-wait` to watch it here.";
523
+ if (flags.has("--no-wait") && !flags.has("--json")) {
524
+ if (values["--lens"]) return "`--no-wait` does not cover lens reviews yet. Run `--lens` without `--no-wait`.";
525
+ for (const flag of ["--delete", "--reanalyse"]) if (flags.has(flag)) return `\`--no-wait\` covers starting, updating and resuming a review, and not \`${flag}\`. Run \`${flag}\` without \`--no-wait\`.`;
526
+ }
467
527
  if (flags.has("--resume") && !values["--review"]) return "`--resume` carries on one review whose analysis stopped, so it needs `--review <review>`. A plain `prreviewbuddy review` already resumes the review of the branch you are on.";
468
528
  if (!flags.has("--json")) return null;
469
529
  if (values["--lens"]) return "`--json` does not cover lens reviews yet. Run `--lens` without `--json`.";
@@ -1388,17 +1448,9 @@ async function statusNow(id, request, deps) {
1388
1448
  ...request.askId ? { ask: await askNow(workspace, request.askId, deps) } : {}
1389
1449
  };
1390
1450
  }
1391
- /**
1392
- * Start a review, or resume one, and hand the work to a detached runner.
1393
- *
1394
- * Never a duplicate: a target that already has a healthy review gets it back with `started: false`,
1395
- * which is the default operation's rule at the terminal too. `fresh` asks for a second one anyway.
1396
- * Named by `reviewId`, it resumes that review's own stopped analysis and nothing else, so recovering
1397
- * from a dead runner can never start an unrelated review.
1398
- */
1399
- async function startReview(input, deps = defaultDeps) {
1451
+ async function startReview(input, deps = defaultDeps, hooks = {}) {
1400
1452
  guard("run", deps.env);
1401
- if (input.reviewId) return resumeReview(input.reviewId, input, deps);
1453
+ if (input.reviewId) return resumeReview(input.reviewId, input, deps, hooks);
1402
1454
  let target;
1403
1455
  try {
1404
1456
  target = await resolveTarget({
@@ -1415,7 +1467,8 @@ async function startReview(input, deps = defaultDeps) {
1415
1467
  if (!input.fresh) {
1416
1468
  const existing = await findExistingReview(target);
1417
1469
  if (existing) {
1418
- if (failureOf(existing.id)) return resumeReview(existing.id, input, deps);
1470
+ if (failureOf(existing.id)) return resumeReview(existing.id, input, deps, hooks);
1471
+ hooks.ready?.();
1419
1472
  return {
1420
1473
  review: await getReview(existing.id, deps),
1421
1474
  job: null,
@@ -1426,6 +1479,7 @@ async function startReview(input, deps = defaultDeps) {
1426
1479
  }
1427
1480
  const resolution = await resolveAgent({ flag: input.agent });
1428
1481
  if (resolution.kind !== "resolved") throw new ApiError("refused", deps.explainAgent(resolution));
1482
+ hooks.ready?.();
1429
1483
  const prepared = await prepareReview(target, resolution.agent.id, true, input.model, input.source);
1430
1484
  return handOff(prepared.kind === "new" ? prepared.workspaceId : prepared.existing.id, input.source, deps);
1431
1485
  }
@@ -1438,10 +1492,11 @@ function declinedSelection(input, what, running = {}) {
1438
1492
  if (!declined.length) return "";
1439
1493
  return ` The ${declined.join(" and ")} you named ${declined.length > 1 ? "were" : "was"} not used: the ${what} keeps its own. Pass fresh to analyse it again with them.`;
1440
1494
  }
1441
- async function resumeReview(id, input, deps) {
1495
+ async function resumeReview(id, input, deps, hooks = {}) {
1442
1496
  const workspace = findReview(id);
1443
1497
  const failure = failureOf(workspace.id);
1444
1498
  if (!failure) {
1499
+ hooks.ready?.();
1445
1500
  const job = workspace.jobId ? loadJob(workspace.jobId) : null;
1446
1501
  const running = job !== null && !isTerminal$1(job) && isClaimed(job.id);
1447
1502
  return {
@@ -1456,6 +1511,7 @@ async function resumeReview(id, input, deps) {
1456
1511
  } catch (error) {
1457
1512
  throw new ApiError("refused", error instanceof Error ? error.message : String(error));
1458
1513
  }
1514
+ hooks.ready?.();
1459
1515
  const handed = await handOff(workspace.id, input.source, deps);
1460
1516
  const note = declinedSelection(input, "resumed job", {
1461
1517
  agent: failure.agentId,
@@ -1526,7 +1582,7 @@ async function requestUpdate(id, source, deps = defaultDeps) {
1526
1582
  };
1527
1583
  const latest = loadWorkspace(workspace.id)?.lastUpdate;
1528
1584
  if (latest && latest.at !== before) {
1529
- const finished = jobsOf(workspace.id).find((candidate) => purposeOf(candidate) === "update" && candidate.startedAt >= (before ?? 0));
1585
+ const finished = jobsOf(workspace.id).find((candidate) => purposeOf(candidate) === "update" && !candidate.scope && candidate.startedAt >= (before ?? 0));
1530
1586
  return {
1531
1587
  review: await getReview(workspace.id, deps),
1532
1588
  job: finished ? toJobRecord(finished, false) : null,
@@ -1683,85 +1739,6 @@ function pidAlive(pid) {
1683
1739
  }
1684
1740
  }
1685
1741
  //#endregion
1686
- //#region src/exit.ts
1687
- /**
1688
- * What a command's exit status means, for every command.
1689
- *
1690
- * One table, the contract's own (`EXIT_CODES`), so a script reads the same number for the same
1691
- * failure whether or not it asked for `--json`, and an MCP error and an exit status name the same
1692
- * thing. A review that finds problems is a review that worked, and exits 0: these say whether the
1693
- * command did what was asked, never what the code under review is like.
1694
- *
1695
- * The sentence printed beside a failure is unchanged by any of this. The number only sorts it.
1696
- */
1697
- var EXIT = {
1698
- ok: EXIT_CODES.ok,
1699
- /** Something nobody anticipated. The message is still printed. */
1700
- unexpected: EXIT_CODES.internal,
1701
- /** The command, a flag or an argument was not one this can act on. */
1702
- usage: EXIT_CODES.invalid_argument,
1703
- /** No review, job or finding by that name, or a prefix that names more than one. */
1704
- notFound: EXIT_CODES.not_found,
1705
- /** Understood, and declined: already running, nothing to update, an agent that cannot run here, a question nobody answered. */
1706
- refused: EXIT_CODES.refused,
1707
- /** The analysis, update or question ran and failed. */
1708
- failed: EXIT_CODES.analysis_failed,
1709
- /** The local workspace server could not be started or reached. */
1710
- unavailable: EXIT_CODES.service_unavailable,
1711
- timeout: EXIT_CODES.timeout,
1712
- /** Started from inside an agent run, which cannot start reviews. */
1713
- recursion: EXIT_CODES.recursion_blocked
1714
- };
1715
- function exitFor(code) {
1716
- return EXIT_CODES[code];
1717
- }
1718
- /** A failure that reached the top unclassified. Naming a target that does not resolve is a usage error, not a crash. */
1719
- function exitForThrown(error) {
1720
- if (error instanceof TargetError || error instanceof AmbiguousRefError) return EXIT.usage;
1721
- return EXIT.unexpected;
1722
- }
1723
- //#endregion
1724
- //#region src/write.ts
1725
- /**
1726
- * Where the two kinds of output go, decided once.
1727
- *
1728
- * Links and results to stdout, everything else to stderr, so `prreviewbuddy review | pbcopy`
1729
- * copies a URL and progress still reaches the terminal it was meant for.
1730
- */
1731
- function out(text) {
1732
- process.stdout.write(`${text}\n`);
1733
- }
1734
- function err(text) {
1735
- process.stderr.write(`${text}\n`);
1736
- }
1737
- //#endregion
1738
- //#region src/json.ts
1739
- /**
1740
- * The one document a `--json` command writes, and how it answers a failure.
1741
- *
1742
- * Exactly one JSON value on stdout, success or not, so a caller can always parse what it gets and
1743
- * branch on `error.code` rather than on a sentence. The sentence still goes to stderr, where a
1744
- * person watching the command reads it.
1745
- */
1746
- function emit(document) {
1747
- out(JSON.stringify({
1748
- schema_version: "1",
1749
- ...document
1750
- }));
1751
- }
1752
- /** A failure, as a document on stdout and a sentence on stderr, and the exit status it maps to. */
1753
- function emitError(error) {
1754
- const document = errorDocument(error);
1755
- out(JSON.stringify(document));
1756
- err(document.error.message);
1757
- return error instanceof ApiError ? exitFor(error.code) : exitForThrown(error);
1758
- }
1759
- /** The same failure without `--json`: the sentence alone, and the same exit status. */
1760
- function reportError(error) {
1761
- err(error instanceof Error ? error.message : String(error));
1762
- return error instanceof ApiError ? exitFor(error.code) : exitForThrown(error);
1763
- }
1764
- //#endregion
1765
1742
  //#region src/text.ts
1766
1743
  /**
1767
1744
  * The agent names, in the help, taken from the registry that actually has them.
@@ -1918,15 +1895,27 @@ Options
1918
1895
  reviewer (somebody reviewing it). Required with --lens kiss
1919
1896
  --concurrency <n> with several --pr URLs, how many are reviewed at once. Default 2
1920
1897
  --queue put each review on your review queue as soon as it exists
1898
+ --collection <name> with --pr, put each review in a collection you have, and not on
1899
+ the queue. Add --also-queue for both
1900
+ --new-collection <name> with --pr, make a collection with that name and put each review in it
1901
+ --also-queue with a collection option, put each review on your review queue too
1921
1902
  --json write one JSON document to stdout and nothing else: the review
1922
- and its job, once the job ends. Never asks a question
1923
- --no-wait with --json, answer as soon as the job has started. It carries
1924
- on in the background; follow it with \`prreviewbuddy status\`
1903
+ and its job, once the job ends. Never asks a question. With a
1904
+ collection option, also the collection and where each pull
1905
+ request went, for one --pr URL or several
1906
+ --no-wait answer as soon as the job has started, rather than waiting for
1907
+ the review. It carries on in the background after this exits;
1908
+ check on it with \`prreviewbuddy status <review>\`. With --json,
1909
+ the review and its job as a document
1925
1910
 
1926
1911
  A target and an operation go together:
1927
1912
  prreviewbuddy review --branch feat/foo --update
1928
1913
  prreviewbuddy review --base origin/main
1929
1914
  prreviewbuddy review --pr <url> <url> --queue
1915
+ prreviewbuddy review --pr <url> <url> --collection "Release review"
1916
+ prreviewbuddy review --pr <url> <url> --new-collection "Friday batch"
1917
+ prreviewbuddy review --pr <url> <url> --collection "Release review" --also-queue
1918
+ prreviewbuddy review --pr <url> --no-wait
1930
1919
  prreviewbuddy review --lens kiss --audience author
1931
1920
  prreviewbuddy review --review <review> --delete
1932
1921
 
@@ -2437,7 +2426,7 @@ var BATCH_QUEUED = "Queued";
2437
2426
  var BATCH_LOOKING_UP = "Looking up the pull request";
2438
2427
  var BATCH_STOPPING = "Stopping. Reviews already running are being stopped, and the rest will not start.";
2439
2428
  /** The end of a batch: one line per pull request, with its link, and the index last. */
2440
- function batchFinished(rows, index) {
2429
+ function batchFinished(rows, index, destination) {
2441
2430
  const width = Math.max(...rows.map((row) => row.label.length));
2442
2431
  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()}`);
2443
2432
  const lines = ["", `${count(rows.length, "pull request")}: ${tally.join(", ")}`];
@@ -2447,7 +2436,8 @@ function batchFinished(rows, index) {
2447
2436
  if (row.detail && row.url) lines.push(`${" ".repeat(lead.length)}${row.url}`);
2448
2437
  }
2449
2438
  const unfinished = rows.some((row) => row.outcome === "stopped" || row.outcome === "not-started" || row.outcome === "failed" && row.url);
2450
- lines.push(...batchQueueLines(rows));
2439
+ lines.push(...batchCollectionLines(rows, destination));
2440
+ lines.push(...batchQueueLines(rows, destination?.strict === true && destination.queue));
2451
2441
  if (unfinished) lines.push("", "Run the same command again to carry on with the reviews that did not finish, from where each got to.");
2452
2442
  const all = indexLinks(index);
2453
2443
  if (all) lines.push("", all);
@@ -2460,18 +2450,129 @@ function batchFinished(rows, index) {
2460
2450
  * ended, and the queue is where the reviewer goes next, so it is said once, together. What was left
2461
2451
  * out is named with its reason, because each reason has a different way round it.
2462
2452
  */
2463
- function batchQueueLines(rows) {
2453
+ function batchQueueLines(rows, strict = false) {
2464
2454
  const asked = rows.filter((row) => row.queue !== void 0);
2465
- if (asked.length === 0) return [];
2455
+ const never = strict ? rows.filter((row) => row.queue === void 0 && row.outcome !== "duplicate") : [];
2456
+ if (asked.length === 0 && never.length === 0) return [];
2466
2457
  const lines = [""];
2467
2458
  const queued = asked.filter((row) => row.queue === "added" || row.queue === "already");
2468
2459
  if (queued.length > 0) lines.push(`In your review queue: ${queued.map((row) => row.label).join(", ")}`);
2469
- for (const row of asked) {
2460
+ for (const row of rows) {
2461
+ if (row.queue === void 0) {
2462
+ if (never.includes(row)) lines.push(`Not added to your review queue: ${row.label}, because ${noReviewReason(row.outcome)}.`);
2463
+ continue;
2464
+ }
2470
2465
  if (row.queue === "added" || row.queue === "already") continue;
2471
2466
  lines.push(`Not added to your review queue: ${notQueuedBecause(row.label, row.queue)}`);
2472
2467
  }
2473
2468
  return lines;
2474
2469
  }
2470
+ /** Why a pull request has no review to put anywhere: it ended before one existed. */
2471
+ function noReviewReason(outcome) {
2472
+ return outcome === "not-started" ? "it did not start" : "it failed before it had a review";
2473
+ }
2474
+ /**
2475
+ * What the collection holds of a batch, what it is missing and why, and where to open it.
2476
+ *
2477
+ * A duplicate is never named: the pull request it repeats is the one in the collection. A collection
2478
+ * this command made is named with the flag that adds to it, because running the same command again
2479
+ * would refuse the name as taken, by this very command.
2480
+ */
2481
+ function batchCollectionLines(rows, destination) {
2482
+ const collection = destination?.collection;
2483
+ if (!collection) return [];
2484
+ const lines = [""];
2485
+ const inside = rows.filter((row) => isDelivered(row.collection));
2486
+ if (inside.length > 0) lines.push(`In ${collection.name}: ${inside.map((row) => row.label).join(", ")}`);
2487
+ let missed = false;
2488
+ for (const row of rows) {
2489
+ if (row.outcome === "duplicate" || isDelivered(row.collection)) continue;
2490
+ missed = true;
2491
+ lines.push(row.collection === void 0 ? `Not added to ${collection.name}: ${row.label}, because ${noReviewReason(row.outcome)}.` : `Not added to ${collection.name}: ${row.label}, because ${collectReason(row.collection)}.`);
2492
+ }
2493
+ if (collection.url) lines.push(`Open collection: ${collection.url}`);
2494
+ if (collection.created) lines.push(createdCollectionLine(collection.name, missed || rows.some((row) => row.queue !== void 0 && !isDelivered(row.queue))));
2495
+ return lines;
2496
+ }
2497
+ /** Why a review is not in a collection, as the end of a sentence. */
2498
+ function collectReason(result) {
2499
+ switch (result) {
2500
+ case "missing": return "the collection no longer exists";
2501
+ case "full": return `the collection is full. It holds 500 reviews at most`;
2502
+ case "gone": return "its review could not be found";
2503
+ default: return "the collection could not be saved";
2504
+ }
2505
+ }
2506
+ /**
2507
+ * `review --no-wait` without `--json`: what was handed to the background runner, and the command
2508
+ * that follows it. The runner is detached, so the terminal can close; `status` takes the whole id.
2509
+ *
2510
+ * Said from the job's state, not from whether anything was asked to start: an update can end before
2511
+ * this answers, most often with nothing new to reassess, and then the service's `note` says how.
2512
+ * Only an update's note is shown. The others are written for MCP callers and name its tools, and
2513
+ * what they say about a declined agent or model is already said on stderr.
2514
+ *
2515
+ * A review that was found rather than started comes with no job, so whether it is still being made
2516
+ * is read from the review's own state.
2517
+ */
2518
+ function reviewInBackground(record, operation) {
2519
+ const { review, job } = record;
2520
+ const repository = review.repository;
2521
+ const subject = repository.target_type === "pull-request" && repository.pr_url ? repository.pr_url : repository.target_type === "commit" ? `commit ${repository.head_sha.slice(0, 7)}` : repository.branch;
2522
+ const live = job !== null && (job.state === "queued" || job.state === "running");
2523
+ const updating = operation === "update" || job?.purpose === "update";
2524
+ let head;
2525
+ if (updating) head = live ? `Started updating the review of ${subject}.` : job ? `The update of the review of ${subject} ended before this could answer: ${job.state}.` : `The review of ${subject} was not updated.`;
2526
+ else if (!record.started) {
2527
+ const beingMade = review.state === "analysing";
2528
+ head = operation === "resume" ? beingMade ? `The review of ${subject} is being made right now, so there is nothing to resume.` : `The review of ${subject} has no stopped analysis to resume.` : beingMade ? `A review of ${subject} is already being made, so nothing new was started.` : `A review of ${subject} already exists, so nothing new was started.`;
2529
+ } else {
2530
+ const verb = operation === "resume" ? "Resumed" : "Started";
2531
+ head = live ? `${verb} the analysis of ${subject}.` : `${verb} the analysis of ${subject}, and it ended before this could answer: ${job ? job.state : "no job"}.`;
2532
+ }
2533
+ return [
2534
+ head,
2535
+ ...updating && record.note && !live ? [record.note] : [],
2536
+ ...live && (record.started || updating) ? ["It carries on in the background, so you can close this terminal."] : [],
2537
+ `Review ${review.id}`,
2538
+ `Job ${job ? job.state : "none running"}`,
2539
+ `Check prreviewbuddy status ${review.id}`,
2540
+ ...review.workspace_url ? [`Open ${review.workspace_url}`] : []
2541
+ ].join("\n");
2542
+ }
2543
+ /** A collection this command made before it failed, named so a retry goes to it. */
2544
+ function collectionMadeBeforeFailure(name) {
2545
+ return createdCollectionLine(name, true);
2546
+ }
2547
+ function createdCollectionLine(name, missed) {
2548
+ return missed ? `Created collection "${name}". To add what is missing, run the same command with --collection "${name}" in place of --new-collection.` : `Created collection "${name}".`;
2549
+ }
2550
+ /**
2551
+ * What a collection did with the one review a single command acted on, and where to open it. To
2552
+ * stderr, like `reviewQueued`. `missed` says whether anything the command asked for was left out,
2553
+ * so a collection this command made is named with the flag that retries against it.
2554
+ */
2555
+ function reviewCollected(collection, result, missed) {
2556
+ const lines = [result === "added" ? `Added to ${collection.name}.` : result === "already" ? `Already in ${collection.name}.` : `Not added to ${collection.name}: ${collectReason(result)}.`];
2557
+ if (collection.url) lines.push(`Open collection: ${collection.url}`);
2558
+ if (collection.created) lines.push(createdCollectionLine(collection.name, missed));
2559
+ return lines.join("\n");
2560
+ }
2561
+ /** Why a collection option could not be used, before anything was reviewed. */
2562
+ function destinationRefused(refusal) {
2563
+ const after = " Nothing was reviewed.";
2564
+ switch (refusal.reason) {
2565
+ case "missing": {
2566
+ const named = `"${refusal.requested}"`;
2567
+ return `No collection is named ${named}.${refusal.candidates.length > 0 ? ` Your collections: ${refusal.candidates.map((name) => `"${name}"`).join(", ")}.` : " You have no collections yet."} To make one, use --new-collection ${named}.${after}`;
2568
+ }
2569
+ case "duplicate": return `A collection named "${refusal.requested}" already exists. To add to it, use --collection "${refusal.requested}".${after}`;
2570
+ case "empty": return `--new-collection needs a collection name after it.${after}`;
2571
+ case "too-long": return `Use at most 60 characters for the collection name.${after}`;
2572
+ case "full": return `You have 100 collections, which is as many as there can be. Delete one in the workspace, or add to one you have with --collection.${after}`;
2573
+ default: return `The collection could not be saved.${after} Try again.`;
2574
+ }
2575
+ }
2475
2576
  function notQueuedBecause(subject, result) {
2476
2577
  switch (result) {
2477
2578
  case "full": return `${subject}, because the review queue is full. It holds 50 reviews at most.`;
@@ -2832,9 +2933,165 @@ function askShown(ask) {
2832
2933
  return `Question ${ask.ask_id}: ${ask.state}${ask.message ? `. ${ask.message}` : ""}`;
2833
2934
  }
2834
2935
  //#endregion
2936
+ //#region src/commands/destination.ts
2937
+ /**
2938
+ * Where a review command sends what it starts or finds, as the shared rule takes it. The parser has
2939
+ * already refused every combination that cannot be honoured; this only spells what was typed.
2940
+ *
2941
+ * A typed collection is always a name. The workspace sends ids; a person never does, and a name that
2942
+ * happens to look like one is still a name.
2943
+ */
2944
+ function destinationRequest(command) {
2945
+ const queue = command.collection ? command.alsoQueue === true : command.queue === true;
2946
+ if (command.collection && "name" in command.collection) return {
2947
+ collectionName: command.collection.name,
2948
+ queue
2949
+ };
2950
+ if (command.collection) return {
2951
+ newCollection: command.collection.create,
2952
+ queue
2953
+ };
2954
+ return { queue };
2955
+ }
2956
+ /**
2957
+ * Settle the destination, or say why not and answer null. Called after every check that can refuse
2958
+ * without writing (the target, the agent), and before any review is prepared, so a refusal there
2959
+ * leaves no empty collection and a refusal here leaves no review.
2960
+ */
2961
+ function openFor(command, say, open = openDestination) {
2962
+ const opened = open(destinationRequest(command));
2963
+ if (!opened.ok) {
2964
+ say(destinationRefused(opened));
2965
+ return null;
2966
+ }
2967
+ return opened.destination;
2968
+ }
2969
+ /** The Reviews page showing one collection, from the index's own link. Null when there is no index. */
2970
+ function collectionUrl(index, id) {
2971
+ return index ? `${index}${collectionHash(id)}` : null;
2972
+ }
2973
+ //#endregion
2974
+ //#region src/commands/destination_json.ts
2975
+ /**
2976
+ * Where a `--collection` or `--new-collection` command with `--json` sent its reviews, as fields of
2977
+ * the one document it writes. The same two fields for one pull request and for several, so an agent
2978
+ * reads both the same way.
2979
+ *
2980
+ * Present only on collection commands. A command without one writes the document it always has.
2981
+ */
2982
+ function destinationDocument(destination, index) {
2983
+ const { id, name, created } = destination.collection;
2984
+ return {
2985
+ collection: {
2986
+ id,
2987
+ name,
2988
+ created,
2989
+ url: collectionUrl(index, id)
2990
+ },
2991
+ queue: destination.queue
2992
+ };
2993
+ }
2994
+ /** One pull request's deliveries. No review id means it never had a review to deliver. */
2995
+ function deliveryOf(pr, reviewId, delivered, destination) {
2996
+ const outcome = (result) => reviewId ? result ?? "unsaved" : "no_review";
2997
+ return {
2998
+ pr,
2999
+ review_id: reviewId ?? null,
3000
+ collection: outcome(delivered?.collection),
3001
+ ...destination.queue ? { queue: outcome(delivered?.queue) } : {},
3002
+ delivered: reviewId !== void 0 && !missedDelivery(delivered, destination)
3003
+ };
3004
+ }
3005
+ //#endregion
3006
+ //#region src/exit.ts
3007
+ /**
3008
+ * What a command's exit status means, for every command.
3009
+ *
3010
+ * One table, the contract's own (`EXIT_CODES`), so a script reads the same number for the same
3011
+ * failure whether or not it asked for `--json`, and an MCP error and an exit status name the same
3012
+ * thing. A review that finds problems is a review that worked, and exits 0: these say whether the
3013
+ * command did what was asked, never what the code under review is like.
3014
+ *
3015
+ * The sentence printed beside a failure is unchanged by any of this. The number only sorts it.
3016
+ */
3017
+ var EXIT = {
3018
+ ok: EXIT_CODES.ok,
3019
+ /** Something nobody anticipated. The message is still printed. */
3020
+ unexpected: EXIT_CODES.internal,
3021
+ /** The command, a flag or an argument was not one this can act on. */
3022
+ usage: EXIT_CODES.invalid_argument,
3023
+ /** No review, job or finding by that name, or a prefix that names more than one. */
3024
+ notFound: EXIT_CODES.not_found,
3025
+ /** Understood, and declined: already running, nothing to update, an agent that cannot run here, a question nobody answered. */
3026
+ refused: EXIT_CODES.refused,
3027
+ /** The analysis, update or question ran and failed. */
3028
+ failed: EXIT_CODES.analysis_failed,
3029
+ /** The local workspace server could not be started or reached. */
3030
+ unavailable: EXIT_CODES.service_unavailable,
3031
+ timeout: EXIT_CODES.timeout,
3032
+ /** Started from inside an agent run, which cannot start reviews. */
3033
+ recursion: EXIT_CODES.recursion_blocked
3034
+ };
3035
+ function exitFor(code) {
3036
+ return EXIT_CODES[code];
3037
+ }
3038
+ /** A failure that reached the top unclassified. Naming a target that does not resolve is a usage error, not a crash. */
3039
+ function exitForThrown(error) {
3040
+ if (error instanceof TargetError || error instanceof AmbiguousRefError) return EXIT.usage;
3041
+ return EXIT.unexpected;
3042
+ }
3043
+ //#endregion
3044
+ //#region src/write.ts
3045
+ /**
3046
+ * Where the two kinds of output go, decided once.
3047
+ *
3048
+ * Links and results to stdout, everything else to stderr, so `prreviewbuddy review | pbcopy`
3049
+ * copies a URL and progress still reaches the terminal it was meant for.
3050
+ */
3051
+ function out(text) {
3052
+ process.stdout.write(`${text}\n`);
3053
+ }
3054
+ function err(text) {
3055
+ process.stderr.write(`${text}\n`);
3056
+ }
3057
+ //#endregion
3058
+ //#region src/json.ts
3059
+ /**
3060
+ * The one document a `--json` command writes, and how it answers a failure.
3061
+ *
3062
+ * Exactly one JSON value on stdout, success or not, so a caller can always parse what it gets and
3063
+ * branch on `error.code` rather than on a sentence. The sentence still goes to stderr, where a
3064
+ * person watching the command reads it.
3065
+ */
3066
+ function emit(document) {
3067
+ out(JSON.stringify({
3068
+ schema_version: "1",
3069
+ ...document
3070
+ }));
3071
+ }
3072
+ /**
3073
+ * A failure, as a document on stdout and a sentence on stderr, and the exit status it maps to.
3074
+ * `extra` adds what the command had already settled before it failed, such as a collection it made.
3075
+ */
3076
+ function emitError(error, extra = {}) {
3077
+ const document = {
3078
+ ...errorDocument(error),
3079
+ ...extra
3080
+ };
3081
+ out(JSON.stringify(document));
3082
+ err(document.error.message);
3083
+ return error instanceof ApiError ? exitFor(error.code) : exitForThrown(error);
3084
+ }
3085
+ /** The same failure without `--json`: the sentence alone, and the same exit status. */
3086
+ function reportError(error) {
3087
+ err(error instanceof Error ? error.message : String(error));
3088
+ return error instanceof ApiError ? exitFor(error.code) : exitForThrown(error);
3089
+ }
3090
+ //#endregion
2835
3091
  //#region src/commands/review_json.ts
2836
3092
  /**
2837
- * `prreviewbuddy review --json`: the same operations as the MCP tools, and through the same calls.
3093
+ * `prreviewbuddy review --json`, and `--no-wait` with or without it: the same operations as the MCP
3094
+ * tools, and through the same calls.
2838
3095
  *
2839
3096
  * Start (or find) a review, bring one up to date, or resume one that stopped. Each goes through
2840
3097
  * `api/service.ts`, which mints the job here, under this terminal's environment, and hands it to a
@@ -2842,6 +3099,11 @@ function askShown(ask) {
2842
3099
  * follows the job to its end on the store and answers with how it ended, so a script that waits
2843
3100
  * gets the finished review rather than a job to poll.
2844
3101
  *
3102
+ * `--json` says how it answers and `--no-wait` whether it waits, and neither implies the other.
3103
+ * `--no-wait` alone comes here rather than to the terminal path because that path runs the job in
3104
+ * its own process, which ends with the terminal; the runner here is detached and outlives it. It
3105
+ * answers in sentences: what was started, its job's state, and the `status` command to check it.
3106
+ *
2845
3107
  * Never a question, never a progress block on stdout, never a browser: an ambiguous agent is a
2846
3108
  * refusal that names the agents, and everything a person would watch goes to stderr.
2847
3109
  */
@@ -2851,32 +3113,97 @@ function selectionOf$1(command) {
2851
3113
  ...command.requestedModel ? { model: command.requestedModel } : {}
2852
3114
  };
2853
3115
  }
2854
- var DEPS = {
2855
- ...defaultDeps,
2856
- explainAgent: unresolvedAgent
3116
+ var JSON_DEPS = {
3117
+ service: {
3118
+ ...defaultDeps,
3119
+ explainAgent: unresolvedAgent
3120
+ },
3121
+ start: startReview,
3122
+ update: async (command, repoPath, deps) => requestUpdate(await reviewToUpdate(command, repoPath), "cli", deps),
3123
+ follow: (started, deps) => follow$1(started, deps),
3124
+ open: openDestination,
3125
+ deliver,
3126
+ index: async () => {
3127
+ try {
3128
+ return reviewsUrl(await ensureServer(), readIndexToken());
3129
+ } catch {
3130
+ return null;
3131
+ }
3132
+ }
2857
3133
  };
2858
- async function reviewJson(command, repoPath, deps = DEPS) {
3134
+ async function reviewByService(command, repoPath, overrides = {}) {
3135
+ const jsonDeps = {
3136
+ ...JSON_DEPS,
3137
+ ...overrides
3138
+ };
3139
+ const deps = jsonDeps.service;
3140
+ /** Settled once the target and agent are, and before the review is written: see `StartHooks`. */
3141
+ let destination = null;
3142
+ const hooks = command.collection ? { ready: () => {
3143
+ const opened = jsonDeps.open(destinationRequest(command));
3144
+ if (!opened.ok) throw new ApiError("refused", destinationRefused(opened));
3145
+ if (opened.destination.collection) destination = {
3146
+ ...opened.destination,
3147
+ collection: opened.destination.collection
3148
+ };
3149
+ } } : {};
3150
+ /** What was delivered and to which review, once it was: a failure after that still reports it. */
3151
+ let reached = null;
2859
3152
  try {
2860
- let started = await operate$1(command, repoPath, deps);
3153
+ let started = await operate$1(command, repoPath, deps, jsonDeps, hooks);
2861
3154
  const continuation = command.operation === "update" ? "update" : command.operation === "resume" ? "resume" : started.started ? null : "existing";
2862
3155
  if (continuation) for (const line of unusedSelection(continuation, selectionOf$1(command))) err(line);
2863
3156
  if (command.queue) err(reviewQueued(queueForReview(started.review.id)));
2864
- if (!command.noWait && started.started && started.job) started = await follow$1(started, deps);
2865
- emit({ ...started });
2866
- return exitOf$1(started.job);
3157
+ const sent = destination;
3158
+ const delivered = sent ? jsonDeps.deliver(started.review.id, sent) : void 0;
3159
+ if (delivered) reached = {
3160
+ reviewId: started.review.id,
3161
+ delivered
3162
+ };
3163
+ const index = sent ? await jsonDeps.index() : null;
3164
+ if (sent && delivered) {
3165
+ if (delivered.queue) err(reviewQueued(delivered.queue));
3166
+ const { name, created, id } = sent.collection;
3167
+ err(reviewCollected({
3168
+ name,
3169
+ created,
3170
+ url: collectionUrl(index, id)
3171
+ }, delivered.collection ?? "unsaved", missedDelivery(delivered, sent)));
3172
+ }
3173
+ if (!command.noWait && started.started && started.job) started = await jsonDeps.follow(started, deps);
3174
+ if (command.json) emit({
3175
+ ...started,
3176
+ ...sent ? {
3177
+ destination: destinationDocument(sent, index),
3178
+ pull_requests: [deliveryOf(command.prUrl, started.review.id, delivered, sent)]
3179
+ } : {}
3180
+ });
3181
+ else out(reviewInBackground(started, command.operation === "update" || command.operation === "resume" ? command.operation : "start"));
3182
+ const code = exitOf$1(started.job);
3183
+ return code === EXIT.ok && sent && missedDelivery(delivered, sent) ? EXIT.failed : code;
2867
3184
  } catch (error) {
2868
- return emitError(error);
3185
+ const sent = destination;
3186
+ if (!command.json) {
3187
+ if (sent?.collection.created && !reached) err(collectionMadeBeforeFailure(sent.collection.name));
3188
+ return reportError(error);
3189
+ }
3190
+ if (!sent) return emitError(error);
3191
+ const got = reached;
3192
+ return emitError(error, {
3193
+ destination: destinationDocument(sent, await jsonDeps.index()),
3194
+ pull_requests: [deliveryOf(command.prUrl, got?.reviewId, got?.delivered, sent)]
3195
+ });
2869
3196
  }
2870
3197
  }
2871
- async function operate$1(command, repoPath, deps) {
3198
+ async function operate$1(command, repoPath, deps, { start, update }, hooks = {}) {
2872
3199
  if (command.prUrls) throw new ApiError("invalid_argument", "`--json` reviews one target at a time. Run it once for each pull request.");
2873
- if (command.operation === "resume") return startReview({
3200
+ if (command.operation === "resume") return start({
2874
3201
  repoPath,
2875
3202
  reviewId: command.reviewId,
2876
3203
  source: "cli"
2877
- }, deps);
2878
- if (command.operation === "update") return requestUpdate(await reviewToUpdate(command, repoPath), "cli", deps);
2879
- return startReview({
3204
+ }, deps, {});
3205
+ if (command.operation === "update") return update(command, repoPath, deps);
3206
+ return start({
2880
3207
  repoPath,
2881
3208
  ...command.branchRef ? { branch: command.branchRef } : {},
2882
3209
  ...command.prUrl ? { pr: command.prUrl } : {},
@@ -2886,7 +3213,7 @@ async function operate$1(command, repoPath, deps) {
2886
3213
  ...command.requestedModel ? { model: command.requestedModel } : {},
2887
3214
  fresh: command.operation === "fresh",
2888
3215
  source: "cli"
2889
- }, deps);
3216
+ }, deps, hooks);
2890
3217
  }
2891
3218
  /** The review an update acts on: the one named, or the one this target already has. */
2892
3219
  async function reviewToUpdate(command, repoPath) {
@@ -3223,8 +3550,24 @@ function labelsFor(urls) {
3223
3550
  }
3224
3551
  async function reviewBatch(command, repoPath, deps = realDeps()) {
3225
3552
  const urls = command.prUrls ?? [];
3226
- const agent = await deps.chooseAgent(command);
3553
+ /**
3554
+ * `--json`, which the parser lets through only beside a collection option: one document on stdout
3555
+ * at the end, refusals included, and no question asked of anybody.
3556
+ */
3557
+ const json = command.json === true;
3558
+ let agent;
3559
+ try {
3560
+ agent = await deps.chooseAgent(command);
3561
+ } catch (error) {
3562
+ if (json) return refuseJson(deps, error);
3563
+ throw error;
3564
+ }
3227
3565
  if (!agent) return EXIT.refused;
3566
+ let refusal = "";
3567
+ const destination = openFor(command, json ? (line) => refusal = line : deps.err, deps.open);
3568
+ if (!destination) return json ? refuseJson(deps, new ApiError("refused", refusal)) : EXIT.refused;
3569
+ /** A collection was asked for, so a review that missed a destination fails the command. */
3570
+ const strict = command.collection !== void 0;
3228
3571
  const rows = labelsFor(urls).map((label) => ({
3229
3572
  label,
3230
3573
  state: { kind: "queued" }
@@ -3264,7 +3607,7 @@ async function reviewBatch(command, repoPath, deps = realDeps()) {
3264
3607
  const prepared = await deps.prepare(target, agent.id, command.operation === "fresh", command.requestedModel);
3265
3608
  const workspaceId = prepared.kind === "new" ? prepared.workspaceId : prepared.existing.id;
3266
3609
  row.workspaceId = workspaceId;
3267
- if (command.queue) row.reviewQueue = deps.enqueue(workspaceId);
3610
+ if (destination.queue || destination.collection) row.delivered = deps.deliver(workspaceId, destination);
3268
3611
  if (prepared.kind === "existing") {
3269
3612
  row.state = {
3270
3613
  kind: "existing",
@@ -3297,13 +3640,74 @@ async function reviewBatch(command, repoPath, deps = realDeps()) {
3297
3640
  stopDrawing();
3298
3641
  stopListening();
3299
3642
  }
3300
- deps.out(batchFinished(rows.map((row) => summaryRow(row, links.review)), links.index));
3643
+ if (json && destination.collection) {
3644
+ const sent = {
3645
+ ...destination,
3646
+ collection: destination.collection
3647
+ };
3648
+ deps.out(JSON.stringify({
3649
+ schema_version: "1",
3650
+ destination: destinationDocument(sent, links.index),
3651
+ pull_requests: rows.map((row, index) => pullRequestJson(rows, index, urls, sent, links.review))
3652
+ }));
3653
+ } else deps.out(batchFinished(rows.map((row) => summaryRow(row, links.review)), links.index, {
3654
+ queue: destination.queue,
3655
+ strict,
3656
+ ...destination.collection ? { collection: {
3657
+ name: destination.collection.name,
3658
+ created: destination.collection.created,
3659
+ url: collectionUrl(links.index, destination.collection.id)
3660
+ } } : {}
3661
+ }));
3301
3662
  if (signal.aborted) return 130;
3302
- return rows.some((row) => row.state.kind === "failed") ? EXIT.failed : EXIT.ok;
3663
+ if (rows.some((row) => row.state.kind === "failed")) return EXIT.failed;
3664
+ if (strict && rows.some((row) => row.state.kind !== "duplicate" && missedDelivery(row.delivered, destination))) return EXIT.failed;
3665
+ return EXIT.ok;
3666
+ }
3667
+ /** A refusal before anything ran, as the one document `--json` writes, and its exit status. */
3668
+ function refuseJson(deps, error) {
3669
+ const document = errorDocument(error);
3670
+ deps.out(JSON.stringify(document));
3671
+ deps.err(document.error.message);
3672
+ return error instanceof ApiError ? exitFor(error.code) : exitForThrown(error);
3673
+ }
3674
+ /** How each row ended, in the words a document uses. */
3675
+ var OUTCOMES = {
3676
+ queued: "not_started",
3677
+ "looking-up": "not_started",
3678
+ running: "not_started",
3679
+ "not-started": "not_started",
3680
+ ready: "ready",
3681
+ existing: "existing",
3682
+ duplicate: "duplicate",
3683
+ failed: "failed",
3684
+ stopped: "stopped"
3685
+ };
3686
+ /**
3687
+ * One pull request of a `--json` batch: how its review ended, and where it went. A duplicate reports
3688
+ * the review and the deliveries of the row it repeats, which are its own too.
3689
+ */
3690
+ function pullRequestJson(rows, index, urls, destination, link) {
3691
+ const row = rows[index];
3692
+ const state = row.state;
3693
+ const first = state.kind === "duplicate" ? rows.findIndex((other) => other.label === state.of && other.state.kind !== "duplicate") : -1;
3694
+ const source = first >= 0 ? rows[first] : row;
3695
+ const url = source.workspaceId ? link(source.workspaceId) : void 0;
3696
+ const outcome = state.kind === "existing" && state.inProgress ? "in_progress" : OUTCOMES[state.kind];
3697
+ return {
3698
+ ...deliveryOf(urls[index], source.workspaceId, source.delivered, destination),
3699
+ outcome,
3700
+ ...url ? { review_url: url } : {},
3701
+ ...state.kind === "failed" ? { detail: state.reason } : {},
3702
+ ...first >= 0 ? { duplicate_of: urls[first] } : {}
3703
+ };
3303
3704
  }
3304
3705
  function summaryRow(row, link) {
3305
3706
  const url = row.workspaceId ? link(row.workspaceId) : void 0;
3306
- const queued = row.reviewQueue ? { queue: row.reviewQueue } : {};
3707
+ const queued = {
3708
+ ...row.delivered?.queue ? { queue: row.delivered.queue } : {},
3709
+ ...row.delivered?.collection ? { collection: row.delivered.collection } : {}
3710
+ };
3307
3711
  const withUrl = {
3308
3712
  ...url ? { url } : {},
3309
3713
  ...queued
@@ -3342,9 +3746,18 @@ function summaryRow(row, link) {
3342
3746
  };
3343
3747
  }
3344
3748
  }
3749
+ /**
3750
+ * The agent for a `--json` batch, never asked for: the one named, or the default, or a refusal that
3751
+ * says which agents there are. As the service resolves one for `review --json` on a single target.
3752
+ */
3753
+ async function agentForJson(command) {
3754
+ const resolution = await resolveAgent({ flag: command.agentId });
3755
+ if (resolution.kind !== "resolved") throw new ApiError("refused", unresolvedAgent(resolution));
3756
+ return resolution.agent;
3757
+ }
3345
3758
  function realDeps() {
3346
3759
  return {
3347
- chooseAgent: (command) => chooseFor(command),
3760
+ chooseAgent: (command) => command.json ? agentForJson(command) : chooseFor(command),
3348
3761
  resolve: (prUrl, repoPath) => resolveTarget({
3349
3762
  repoPath,
3350
3763
  prUrl,
@@ -3353,7 +3766,9 @@ function realDeps() {
3353
3766
  prepare: (target, agentId, fresh, requestedModel) => prepareReview(target, agentId, fresh, requestedModel, "cli"),
3354
3767
  settle: (workspaceId, signal) => settleReview(workspaceId, "standard", signal, "cli"),
3355
3768
  jobIdOf: (workspaceId) => loadWorkspace(workspaceId)?.jobId,
3356
- enqueue: queueForReview,
3769
+ open: openDestination,
3770
+ deliver,
3771
+ err,
3357
3772
  links: async () => {
3358
3773
  try {
3359
3774
  const port = await ensureServer();
@@ -3588,8 +4003,17 @@ function describeReview(id) {
3588
4003
  createdAt: workspace?.createdAt ?? 0
3589
4004
  };
3590
4005
  }
4006
+ /**
4007
+ * Whether a command goes through the service rather than the terminal path. `--json` does, to
4008
+ * answer in a document. `--no-wait` does with or without it, because only the service's runner is
4009
+ * detached and outlives this process. Neither, and the terminal path shows progress and waits. A
4010
+ * batch into a collection answers `--json` itself.
4011
+ */
4012
+ function answersThroughService(command) {
4013
+ return (command.json === true || command.noWait === true) && !(command.prUrls && command.collection);
4014
+ }
3591
4015
  async function review(command, repoPath) {
3592
- if (command.json) return reviewJson(command, repoPath);
4016
+ if (answersThroughService(command)) return reviewByService(command, repoPath);
3593
4017
  if (command.operation === "delete") return deleteReview(command);
3594
4018
  if (command.prUrls) return reviewBatch(command, repoPath);
3595
4019
  if (command.reviewId) {
@@ -3617,13 +4041,15 @@ async function review(command, repoPath) {
3617
4041
  if (command.lens === "kiss") return kiss(command, target);
3618
4042
  const agent = await chooseFor(command);
3619
4043
  if (!agent) return EXIT.refused;
4044
+ const destination = openFor(command, err);
4045
+ if (!destination) return EXIT.refused;
3620
4046
  const prepared = await prepareReview(target, agent.id, command.operation === "fresh", command.requestedModel, "cli");
3621
- enqueue(command, prepared.kind === "new" ? prepared.workspaceId : prepared.existing.id);
4047
+ const missed = await deliverTo(command, destination, prepared.kind === "new" ? prepared.workspaceId : prepared.existing.id);
3622
4048
  if (prepared.kind === "existing") {
3623
4049
  const url = linkTo(await ensureServer(), prepared.existing.id, prepared.existing.token);
3624
4050
  out(existingReview(prepared.existing, url));
3625
4051
  for (const line of unusedSelection("existing", selectionOf(command))) err(line);
3626
- return 0;
4052
+ return missed ? EXIT.failed : 0;
3627
4053
  }
3628
4054
  if (prepared.kind === "resume") {
3629
4055
  const { existing, failure } = prepared;
@@ -3634,12 +4060,47 @@ async function review(command, repoPath) {
3634
4060
  ...failure.requestedModel ? { model: failure.requestedModel } : {}
3635
4061
  };
3636
4062
  for (const line of unusedSelection("resume", selectionOf(command), running)) err(line);
3637
- return drive(existing.id, url);
4063
+ return failedIfMissed(await drive(existing.id, url), missed);
3638
4064
  }
3639
4065
  const { workspaceId } = prepared;
3640
4066
  const url = linkTo(await ensureServer(), workspaceId, loadWorkspace(workspaceId).token);
3641
4067
  out(startedReview(url, target));
3642
- return drive(workspaceId, url);
4068
+ return failedIfMissed(await drive(workspaceId, url), missed);
4069
+ }
4070
+ /**
4071
+ * A review that ended well is still a failed command when a collection was asked for and it, or the
4072
+ * queue beside it, does not have the review. A review that failed keeps its own status, which says
4073
+ * more. `missed` is only ever true for a collection invocation: see `deliverTo`.
4074
+ */
4075
+ function failedIfMissed(code, missed) {
4076
+ return code === 0 && missed ? EXIT.failed : code;
4077
+ }
4078
+ /**
4079
+ * Put the review where the command sends it, now, and say how that went. Answers whether a
4080
+ * destination that was asked for is missing it.
4081
+ *
4082
+ * Without a collection this is `enqueue`, exactly: `--queue` alone has never failed a command, and
4083
+ * does not start now. With one, both destinations are reported, the collection with its link.
4084
+ */
4085
+ async function deliverTo(command, destination, workspaceId) {
4086
+ if (!command.collection || !destination.collection) {
4087
+ enqueue(command, workspaceId);
4088
+ return false;
4089
+ }
4090
+ const delivered = deliver(workspaceId, destination);
4091
+ if (delivered.queue) err(reviewQueued(delivered.queue));
4092
+ const missed = missedDelivery(delivered, destination);
4093
+ let index = null;
4094
+ try {
4095
+ index = reviewsUrl(await ensureServer(), readIndexToken());
4096
+ } catch {}
4097
+ const { id, name, created } = destination.collection;
4098
+ err(reviewCollected({
4099
+ name,
4100
+ created,
4101
+ url: collectionUrl(index, id)
4102
+ }, delivered.collection ?? "unsaved", missed));
4103
+ return missed;
3643
4104
  }
3644
4105
  /**
3645
4106
  * `--lens kiss`: the other question, asked of the same change.
@@ -4724,7 +5185,7 @@ async function announceInstallation() {
4724
5185
  if (!telemetryUploadEnabled()) return;
4725
5186
  if (announced()) return;
4726
5187
  const line = stamp({ event: "installation_started" });
4727
- if (!line || !await deliver(line)) return;
5188
+ if (!line || !await deliver$1(line)) return;
4728
5189
  writeMarker();
4729
5190
  append(line);
4730
5191
  } catch {}