prreviewbuddy 0.12.0 → 0.12.16

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.
Files changed (101) hide show
  1. package/dist/{log-DdPaO6Wo.js → log-CFa7EzgZ.js} +504 -177
  2. package/dist/main.js +42 -8
  3. package/dist/server.js +852 -127
  4. package/package.json +2 -2
  5. package/static/chunk-5rwdhcVY.js +1 -0
  6. package/static/chunk-7inUl5Il.js +1 -0
  7. package/static/chunk-9-3VOpWX.js +24 -0
  8. package/static/chunk-B0xoFuVi.js +1 -0
  9. package/static/chunk-B80S6zbH.js +44 -0
  10. package/static/chunk-B8V5WPf1.js +292 -0
  11. package/static/chunk-BFRx3GcN.js +1 -0
  12. package/static/chunk-BFoDCU3G.js +1 -0
  13. package/static/chunk-BHBwEv5G.js +1 -0
  14. package/static/chunk-BOfOlrLQ.js +1 -0
  15. package/static/chunk-BSUrFSfn.js +2 -0
  16. package/static/chunk-BVDh-dl-.js +88 -0
  17. package/static/chunk-BVm3MgB2.js +139 -0
  18. package/static/chunk-BVqE4wlY.js +1 -0
  19. package/static/chunk-BZFc_oT5.js +1 -0
  20. package/static/chunk-Bd8oAEBF.js +62 -0
  21. package/static/chunk-Bdvr7G0w.js +1 -0
  22. package/static/chunk-BfpeM2_Y.js +1 -0
  23. package/static/chunk-BifhSqXX.js +1 -0
  24. package/static/chunk-Bk4d_k6M.js +62 -0
  25. package/static/chunk-BmkZxg03.js +1 -0
  26. package/static/chunk-BoTbRR8S.js +4 -0
  27. package/static/chunk-BokiwdgS.js +40 -0
  28. package/static/chunk-BqFMUyVw.js +168 -0
  29. package/static/chunk-Bs98n_vr.js +1 -0
  30. package/static/chunk-BvO9qrHb.js +1 -0
  31. package/static/chunk-BxpAJfRF.js +36 -0
  32. package/static/chunk-C-OQMol4.js +1 -0
  33. package/static/chunk-C26MXXwi.js +1 -0
  34. package/static/chunk-C6X32c5Y.js +1 -0
  35. package/static/chunk-CGd-uY3x.js +321 -0
  36. package/static/chunk-CMKpUUG0.js +1 -0
  37. package/static/chunk-CNXFALfL.js +89 -0
  38. package/static/chunk-CQfKfv79.js +1 -0
  39. package/static/chunk-CQhDWCuO.js +1 -0
  40. package/static/chunk-CU_yc7c0.js +7 -0
  41. package/static/chunk-CUsQ7lH1.js +34 -0
  42. package/static/chunk-CXMH3UgJ.js +257 -0
  43. package/static/chunk-CZus5tIC.js +1 -0
  44. package/static/chunk-CeiETtR3.js +127 -0
  45. package/static/chunk-ChQTsUB2.js +30 -0
  46. package/static/chunk-CiB0fz5M.js +1 -0
  47. package/static/chunk-CmOPaNov.js +1 -0
  48. package/static/chunk-CoIQ0XcM.js +8 -0
  49. package/static/chunk-Cp5EDur4.js +38 -0
  50. package/static/chunk-CqMjOmHM.js +1 -0
  51. package/static/chunk-CqbP8tav.js +231 -0
  52. package/static/chunk-Crk3W7xi.js +1 -0
  53. package/static/chunk-CyJ4bWgC.js +1 -0
  54. package/static/chunk-D289T3W2.js +39 -0
  55. package/static/chunk-D3k4QmBl.js +162 -0
  56. package/static/chunk-D5VxBvZv.js +7 -0
  57. package/static/chunk-D7FarRGf.js +1 -0
  58. package/static/chunk-D87EWWOh.js +206 -0
  59. package/static/chunk-DBVuY2lB.js +96 -0
  60. package/static/chunk-DEKSwylx.js +10 -0
  61. package/static/chunk-DHkjHDYG.js +1 -0
  62. package/static/chunk-DHwB1DNv.js +161 -0
  63. package/static/chunk-DQT7Z75O.js +99 -0
  64. package/static/chunk-DS17s2tU.js +1 -0
  65. package/static/chunk-DTTB1Rjf.js +1 -0
  66. package/static/chunk-DVXDUcQZ.js +1 -0
  67. package/static/chunk-DdmKGZXf.js +1 -0
  68. package/static/chunk-Dex9JiR1.js +3 -0
  69. package/static/chunk-DfMSIask.js +1 -0
  70. package/static/chunk-DfcNMhQb.js +41 -0
  71. package/static/chunk-Dh6xxjov.js +1 -0
  72. package/static/chunk-Dk6P4vVY.js +1 -0
  73. package/static/chunk-DlMt7LBR.js +1 -0
  74. package/static/chunk-DlPtUH2x.js +24 -0
  75. package/static/chunk-DmHr41SG.js +72 -0
  76. package/static/chunk-DpHRgEqx.js +1 -0
  77. package/static/chunk-DqZGGQx6.js +2 -0
  78. package/static/chunk-DsF7k-Jl.js +1 -0
  79. package/static/chunk-DtQPBrIL.js +129 -0
  80. package/static/chunk-DufL1VKq.js +1 -0
  81. package/static/chunk-DxSMoRAg.js +1 -0
  82. package/static/chunk-Dy-Kn_BL.js +1 -0
  83. package/static/chunk-H9AITnpH.js +1 -0
  84. package/static/chunk-IOULrYuo.js +7 -0
  85. package/static/chunk-IsizPYbz.js +70 -0
  86. package/static/chunk-LHD4D-55.js +1 -0
  87. package/static/chunk-MLIlWKCY.js +1 -0
  88. package/static/chunk-SWs44m7G.js +1 -0
  89. package/static/chunk-Wi1hfKnY.js +15 -0
  90. package/static/chunk-dQvmgBo8.js +106 -0
  91. package/static/chunk-fybaL0A-.js +1 -0
  92. package/static/chunk-lpcXIlNC.js +84 -0
  93. package/static/chunk-nKL3T2Rn.js +78 -0
  94. package/static/chunk-oZJp8_Oh.js +1 -0
  95. package/static/chunk-oxFaA6rD.js +120 -0
  96. package/static/chunk-pzXl7dPH.js +1 -0
  97. package/static/chunk-qMjAlJP9.js +1 -0
  98. package/static/chunk-qnagBFlM.js +1 -0
  99. package/static/shell.css +372 -41
  100. package/static/sidebar.css +140 -4
  101. package/static/workspace.js +0 -0
@@ -10,6 +10,13 @@ import { platform } from "node:process";
10
10
  //#region ../../packages/review-harness/src/agents/agent.ts
11
11
  /** The agent could not be run at all, as distinct from running and failing. */
12
12
  var AgentUnavailableError = class extends Error {};
13
+ /**
14
+ * The caller stopped waiting, so the run was stopped.
15
+ *
16
+ * Its own class rather than a message, because every caller has to tell it from a failure: a run
17
+ * the reviewer cancelled is not an error to report to them, and it is not a turn to store.
18
+ */
19
+ var AgentCancelledError = class extends Error {};
13
20
  /** The run was refused before it started, because it would have breached isolation. */
14
21
  var PromptIsolationError = class extends Error {};
15
22
  /**
@@ -81,6 +88,10 @@ var CLAUDE_AGENT_ID = "claude";
81
88
  * about sign-in to errors that have nothing to do with it, which is worse than staying quiet.
82
89
  */
83
90
  var AUTHENTICATION = /\b(?:auth(?:enticat\w*)?|oauth|credential|session expired|not logged in|log ?in)\b/i;
91
+ /** Said to the caller that asked for the stop, and never shown to the reviewer who pressed it. */
92
+ var STOPPED = "The agent was stopped.";
93
+ /** How long a stopped agent gets to end on its own terms before it is ended for it. */
94
+ var SIGKILL_GRACE_MS = 2e3;
84
95
  /** The two variables that decide which Claude answers, and nothing else. */
85
96
  var CARRIED = ["CLAUDE_CONFIG_DIR", "PATH"];
86
97
  /**
@@ -302,6 +313,7 @@ var claudeAgent = {
302
313
  },
303
314
  async run(input) {
304
315
  guardPrompt(input);
316
+ if (input.signal?.aborted) throw new AgentCancelledError(STOPPED);
305
317
  const reader = createStreamReader(input.onProgress ?? (() => {}));
306
318
  return new Promise((resolvePromise, reject) => {
307
319
  const child = spawn("claude", claudeArgs(input.capability), {
@@ -318,6 +330,14 @@ var claudeAgent = {
318
330
  child.kill("SIGKILL");
319
331
  reject(/* @__PURE__ */ new Error("The agent did not finish in time and was stopped."));
320
332
  }, input.timeoutMs ?? DEFAULT_TIMEOUT_MS[input.capability]);
333
+ const stop = () => {
334
+ clearTimeout(timer);
335
+ child.kill("SIGTERM");
336
+ setTimeout(() => child.kill("SIGKILL"), SIGKILL_GRACE_MS).unref();
337
+ reject(new AgentCancelledError(STOPPED));
338
+ };
339
+ input.signal?.addEventListener("abort", stop, { once: true });
340
+ const release = () => input.signal?.removeEventListener("abort", stop);
321
341
  child.stdout.setEncoding("utf8");
322
342
  child.stdout.on("data", (chunk) => reader.push(chunk));
323
343
  child.stderr.setEncoding("utf8");
@@ -326,11 +346,13 @@ var claudeAgent = {
326
346
  });
327
347
  child.on("error", (error) => {
328
348
  clearTimeout(timer);
349
+ release();
329
350
  if (error.code === "ENOENT") return reject(new ClaudeUnavailableError("The `claude` command is not on PATH, so this cannot be run from here. Install Claude Code, or start the workspace from a shell where `claude` runs."));
330
351
  reject(error);
331
352
  });
332
353
  child.on("close", () => {
333
354
  clearTimeout(timer);
355
+ release();
334
356
  reader.end();
335
357
  if (reader.overflowed()) return reject(/* @__PURE__ */ new Error("The agent produced more output than could be read, so it was stopped."));
336
358
  const failure = reader.failed();
@@ -404,11 +426,7 @@ var GitError = class extends Error {
404
426
  };
405
427
  async function git(cwd, args, options = {}) {
406
428
  try {
407
- const { stdout } = await execFileAsync$1("git", [
408
- "-c",
409
- "core.quotePath=false",
410
- ...args
411
- ], {
429
+ const { stdout } = await execFileAsync$1("git", [...["core.quotePath=false", ...options.config ?? []].flatMap((one) => ["-c", one]), ...args], {
412
430
  cwd,
413
431
  encoding: "utf8",
414
432
  maxBuffer: MAX_BUFFER$1,
@@ -1197,6 +1215,73 @@ function inferLanguage(files) {
1197
1215
  }[topExt] ?? topExt;
1198
1216
  }
1199
1217
  //#endregion
1218
+ //#region ../../packages/review-harness/src/domain/ai_review/outstanding.ts
1219
+ function reviewerDispositions(session) {
1220
+ const byId = new Map((session.dismissedIssues ?? []).map((entry) => [entry.id, entry]));
1221
+ return {
1222
+ isDismissed: (issue) => byId.has(issue.id),
1223
+ isStale: (issue) => issue.reconciliationStatus === "affected" && (byId.get(issue.id)?.sessionId ?? session.id) !== session.id
1224
+ };
1225
+ }
1226
+ /**
1227
+ * An issue is done when the commits fixed it, or when the reviewer dismissed it and that decision
1228
+ * still stands.
1229
+ *
1230
+ * `still-open` and `new` are outstanding unless dismissed. `affected` is the one worth stating: the
1231
+ * code under it moved without settling it, so it needs the reviewer's eyes *more* than an untouched
1232
+ * finding does — and that outranks a dismissal made before the code moved. The finding comes back
1233
+ * into the count **carrying its dismissal**, which the card shows: the reviewer's decision is not
1234
+ * deleted, its applicability has expired, and re-affirming it closes the finding again.
1235
+ */
1236
+ function isIssueOutstanding(issue, dispositions) {
1237
+ if (issue.reconciliationStatus === "resolved") return false;
1238
+ if (dispositions.isDismissed(issue)) return dispositions.isStale(issue);
1239
+ return true;
1240
+ }
1241
+ /**
1242
+ * A question is done when the reviewer has ticked it, or when the new commits answered it.
1243
+ *
1244
+ * Either alone is enough. The reviewer's tick is the older of the two and the only one the first
1245
+ * analysis can produce; `reconciliationStatus === 'answered'` arrives with an update and carries
1246
+ * `reconciliationAnswer` beside it, which is the answer itself. Requiring the tick as well would
1247
+ * make the reviewer hand-close seven questions the page had already answered in front of them.
1248
+ *
1249
+ * No `affected` expiry here, unlike an issue's dismissal. An issue is a claim about the code and a
1250
+ * dismissal is a bet on that code; a question the reviewer has answered was answered out of their
1251
+ * own head, and a commit somewhere near it does not un-know what they now know.
1252
+ */
1253
+ function isQuestionOutstanding(question, answered) {
1254
+ return !answered.has(question.id) && question.reconciliationStatus !== "answered";
1255
+ }
1256
+ function tally(items, outstanding, byReviewer) {
1257
+ let open = 0;
1258
+ let reviewerClosed = 0;
1259
+ for (const item of items) if (outstanding(item)) open += 1;
1260
+ else if (byReviewer(item)) reviewerClosed += 1;
1261
+ return {
1262
+ total: items.length,
1263
+ open,
1264
+ done: items.length - open,
1265
+ reviewerClosed
1266
+ };
1267
+ }
1268
+ function issuesOutstanding(issues, dispositions) {
1269
+ return tally(issues, (issue) => isIssueOutstanding(issue, dispositions), (issue) => issue.reconciliationStatus !== "resolved" && dispositions.isDismissed(issue));
1270
+ }
1271
+ function questionsOutstanding(questions, answered) {
1272
+ return tally(questions, (question) => isQuestionOutstanding(question, answered), (question) => question.reconciliationStatus !== "answered" && answered.has(question.id));
1273
+ }
1274
+ /**
1275
+ * What to call the pile of findings nobody has to act on any more.
1276
+ *
1277
+ * "Resolved" is a claim about the code and is only true while the commits did all the closing. One
1278
+ * dismissal in there and the honest word is the vaguer one, because the reviewer deciding a finding
1279
+ * is acceptable did not resolve anything.
1280
+ */
1281
+ function doneVerb(counts) {
1282
+ return counts.reviewerClosed > 0 ? "dealt with" : "resolved";
1283
+ }
1284
+ //#endregion
1200
1285
  //#region ../../packages/review-harness/src/workspace/store.ts
1201
1286
  var STORE_ROOT = join(homedir(), ".prreviewbuddy");
1202
1287
  var WORKSPACES_DIR = join(STORE_ROOT, "workspaces");
@@ -1339,8 +1424,9 @@ function summarise(workspace) {
1339
1424
  summary: result?.summary ?? "",
1340
1425
  overallRisk: result?.overallRisk ?? "unknown",
1341
1426
  issues: result?.issues.length ?? 0,
1427
+ openIssues: issuesOutstanding(result?.issues ?? [], reviewerDispositions(workspace.session)).open,
1342
1428
  questions: questions.length,
1343
- unansweredQuestions: questions.filter((question) => !answered.has(question.id)).length,
1429
+ unansweredQuestions: questionsOutstanding(questions, answered).open,
1344
1430
  messages: workspace.transcript.length,
1345
1431
  lastViewedAt: workspace.lastViewedAt,
1346
1432
  completedAt: workspace.completedAt,
@@ -1352,6 +1438,16 @@ function summarise(workspace) {
1352
1438
  };
1353
1439
  }
1354
1440
  /**
1441
+ * The commit a review was made from, however the record spells it. Null when it recorded none.
1442
+ *
1443
+ * Lives here rather than beside one of its callers because it is a reading of the record, and the
1444
+ * two things that need it are unrelated: pinning a checkout to answer questions against, and
1445
+ * reading a single file the reviewer's own checkout does not have.
1446
+ */
1447
+ function reviewedCommit(workspace) {
1448
+ return workspace.session.metadata?.commitSha || workspace.changeSet.data.pullRequest.head.sha || null;
1449
+ }
1450
+ /**
1355
1451
  * Record that a workspace was opened.
1356
1452
  *
1357
1453
  * Best-effort and deliberately silent: a review that cannot record having been looked at is still
@@ -1460,6 +1556,7 @@ async function buildChangeSet(options) {
1460
1556
  },
1461
1557
  repoPath,
1462
1558
  baseRef: displayRef(baseRef),
1559
+ baseResolvedRef: baseRef,
1463
1560
  headRef,
1464
1561
  mergeBase,
1465
1562
  includesUncommitted: false,
@@ -1853,6 +1950,90 @@ function resumeFrom(job) {
1853
1950
  return WORK_PHASES.find((phase) => !job.completed.includes(phase)) ?? "analysing";
1854
1951
  }
1855
1952
  //#endregion
1953
+ //#region ../../packages/review-harness/src/workspace/request_branch.ts
1954
+ /**
1955
+ * Where the change under review is now.
1956
+ *
1957
+ * One question, asked by two things that must never answer it differently. `review_update.ts` asks
1958
+ * it to know what to reconcile against; `freshness.ts` asks it to know whether the findings still
1959
+ * describe the change. When those two disagree the product contradicts itself on one screen: the
1960
+ * strip says the code was rewritten and the button says the review is already current, and the
1961
+ * reviewer has no way to tell which of them is lying.
1962
+ *
1963
+ * The refs are tried in the order that best answers it, and the first that resolves wins:
1964
+ *
1965
+ * 1. `refs/remotes/origin/<the request's branch>` — the pull request's own branch, as the forge
1966
+ * named it. A plain `git fetch` writes this for every branch on origin, and the review path
1967
+ * already ran one to get the head commit here at all.
1968
+ * 2. `refs/remotes/<the request's branch>` — the same thing on a remote that is not called origin.
1969
+ * 3. `<the review's branch>@{upstream}` — whatever the local branch tracks, resolved without
1970
+ * reading HEAD, which matters because nothing here is entitled to care what is checked out.
1971
+ *
1972
+ * Nothing is guessed past that. A pull request from a fork has its branch on a remote this
1973
+ * repository does not have, and the honest answer there is that we cannot see where the change is,
1974
+ * not a comparison against some other branch that happens to be at hand.
1975
+ *
1976
+ * Read-only, and it has to stay that way: `freshness.ts` may reach nothing that writes or fetches,
1977
+ * and `freshness_read_only.test.ts` scans this file for the same reason it scans that one.
1978
+ */
1979
+ function requestBranchRefs(branch, remoteHeadRef) {
1980
+ return remoteHeadRef ? [
1981
+ `refs/remotes/origin/${remoteHeadRef}`,
1982
+ `refs/remotes/${remoteHeadRef}`,
1983
+ `${branch}@{upstream}`
1984
+ ] : [`${branch}@{upstream}`];
1985
+ }
1986
+ async function resolveRequestBranch(repoPath, branch, remoteHeadRef) {
1987
+ for (const ref of requestBranchRefs(branch, remoteHeadRef)) {
1988
+ const sha = await refSha(repoPath, ref);
1989
+ if (sha) return {
1990
+ sha,
1991
+ ref: await shortName(repoPath, ref) ?? ref,
1992
+ remote: true
1993
+ };
1994
+ }
1995
+ return null;
1996
+ }
1997
+ /**
1998
+ * The name to show for a ref that has already resolved.
1999
+ *
2000
+ * Asked of git rather than trimmed off the string, because the two candidate forms need opposite
2001
+ * treatments and only git knows the answer to the second: `refs/remotes/origin/x` is `origin/x` by
2002
+ * removing a prefix, and `x@{upstream}` is also `origin/x`, which no amount of trimming produces.
2003
+ */
2004
+ async function shortName(repoPath, ref) {
2005
+ try {
2006
+ return (await git(repoPath, [
2007
+ "rev-parse",
2008
+ "--abbrev-ref",
2009
+ ref
2010
+ ])).trim() || null;
2011
+ } catch {
2012
+ return null;
2013
+ }
2014
+ }
2015
+ /** A local branch by its full path, so a ref that is really a sha cannot be mistaken for one. */
2016
+ async function localBranch(repoPath, branch) {
2017
+ const sha = await refSha(repoPath, `refs/heads/${branch}`);
2018
+ return sha ? {
2019
+ sha,
2020
+ ref: branch,
2021
+ remote: false
2022
+ } : null;
2023
+ }
2024
+ async function refSha(repoPath, ref) {
2025
+ try {
2026
+ return (await git(repoPath, [
2027
+ "rev-parse",
2028
+ "--verify",
2029
+ "--quiet",
2030
+ `${ref}^{commit}`
2031
+ ])).trim() || null;
2032
+ } catch {
2033
+ return null;
2034
+ }
2035
+ }
2036
+ //#endregion
1856
2037
  //#region ../../packages/review-harness/src/workspace/freshness.ts
1857
2038
  /**
1858
2039
  * Is this review still describing the code in front of you?
@@ -1866,6 +2047,10 @@ function resumeFrom(job) {
1866
2047
  * come from three different places:
1867
2048
  *
1868
2049
  * 1. **Has the code moved?** Local git against the commit the review was made from. Free.
2050
+ * What it is compared *to* is the branch this review is of, never whatever happens to be
2051
+ * checked out. A pull request is reviewed in a checkout of its own and the reviewer never
2052
+ * stands on its branch, so HEAD is a different change entirely: measuring against it announced
2053
+ * "Code rewritten, 343 files differ" about a review that was perfectly current.
1869
2054
  * 2. **Is the checkout what the pull request had?** The stored head sha against local HEAD.
1870
2055
  * Answerable offline precisely because the sha was written down at import time.
1871
2056
  * 3. **Has anyone commented since?** Not answerable here at all. The daemon reaches no forge,
@@ -1895,7 +2080,7 @@ async function checkFreshness(workspace) {
1895
2080
  } catch {}
1896
2081
  return {
1897
2082
  checkedAt: Date.now(),
1898
- code: await codeFreshness(workspace, reviewedSha, headSha),
2083
+ code: await codeFreshness(workspace, reviewedSha, headSha, await latest(workspace)),
1899
2084
  checkout: await checkoutFreshness(workspace, headSha),
1900
2085
  conversation: workspace.prContext ? {
1901
2086
  attached: true,
@@ -1904,17 +2089,45 @@ async function checkFreshness(workspace) {
1904
2089
  } : { attached: false }
1905
2090
  };
1906
2091
  }
1907
- async function codeFreshness(workspace, reviewedSha, headSha) {
2092
+ /**
2093
+ * The branch this review is of, and where it currently is.
2094
+ *
2095
+ * The comparison the whole panel rests on, and the one that used to read `git rev-parse HEAD`. That
2096
+ * was right while every review was of the checkout you were standing in. It stopped being right the
2097
+ * day pull requests were reviewed in a worktree of their own: the reviewer never checks that branch
2098
+ * out, so HEAD is some unrelated branch and the distance to it is not a fact about this review.
2099
+ *
2100
+ * A pull request is measured against its own branch as this repository last saw it. A branch review
2101
+ * is measured against that branch by name, `refs/heads/<name>` rather than HEAD, so switching
2102
+ * branches to look at something else does not make the review look rewritten. Neither reads HEAD,
2103
+ * and that is deliberate: what is checked out is a fact about the reviewer, not about the change.
2104
+ *
2105
+ * Null when the branch is not in this repository at all, which is a real answer with two ordinary
2106
+ * causes: a pull request from a fork, whose branch lives on a remote this clone does not have, and
2107
+ * a branch deleted after it merged. Neither is a licence to compare against something else.
2108
+ */
2109
+ async function latest(workspace) {
2110
+ const branch = workspace.changeSet.headRef;
2111
+ const requestRef = workspace.prContext?.pullRequest.headRef;
2112
+ if (requestRef) return resolveRequestBranch(workspace.repoPath, branch, requestRef);
2113
+ return await localBranch(workspace.repoPath, branch) ?? resolveRequestBranch(workspace.repoPath, branch);
2114
+ }
2115
+ async function codeFreshness(workspace, reviewedSha, readable, latest) {
2116
+ const latestSha = latest?.sha ?? null;
1908
2117
  const base = {
1909
2118
  state: "unknown",
1910
2119
  reviewedSha,
1911
- headSha,
2120
+ latestSha,
2121
+ comparison: latest ? {
2122
+ ref: latest.ref,
2123
+ remote: latest.remote
2124
+ } : null,
1912
2125
  newCommits: null,
1913
2126
  changedFiles: null,
1914
2127
  rewritten: false,
1915
2128
  reason: null
1916
2129
  };
1917
- if (!headSha || !reviewedSha) return {
2130
+ if (!readable || !reviewedSha) return {
1918
2131
  ...base,
1919
2132
  reason: "git-failed"
1920
2133
  };
@@ -1923,14 +2136,18 @@ async function codeFreshness(workspace, reviewedSha, headSha) {
1923
2136
  ...base,
1924
2137
  reason: "missing-commit"
1925
2138
  };
1926
- if (headSha !== reviewedSha) {
1927
- const rewritten = !await isAncestor(repoPath, reviewedSha, headSha);
2139
+ if (!latestSha) return {
2140
+ ...base,
2141
+ reason: "no-branch"
2142
+ };
2143
+ if (latestSha !== reviewedSha) {
2144
+ const rewritten = !await isAncestor(repoPath, reviewedSha, latestSha);
1928
2145
  return {
1929
2146
  ...base,
1930
2147
  state: "changed",
1931
2148
  rewritten,
1932
- newCommits: rewritten ? null : await countCommits(repoPath, reviewedSha, headSha),
1933
- changedFiles: await countChangedFiles(repoPath, reviewedSha, headSha)
2149
+ newCommits: rewritten ? null : await countCommits(repoPath, reviewedSha, latestSha),
2150
+ changedFiles: await countChangedFiles(repoPath, reviewedSha, latestSha)
1934
2151
  };
1935
2152
  }
1936
2153
  if (workspace.changeSet.includesUncommitted) return {
@@ -2092,6 +2309,25 @@ function relativeTime(ms) {
2092
2309
  */
2093
2310
  var MANAGED_ROOT = join(STORE_ROOT, "worktrees");
2094
2311
  /**
2312
+ * An empty directory, pointed at so that the repository's own hooks do not run in our checkout.
2313
+ *
2314
+ * `git worktree add` checks the tree out, which fires `post-checkout` in the reviewer's repository.
2315
+ * Found on a 31,644 file monorepo with husky: its hook asks for `HEAD@{1}`, a brand new worktree's
2316
+ * HEAD reflog has exactly one entry, git answers `fatal: log for 'HEAD' only has 1 entries`, and the
2317
+ * add exits 128 *after* writing the whole tree. Every review of that repository failed, in the first
2318
+ * phase, for a reason that was nothing to do with the review.
2319
+ *
2320
+ * Turning them off is the right answer rather than a workaround. These hooks were written for a
2321
+ * person checking out a branch to work on: install dependencies, warn about a stale lockfile, prompt
2322
+ * something. Nobody works in this directory. It is read once by an agent and deleted. It is also
2323
+ * arbitrary code from the repository being reviewed, and a review that runs it has quietly made
2324
+ * "point PR Review Buddy at this repository" mean "execute this repository".
2325
+ *
2326
+ * A directory rather than `/dev/null` because git is being told where hooks live, and a real empty
2327
+ * one is true on every platform.
2328
+ */
2329
+ var NO_HOOKS = join(STORE_ROOT, "no-hooks");
2330
+ /**
2095
2331
  * The proof that we made a directory, kept inside the directory itself.
2096
2332
  *
2097
2333
  * It exists for the removal guard. Git normally answers "is this yours", and when git has
@@ -2101,14 +2337,26 @@ var MANAGED_ROOT = join(STORE_ROOT, "worktrees");
2101
2337
  var MARKER_FILE = ".prreviewbuddy-worktree.json";
2102
2338
  async function createWorktree(input) {
2103
2339
  mkdirSync(MANAGED_ROOT, { recursive: true });
2340
+ mkdirSync(NO_HOOKS, { recursive: true });
2104
2341
  const path = join(MANAGED_ROOT, input.jobId);
2105
- await git(input.originRepoPath, [
2106
- "worktree",
2107
- "add",
2108
- "--detach",
2109
- path,
2110
- input.sha
2111
- ], { timeoutMs: WORKTREE_TIMEOUT_MS });
2342
+ try {
2343
+ await git(input.originRepoPath, [
2344
+ "worktree",
2345
+ "add",
2346
+ "--detach",
2347
+ path,
2348
+ input.sha
2349
+ ], {
2350
+ config: [`core.hooksPath=${NO_HOOKS}`],
2351
+ timeoutMs: WORKTREE_TIMEOUT_MS
2352
+ });
2353
+ } catch (error) {
2354
+ await removeWorktree({
2355
+ originRepoPath: input.originRepoPath,
2356
+ worktreePath: path
2357
+ }).catch(() => {});
2358
+ throw error;
2359
+ }
2112
2360
  const marker = {
2113
2361
  jobId: input.jobId,
2114
2362
  originRepoPath: input.originRepoPath,
@@ -5127,113 +5375,6 @@ async function applyGuide(workspaceId, payload) {
5127
5375
  });
5128
5376
  }
5129
5377
  //#endregion
5130
- //#region ../../packages/review-harness/src/workspace/landing.ts
5131
- /**
5132
- * Long enough for a loopback round trip many times over. Short because nothing rests on it: when
5133
- * it expires the reader gets a link rather than a verdict.
5134
- */
5135
- var PROBE_TIMEOUT_MS = 1500;
5136
- /**
5137
- * The landing page, as a string.
5138
- *
5139
- * Pure, and separated from writing it for the usual reason: what this page says when the server is
5140
- * down is the half that is hard to look at by hand, because reproducing it means killing a daemon.
5141
- */
5142
- /**
5143
- * A record with no token renders as stopped, and that is not a lie anyone can be told: nothing
5144
- * writes such a record. `cli/server.ts` mints a token before it listens, and `healthyPort` stops a
5145
- * server whose version does not match this build, which is the only way one could ever be seen.
5146
- * The case is here because the field is optional in the type, not because it is a state.
5147
- */
5148
- function renderLanding(record) {
5149
- const url = record?.token ? indexUrl(record) : null;
5150
- return url ? running(record, url) : stopped();
5151
- }
5152
- function indexUrl(record) {
5153
- return `http://127.0.0.1:${record.port}/?t=${encodeURIComponent(record.token ?? "")}`;
5154
- }
5155
- function running(record, url) {
5156
- return page("Opening your reviews", `<h1>Opening your reviews</h1>
5157
- <p class="prb-quiet" id="waiting">Looking for the workspace server on port ${record.port}.</p>
5158
- <div id="gone" hidden>
5159
- <p><a class="prb-go" href="${escapeHtml(url)}">Open your reviews on port ${record.port}</a></p>
5160
- <p>This page could not confirm the server was answering, so it has not sent you there on its
5161
- own. Follow the link and see.</p>
5162
- <p>If it does not open, the server has stopped since this page was written. Nothing is lost:
5163
- every review is on disk in <code>~/.prreviewbuddy/workspaces</code>. Run
5164
- <code>/prreviewbuddy:start-server</code> in Claude Code, and this page will point at the new
5165
- server by the time you reload it.</p>
5166
- </div>
5167
- <script>
5168
- // An image, not a fetch: a file:// page cannot read a cross-origin response, but it can be
5169
- // told whether one arrived. /assets is the only route that answers without a credential.
5170
- var settled = false;
5171
- var probe = new Image();
5172
- var give_up = setTimeout(function () { fail(); }, ${PROBE_TIMEOUT_MS});
5173
- function fail() {
5174
- if (settled) return;
5175
- settled = true;
5176
- clearTimeout(give_up);
5177
- document.getElementById('waiting').hidden = true;
5178
- document.getElementById('gone').hidden = false;
5179
- }
5180
- probe.onload = function () {
5181
- if (settled) return;
5182
- settled = true;
5183
- clearTimeout(give_up);
5184
- // replace, not assign: going back should leave the reviews, not bounce through here again.
5185
- window.location.replace(${JSON.stringify(url)});
5186
- };
5187
- probe.onerror = fail;
5188
- probe.src = ${JSON.stringify(`http://127.0.0.1:${record.port}/assets/icon128.png?probe=`)} + Date.now();
5189
- <\/script>`);
5190
- }
5191
- function stopped() {
5192
- return page("Your reviews", `<h1>The workspace server is not running</h1>
5193
- <p>Every review is still on disk in <code>~/.prreviewbuddy/workspaces</code>, with its guide,
5194
- its diff and its conversation.</p>
5195
- <p>Run <code>/prreviewbuddy:start-server</code> in Claude Code to serve them again. This page
5196
- is rewritten when it starts, so keep the bookmark and reload it then.</p>`);
5197
- }
5198
- function page(title, body) {
5199
- return `<!doctype html>
5200
- <html lang="en">
5201
- <head>
5202
- <meta charset="utf-8">
5203
- <meta name="viewport" content="width=device-width, initial-scale=1">
5204
- <title>${escapeHtml(title)} · PR Review Buddy</title>
5205
- <style>
5206
- :root { color-scheme: light dark; --prb-ink: #1b1b1f; --prb-paper: #fbfbfd; --prb-quiet: #5c5c66; }
5207
- @media (prefers-color-scheme: dark) {
5208
- :root { --prb-ink: #e8e8ec; --prb-paper: #17171b; --prb-quiet: #9a9aa4; }
5209
- }
5210
- body {
5211
- margin: 0; min-height: 100vh; display: grid; place-items: center;
5212
- background: var(--prb-paper); color: var(--prb-ink);
5213
- font: 15px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
5214
- }
5215
- main { max-width: 34rem; padding: 2rem; }
5216
- h1 { font-size: 1.25rem; margin: 0 0 0.75rem; }
5217
- p { margin: 0 0 0.75rem; }
5218
- code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; }
5219
- .prb-quiet { color: var(--prb-quiet); }
5220
- .prb-go { font-weight: 600; }
5221
- [hidden] { display: none; }
5222
- </style>
5223
- </head>
5224
- <body><main>${body}</main></body>
5225
- </html>
5226
- `;
5227
- }
5228
- /**
5229
- * The token is minted from `base64url` and cannot contain any of these, and it is escaped anyway.
5230
- * A credential is the last value in this product to be interpolated on the strength of what it is
5231
- * currently generated from.
5232
- */
5233
- function escapeHtml(value) {
5234
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
5235
- }
5236
- //#endregion
5237
5378
  //#region ../../packages/review-harness/src/domain/feedback/feedback_url.ts
5238
5379
  /** How each client names itself in the prefilled message, which is the half a screenshot carries. */
5239
5380
  var CLIENT_LABELS = {
@@ -5373,16 +5514,188 @@ function feedbackUrl() {
5373
5514
  });
5374
5515
  }
5375
5516
  //#endregion
5517
+ //#region ../../packages/review-harness/src/workspace/way_back.ts
5518
+ /**
5519
+ * How a reviewer gets back to the list of their reviews.
5520
+ *
5521
+ * Three places have to answer this, and until now all three answered it for the plugin: the
5522
+ * refusal at `/`, and both states of the bookmark page. A reviewer who installed the CLI was told
5523
+ * to run `/prreviewbuddy:start-server`, which is a Claude Code slash command they do not have,
5524
+ * from a product that has never mentioned Claude Code to them. Every one of those readers is
5525
+ * already stuck, which is the worst moment to be handed a command that does not exist.
5526
+ *
5527
+ * The build knows which product it is. `SURFACE.client` is read from the `package.json` beside the
5528
+ * running code, and each app bundles its own copy of the workspace server, so the server that
5529
+ * refuses the request is the CLI's server or the plugin's server and can say so. A build that
5530
+ * cannot name itself names both, which is never wrong and is only slightly longer.
5531
+ *
5532
+ * The bookmark is here too, because it is the other half of the same answer and the durable half:
5533
+ * the command is what unsticks this browser now, and the bookmark is what stops the question being
5534
+ * asked again after the next restart.
5535
+ */
5536
+ /**
5537
+ * The one address in this product that never changes.
5538
+ *
5539
+ * Rewritten by `daemon.ts` at both moments the truth changes. It lives here rather than there
5540
+ * because the pages that tell a reviewer about it must not have to import the daemon to do it.
5541
+ */
5542
+ var LANDING_PAGE = join(STORE_ROOT, "reviews.html");
5543
+ var CLI = {
5544
+ command: "prreviewbuddy open",
5545
+ where: "your terminal"
5546
+ };
5547
+ var PLUGIN = {
5548
+ command: "/prreviewbuddy:start-server",
5549
+ where: "Claude Code"
5550
+ };
5551
+ function wayBack() {
5552
+ return {
5553
+ commands: SURFACE.client === "cli" ? [CLI] : SURFACE.client === "plugin" ? [PLUGIN] : [CLI, PLUGIN],
5554
+ bookmark: LANDING_PAGE
5555
+ };
5556
+ }
5557
+ /** The commands as one sentence, for somewhere with no room to make a list of them. */
5558
+ function wayBackSentence(way = wayBack()) {
5559
+ return way.commands.map(({ command, where }) => `${command} in ${where}`).join(", or ");
5560
+ }
5561
+ //#endregion
5562
+ //#region ../../packages/review-harness/src/workspace/landing.ts
5563
+ /**
5564
+ * Long enough for a loopback round trip many times over. Short because nothing rests on it: when
5565
+ * it expires the reader gets a link rather than a verdict.
5566
+ */
5567
+ var PROBE_TIMEOUT_MS = 1500;
5568
+ /**
5569
+ * The landing page, as a string.
5570
+ *
5571
+ * Pure, and separated from writing it for the usual reason: what this page says when the server is
5572
+ * down is the half that is hard to look at by hand, because reproducing it means killing a daemon.
5573
+ */
5574
+ /**
5575
+ * A record with no token renders as stopped, and that is not a lie anyone can be told: nothing
5576
+ * writes such a record. `cli/server.ts` mints a token before it listens, and `healthyPort` stops a
5577
+ * server whose version does not match this build, which is the only way one could ever be seen.
5578
+ * The case is here because the field is optional in the type, not because it is a state.
5579
+ */
5580
+ function renderLanding(record) {
5581
+ const url = record?.token ? indexUrl(record) : null;
5582
+ return url ? running(record, url) : stopped();
5583
+ }
5584
+ function indexUrl(record) {
5585
+ return `http://127.0.0.1:${record.port}/?t=${encodeURIComponent(record.token ?? "")}`;
5586
+ }
5587
+ function running(record, url) {
5588
+ return page("Opening your reviews", `<h1>Opening your reviews</h1>
5589
+ <p class="prb-quiet" id="waiting">Looking for the workspace server on port ${record.port}.</p>
5590
+ <div id="gone" hidden>
5591
+ <p><a class="prb-go" href="${escapeHtml(url)}">Open your reviews on port ${record.port}</a></p>
5592
+ <p>This page could not confirm the server was answering, so it has not sent you there on its
5593
+ own. Follow the link and see.</p>
5594
+ <p>If it does not open, the server has stopped since this page was written. Nothing is lost:
5595
+ every review is on disk in <code>~/.prreviewbuddy/workspaces</code>. Start it again and this
5596
+ page will point at the new server by the time you reload it.</p>
5597
+ ${commandList(wayBack())}
5598
+ </div>
5599
+ <script>
5600
+ // An image, not a fetch: a file:// page cannot read a cross-origin response, but it can be
5601
+ // told whether one arrived. /assets is the only route that answers without a credential.
5602
+ var settled = false;
5603
+ var probe = new Image();
5604
+ var give_up = setTimeout(function () { fail(); }, ${PROBE_TIMEOUT_MS});
5605
+ function fail() {
5606
+ if (settled) return;
5607
+ settled = true;
5608
+ clearTimeout(give_up);
5609
+ document.getElementById('waiting').hidden = true;
5610
+ document.getElementById('gone').hidden = false;
5611
+ }
5612
+ probe.onload = function () {
5613
+ if (settled) return;
5614
+ settled = true;
5615
+ clearTimeout(give_up);
5616
+ // replace, not assign: going back should leave the reviews, not bounce through here again.
5617
+ window.location.replace(${JSON.stringify(url)});
5618
+ };
5619
+ probe.onerror = fail;
5620
+ probe.src = ${JSON.stringify(`http://127.0.0.1:${record.port}/assets/icon128.png?probe=`)} + Date.now();
5621
+ <\/script>`);
5622
+ }
5623
+ function stopped() {
5624
+ return page("Your reviews", `<h1>The workspace server is not running</h1>
5625
+ <p>Every review is still on disk in <code>~/.prreviewbuddy/workspaces</code>, with its guide,
5626
+ its diff and its conversation.</p>
5627
+ <p>Serve them again with:</p>
5628
+ ${commandList(wayBack())}
5629
+ <p>This page is rewritten when the server starts, so keep the bookmark and reload it then.</p>`);
5630
+ }
5631
+ /**
5632
+ * The commands that start a server, named for the product the reader actually installed.
5633
+ *
5634
+ * A list rather than a sentence because a build that cannot name itself offers both, and two
5635
+ * commands run together in prose read as one command with a typo in it.
5636
+ */
5637
+ function commandList(way) {
5638
+ return `<ul class="prb-commands">${way.commands.map(({ command, where }) => `<li><code>${escapeHtml(command)}</code> in ${escapeHtml(where)}</li>`).join("")}</ul>`;
5639
+ }
5640
+ /**
5641
+ * The shell both of this file's states use, and the refusal at `/` uses too.
5642
+ *
5643
+ * Exported so that the three pages a stuck reviewer can land on look like one product rather than
5644
+ * like three accidents. It carries no script and no asset request, which is what lets it render
5645
+ * identically from `file://` and from the server.
5646
+ */
5647
+ function page(title, body) {
5648
+ return `<!doctype html>
5649
+ <html lang="en">
5650
+ <head>
5651
+ <meta charset="utf-8">
5652
+ <meta name="viewport" content="width=device-width, initial-scale=1">
5653
+ <title>${escapeHtml(title)} · PR Review Buddy</title>
5654
+ <style>
5655
+ :root { color-scheme: light dark; --prb-ink: #1b1b1f; --prb-paper: #fbfbfd; --prb-quiet: #5c5c66; }
5656
+ @media (prefers-color-scheme: dark) {
5657
+ :root { --prb-ink: #e8e8ec; --prb-paper: #17171b; --prb-quiet: #9a9aa4; }
5658
+ }
5659
+ body {
5660
+ margin: 0; min-height: 100vh; display: grid; place-items: center;
5661
+ background: var(--prb-paper); color: var(--prb-ink);
5662
+ font: 15px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
5663
+ }
5664
+ main { max-width: 34rem; padding: 2rem; }
5665
+ h1 { font-size: 1.25rem; margin: 0 0 0.75rem; }
5666
+ p { margin: 0 0 0.75rem; }
5667
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; }
5668
+ .prb-quiet { color: var(--prb-quiet); }
5669
+ .prb-go { font-weight: 600; }
5670
+ .prb-commands { margin: 0 0 0.75rem; padding-left: 1.25rem; }
5671
+ .prb-commands li { margin: 0 0 0.25rem; }
5672
+ [hidden] { display: none; }
5673
+ </style>
5674
+ </head>
5675
+ <body><main>${body}</main></body>
5676
+ </html>
5677
+ `;
5678
+ }
5679
+ /**
5680
+ * The token is minted from `base64url` and cannot contain any of these, and it is escaped anyway.
5681
+ * A credential is the last value in this product to be interpolated on the strength of what it is
5682
+ * currently generated from.
5683
+ */
5684
+ function escapeHtml(value) {
5685
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
5686
+ }
5687
+ //#endregion
5376
5688
  //#region ../../packages/review-harness/src/workspace/daemon.ts
5377
- var SERVER_RECORD = join(STORE_ROOT, "server.json");
5378
- var SPAWN_LOCK = join(STORE_ROOT, "server.lock");
5379
5689
  /**
5380
5690
  * The one address in this product that never changes.
5381
5691
  *
5382
- * Rewritten at both moments the truth changes, which is why it is here rather than anywhere that
5692
+ * Declared in `way_back.ts` with the rest of the answer to "how do I get back to my reviews", and
5693
+ * re-exported here because this is where callers already look for it. Rewritten by this file at
5694
+ * both moments the truth changes, which is why the writing is here rather than anywhere that
5383
5695
  * merely knows the port: this file is where a server starts existing and stops existing.
5384
5696
  */
5385
- var LANDING_PAGE = join(STORE_ROOT, "reviews.html");
5697
+ var SERVER_RECORD = join(STORE_ROOT, "server.json");
5698
+ var SPAWN_LOCK = join(STORE_ROOT, "server.lock");
5386
5699
  var SERVER_ENTRY = process.env.PRB_SERVER_ENTRY ?? fileURLToPath(new URL("./server.js", import.meta.url));
5387
5700
  var STARTUP_TIMEOUT_MS = 15e3;
5388
5701
  var POLL_INTERVAL_MS = 100;
@@ -5586,6 +5899,29 @@ async function waitForHealthyServer() {
5586
5899
  function workspaceUrl(port, id, token) {
5587
5900
  return `http://127.0.0.1:${port}/r/${id}?t=${token}`;
5588
5901
  }
5902
+ /**
5903
+ * A workspace link that also teaches the browser its way back to the list.
5904
+ *
5905
+ * The reviews index is opened by a credential no review page is allowed to carry, and the only
5906
+ * thing that puts that credential into a browser is arriving at the index with it once. Nothing on
5907
+ * the `prreviewbuddy review` path ever sent anyone there, so "All reviews" on the page it printed
5908
+ * refused every reviewer who had never separately run `prreviewbuddy open`. The bootstrap belongs
5909
+ * here, in the link, rather than as a step for the reviewer to discover after being refused.
5910
+ *
5911
+ * It goes through the index because that is the route that sets the cookie, and it carries `go` so
5912
+ * that the browser does not stop there: cookie set, then straight on to the review, leaving
5913
+ * `/r/<id>?t=<reviewToken>` in the address bar. The page still never receives the index credential,
5914
+ * which is the boundary that matters. What is in the address bar afterwards is safe to copy.
5915
+ *
5916
+ * Printing this rather than the plain link costs nothing when the credential is missing or stale:
5917
+ * `handleIndex` forwards to `go` either way, and only the cookie depends on the credential being
5918
+ * right. The worst case is exactly the link this used to print.
5919
+ */
5920
+ function bootstrapUrl(port, id, token, indexToken) {
5921
+ const workspace = `/r/${id}?t=${encodeURIComponent(token)}`;
5922
+ if (!indexToken) return `http://127.0.0.1:${port}${workspace}`;
5923
+ return `http://127.0.0.1:${port}/?t=${encodeURIComponent(indexToken)}&go=${encodeURIComponent(workspace)}`;
5924
+ }
5589
5925
  /** The reviews index. Null when the running server predates the index and has no token. */
5590
5926
  function reviewsUrl(port, token) {
5591
5927
  return token ? `http://127.0.0.1:${port}/?t=${token}` : null;
@@ -5957,10 +6293,6 @@ function countByStatus(issues) {
5957
6293
  * same policy as every other managed directory and a crash between creating one and recording it
5958
6294
  * leaves residue the orphan rule can still reach.
5959
6295
  */
5960
- /** The commit a review was made from, however the record spells it. Null when it recorded none. */
5961
- function reviewedCommit(workspace) {
5962
- return workspace.session.metadata?.commitSha || workspace.changeSet.data.pullRequest.head.sha || null;
5963
- }
5964
6296
  var NoCheckoutError = class extends Error {};
5965
6297
  /**
5966
6298
  * A worktree detached at `sha`, and the job that owns it.
@@ -6333,31 +6665,21 @@ async function reconcile(workspace, runners, reviewedSha, remote, conversationOk
6333
6665
  }
6334
6666
  var BASELINE_GONE_MESSAGE = "The commit this review was made from is no longer in this repository, so there is nothing to measure the new code against. This one needs starting fresh: run /prreviewbuddy:fresh-review.";
6335
6667
  /**
6336
- * The candidate refs, in the order that best answers "where is this change now".
6668
+ * Where the request's branch is, after a fetch.
6669
+ *
6670
+ * The candidates live in `request_branch.ts` rather than here because the freshness strip has to
6671
+ * ask the same question and get the same answer. Two answers means the panel says the code was
6672
+ * rewritten while this button says the review is already current, on one screen, with nothing to
6673
+ * tell the reviewer which is right.
6337
6674
  *
6338
- * `<branch>@{upstream}` resolves the configured remote-tracking ref regardless of what is
6339
- * currently checked out, which is what makes asking here safe: nothing is checked out to ask, and
6340
- * the reviewer's own HEAD is never consulted.
6675
+ * The fetch is the only difference between the two callers, and it is the honest one: this button
6676
+ * is allowed to reach the network and that panel is not, so it looks at a newer copy of the same
6677
+ * refs.
6341
6678
  */
6342
- function candidateRefs(branch, remoteHeadRef) {
6343
- return remoteHeadRef ? [
6344
- `refs/remotes/origin/${remoteHeadRef}`,
6345
- `refs/remotes/${remoteHeadRef}`,
6346
- `${branch}@{upstream}`
6347
- ] : [`${branch}@{upstream}`];
6348
- }
6349
6679
  async function remoteHead(workspace) {
6350
6680
  const fetchFailed = !await fetchOrigin(workspace.repoPath);
6351
- const refs = candidateRefs(workspace.changeSet.headRef, workspace.prContext?.pullRequest.headRef);
6352
- for (const ref of refs) {
6353
- const sha = await refSha(workspace.repoPath, ref);
6354
- if (sha) return {
6355
- sha,
6356
- fetchFailed
6357
- };
6358
- }
6359
6681
  return {
6360
- sha: null,
6682
+ sha: (await resolveRequestBranch(workspace.repoPath, workspace.changeSet.headRef, workspace.prContext?.pullRequest.headRef))?.sha ?? null,
6361
6683
  fetchFailed
6362
6684
  };
6363
6685
  }
@@ -6378,18 +6700,6 @@ async function fetchOrigin(repoPath) {
6378
6700
  return false;
6379
6701
  }
6380
6702
  }
6381
- async function refSha(repoPath, ref) {
6382
- try {
6383
- return (await git(repoPath, [
6384
- "rev-parse",
6385
- "--verify",
6386
- "--quiet",
6387
- `${ref}^{commit}`
6388
- ])).trim() || null;
6389
- } catch {
6390
- return null;
6391
- }
6392
- }
6393
6703
  /** Whether the object itself is still in the repository, whatever ref does or does not name it. */
6394
6704
  async function commitPresent(repoPath, sha) {
6395
6705
  try {
@@ -6438,7 +6748,7 @@ async function applyUpdate(workspace, result, replaced, newHead, worktreePath) {
6438
6748
  try {
6439
6749
  const plain = await buildReviewContext({
6440
6750
  repoPath: worktreePath,
6441
- base: await resolvableBase(worktreePath, workspace.changeSet.baseRef)
6751
+ base: await resolvableBase(worktreePath, workspace.changeSet)
6442
6752
  });
6443
6753
  whole = workspace.prContext ? await withPrContext(plain, workspace.prContext) : plain;
6444
6754
  workspace.changeSet = whole.changeSet;
@@ -6449,15 +6759,32 @@ async function applyUpdate(workspace, result, replaced, newHead, worktreePath) {
6449
6759
  delete workspace.dismissedFreshnessSha;
6450
6760
  }
6451
6761
  /**
6452
- * The stored base, turned back into something git will accept.
6762
+ * The base this review was made against, turned back into something git will accept.
6763
+ *
6764
+ * `ChangeSet.baseResolvedRef` is the ref git actually resolved when the review was built, so a
6765
+ * review that has one needs no reasoning at all: it is the same comparison or it is nothing.
6453
6766
  *
6454
- * `ChangeSet.baseRef` is a *display* string: `origin/main` is stored as `main`, because that is
6455
- * what a reviewer calls it. In a checkout with no local `main` branch, which is the ordinary shape
6456
- * of a clone made for one branch, handing that back to git resolves to nothing. Undefined is the
6457
- * last resort and means "work it out again", which is what the change set did in the first place.
6767
+ * Everything else is a review made before that field existed, and all such a review kept is
6768
+ * `baseRef`, which is a *display* string: `origin/main` is stored as `main`, because that is what
6769
+ * a reviewer calls it. Both candidates have to be tried, and the order matters more than it looks.
6770
+ * A clone made for one branch has no local `main` at all, so the display name resolves to nothing
6771
+ * and `origin/main` is the only answer. An ordinary clone has both, `main` sits behind the remote
6772
+ * from the first day nobody pulls, and picking it silently widens the review to every upstream
6773
+ * commit that has not been fetched into the working branch. So the remote-tracking ref goes first:
6774
+ * it is what `resolveBase` itself prefers when nobody names a base, and no `prreviewbuddy review
6775
+ * <pr>` ever names one.
6776
+ *
6777
+ * Undefined is the last resort and means "work it out again", which is what the change set did in
6778
+ * the first place.
6458
6779
  */
6459
- async function resolvableBase(repoPath, stored) {
6460
- for (const candidate of [stored, `origin/${stored}`]) if (candidate && await refExists(repoPath, candidate)) return candidate;
6780
+ async function resolvableBase(repoPath, changeSet) {
6781
+ const stored = changeSet.baseRef;
6782
+ const candidates = [
6783
+ changeSet.baseResolvedRef,
6784
+ `origin/${stored}`,
6785
+ stored
6786
+ ];
6787
+ for (const candidate of candidates) if (candidate && await refExists(repoPath, candidate)) return candidate;
6461
6788
  }
6462
6789
  /** Null when there is no conversation attached, which is neither success nor failure. */
6463
6790
  async function reReadConversation(workspace, fetch) {
@@ -6637,4 +6964,4 @@ function loadConfig() {
6637
6964
  return config;
6638
6965
  }
6639
6966
  //#endregion
6640
- export { agentById as $, WORK_PHASES as A, lineagePosition as B, clearClaim as C, removeWorktree as D, readMarker as E, loadJob as F, saveWorkspace as G, positionInLineage as H, saveJob as I, processDiscussion as J, summarise as K, displayRef as L, deleteJob as M, fail as N, relativeTime as O, isTerminal as P, AGENT_IDS as Q, STORE_ROOT as R, fillFileUrlTemplate as S, MANAGED_ROOT as T, recentReviewGroups as U, loadWorkspace as V, recentWorkspaces as W, resolveTarget as X, describeAuthorship as Y, withBase as Z, feedbackUrl as _, refreshPrContext as a, EXPLAIN_SIMPLY_PROMPT as b, agentEnvOf as c, ensureServer as d, agentFor as et, readServerRecord as f, BUILD_VERSION as g, writeServerRecord as h, updateReview as i, allJobs as j, checkFreshness as k, LANDING_PAGE as l, workspaceUrl as m, record as n, AgentUnavailableError as nt, isUnchanged as o, reviewsUrl as p, touchWorkspace as q, RefreshUnavailableError as r, askCheckout as s, readEvents as t, defaultAgent as tt, clearServerRecord as u, runJob as v, isClaimed as w, forgeResolver as x, startJob as y, groupByLineage as z };
6967
+ export { touchWorkspace as $, MANAGED_ROOT as A, loadJob as B, runJob as C, fillFileUrlTemplate as D, forgeResolver as E, WORK_PHASES as F, lineagePosition as G, displayRef as H, allJobs as I, recentReviewGroups as J, loadWorkspace as K, deleteJob as L, removeWorktree as M, relativeTime as N, clearClaim as O, checkFreshness as P, summarise as Q, fail as R, feedbackUrl as S, EXPLAIN_SIMPLY_PROMPT as T, STORE_ROOT as U, saveJob as V, groupByLineage as W, reviewedCommit as X, recentWorkspaces as Y, saveWorkspace as Z, page as _, refreshPrContext as a, reviewerDispositions as at, wayBackSentence as b, agentEnvOf as c, resolveTarget as ct, ensureServer as d, AGENT_IDS as dt, doneVerb as et, readServerRecord as f, agentById as ft, commandList as g, AgentUnavailableError as gt, writeServerRecord as h, AgentCancelledError as ht, updateReview as i, questionsOutstanding as it, readMarker as j, isClaimed as k, bootstrapUrl as l, withBase as lt, workspaceUrl as m, defaultAgent as mt, record as n, isQuestionOutstanding as nt, isUnchanged as o, processDiscussion as ot, reviewsUrl as p, agentFor as pt, positionInLineage as q, RefreshUnavailableError as r, issuesOutstanding as rt, askCheckout as s, describeAuthorship as st, readEvents as t, isIssueOutstanding as tt, clearServerRecord as u, git as ut, LANDING_PAGE as v, startJob as w, BUILD_VERSION as x, wayBack as y, isTerminal as z };