prreviewbuddy 0.42.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { $ as ensureServer, $t as reviewedRepositories, An as AGENT_IDS, At as loadJob, Bt as followedRefName, Cn as resolveAgent, Ct as checkReview, Dn as writeAgentPreference, E as telemetryUploadConsent, En as readAgentPreference, Et as allJobs, Ft as progressSteps, G as MANAGED_ROOT, It as purposeOf, J as modelHelpLines, Jt as mutateWorkspace, K as readMarker, Kt as loadWorkspace, Lt as clearClaim, Mn as agentById, Mt as PHASES, On as CONFIG_PATH, Pn as detectAgents, Rt as driverGone, S as startJob, Sn as git, T as recordTelemetryUploadConsent, Vt as groupByLineage, Wt as lineageKeyFor, X as readIndexToken, Y as relativeTime, Yt as positionInLineage, Z as bootstrapUrl, Zt as recentWorkspaces, a as wasBlocked, at as workspaceUrl, b as reanalyseReview, bn as displayRef, ct as PACKAGE_NAME, dt as MAX_WORKSPACE_PORT, en as summarise, ft as MIN_WORKSPACE_PORT, g as runningJobs, gt as writePortPreference, h as liveUpdateFor, ht as statedWorkspacePort, i as liveJobsFor, it as stopServer, jn as DEFAULT_AGENT_ID, kn as STORE_ROOT, kt as isTerminal, l as mergeUpdateInto, m as UpdateAlreadyRunningError, n as deleteReview$1, pt as parseWorkspacePort, q as removeWorktree, qt as matchingWorkspaceIds, st as BUILD_VERSION, tt as reviewsUrl, u as updateReview, ut as DEFAULT_WORKSPACE_PORT, v as recordKissRun, vt as forgeResolver, w as record$1, wn as resolveModel, x as runJob, yn as resolveTarget, zt as isClaimed } from "./delete_review-DPRL5IPt.js";
2
+ import { $ as bootstrapUrl, $t as recentWorkspaces, An as STORE_ROOT, Bt as driverGone, Cn as git, Dn as readAgentPreference, E as telemetryUploadConsent, Fn as detectAgents, G as MANAGED_ROOT, Ht as followedRefName, J as modelHelpLines, Jt as loadWorkspace, K as readMarker, Kt as lineageKeyFor, Lt as progressSteps, Mn as DEFAULT_AGENT_ID, Mt as loadJob, Nn as agentById, On as writeAgentPreference, Ot as allJobs, Pt as PHASES, Q as readIndexToken, Rt as purposeOf, S as startJob, T as recordTelemetryUploadConsent, Tn as resolveModel, Tt as checkReview, Ut as groupByLineage, Vt as isClaimed, Xt as mutateWorkspace, Y as relativeTime, Yt as matchingWorkspaceIds, Z as queueForReview, Zt as positionInLineage, _t as statedWorkspacePort, a as wasBlocked, b as reanalyseReview, bn as resolveTarget, bt as forgeResolver, ft as DEFAULT_WORKSPACE_PORT, g as runningJobs, h as liveUpdateFor, ht as parseWorkspacePort, i as liveJobsFor, jn as AGENT_IDS, jt as isTerminal, kn as CONFIG_PATH, l as mergeUpdateInto, lt as BUILD_VERSION, m as UpdateAlreadyRunningError, mt as MIN_WORKSPACE_PORT, n as deleteReview$1, nn as summarise, ot as stopServer, pt as MAX_WORKSPACE_PORT, q as removeWorktree, rt as reviewsUrl, st as workspaceUrl, tn as reviewedRepositories, tt as ensureServer, u as updateReview, ut as PACKAGE_NAME, v as recordKissRun, vt as writePortPreference, w as record$1, wn as resolveAgent, x as runJob, xn as displayRef, zt as clearClaim } from "./delete_review-BWMmO81Q.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";
@@ -33,7 +33,8 @@ var BARE = {
33
33
  "--update",
34
34
  "--reanalyse",
35
35
  "--delete",
36
- "--yes"
36
+ "--yes",
37
+ "--queue"
37
38
  ],
38
39
  open: [],
39
40
  agents: [],
@@ -117,16 +118,21 @@ function parse(argv) {
117
118
  const values = {};
118
119
  const flags = /* @__PURE__ */ new Set();
119
120
  const positional = [];
121
+ /** The flag each positional came after, as typed, so a stray URL can be told where it went wrong. */
122
+ const after = /* @__PURE__ */ new Map();
123
+ let lastFlag;
120
124
  /** Every URL `--pr` took. `review` has no positionals, so what follows `--pr` up to the next flag is its. */
121
125
  const prs = [];
122
126
  for (let index = 0; index < rest.length; index += 1) {
123
127
  const argument = rest[index];
124
128
  if (!argument.startsWith("--")) {
125
129
  positional.push(argument);
130
+ if (lastFlag && !after.has(argument)) after.set(argument, lastFlag);
126
131
  continue;
127
132
  }
128
133
  const equals = argument.indexOf("=");
129
134
  const name = equals === -1 ? argument : argument.slice(0, equals);
135
+ lastFlag = name;
130
136
  if (VALUED[head]?.includes(name)) {
131
137
  const value = equals === -1 ? rest[index + 1] : argument.slice(equals + 1);
132
138
  if (!value) return {
@@ -153,10 +159,18 @@ function parse(argv) {
153
159
  message: `\`${head}\` does not take ${name}. Run \`prreviewbuddy --help\` to see what it takes.`
154
160
  };
155
161
  }
156
- if (head === "review" && positional[0]) return {
157
- name: "error",
158
- message: strayArgument(positional[0])
159
- };
162
+ if (head === "review" && positional[0]) {
163
+ const stray = positional[0];
164
+ const flag = after.get(stray);
165
+ if (prs.length > 0 && flag && LOOKS_LIKE_A_REQUEST.test(stray)) return {
166
+ name: "error",
167
+ message: `\`${stray}\` comes after \`${flag}\`, so \`--pr\` did not take it: \`--pr\` takes the URLs straight after it, up to the next flag. Put all the URLs together after \`--pr\`, and the other flags before or after them.`
168
+ };
169
+ return {
170
+ name: "error",
171
+ message: strayArgument(stray)
172
+ };
173
+ }
160
174
  if (head === "uninstall" && positional[0]) return {
161
175
  name: "error",
162
176
  message: `\`uninstall\` removes everything on this machine, so it takes no target and \`${positional[0]}\` means nothing to it. It takes \`--keep-reviews\`, \`--yes\` and \`--force\`.`
@@ -184,7 +198,8 @@ function parse(argv) {
184
198
  ...values["--concurrency"] ? { concurrency: Number(values["--concurrency"]) } : {},
185
199
  ...values["--agent"] ? { agentId: values["--agent"] } : {},
186
200
  ...values["--model"] ? { requestedModel: values["--model"] } : {},
187
- ...flags.has("--fresh") ? { operation: "fresh" } : {}
201
+ ...flags.has("--fresh") ? { operation: "fresh" } : {},
202
+ ...flags.has("--queue") ? { queue: true } : {}
188
203
  };
189
204
  const target = Object.keys(TARGETS).find((flag) => values[flag]);
190
205
  const operation = Object.keys(OPERATIONS).find((flag) => flags.has(flag));
@@ -197,7 +212,8 @@ function parse(argv) {
197
212
  ...operation ? { operation: OPERATIONS[operation][0] } : {},
198
213
  ...values["--lens"] ? { lens: values["--lens"] } : {},
199
214
  ...values["--audience"] ? { audience: values["--audience"] } : {},
200
- ...flags.has("--yes") ? { yes: true } : {}
215
+ ...flags.has("--yes") ? { yes: true } : {},
216
+ ...flags.has("--queue") ? { queue: true } : {}
201
217
  };
202
218
  }
203
219
  if (head === "uninstall") return {
@@ -313,6 +329,7 @@ function refuseCombination(values, flags) {
313
329
  }
314
330
  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.";
315
331
  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.";
332
+ if (flags.has("--queue")) return "`--queue` puts a review on your review queue to work through, and `--delete` removes the review, so there would be nothing left to queue. Ask for one of them.";
316
333
  }
317
334
  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.";
318
335
  if (values["--review"]) {
@@ -980,6 +997,8 @@ your own checkout is never read or written while it works.
980
997
  prreviewbuddy review --pr <url> <url> ...
981
998
  review several pull requests, each on its own, two at a
982
999
  time; --concurrency <n> changes how many
1000
+ prreviewbuddy review ... --queue also put each review on your review queue, as soon as
1001
+ it exists rather than when it is ready
983
1002
  prreviewbuddy review --base <ref> compare against a base of your choosing
984
1003
  prreviewbuddy review --agent <agent> review with ${AGENT_NAMES}
985
1004
  prreviewbuddy review --model <model> ask that agent for a particular model. Reviews keep
@@ -1408,11 +1427,52 @@ function batchFinished(rows, index) {
1408
1427
  lines.push(`${lead}${row.detail ?? row.url ?? ""}`.trimEnd());
1409
1428
  if (row.detail && row.url) lines.push(`${" ".repeat(lead.length)}${row.url}`);
1410
1429
  }
1411
- 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.");
1430
+ const unfinished = rows.some((row) => row.outcome === "stopped" || row.outcome === "not-started" || row.outcome === "failed" && row.url);
1431
+ lines.push(...batchQueueLines(rows));
1432
+ if (unfinished) lines.push("", "Run the same command again to carry on with the reviews that did not finish, from where each got to.");
1412
1433
  const all = indexLinks(index);
1413
1434
  if (all) lines.push("", all);
1414
1435
  return lines.join("\n");
1415
1436
  }
1437
+ /**
1438
+ * What `--queue` did across a batch, or nothing at all when it was not asked for.
1439
+ *
1440
+ * One line for what is in the queue, rather than a word on every row: the rows say how each review
1441
+ * ended, and the queue is where the reviewer goes next, so it is said once, together. What was left
1442
+ * out is named with its reason, because each reason has a different way round it.
1443
+ */
1444
+ function batchQueueLines(rows) {
1445
+ const asked = rows.filter((row) => row.queue !== void 0);
1446
+ if (asked.length === 0) return [];
1447
+ const lines = [""];
1448
+ const queued = asked.filter((row) => row.queue === "added" || row.queue === "already");
1449
+ if (queued.length > 0) lines.push(`In your review queue: ${queued.map((row) => row.label).join(", ")}`);
1450
+ for (const row of asked) {
1451
+ if (row.queue === "added" || row.queue === "already") continue;
1452
+ lines.push(`Not added to your review queue: ${notQueuedBecause(row.label, row.queue)}`);
1453
+ }
1454
+ return lines;
1455
+ }
1456
+ function notQueuedBecause(subject, result) {
1457
+ switch (result) {
1458
+ case "complete": return `${subject} is marked complete. Move it back to In progress in the workspace to queue it.`;
1459
+ case "full": return `${subject}, because the review queue is full. It holds 50 reviews at most.`;
1460
+ default: return `${subject}, because the review queue could not be saved.`;
1461
+ }
1462
+ }
1463
+ /**
1464
+ * What `--queue` did with the one review a single command acted on. To stderr, like every line that
1465
+ * is not the review's own link.
1466
+ */
1467
+ function reviewQueued(result) {
1468
+ switch (result) {
1469
+ case "added": return "Added to review queue.";
1470
+ case "already": return "Already in your review queue.";
1471
+ case "complete": return "Not added to your review queue: this review is marked complete. Move it back to In progress in the workspace to queue it.";
1472
+ case "full": return `Not added to your review queue: it is full, and holds 50 reviews at most.`;
1473
+ default: return "Not added to your review queue: the queue could not be saved.";
1474
+ }
1475
+ }
1416
1476
  function noReviews() {
1417
1477
  return "No reviews stored yet. Run `prreviewbuddy review` in a repository to make one.";
1418
1478
  }
@@ -1908,16 +1968,16 @@ async function reviewBatch(command, repoPath, deps = realDeps()) {
1908
1968
  }
1909
1969
  claimed.set(identity, index);
1910
1970
  const prepared = await deps.prepare(target, agent.id, command.operation === "fresh", command.requestedModel);
1971
+ const workspaceId = prepared.kind === "new" ? prepared.workspaceId : prepared.existing.id;
1972
+ row.workspaceId = workspaceId;
1973
+ if (command.queue) row.reviewQueue = deps.enqueue(workspaceId);
1911
1974
  if (prepared.kind === "existing") {
1912
- row.workspaceId = prepared.existing.id;
1913
1975
  row.state = {
1914
1976
  kind: "existing",
1915
1977
  inProgress: prepared.existing.inProgress
1916
1978
  };
1917
1979
  return;
1918
1980
  }
1919
- const workspaceId = prepared.kind === "resume" ? prepared.existing.id : prepared.workspaceId;
1920
- row.workspaceId = workspaceId;
1921
1981
  row.state = {
1922
1982
  kind: "running",
1923
1983
  jobId: deps.jobIdOf(workspaceId) ?? ""
@@ -1949,7 +2009,11 @@ async function reviewBatch(command, repoPath, deps = realDeps()) {
1949
2009
  }
1950
2010
  function summaryRow(row, link) {
1951
2011
  const url = row.workspaceId ? link(row.workspaceId) : void 0;
1952
- const withUrl = url ? { url } : {};
2012
+ const queued = row.reviewQueue ? { queue: row.reviewQueue } : {};
2013
+ const withUrl = {
2014
+ ...url ? { url } : {},
2015
+ ...queued
2016
+ };
1953
2017
  switch (row.state.kind) {
1954
2018
  case "ready": return {
1955
2019
  label: row.label,
@@ -1979,7 +2043,8 @@ function summaryRow(row, link) {
1979
2043
  };
1980
2044
  default: return {
1981
2045
  label: row.label,
1982
- outcome: "not-started"
2046
+ outcome: "not-started",
2047
+ ...queued
1983
2048
  };
1984
2049
  }
1985
2050
  }
@@ -1994,6 +2059,7 @@ function realDeps() {
1994
2059
  prepare: prepareReview,
1995
2060
  settle: (workspaceId, signal) => settleReview(workspaceId, "standard", signal),
1996
2061
  jobIdOf: (workspaceId) => loadWorkspace(workspaceId)?.jobId,
2062
+ enqueue: queueForReview,
1997
2063
  links: async () => {
1998
2064
  try {
1999
2065
  const port = await ensureServer();
@@ -2257,6 +2323,7 @@ async function review(command, repoPath) {
2257
2323
  const agent = await chooseFor(command);
2258
2324
  if (!agent) return 1;
2259
2325
  const prepared = await prepareReview(target, agent.id, command.operation === "fresh", command.requestedModel);
2326
+ enqueue(command, prepared.kind === "new" ? prepared.workspaceId : prepared.existing.id);
2260
2327
  if (prepared.kind === "existing") {
2261
2328
  const url = linkTo(await ensureServer(), prepared.existing.id, prepared.existing.token);
2262
2329
  out(existingReview(prepared.existing, url));
@@ -2311,6 +2378,7 @@ async function kiss(command, target) {
2311
2378
  ...workspace ? { review: { session: workspace.session } } : {}
2312
2379
  }) ?? void 0;
2313
2380
  if (workspace) {
2381
+ enqueue(command, workspace.id);
2314
2382
  const job = await recordKissRun({
2315
2383
  workspaceId: workspace.id,
2316
2384
  audience,
@@ -2330,6 +2398,7 @@ async function kiss(command, target) {
2330
2398
  agentId: agent.id,
2331
2399
  ...requestedModel ? { requestedModel } : {}
2332
2400
  });
2401
+ enqueue(command, workspaceId);
2333
2402
  const fresh = loadWorkspace(workspaceId);
2334
2403
  const run = (fresh.kissRuns ?? [])[0];
2335
2404
  if (!run) {
@@ -2390,6 +2459,7 @@ async function operate(command, workspace) {
2390
2459
  err(commitReviewCannotUpdate(workspace.changeSet.data.pullRequest.head.sha));
2391
2460
  return 1;
2392
2461
  }
2462
+ enqueue(command, workspace.id);
2393
2463
  if (command.operation === "reanalyse") {
2394
2464
  const agent = await chooseFor(command, workspace);
2395
2465
  if (!agent) return 1;
@@ -2443,6 +2513,17 @@ async function operate(command, workspace) {
2443
2513
  return 0;
2444
2514
  }
2445
2515
  /**
2516
+ * `--queue`: put the review on the reviewer's review queue, now, and say how that went.
2517
+ *
2518
+ * Called the moment a review exists and before its analysis is awaited, so the card is in the
2519
+ * workspace's Queue while the review is still being made. Never a reason to fail the command: the
2520
+ * review is what was asked for, and the queue is bookkeeping around it. To stderr, so a piped link
2521
+ * is still only a link.
2522
+ */
2523
+ function enqueue(command, workspaceId) {
2524
+ if (command.queue) err(reviewQueued(queueForReview(workspaceId)));
2525
+ }
2526
+ /**
2446
2527
  * Draw a job's progress block, from what the job itself records about its target.
2447
2528
  *
2448
2529
  * From the record rather than the caller, for the reason `drive` gives: the job knows what it is
package/dist/server.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as createPrState, An as AGENT_IDS, At as loadJob, B as toneOf, Bt as followedRefName, C as readEvents, Ct as checkReview, D as announceJob, Dn as writeAgentPreference, Dt as endedAt, E as telemetryUploadConsent, En as readAgentPreference, Et as allJobs, F as markAllRead, Fn as AgentCancelledError, Ft as progressSteps, G as MANAGED_ROOT, Gt as lineagePosition, H as withUsageRecorded, Ht as indexReviewGroups, I as markRead, In as AgentUnavailableError, It as purposeOf, Jt as mutateWorkspace, K as readMarker, Kt as loadWorkspace, L as pruneNotifications, M as eventName, N as headline, Nn as agentFor, Nt as isLiveJob, O as branchOf, Ot as fail, P as listNotifications, Pt as phasesFor, Q as clearServerRecord, Qt as reviewedCommit, R as readNotification, Sn as git, St as checkCodeFreshness, T as recordTelemetryUploadConsent, Tn as clearAgentPreference, Tt as reviewedShaOf, U as EXPLAIN_SIMPLY_PROMPT, Ut as latestKissRun, V as unreadCount$1, W as ReviewBeingDeletedError, X as readIndexToken, Xt as previousKissResult, Y as relativeTime$1, Zt as recentWorkspaces, _ as agentEnvOf, _n as processDiscussion, _t as claimRequestUrl, a as wasBlocked, an as queueAdd, bt as remoteHost, c as RefreshUnavailableError, cn as queueStamp, d as refreshPrContext, dn as doneVerb, dt as MAX_WORKSPACE_PORT, et as readServerRecord, f as isUnchanged, fn as isIssueOutstanding, ft as MIN_WORKSPACE_PORT, gn as reviewerDispositions, gt as writePortPreference, h as liveUpdateFor, hn as questionsOutstanding, ht as statedWorkspacePort, i as liveJobsFor, in as dequeueOnComplete, j as createPrUpdate, jt as saveJob, k as subjectOf, kn as STORE_ROOT, kt as isTerminal, l as mergeUpdateInto, ln as readQueue, lt as feedbackUrl, m as UpdateAlreadyRunningError, mn as issuesOutstanding, mt as resolveWorkspacePort, n as deleteReview, nn as workspaceRevision, nt as serverEntryExists, o as discardJob, on as queueAddMany, ot as writeServerRecord, p as askCheckout, pn as isQuestionOutstanding, pt as parseWorkspacePort, q as removeWorktree, r as lineageIds, rn as workspaceStamps, rt as spawnServer, s as FETCH_TIMEOUT_MS, sn as queueRemove, st as BUILD_VERSION, t as deleteLineage, tn as touchWorkspace, u as updateReview, un as writeQueue, ut as DEFAULT_WORKSPACE_PORT, vn as describeAuthorship, w as record, wn as resolveModel, wt as followedBranch, x as runJob, xn as isCommitReview, xt as fillFileUrlTemplate, y as startKissJob, yt as forgeFor, z as removeNotification, zt as isClaimed } from "./delete_review-DPRL5IPt.js";
1
+ import { $t as recentWorkspaces, A as createPrState, An as STORE_ROOT, At as fail, B as toneOf, C as readEvents, Cn as git, Ct as fillFileUrlTemplate, D as announceJob, Dn as readAgentPreference, Dt as reviewedShaOf, E as telemetryUploadConsent, En as clearAgentPreference, Et as followedBranch, F as markAllRead, Ft as isLiveJob, G as MANAGED_ROOT, Gt as latestKissRun, H as withUsageRecorded, Ht as followedRefName, I as markRead, In as AgentCancelledError, It as phasesFor, Jt as loadWorkspace, K as readMarker, L as pruneNotifications, Ln as AgentUnavailableError, Lt as progressSteps, M as eventName, Mt as loadJob, N as headline, Nt as saveJob, O as branchOf, On as writeAgentPreference, Ot as allJobs, P as listNotifications, Pn as agentFor, Q as readIndexToken, Qt as previousKissResult, R as readNotification, Rt as purposeOf, Sn as isCommitReview, St as remoteHost, T as recordTelemetryUploadConsent, Tn as resolveModel, Tt as checkReview, U as EXPLAIN_SIMPLY_PROMPT, V as unreadCount$1, Vt as isClaimed, W as ReviewBeingDeletedError, Wt as indexReviewGroups, X as compactQueue, Xt as mutateWorkspace, Y as relativeTime$1, _ as agentEnvOf, _n as reviewerDispositions, _t as statedWorkspacePort, a as wasBlocked, an as workspaceStamps, at as spawnServer, c as RefreshUnavailableError, cn as queueAddMany, ct as writeServerRecord, d as refreshPrContext, dn as readQueue, dt as feedbackUrl, en as reviewedCommit, et as clearServerRecord, f as isUnchanged, fn as doneVerb, ft as DEFAULT_WORKSPACE_PORT, gn as questionsOutstanding, gt as resolveWorkspacePort, h as liveUpdateFor, hn as issuesOutstanding, ht as parseWorkspacePort, i as liveJobsFor, in as workspaceRevision, it as serverEntryExists, j as createPrUpdate, jn as AGENT_IDS, jt as isTerminal, k as subjectOf, kt as endedAt, l as mergeUpdateInto, ln as queueRemove, lt as BUILD_VERSION, m as UpdateAlreadyRunningError, mn as isQuestionOutstanding, mt as MIN_WORKSPACE_PORT, n as deleteReview, nt as readServerRecord, o as discardJob, on as dequeueOnComplete, p as askCheckout, pn as isIssueOutstanding, pt as MAX_WORKSPACE_PORT, q as removeWorktree, qt as lineagePosition, r as lineageIds, rn as touchWorkspace, s as FETCH_TIMEOUT_MS, sn as queueAdd, t as deleteLineage, u as updateReview, un as queueStamp, vn as processDiscussion, vt as writePortPreference, w as record, wt as checkCodeFreshness, x as runJob, xt as forgeFor, y as startKissJob, yn as describeAuthorship, yt as claimRequestUrl, z as removeNotification } from "./delete_review-BWMmO81Q.js";
2
2
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { createHash, timingSafeEqual } from "node:crypto";
@@ -744,19 +744,30 @@ var STATES$1 = {
744
744
  function renderMessageCopySource(source) {
745
745
  return button(`${MESSAGE_SOURCE_ATTR}="${escapeHtml$1(source)}"`);
746
746
  }
747
+ /** The class that places a finding's copy control in its card's heading, and gives it its tooltip. */
748
+ var FINDING_COPY_CLASS = "pr-helper-finding-copy";
747
749
  /**
748
750
  * The same control on an issue or question card, carrying that finding as Markdown.
749
751
  *
750
- * No action-row wrapper, because the card already has one to sit in. It shows a word at rest where
751
- * a chat reply shows only the icon: under a reply the icon is the whole affordance and the text is
752
- * the subject, but a card's row is a line of worded buttons, and an icon alone among them reads as
753
- * decoration. Both resting words are carried on the button so the reset after "Copied" returns to
754
- * them rather than to the chat reply's.
752
+ * It sits in the card's heading, not its footer. It copies the whole finding, so the heading is
753
+ * the thing it acts on. And it is a different kind of act from Investigate, Dismiss and Mark
754
+ * answered, which change the review: those keep the footer to themselves, so the footer holds
755
+ * only actions that change something.
756
+ *
757
+ * Icon only at rest, like a chat reply's. It used to show a word because it sat in a row of worded
758
+ * buttons, where an icon alone read as decoration. In the heading it is on its own, and a word
759
+ * would give a utility the weight of the title beside it. What it does is said by a hover
760
+ * tooltip (`data-tip`, the same CSS tooltip the pills use, not a native `title`) and by its
761
+ * accessible name. "Copied" and "Copy failed" still show as words, because a picture cannot say
762
+ * that the click did something. The resting name is kept on the button so the reset returns to
763
+ * "Copy finding" rather than to a chat reply's "Copy message".
755
764
  */
756
765
  function renderFindingCopySource(source, kind) {
766
+ const name = kind === "issue" ? "Copy finding" : "Copy question";
757
767
  return button(`${MESSAGE_SOURCE_ATTR}="${escapeHtml$1(source)}"`, {
758
- label: "Copy",
759
- aria: `Copy ${kind}`
768
+ label: "",
769
+ aria: name,
770
+ tip: name
760
771
  });
761
772
  }
762
773
  /** Where a finding's button keeps its resting word and name. Absent on a chat reply's. */
@@ -766,7 +777,8 @@ function button(handle, idle) {
766
777
  const { icon } = STATES$1.idle;
767
778
  const label = idle?.label ?? STATES$1.idle.label;
768
779
  const aria = idle?.aria ?? STATES$1.idle.aria;
769
- const control = `<button type="button" class="${MESSAGE_COPY_CLASS}" ${handle}${idle ? ` ${IDLE_LABEL_ATTR}="${escapeHtml$1(label)}" ${IDLE_ARIA_ATTR}="${escapeHtml$1(aria)}"` : ""} aria-label="${escapeHtml$1(aria)}"><span class="${MESSAGE_COPY_CLASS}-icon" aria-hidden="true">${svg(icon)}</span><span class="${MESSAGE_COPY_CLASS}-label"${label ? "" : " hidden"}>${escapeHtml$1(label)}</span></button>`;
780
+ const remembered = idle ? ` ${IDLE_LABEL_ATTR}="${escapeHtml$1(label)}" ${IDLE_ARIA_ATTR}="${escapeHtml$1(aria)}"` : "";
781
+ const control = `<button type="button" class="${idle ? `${MESSAGE_COPY_CLASS} ${FINDING_COPY_CLASS}` : MESSAGE_COPY_CLASS}" ${handle}${remembered}${idle ? ` data-tip="${escapeHtml$1(idle.tip)}"` : ""} aria-label="${escapeHtml$1(aria)}"><span class="${MESSAGE_COPY_CLASS}-icon" aria-hidden="true">${svg(icon)}</span><span class="${MESSAGE_COPY_CLASS}-label"${label ? "" : " hidden"}>${escapeHtml$1(label)}</span></button>`;
770
782
  return idle ? control : `<div class="${MESSAGE_ACTIONS_CLASS}">${control}</div>`;
771
783
  }
772
784
  function svg(paths) {
@@ -2027,7 +2039,7 @@ function renderReconciliationNote(note) {
2027
2039
  * stuck. Outlined rather than filled — see sidebar.css.
2028
2040
  *
2029
2041
  * Text only, no leading icon. Every other action in the sidebar (Jump to file, Mark answered,
2030
- * Explain, Analyse, Copy) is a bare label, and an emoji here read as decoration on a control
2042
+ * Explain, Analyse) is a bare label, and an emoji here read as decoration on a control
2031
2043
  * whose neighbours have none rather than as emphasis.
2032
2044
  */
2033
2045
  function renderInvestigateButton(attribute, id, label) {
@@ -2147,7 +2159,10 @@ function renderIssuesTab(session, investigatingId) {
2147
2159
  return `
2148
2160
  <div class="pr-helper-issue-card pr-helper-severity-${issue.severity}${issue.reconciliationStatus ? ` pr-helper-issue-${issue.reconciliationStatus}` : ""}${issue.investigationImpact ? ` pr-helper-issue-investigation-${issue.investigationImpact}` : ""}${isDismissed && !stale ? " pr-helper-issue-card-dismissed" : ""}" data-finding-card="${escapeHtml$1(issue.id)}">
2149
2161
  <div class="pr-helper-issue-header">
2150
- <p class="pr-helper-issue-title">${renderInlineText(issue.title)}</p>
2162
+ <div class="pr-helper-issue-title-row">
2163
+ <p class="pr-helper-issue-title">${renderInlineText(issue.title)}</p>
2164
+ ${renderFindingCopySource(issueMarkdown(issue), "issue")}
2165
+ </div>
2151
2166
  <div class="pr-helper-issue-badges">
2152
2167
  ${renderFindingStatus(issue.reconciliationStatus)}
2153
2168
  ${renderDismissedBadge(isDismissed)}
@@ -2167,7 +2182,6 @@ function renderIssuesTab(session, investigatingId) {
2167
2182
  ${renderRelatedFiles(issue.relatedFiles)}
2168
2183
  ${renderInvestigationProvenance("issue", issue.id, records, now)}
2169
2184
  <div class="pr-helper-issue-actions">
2170
- ${renderFindingCopySource(issueMarkdown(issue), "issue")}
2171
2185
  ${renderInvestigationActions("issue", issue.id, records, investigatingId)}
2172
2186
  ${renderDismissToggle(issue, isDismissed, stale, dismissals.get(issue.id))}
2173
2187
  </div>
@@ -2236,7 +2250,10 @@ function renderQuestionCard(q, sections, answered, records, now = Date.now(), in
2236
2250
  return `
2237
2251
  <div class="pr-helper-question-card${answered ? " pr-helper-question-card-answered" : ""}${q.investigationImpact ? ` pr-helper-issue-investigation-${q.investigationImpact}` : ""}" data-finding-card="${escapeHtml$1(q.id)}"${q.sectionId ? ` data-section-id="${escapeHtml$1(q.sectionId)}"` : ""}>
2238
2252
  <div class="pr-helper-issue-header">
2239
- <p class="pr-helper-issue-title">${renderInlineText(q.question)}</p>
2253
+ <div class="pr-helper-issue-title-row">
2254
+ <p class="pr-helper-issue-title">${renderInlineText(q.question)}</p>
2255
+ ${renderFindingCopySource(questionMarkdown(q), "question")}
2256
+ </div>
2240
2257
  <div class="pr-helper-issue-badges">
2241
2258
  ${renderQuestionStatus(q.reconciliationStatus)}
2242
2259
  ${renderInvestigationImpact(q.investigationImpact)}
@@ -2259,7 +2276,6 @@ function renderQuestionCard(q, sections, answered, records, now = Date.now(), in
2259
2276
  <div class="pr-helper-question-actions">
2260
2277
  ${jumpLink}
2261
2278
  <div class="pr-helper-question-actions-group">
2262
- ${renderFindingCopySource(questionMarkdown(q), "question")}
2263
2279
  ${renderInvestigationActions("question", q.id, records, investigatingId)}
2264
2280
  ${answerToggle}
2265
2281
  </div>
@@ -4015,6 +4031,20 @@ function renderUpdateProgress(job) {
4015
4031
  </div>`;
4016
4032
  }
4017
4033
  //#endregion
4034
+ //#region ../../packages/review-harness/src/workspace/pr_state_view.ts
4035
+ /**
4036
+ * The pull request's state, beside the review's status and never instead of it: `Reviewed` is the
4037
+ * reviewer's decision, `Merged` is the upstream's. Open says nothing, because it is what a review
4038
+ * of a pull request usually is, and a label that never varies teaches nothing.
4039
+ *
4040
+ * `besideNumber` is whether the pull request's number sits right before the pill. There, "Closed"
4041
+ * is enough; anywhere else it would read as the branch closing, so the pill names the PR itself.
4042
+ */
4043
+ function renderPrState(state, besideNumber = false) {
4044
+ if (state !== "merged" && state !== "closed") return "";
4045
+ return `<span class="prb-card-pr-state" data-pr-state="${state}">${state === "merged" ? "Merged" : besideNumber ? "Closed" : "PR closed"}</span>`;
4046
+ }
4047
+ //#endregion
4018
4048
  //#region ../../packages/review-harness/src/workspace/page.ts
4019
4049
  function renderWorkspace(view) {
4020
4050
  const { session, changeSet } = view;
@@ -4394,6 +4424,7 @@ function renderReviewBar(view, freshness) {
4394
4424
  <span class="prb-change-ref" title="${escapeHtml$1(changeSet.repoPath)}">
4395
4425
  ${changeSet.isLinkedWorktree ? `<span class="prb-checkout">${escapeHtml$1(changeSet.checkoutName)}</span>` : ""}
4396
4426
  ${view.prContext ? `<a class="prb-pr-chip" href="${escapeHtml$1(view.prContext.prContext.pullRequest.url)}" target="_blank" rel="noopener noreferrer" title="${escapeHtml$1(view.prContext.prContext.pullRequest.title ?? "")}">#${view.prContext.prContext.pullRequest.number}</a>` : ""}
4427
+ ${view.prState ? `<span class="prb-pr-state-slot" data-pr-state-slot${view.prContext ? " data-beside-number" : ""}>${renderPrState(view.prState.state, view.prContext !== void 0)}</span>` : ""}
4397
4428
  ${renderSubject(changeSet)}
4398
4429
  ${changeSet.includesUncommitted ? "<span class=\"prb-file-status\">+ working tree</span>" : ""}
4399
4430
  ${renderAuthor(view)}
@@ -5912,6 +5943,20 @@ function storedRequestKeys() {
5912
5943
  function prStatesByRequest(states = readPrStates()) {
5913
5944
  return new Map(Object.entries(states).map(([key, entry]) => [key, entry.state]));
5914
5945
  }
5946
+ /**
5947
+ * One review's pull request state, for its page, filed under the same key the list looks up. Null
5948
+ * for a review with no request or one never observed: unknown, not open. Only ever what the watcher
5949
+ * last saw; nothing here reaches the forge.
5950
+ */
5951
+ function prStateOf$1(workspace, states = readPrStates()) {
5952
+ const request = requestOf(workspace);
5953
+ if (!request) return null;
5954
+ return states[requestKey(workspace.repoPath, request.number)]?.state ?? null;
5955
+ }
5956
+ /** Whether a review is of a pull request at all, whatever its state. See `requestOf`. */
5957
+ function hasRequest(workspace) {
5958
+ return requestOf(workspace) !== null;
5959
+ }
5915
5960
  function defaultDeps() {
5916
5961
  return {
5917
5962
  candidates: watchedCandidates,
@@ -6417,15 +6462,6 @@ function statusOf(review) {
6417
6462
  if (review.completedAt) return "complete";
6418
6463
  return review.lastViewedAt ? "in-progress" : "new";
6419
6464
  }
6420
- /**
6421
- * The pull request's state, beside the review's status and never instead of it: `Reviewed` is the
6422
- * reviewer's decision, `Merged` is the upstream's. Open says nothing, because it is what a review
6423
- * of a pull request usually is, and a label that never varies teaches nothing.
6424
- */
6425
- function renderPrState(state) {
6426
- if (state !== "merged" && state !== "closed") return "";
6427
- return `<span class="prb-card-pr-state" data-pr-state="${state}">${state === "merged" ? "Merged" : "PR closed"}</span>`;
6428
- }
6429
6465
  function renderStatus(status) {
6430
6466
  return `<span class="prb-card-status" data-status="${status}">${statusLabel(status)}</span>`;
6431
6467
  }
@@ -8636,11 +8672,6 @@ function addMany(res, ids) {
8636
8672
  refused
8637
8673
  });
8638
8674
  }
8639
- /** Compacted here rather than in `queue.ts`, which must not learn to read the review store. */
8640
- function compactQueue(stored) {
8641
- const live = stored.filter((queued) => loadWorkspace(queued) !== null);
8642
- return live.length === stored.length ? stored : writeQueue(live) ?? stored;
8643
- }
8644
8675
  /**
8645
8676
  * Delete one review, or everything on one card.
8646
8677
  *
@@ -8881,6 +8912,7 @@ async function sendWorkspacePage(res, workspace) {
8881
8912
  freshness: freshness ?? void 0,
8882
8913
  availability: standing?.availability ?? null,
8883
8914
  remote: await remoteOrNull(workspace),
8915
+ ...hasRequest(workspace) ? { prState: { state: prStateOf$1(workspace) } } : {},
8884
8916
  dismissedFreshnessSha: workspace.dismissedFreshnessSha,
8885
8917
  lastPrRefresh: workspace.lastPrRefresh,
8886
8918
  lastUpdate: notice,
@@ -8999,10 +9031,12 @@ async function handleFreshness(res, workspace, quiet) {
8999
9031
  const standing = await standingOrNull(workspace);
9000
9032
  const report = standing?.freshness ?? null;
9001
9033
  const remote = await remoteOrNull(workspace);
9034
+ const prState = prStateOf$1(workspace);
9002
9035
  const revision = workspaceRevision(workspace) ?? null;
9003
9036
  if (!report) return send(res, 200, {
9004
9037
  report: null,
9005
9038
  remote,
9039
+ prState,
9006
9040
  kiss,
9007
9041
  kissDismissedSha: workspace.dismissedKissFreshnessSha ?? null,
9008
9042
  revision
@@ -9018,6 +9052,7 @@ async function handleFreshness(res, workspace, quiet) {
9018
9052
  report,
9019
9053
  availability: standing?.availability ?? null,
9020
9054
  remote,
9055
+ prState,
9021
9056
  dismissedSha: workspace.dismissedFreshnessSha ?? null,
9022
9057
  kiss,
9023
9058
  kissDismissedSha: workspace.dismissedKissFreshnessSha ?? null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prreviewbuddy",
3
- "version": "0.42.0",
3
+ "version": "0.44.0",
4
4
  "description": "Review a branch or pull request in an isolated checkout, from your terminal, with the coding agent you already have installed.",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://prreviewbuddy.com",
package/static/shell.css CHANGED
@@ -562,6 +562,10 @@ body[data-server-stopped] .prb-shell { opacity: 0.55; pointer-events: none; }
562
562
  text-decoration: none;
563
563
  }
564
564
  .prb-pr-chip:hover { background: var(--brand); color: #fff; }
565
+ /* Where the pull request's Merged or Closed pill goes, right after the chip. No box of its own, so an
566
+ open pull request's empty slot leaves no extra gap in the row and a filled one lays the pill out
567
+ as the chip's sibling. The pill is the Reviews card's, `.prb-card-pr-state`. */
568
+ .prb-pr-state-slot { display: contents; }
565
569
 
566
570
  /* Beside the branch, after it, because the branch is what the reviewer came for and this is who
567
571
  it belongs to. One line, ellipsed rather than wrapped: the header is a single row and a long
@@ -876,6 +876,64 @@
876
876
 
877
877
  .pr-helper-issue-title { font-size: 14px; font-weight: 600; margin: 0; text-align: left; width: 100%; }
878
878
 
879
+ /* The title and the one control that acts on the whole finding, side by side. The title takes the
880
+ room and wraps; the copy icon keeps its size at the top-right, level with the title's first line
881
+ however many lines the title wraps to. */
882
+ .pr-helper-issue-title-row {
883
+ display: flex;
884
+ align-items: flex-start;
885
+ gap: 8px;
886
+ width: 100%;
887
+ }
888
+
889
+ .pr-helper-issue-title-row > .pr-helper-issue-title { flex: 1; min-width: 0; }
890
+
891
+ /* Full strength rather than a chat reply's 0.65: at 0.65 its grey falls under 3:1 on the white card.
892
+ Pulled up a pixel so the 14px glyph sits on the 14px title's first line rather than below it.
893
+ Positioned so its tooltip can anchor to it: unlike a pill's, which spans its whole row, this one
894
+ belongs to one small control at the card's edge. */
895
+ .pr-helper-finding-copy {
896
+ position: relative;
897
+ flex-shrink: 0;
898
+ margin-top: -1px;
899
+ opacity: 1;
900
+ font-size: 12px;
901
+ }
902
+
903
+ /* The tooltip grows leftward from the right edge, back over the card rather than out of the pane,
904
+ and stays on one line. The same look, delay and text reset as `.pr-helper-tip`, which it cannot
905
+ reuse: that one pins itself to its positioned row, and this control is its own anchor. */
906
+ .pr-helper-finding-copy::after {
907
+ content: attr(data-tip);
908
+ position: absolute;
909
+ top: calc(100% + 6px);
910
+ right: 0;
911
+ z-index: 3;
912
+ padding: 5px 8px;
913
+ border-radius: 6px;
914
+ background: #24292f;
915
+ color: #ffffff;
916
+ font-size: 11px;
917
+ font-weight: 400;
918
+ font-family: inherit;
919
+ line-height: 1.4;
920
+ white-space: nowrap;
921
+ opacity: 0;
922
+ pointer-events: none;
923
+ transition: opacity 0.1s;
924
+ }
925
+
926
+ .pr-helper-finding-copy:hover::after,
927
+ .pr-helper-finding-copy:focus-visible::after {
928
+ opacity: 1;
929
+ transition-delay: 0.35s;
930
+ }
931
+
932
+ /* The button is saying "Copied" or "Copy failed" in words right now; a tooltip offering to copy
933
+ would argue with it. */
934
+ .pr-helper-finding-copy.copied::after,
935
+ .pr-helper-finding-copy.failed::after { display: none; }
936
+
879
937
  .pr-helper-issue-desc {
880
938
  font-size: 13px;
881
939
  line-height: 1.5;
@@ -4187,20 +4245,6 @@
4187
4245
  margin-top: 12px;
4188
4246
  }
4189
4247
 
4190
- /* Copying the finding is a different kind of act from deciding about it, so it takes the far edge
4191
- rather than joining Investigate and Dismiss. On a question card it sits in the group instead,
4192
- because the far edge there already belongs to Jump to file. */
4193
- .pr-helper-issue-actions > .pr-helper-msg-copy { margin-right: auto; }
4194
-
4195
- /* Full strength on a card. Under a chat reply the control is a quiet afterthought to the text, and
4196
- its 0.65 is right there; here it is a worded button among worded buttons, and at 0.65 its grey
4197
- falls under 3:1 on the white card. The size is its neighbours'. */
4198
- .pr-helper-issue-actions .pr-helper-msg-copy,
4199
- .pr-helper-question-actions .pr-helper-msg-copy {
4200
- opacity: 1;
4201
- font-size: 12px;
4202
- }
4203
-
4204
4248
  /* margin-left:auto rather than relying on the row's space-between: the jump link beside it is
4205
4249
  conditional (a question without a file has none), and space-between alone would swing this
4206
4250
  group to the left edge whenever it is the row's only child. Wraps because three controls do