prreviewbuddy 0.29.1 → 0.31.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/CHANGELOG.md CHANGED
@@ -1,5 +1,59 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.31.0
4
+
5
+ **Review a single commit.** `prreviewbuddy review --commit <commit>` reviews the change one commit
6
+ made, compared against the commit before it. Name the commit however git lets you: a sha, `HEAD~2`
7
+ or a tag. A merge commit is compared against its first parent, and the review says so; the very
8
+ first commit in a repository is compared against nothing, so every file in it is new.
9
+
10
+ A commit never changes, so its review does not go out of date. The review page says "Freshness:
11
+ Fixed" instead of offering to update it, and `--update` is refused with a pointer to `--reanalyse`,
12
+ which analyses the same commit again and keeps the earlier analysis beside the new one. If the commit
13
+ later disappears from your repository, after a force-push for example, the review still opens in
14
+ full and says the commit is gone. `--commit` cannot be combined with `--base`, because the commit
15
+ before it is the base; to compare a branch against a base of your choosing, use `--branch` with
16
+ `--base` as before.
17
+
18
+ Reviews of the same commit share one card on the reviews list, named `commit` and its short sha, and
19
+ searching the list for the sha finds it. A review of a commit and a review of the branch that
20
+ contains it stay separate.
21
+
22
+ **A detached checkout points at `--commit HEAD`.** Running `prreviewbuddy review` on a detached HEAD
23
+ is still refused, because there is no branch to file the review under, and the message now offers
24
+ `prreviewbuddy review --commit HEAD` to review the commit you are on.
25
+
26
+ **A calmer Files tab.** Each file's issue and question counts are now small icons with a number, in
27
+ a column of their own on the right, instead of labels that squeezed long filenames onto several
28
+ lines. The label naming a second theme that also mentions a file is gone: the file is already listed
29
+ under its own theme, and its counts still include every finding. Added, modified, removed and renamed
30
+ files now each have their own colour, and the labels are all one width so the paths line up.
31
+
32
+ **The review header stays on one line.** On a narrow window the branch, commit and controls at the
33
+ top of a review no longer wrap onto a second line.
34
+
35
+ ## 0.30.0
36
+
37
+ **Usage telemetry is now on by default.** Non-identifying usage telemetry is now enabled by default
38
+ on installations that have not previously made a choice. Existing opt-outs remain respected: if you
39
+ answered no when PR Review Buddy asked, nothing is sent, and nothing about your machine changes.
40
+ The first-run question is gone, replaced by a one-time notice in the terminal that says what is
41
+ sent and how to stop it. That notice is shown only to installations the new default actually
42
+ changes, so a machine that already answered sees nothing.
43
+
44
+ Nothing new is collected. The same two events carry the same eight fields they did before: review
45
+ start and finish, a random installation ID, the version, the platform, the agent, whether the
46
+ analysis succeeded and how long it took. Never your code, diffs, file paths, repository names,
47
+ prompts or review content. Rows are still deleted after 90 days.
48
+
49
+ `prreviewbuddy config set telemetry-upload off` turns it off, as does the Privacy section of the
50
+ workspace Settings page, and `PRB_TELEMETRY=off` still stops the local log as well. The reasoning
51
+ for the change, and what it replaced, is written out at https://prreviewbuddy.com/privacy.
52
+
53
+ **Copy says "non-identifying" rather than "anonymous".** The events carry a random installation ID
54
+ that persists, and a stable identifier makes data pseudonymous however little else is attached to
55
+ it. The word changed; what is sent did not.
56
+
3
57
  ## 0.29.1
4
58
 
5
59
  **Icons in the review sidebar.** Every item in a review's sidebar now has an icon: Review, KISS,
package/README.md CHANGED
@@ -44,10 +44,11 @@ an assistant that has read the diff. The job is durable, so closing the terminal
44
44
  Your code goes to the coding agent you chose, exactly as it would if you had run that agent
45
45
  yourself. It does not pass through us, and no server of ours is in the path of a review.
46
46
 
47
- PR Review Buddy asks once, on first run, whether it may send anonymous usage counts: two event
48
- names, a random ID generated on your machine, the version, the platform, the agent and a duration.
49
- Never your code, diffs, file paths, repository names, prompts or review content, and never anything
50
- at all unless you said yes. [The full field table is published](https://prreviewbuddy.com/privacy).
47
+ PR Review Buddy sends non-identifying usage telemetry, on by default and off with one command: two
48
+ event names, a random ID generated on your machine, the version, the platform, the agent and a
49
+ duration. Never your code, diffs, file paths, repository names, prompts or review content.
50
+ `prreviewbuddy config set telemetry-upload off` stops it.
51
+ [The full field table is published](https://prreviewbuddy.com/privacy).
51
52
 
52
53
  More at [prreviewbuddy.com](https://prreviewbuddy.com).
53
54
 
@@ -80,7 +81,7 @@ prreviewbuddy open <review> open one review
80
81
  prreviewbuddy agents which coding agents you have
81
82
  prreviewbuddy config set agent <agent> remember which to use when you do not name one
82
83
  prreviewbuddy config set telemetry-upload on|off
83
- send anonymous usage counts, or stop
84
+ send usage telemetry, or stop. On by default
84
85
 
85
86
  prreviewbuddy uninstall remove everything this put on your machine
86
87
  ```
@@ -167,10 +168,10 @@ ID** and **Delete review**, and the menu on a card deletes every review of that
167
168
  </details>
168
169
 
169
170
  <details>
170
- <summary><b>Anonymous usage counts</b> — what is sent, and how to stop it</summary>
171
+ <summary><b>Usage telemetry</b> — what is sent, and how to stop it</summary>
171
172
 
172
- Two events reach a server we run, and only if you answered yes when asked on first run:
173
- `review_started` and `review_finished`. Between them they carry a random UUID generated on your
173
+ Two events reach a server we run, on by default since 0.30.0: `review_started` and
174
+ `review_finished`. Between them they carry a random UUID generated on your
174
175
  machine, the CLI version, the platform, which agent ran the analysis, a timestamp, whether it
175
176
  succeeded and how long it took. That is the whole list, and the server refuses any field that is
176
177
  not on it rather than accepting the request and dropping what it does not recognise.
@@ -2060,6 +2060,17 @@ var EmptyChangeError = class extends Error {
2060
2060
  }
2061
2061
  };
2062
2062
  /**
2063
+ * Whether a stored change set is a review of one commit. See `ChangeSet.targetType`.
2064
+ *
2065
+ * A function rather than the comparison written out, and not only for tidiness:
2066
+ * `freshness_read_only.test.ts` refuses any quoted git subcommand in `freshness.ts`, and the
2067
+ * discriminator's value is spelled the same as one. That guard is right to be blunt, so the
2068
+ * comparison lives here, beside the field it reads.
2069
+ */
2070
+ function isCommitReview(changeSet) {
2071
+ return changeSet.targetType === "commit";
2072
+ }
2073
+ /**
2063
2074
  * Decide what this change is being compared against.
2064
2075
  *
2065
2076
  * Refuses rather than guessing. A base we picked wrongly produces a guide that looks completely
@@ -2142,7 +2153,7 @@ async function defaultRemoteBranch(repoPath) {
2142
2153
  async function buildChangeSet(options) {
2143
2154
  const repoPath = (await git(options.repoPath, ["rev-parse", "--show-toplevel"])).trim();
2144
2155
  const base = options.resolvedBase ?? await resolveBase(repoPath, options.base);
2145
- const mergeBase = await mergeBaseOf(repoPath, base, "HEAD");
2156
+ const mergeBase = base.exact ? base.revision : await mergeBaseOf(repoPath, base, "HEAD");
2146
2157
  const headRef = await currentBranch(repoPath);
2147
2158
  const isLinkedWorktree = await linkedWorktree(repoPath);
2148
2159
  const files = await collectFiles(repoPath, [mergeBase, "HEAD"]);
@@ -2150,7 +2161,7 @@ async function buildChangeSet(options) {
2150
2161
  disagreement(files.length, base.display, options.expected);
2151
2162
  const blobShas = await headBlobShas(repoPath);
2152
2163
  for (const file of files) file.sha = blobShas.get(file.name) ?? "";
2153
- const { subjects, authors } = await commitLog(repoPath, mergeBase);
2164
+ const { subjects, authors } = await commitLog(repoPath, base.exact && !await refExists(repoPath, mergeBase) ? "HEAD" : `${mergeBase}..HEAD`);
2154
2165
  return {
2155
2166
  data: {
2156
2167
  pullRequest: {
@@ -2286,12 +2297,12 @@ async function currentBranch(repoPath) {
2286
2297
  * Uncommitted work has no author in the log, and needs none. It belongs to whoever is sitting at
2287
2298
  * this checkout, and that is the person reading the page.
2288
2299
  */
2289
- async function commitLog(repoPath, mergeBase) {
2300
+ async function commitLog(repoPath, range) {
2290
2301
  const out = await git(repoPath, [
2291
2302
  "log",
2292
2303
  "--reverse",
2293
2304
  "--format=%an%x1f%s",
2294
- `${mergeBase}..HEAD`
2305
+ range
2295
2306
  ]);
2296
2307
  const subjects = [];
2297
2308
  const counts = /* @__PURE__ */ new Map();
@@ -2365,7 +2376,7 @@ async function mergeBaseOf(repoPath, base, head) {
2365
2376
  */
2366
2377
  async function hasCommittedChanges(repoPath, base, head) {
2367
2378
  return parseNameStatus(await git(repoPath, [
2368
- ...rangeDiff([await mergeBaseOf(repoPath, base, head), head]),
2379
+ ...rangeDiff([base.exact ? base.revision : await mergeBaseOf(repoPath, base, head), head]),
2369
2380
  "--name-status",
2370
2381
  "-z"
2371
2382
  ])).length > 0;
@@ -2610,6 +2621,97 @@ function ambiguous(named, found) {
2610
2621
  ].join("\n");
2611
2622
  }
2612
2623
  //#endregion
2624
+ //#region ../../packages/review-harness/src/workspace/commit_target.ts
2625
+ /**
2626
+ * One commit the reviewer named, resolved to what it introduced, or refused.
2627
+ *
2628
+ * `prreviewbuddy review --commit 9907c64` reviews *the change that commit made*, which is the diff
2629
+ * against its first parent. That is a different object from everything else this product reviews,
2630
+ * and the difference is not the spelling of the target but what can be done to it afterwards.
2631
+ *
2632
+ * **A branch is followed; a commit is not.** `branch_target.ts` refuses a commit and says why: a
2633
+ * review follows its branch so it can be brought up to date as commits arrive, and a commit has
2634
+ * nowhere to move to. That refusal stands. This module is the other surface that docblock asked for,
2635
+ * and the thing it has to supply that `--branch` could not is an answer to what the lineage key is:
2636
+ * the commit's own full sha, never a ref manufactured from it. See `lineageKeyFor`.
2637
+ *
2638
+ * **The parent is the base, and the reviewer cannot name another one.** `--commit` with `--base` is
2639
+ * refused in the parser, because the two together are arbitrary range review arriving by the back
2640
+ * door: the findings would be about a different change under a page still naming the commit.
2641
+ *
2642
+ * **Nothing fetches,** for the same reason as `branch_target.ts`: the reviewer named something they
2643
+ * believe is here, and a commit-ish that does not resolve locally is far more often a typo than a
2644
+ * missing fetch.
2645
+ */
2646
+ async function resolveNamedCommit(repoPath, value) {
2647
+ const named = value.trim();
2648
+ if (!named) throw new TargetError("No commit was named. Give `--commit` a commit, such as `--commit 9907c64` or `--commit HEAD~2`.");
2649
+ const sha = await peel(repoPath, named);
2650
+ const parents = (await git(repoPath, [
2651
+ "rev-list",
2652
+ "--parents",
2653
+ "-n",
2654
+ "1",
2655
+ sha
2656
+ ])).trim().split(/\s+/).slice(1);
2657
+ const subject = (await git(repoPath, [
2658
+ "log",
2659
+ "-1",
2660
+ "--format=%s",
2661
+ sha
2662
+ ])).trim();
2663
+ return {
2664
+ sha,
2665
+ parentSha: parents[0] ?? null,
2666
+ parentCount: parents.length,
2667
+ subject
2668
+ };
2669
+ }
2670
+ /**
2671
+ * The empty tree, asked of the repository rather than written down.
2672
+ *
2673
+ * A root commit is compared against nothing, and git spells nothing as the empty tree object. The
2674
+ * constant everybody knows, `4b825dc642cb6eb9a060e54bf8d69288fbee4904`, is the **sha-1** spelling of
2675
+ * it, and a repository created with `--object-format=sha256` has a different one. Hardcoding it
2676
+ * would make root-commit review silently wrong there rather than loudly broken, so the repository is
2677
+ * asked. `/dev/null` is an empty file, and hashing one as a tree is the documented way to name it.
2678
+ */
2679
+ async function emptyTreeSha(repoPath) {
2680
+ return (await git(repoPath, [
2681
+ "hash-object",
2682
+ "-t",
2683
+ "tree",
2684
+ "/dev/null"
2685
+ ])).trim();
2686
+ }
2687
+ /**
2688
+ * The full commit sha behind whatever was typed, or a refusal naming what it found instead.
2689
+ *
2690
+ * A tree or a blob is refused **by name**, the way `branch_target.ts` refuses `refs/tags/v1` by
2691
+ * name: `--commit HEAD^{tree}` resolves perfectly well to an object that is right there, and telling
2692
+ * the reviewer it could not be found would send them looking for a commit they have.
2693
+ */
2694
+ async function peel(repoPath, named) {
2695
+ try {
2696
+ return (await git(repoPath, [
2697
+ "rev-parse",
2698
+ "--verify",
2699
+ "--quiet",
2700
+ `${named}^{commit}`
2701
+ ])).trim();
2702
+ } catch {}
2703
+ let type = "";
2704
+ try {
2705
+ type = (await git(repoPath, [
2706
+ "cat-file",
2707
+ "-t",
2708
+ named
2709
+ ])).trim();
2710
+ } catch {}
2711
+ if (type === "tree" || type === "blob") throw new TargetError(`\`${named}\` is a ${type}, and a review is of a commit.\n\nA commit review is the change that commit introduced, which is its diff against its parent. A ${type} is a snapshot with nothing to compare it to. Name the commit instead.`);
2712
+ throw new TargetError(`There is nothing called \`${named}\` in this repository.\n\n\`--commit\` takes anything git can resolve to a commit: a sha, \`HEAD~2\`, a tag. If the commit is on a remote this clone has not seen, run \`git fetch\` and try again.`);
2713
+ }
2714
+ //#endregion
2613
2715
  //#region ../../packages/review-harness/src/workspace/target.ts
2614
2716
  /**
2615
2717
  * What is being reviewed, decided once and used everywhere.
@@ -2628,6 +2730,7 @@ function ambiguous(named, found) {
2628
2730
  */
2629
2731
  async function resolveTarget(input) {
2630
2732
  const root = await repositoryRoot(input.repoPath);
2733
+ if (input.commitRef) return commitTarget(root, input.commitRef);
2631
2734
  if (input.base) await verifyBase(root, input.base);
2632
2735
  else await verifyDefaultBase(root);
2633
2736
  const named = input.base ? { base: input.base } : {};
@@ -2684,6 +2787,46 @@ async function resolveTarget(input) {
2684
2787
  };
2685
2788
  }
2686
2789
  /**
2790
+ * One commit, and the parent it will be compared against, settled here and never re-derived.
2791
+ *
2792
+ * The base is built as a `ResolvedBase` rather than left as a name for `resolveBase` to work out,
2793
+ * which is the seam the pull request path already uses: `ChangeSetOptions.resolvedBase` is
2794
+ * documented as "a base already decided by the caller... The caller that set it had an authority
2795
+ * this module does not". The forge is that authority there. Here it is git's own commit graph, which
2796
+ * is a stronger one still, and there is nothing for a ladder of branch names to contribute to it.
2797
+ *
2798
+ * `followRef` is deliberately absent. It exists so a later rebuild can re-resolve a moving base, and
2799
+ * this base cannot move: a commit's parent is the same commit for ever. Recording a ref here would
2800
+ * invite exactly the re-resolution the immutability is for.
2801
+ *
2802
+ * `exact` is set because the parent *is* the comparison point. For an ordinary commit that agrees
2803
+ * with `merge-base(parent, commit)` anyway; for a merge it is what pins the diff to the first parent
2804
+ * rather than to wherever the two histories converge, and for a root commit it is the only way the
2805
+ * empty tree can be a base at all.
2806
+ */
2807
+ async function commitTarget(root, commitRef) {
2808
+ const commit = await resolveNamedCommit(root, commitRef);
2809
+ const short = commit.sha.slice(0, 7);
2810
+ const parent = commit.parentSha;
2811
+ const resolvedBase = parent ? {
2812
+ revision: parent,
2813
+ display: parent.slice(0, 7),
2814
+ exact: true
2815
+ } : {
2816
+ revision: await emptyTreeSha(root),
2817
+ display: "the empty tree",
2818
+ exact: true
2819
+ };
2820
+ return {
2821
+ originRepoPath: root,
2822
+ branch: short,
2823
+ sha: commit.sha,
2824
+ base: resolvedBase.display,
2825
+ resolvedBase,
2826
+ commit
2827
+ };
2828
+ }
2829
+ /**
2687
2830
  * Which commit this request was cut from, decided before anything expensive happens.
2688
2831
  *
2689
2832
  * Strict, and it refuses rather than falling back to a local branch of the right name. The local
@@ -2818,6 +2961,14 @@ async function uncommittedIn(root, target) {
2818
2961
  * to the display name, which `resolveBase` now resolves remote-first anyway.
2819
2962
  */
2820
2963
  function baseOf(changeSet) {
2964
+ if (changeSet.targetType === "commit" && changeSet.mergeBase) return {
2965
+ base: changeSet.baseRef,
2966
+ resolvedBase: {
2967
+ revision: changeSet.mergeBase,
2968
+ display: changeSet.baseRef,
2969
+ exact: true
2970
+ }
2971
+ };
2821
2972
  if (!changeSet.baseRef) return {};
2822
2973
  const followRef = changeSet.baseResolvedRef;
2823
2974
  return {
@@ -2885,7 +3036,7 @@ async function branchOf(root) {
2885
3036
  branch = "";
2886
3037
  }
2887
3038
  if (branch) return branch;
2888
- throw new TargetError("This checkout is on a detached HEAD, so there is no branch to file a review under. Check out a branch, or name the pull request you want reviewed.");
3039
+ throw new TargetError("This checkout is on a detached HEAD, so there is no branch to file a review under. To review the commit you are on, run `prreviewbuddy review --commit HEAD`. Otherwise check out a branch, or name the pull request you want reviewed.");
2889
3040
  }
2890
3041
  /**
2891
3042
  * The commit has to be here before a worktree can be pinned to it.
@@ -4036,9 +4187,22 @@ function reviewedRepositories() {
4036
4187
  *
4037
4188
  * A newline separates the halves because it cannot appear in any of them, so no set of values can
4038
4189
  * collide by running together.
4190
+ *
4191
+ * **A commit review keys by the commit, not by a branch.** It follows nothing, so the ref half has
4192
+ * no meaning for it, and its display name is a short sha that two different commits can share.
4039
4193
  */
4040
4194
  function lineageKey(review) {
4041
- return lineageKeyFor(review.repoPath, review.branch, review.headResolvedRef);
4195
+ if (review.targetType === "commit") return lineageKeyFor({
4196
+ kind: "commit",
4197
+ repoPath: review.repoPath,
4198
+ sha: review.commitSha ?? ""
4199
+ });
4200
+ return lineageKeyFor({
4201
+ kind: "branch",
4202
+ repoPath: review.repoPath,
4203
+ branch: review.branch,
4204
+ followedRef: review.headResolvedRef
4205
+ });
4042
4206
  }
4043
4207
  /**
4044
4208
  * The same key, from the pieces, so a target and a stored review cannot arrive at it differently.
@@ -4047,8 +4211,19 @@ function lineageKey(review) {
4047
4211
  * asks whether a target is the target of a stored review, and if it compared its own three fields
4048
4212
  * by hand then widening the key here and widening the match there would be two changes that have
4049
4213
  * to stay in step forever.
4214
+ *
4215
+ * **The commit arm is decided before the branch arm's defaulting, and that order is the point.** The
4216
+ * branch arm reads an absent ref as `refs/heads/<branch>`, so a commit reaching it would derive
4217
+ * `refs/heads/<sha>` and file under a ref nobody has. The middle segment is the literal `commit`,
4218
+ * which no branch key can produce in that position with a full sha after it, because a branch key's
4219
+ * last segment always starts with `refs/`.
4220
+ *
4221
+ * The branch arm is byte-for-byte the key every review on disk already has. Changing it re-files
4222
+ * every stored review under a new card, silently, on every machine.
4050
4223
  */
4051
- function lineageKeyFor(repoPath, branch, followedRef) {
4224
+ function lineageKeyFor(subject) {
4225
+ if (subject.kind === "commit") return `${subject.repoPath}\ncommit\n${subject.sha}`;
4226
+ const { repoPath, branch, followedRef } = subject;
4052
4227
  return `${repoPath}\n${branch}\n${followedRef ?? `refs/heads/${branch}`}`;
4053
4228
  }
4054
4229
  /**
@@ -4206,6 +4381,11 @@ function summarise(workspace) {
4206
4381
  branch: workspace.changeSet.headRef,
4207
4382
  baseRef: workspace.changeSet.baseRef ?? "",
4208
4383
  ...workspace.changeSet.headResolvedRef ? { headResolvedRef: workspace.changeSet.headResolvedRef } : {},
4384
+ ...workspace.changeSet.targetType === "commit" ? {
4385
+ targetType: "commit",
4386
+ parentSha: workspace.changeSet.parentSha ?? null,
4387
+ ...workspace.changeSet.parentCount !== void 0 ? { parentCount: workspace.changeSet.parentCount } : {}
4388
+ } : {},
4209
4389
  repoPath: workspace.repoPath,
4210
4390
  changedFiles: totalChangedFiles(workspace.changeSet.data),
4211
4391
  additions: files.reduce((total, file) => total + (file.additions ?? 0), 0),
@@ -4224,7 +4404,7 @@ function summarise(workspace) {
4224
4404
  pinned: workspace.pinned === true,
4225
4405
  paths: files.slice(0, MAX_INDEXED_PATHS).map((file) => file.name),
4226
4406
  prNumber: workspace.changeSet.data.pullRequest?.number || workspace.prContext?.pullRequest.number,
4227
- commitSha: workspace.session.metadata?.commitSha || void 0,
4407
+ commitSha: workspace.session.metadata?.commitSha || (workspace.changeSet.targetType === "commit" ? workspace.changeSet.data.pullRequest.head.sha : "") || void 0,
4228
4408
  author: describeAuthorship(workspace.changeSet, workspace.prContext?.pullRequest)?.label,
4229
4409
  authorHandle: workspace.prContext?.pullRequest.author?.handle || void 0
4230
4410
  };
@@ -5110,18 +5290,47 @@ async function refSha(repoPath, ref) {
5110
5290
  * - **Describe, do not re-analyse.** The workspace says what changed. Deciding what it means for
5111
5291
  * the findings is the analyser's job, and the analyser is Claude Code.
5112
5292
  */
5113
- async function checkFreshness(workspace) {
5293
+ /**
5294
+ * Freshness, and for a commit review whether its commit is still here, in one pass.
5295
+ *
5296
+ * **`latest()` is never asked about a commit review.** Its null already means "the branch is not in
5297
+ * this repository", a real answer with two documented causes. A commit review has no branch to look
5298
+ * for at all, and teaching that function to return null for this too would make one value mean two
5299
+ * unrelated things, told apart only by knowing the target type: the ambiguity a commit target exists
5300
+ * to remove. So this path does not reach it.
5301
+ */
5302
+ async function checkReview(workspace) {
5114
5303
  const reviewedSha = workspace.session.metadata?.commitSha || workspace.changeSet.data.pullRequest.head.sha || null;
5115
5304
  const headSha = await readHead(workspace.repoPath);
5305
+ const commitReview = isCommitReview(workspace.changeSet);
5116
5306
  return {
5117
- checkedAt: Date.now(),
5118
- code: await codeFreshness(workspace, reviewedSha, headSha, await latest(workspace)),
5119
- checkout: await checkoutFreshness(workspace, headSha),
5120
- conversation: workspace.prContext ? {
5121
- attached: true,
5122
- state: "unknown",
5123
- importedAt: workspace.prContext.discussion.fetchedAt
5124
- } : { attached: false }
5307
+ freshness: {
5308
+ checkedAt: Date.now(),
5309
+ code: commitReview ? fixedCode(reviewedSha) : await codeFreshness(workspace, reviewedSha, headSha, await latest(workspace)),
5310
+ checkout: await checkoutFreshness(workspace, headSha),
5311
+ conversation: workspace.prContext ? {
5312
+ attached: true,
5313
+ state: "unknown",
5314
+ importedAt: workspace.prContext.discussion.fetchedAt
5315
+ } : { attached: false }
5316
+ },
5317
+ availability: commitReview && reviewedSha ? {
5318
+ state: await commitExists(workspace.repoPath, reviewedSha) ? "available" : "missing",
5319
+ sha: reviewedSha
5320
+ } : null
5321
+ };
5322
+ }
5323
+ /** The code answer for a commit review. Nothing is compared, so every count is absent rather than zero. */
5324
+ function fixedCode(reviewedSha) {
5325
+ return {
5326
+ state: "fixed",
5327
+ reviewedSha,
5328
+ latestSha: null,
5329
+ comparison: null,
5330
+ newCommits: null,
5331
+ changedFiles: null,
5332
+ rewritten: false,
5333
+ reason: null
5125
5334
  };
5126
5335
  }
5127
5336
  /**
@@ -5139,6 +5348,7 @@ async function checkFreshness(workspace) {
5139
5348
  * a whole `FreshnessReport` around a single run would imply it had its own answer to both.
5140
5349
  */
5141
5350
  async function checkCodeFreshness(workspace, reviewedSha) {
5351
+ if (isCommitReview(workspace.changeSet)) return fixedCode(reviewedSha);
5142
5352
  return codeFreshness(workspace, reviewedSha, await readHead(workspace.repoPath), await latest(workspace));
5143
5353
  }
5144
5354
  /**
@@ -9298,26 +9508,32 @@ async function withUsageRecorded(workspaceId, kind, env, body, runId, requestedM
9298
9508
  */
9299
9509
  var UPLOAD_KEY = "telemetryUpload";
9300
9510
  /**
9301
- * The answer to the first-run question, or `null` if it has not been asked yet.
9511
+ * What this machine was told to do about transmission, or `null` if nobody ever said.
9302
9512
  *
9303
- * Three states rather than two, and the third is load-bearing: an install that has never been
9304
- * asked must be asked, and must not be treated as having declined. Collapsing `null` to `false`
9305
- * here would make the question unaskable the moment anything read the setting.
9513
+ * Three states rather than two, and the third is load-bearing in both directions: it is what lets
9514
+ * `upload.ts` apply a default without overwriting anybody's answer, and what lets the CLI disclose
9515
+ * the default to the machines it actually applies to and nobody else.
9306
9516
  */
9307
9517
  function telemetryUploadConsent() {
9308
9518
  return loadConfig().telemetryUpload;
9309
9519
  }
9310
- /** Whether a subset may go over the wire. Yes only if somebody said yes. */
9520
+ /**
9521
+ * Whether a subset may go over the wire. Yes unless somebody said no.
9522
+ *
9523
+ * `!== false` rather than `=== true`, and that is the whole of the 0.30.0 change in one operator.
9524
+ * Opt in measured who answers questions rather than who uses this: one or two installations
9525
+ * reporting against hundreds of weekly downloads. What is sent did not widen by a field.
9526
+ */
9311
9527
  function telemetryUploadEnabled() {
9312
9528
  const config = loadConfig();
9313
- return config.telemetry && config.telemetryUpload === true;
9529
+ return config.telemetry && config.telemetryUpload !== false;
9314
9530
  }
9315
9531
  /**
9316
- * Remember the answer, so the question is asked once on a machine and never again.
9532
+ * Remember a decision somebody made, so it outlives any change to the default.
9317
9533
  *
9318
9534
  * Read-modify-write through `config_file.ts`, and refuses outright while `PRB_TELEMETRY=off` is
9319
- * set: the kill switch means nothing is asked, so there is no answer to store, and writing one
9320
- * would record a decision nobody made.
9535
+ * set: the kill switch means nothing is being sent and nobody is being asked anything, so there is
9536
+ * no answer to store, and writing one would record a decision nobody made.
9321
9537
  */
9322
9538
  function recordTelemetryUploadConsent(answer) {
9323
9539
  if (process.env.PRB_TELEMETRY === "off") return;
@@ -9366,7 +9582,7 @@ function loadConfig() {
9366
9582
  } catch {}
9367
9583
  return config;
9368
9584
  }
9369
- /** Anything that is not exactly `true` or `false` is nobody having answered. */
9585
+ /** Anything that is not exactly `true` or `false` is nobody having chosen. */
9370
9586
  function consentOf(value) {
9371
9587
  return typeof value === "boolean" ? value : null;
9372
9588
  }
@@ -9415,7 +9631,7 @@ function project(record) {
9415
9631
  } : base;
9416
9632
  }
9417
9633
  /**
9418
- * Send one event, if there is one to send and somebody said it could be.
9634
+ * Send one event, if there is one to send and nobody has said not to.
9419
9635
  *
9420
9636
  * Returns nothing and awaits nothing. A failed send is a lost data point, which is a cost we
9421
9637
  * accept; a thrown error here would be a review that did not happen, which is not. The same
@@ -9742,6 +9958,7 @@ async function drive(jobId, workspaceId, runners, driver, signal) {
9742
9958
  repoPath: job.target.originRepoPath,
9743
9959
  headRef: job.target.branch,
9744
9960
  ...job.target.ref ? { headResolvedRef: job.target.ref } : {},
9961
+ ...commitIdentity(job.target, stored.changeSet),
9745
9962
  checkoutName,
9746
9963
  isLinkedWorktree,
9747
9964
  data: {
@@ -9910,7 +10127,7 @@ function pendingChangeSet(target) {
9910
10127
  data: {
9911
10128
  pullRequest: {
9912
10129
  number: target.pullRequest?.number ?? 0,
9913
- title: target.branch,
10130
+ title: target.commit?.subject || target.branch,
9914
10131
  body: null,
9915
10132
  base: {
9916
10133
  ref: target.base ?? "",
@@ -9929,6 +10146,7 @@ function pendingChangeSet(target) {
9929
10146
  repoPath: target.originRepoPath,
9930
10147
  headRef: target.branch,
9931
10148
  ...target.ref ? { headResolvedRef: target.ref } : {},
10149
+ ...commitIdentity(target, void 0),
9932
10150
  baseRef: target.base ?? "",
9933
10151
  mergeBase: "",
9934
10152
  includesUncommitted: false,
@@ -9937,6 +10155,27 @@ function pendingChangeSet(target) {
9937
10155
  };
9938
10156
  }
9939
10157
  /**
10158
+ * What makes a stored change set a commit review, carried from wherever it is known.
10159
+ *
10160
+ * Two sources, because a target is rebuilt from a stored review in three places (`reanalyseReview`,
10161
+ * `pinned_checkout.ts`, `kiss_run.ts`) and none of them has the `NamedCommit` the original command
10162
+ * resolved. They do not need it: the stored record already says what it is, and that answer is the
10163
+ * one to keep. A fresh target wins when there is one, since it is where the answer was decided.
10164
+ */
10165
+ function commitIdentity(target, stored) {
10166
+ if (target.commit) return {
10167
+ targetType: "commit",
10168
+ parentSha: target.commit.parentSha,
10169
+ parentCount: target.commit.parentCount
10170
+ };
10171
+ if (stored?.targetType === "commit") return {
10172
+ targetType: "commit",
10173
+ parentSha: stored.parentSha ?? null,
10174
+ ...stored.parentCount !== void 0 ? { parentCount: stored.parentCount } : {}
10175
+ };
10176
+ return {};
10177
+ }
10178
+ /**
9940
10179
  * Rebuild the prompt a phase needs, from what the job and the workspace already hold.
9941
10180
  *
9942
10181
  * Not cached in memory: the daemon can restart between `context` finishing and `analysing`
@@ -10959,6 +11198,8 @@ function explainUpdateFailure(error, env, requestedModel) {
10959
11198
  var RefreshUnavailableError = class extends Error {};
10960
11199
  /** A fetch reaches the network, so it gets a ceiling. Nothing else here can hang. */
10961
11200
  var FETCH_TIMEOUT_MS = 6e4;
11201
+ /** Said wherever an update is asked of a commit review. One sentence, so both surfaces agree. */
11202
+ var COMMIT_REVIEW_CANNOT_UPDATE = "This review is of one commit, and a commit cannot move, so there is nothing to bring it up to date with. To analyse the same commit again, reanalyse it instead.";
10962
11203
  /**
10963
11204
  * Computes and mutates the workspace in memory; does not persist.
10964
11205
  *
@@ -10967,6 +11208,7 @@ var FETCH_TIMEOUT_MS = 6e4;
10967
11208
  */
10968
11209
  async function updateReview(workspace, runners = {}) {
10969
11210
  const reviewedSha = workspace.session.metadata?.commitSha || null;
11211
+ if (workspace.changeSet.targetType === "commit") return refuse(reviewedSha, false, COMMIT_REVIEW_CANNOT_UPDATE);
10970
11212
  const held = await claimCheckoutJob(workspace, reviewedSha ?? "", {
10971
11213
  purpose: "update",
10972
11214
  ...workspace.prContext ? { conversation: {
@@ -11572,4 +11814,4 @@ function lineageIds(id) {
11572
11814
  return [.../* @__PURE__ */ new Set([id, ...lineage.map((review) => review.id)])];
11573
11815
  }
11574
11816
  //#endregion
11575
- export { fillFileUrlTemplate as $, writeAgentPreference as $t, removeWorktree as A, workspaceRevision as At, workspaceUrl as B, isQuestionOutstanding as Bt, recordTelemetryUploadConsent as C, previousKissResult as Ct, ReviewBeingDeletedError as D, saveWorkspace as Dt, EXPLAIN_SIMPLY_PROMPT as E, reviewedRepositories as Et, clearServerRecord as F, queueStamp as Ft, DEFAULT_WORKSPACE_PORT as G, describeAuthorship as Gt, BUILD_VERSION as H, questionsOutstanding as Ht, ensureServer as I, readQueue as It, parseWorkspacePort as J, git as Jt, MAX_WORKSPACE_PORT as K, resolveTarget as Kt, readServerRecord as L, writeQueue as Lt, relativeTime as M, dequeueOnComplete as Mt, readIndexToken as N, queueAdd as Nt, MANAGED_ROOT as O, summarise as Ot, bootstrapUrl as P, queueRemove as Pt, forgeResolver as Q, readAgentPreference as Qt, reviewsUrl as R, doneVerb as Rt, record as S, positionInLineage as St, withUsageRecorded as T, reviewedCommit as Tt, PACKAGE_NAME as U, reviewerDispositions as Ut, writeServerRecord as V, issuesOutstanding as Vt, feedbackUrl as W, processDiscussion as Wt, statedWorkspacePort as X, resolveModel as Xt, resolveWorkspacePort as Y, resolveAgent as Yt, writePortPreference as Z, clearAgentPreference as Zt, startKissJob as _, latestKissRun as _t, wasBlocked as a, agentFor as an, isTerminal as at, startJob as b, loadWorkspace as bt, updateReview as c, AgentUnavailableError as cn, PHASES as ct, askCheckout as d, purposeOf as dt, CONFIG_PATH as en, checkCodeFreshness as et, UpdateAlreadyRunningError as f, clearClaim as ft, recordKissRun as g, indexReviewGroups as gt, agentEnvOf as h, groupByLineage as ht, liveJobsFor as i, agentById as in, fail as it, modelHelpLines as j, workspaceStamps as jt, readMarker as k, touchWorkspace as kt, refreshPrContext as l, phasesFor as lt, runningJobs as m, followedRefName as mt, deleteReview as n, AGENT_IDS as nn, allJobs as nt, discardJob as o, detectAgents as on, loadJob as ot, liveUpdateFor as p, isClaimed as pt, MIN_WORKSPACE_PORT as q, displayRef as qt, lineageIds as r, DEFAULT_AGENT_ID as rn, endedAt as rt, RefreshUnavailableError as s, AgentCancelledError as sn, saveJob as st, deleteLineage as t, STORE_ROOT as tn, checkFreshness as tt, isUnchanged as u, progressSteps as ut, reanalyseReview as v, lineageKeyFor as vt, telemetryUploadConsent as w, recentWorkspaces as wt, readEvents as x, matchingWorkspaceIds as xt, runJob as y, lineagePosition as yt, stopServer as z, isIssueOutstanding as zt };
11817
+ export { fillFileUrlTemplate as $, writeAgentPreference as $t, removeWorktree as A, workspaceRevision as At, workspaceUrl as B, isQuestionOutstanding as Bt, recordTelemetryUploadConsent as C, previousKissResult as Ct, ReviewBeingDeletedError as D, saveWorkspace as Dt, EXPLAIN_SIMPLY_PROMPT as E, reviewedRepositories as Et, clearServerRecord as F, queueStamp as Ft, DEFAULT_WORKSPACE_PORT as G, describeAuthorship as Gt, BUILD_VERSION as H, questionsOutstanding as Ht, ensureServer as I, readQueue as It, parseWorkspacePort as J, git as Jt, MAX_WORKSPACE_PORT as K, resolveTarget as Kt, readServerRecord as L, writeQueue as Lt, relativeTime as M, dequeueOnComplete as Mt, readIndexToken as N, queueAdd as Nt, MANAGED_ROOT as O, summarise as Ot, bootstrapUrl as P, queueRemove as Pt, forgeResolver as Q, readAgentPreference as Qt, reviewsUrl as R, doneVerb as Rt, record as S, positionInLineage as St, withUsageRecorded as T, reviewedCommit as Tt, PACKAGE_NAME as U, reviewerDispositions as Ut, writeServerRecord as V, issuesOutstanding as Vt, feedbackUrl as W, processDiscussion as Wt, statedWorkspacePort as X, resolveModel as Xt, resolveWorkspacePort as Y, resolveAgent as Yt, writePortPreference as Z, clearAgentPreference as Zt, startKissJob as _, latestKissRun as _t, wasBlocked as a, agentFor as an, isTerminal as at, startJob as b, loadWorkspace as bt, updateReview as c, AgentUnavailableError as cn, PHASES as ct, askCheckout as d, purposeOf as dt, CONFIG_PATH as en, checkCodeFreshness as et, UpdateAlreadyRunningError as f, clearClaim as ft, recordKissRun as g, indexReviewGroups as gt, agentEnvOf as h, groupByLineage as ht, liveJobsFor as i, agentById as in, fail as it, modelHelpLines as j, workspaceStamps as jt, readMarker as k, touchWorkspace as kt, refreshPrContext as l, phasesFor as lt, runningJobs as m, followedRefName as mt, deleteReview as n, AGENT_IDS as nn, allJobs as nt, discardJob as o, detectAgents as on, loadJob as ot, liveUpdateFor as p, isClaimed as pt, MIN_WORKSPACE_PORT as q, displayRef as qt, lineageIds as r, DEFAULT_AGENT_ID as rn, endedAt as rt, RefreshUnavailableError as s, AgentCancelledError as sn, saveJob as st, deleteLineage as t, STORE_ROOT as tn, checkReview as tt, isUnchanged as u, progressSteps as ut, reanalyseReview as v, lineageKeyFor as vt, telemetryUploadConsent as w, recentWorkspaces as wt, readEvents as x, matchingWorkspaceIds as xt, runJob as y, lineagePosition as yt, stopServer as z, isIssueOutstanding as zt };