prreviewbuddy 0.30.0 → 0.34.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.
@@ -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 } : {};
@@ -2656,7 +2759,7 @@ async function resolveTarget(input) {
2656
2759
  ...named
2657
2760
  };
2658
2761
  }
2659
- const branch = await branchOf(root);
2762
+ const branch = await branchOf$1(root);
2660
2763
  let sha;
2661
2764
  try {
2662
2765
  sha = (await git(root, ["rev-parse", "HEAD"])).trim();
@@ -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
@@ -2798,7 +2941,7 @@ async function refuseEmptyTarget(target) {
2798
2941
  async function uncommittedIn(root, target) {
2799
2942
  if (target.pullRequest) return 0;
2800
2943
  try {
2801
- if ((await git(root, ["rev-parse", "HEAD"])).trim() !== target.sha || await branchOf(root) !== target.branch) return 0;
2944
+ if ((await git(root, ["rev-parse", "HEAD"])).trim() !== target.sha || await branchOf$1(root) !== target.branch) return 0;
2802
2945
  return (await git(root, ["status", "--porcelain"])).split("\n").filter((line) => line.trim()).length;
2803
2946
  } catch {
2804
2947
  return 0;
@@ -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 {
@@ -2872,7 +3023,7 @@ async function repositoryRoot(repoPath) {
2872
3023
  } catch {}
2873
3024
  throw new TargetError(`${repoPath} is not inside a git repository.`);
2874
3025
  }
2875
- async function branchOf(root) {
3026
+ async function branchOf$1(root) {
2876
3027
  let branch = "";
2877
3028
  try {
2878
3029
  branch = (await git(root, [
@@ -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
  };
@@ -4586,6 +4766,18 @@ function isClaimed(jobId, now = Date.now()) {
4586
4766
  return liveClaim(jobId, now) !== null;
4587
4767
  }
4588
4768
  /**
4769
+ * Whether a driver took this job and is no longer there to finish it.
4770
+ *
4771
+ * The claim file is the evidence. A job minted a moment ago and not yet driven has no claim at all,
4772
+ * and a driver that finished released its claim on the way out, so a claim that is still on disk
4773
+ * with nobody live behind it can only belong to a run that ended without finishing: a terminal that
4774
+ * closed, a process that was killed. Answered at once rather than after the sweep's half hour,
4775
+ * because the reviewer asking is usually the one who just closed that terminal.
4776
+ */
4777
+ function driverGone(jobId, now = Date.now()) {
4778
+ return readClaim(jobId) !== null && liveClaim(jobId, now) === null;
4779
+ }
4780
+ /**
4589
4781
  * Take the claim on a job, or return null because another driver holds it.
4590
4782
  *
4591
4783
  * Two retries fired together, a retry racing the run `startJob` already kicked off, or a retry
@@ -4854,7 +5046,7 @@ function deleteJob(id) {
4854
5046
  }
4855
5047
  function advance(job, phase, progress) {
4856
5048
  const leaving = job.phase;
4857
- const completed = WORK_PHASES.includes(leaving) && !job.completed.includes(leaving) ? [...job.completed, leaving] : job.completed;
5049
+ const completed = WORK_PHASES.includes(leaving) && leaving !== phase && !job.completed.includes(leaving) ? [...job.completed, leaving] : job.completed;
4858
5050
  return {
4859
5051
  ...job,
4860
5052
  phase,
@@ -5110,18 +5302,47 @@ async function refSha(repoPath, ref) {
5110
5302
  * - **Describe, do not re-analyse.** The workspace says what changed. Deciding what it means for
5111
5303
  * the findings is the analyser's job, and the analyser is Claude Code.
5112
5304
  */
5113
- async function checkFreshness(workspace) {
5305
+ /**
5306
+ * Freshness, and for a commit review whether its commit is still here, in one pass.
5307
+ *
5308
+ * **`latest()` is never asked about a commit review.** Its null already means "the branch is not in
5309
+ * this repository", a real answer with two documented causes. A commit review has no branch to look
5310
+ * for at all, and teaching that function to return null for this too would make one value mean two
5311
+ * unrelated things, told apart only by knowing the target type: the ambiguity a commit target exists
5312
+ * to remove. So this path does not reach it.
5313
+ */
5314
+ async function checkReview(workspace) {
5114
5315
  const reviewedSha = workspace.session.metadata?.commitSha || workspace.changeSet.data.pullRequest.head.sha || null;
5115
5316
  const headSha = await readHead(workspace.repoPath);
5317
+ const commitReview = isCommitReview(workspace.changeSet);
5116
5318
  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 }
5319
+ freshness: {
5320
+ checkedAt: Date.now(),
5321
+ code: commitReview ? fixedCode(reviewedSha) : await codeFreshness(workspace, reviewedSha, headSha, await latest(workspace)),
5322
+ checkout: await checkoutFreshness(workspace, headSha),
5323
+ conversation: workspace.prContext ? {
5324
+ attached: true,
5325
+ state: "unknown",
5326
+ importedAt: workspace.prContext.discussion.fetchedAt
5327
+ } : { attached: false }
5328
+ },
5329
+ availability: commitReview && reviewedSha ? {
5330
+ state: await commitExists(workspace.repoPath, reviewedSha) ? "available" : "missing",
5331
+ sha: reviewedSha
5332
+ } : null
5333
+ };
5334
+ }
5335
+ /** The code answer for a commit review. Nothing is compared, so every count is absent rather than zero. */
5336
+ function fixedCode(reviewedSha) {
5337
+ return {
5338
+ state: "fixed",
5339
+ reviewedSha,
5340
+ latestSha: null,
5341
+ comparison: null,
5342
+ newCommits: null,
5343
+ changedFiles: null,
5344
+ rewritten: false,
5345
+ reason: null
5125
5346
  };
5126
5347
  }
5127
5348
  /**
@@ -5139,6 +5360,7 @@ async function checkFreshness(workspace) {
5139
5360
  * a whole `FreshnessReport` around a single run would imply it had its own answer to both.
5140
5361
  */
5141
5362
  async function checkCodeFreshness(workspace, reviewedSha) {
5363
+ if (isCommitReview(workspace.changeSet)) return fixedCode(reviewedSha);
5142
5364
  return codeFreshness(workspace, reviewedSha, await readHead(workspace.repoPath), await latest(workspace));
5143
5365
  }
5144
5366
  /**
@@ -9283,6 +9505,229 @@ async function withUsageRecorded(workspaceId, kind, env, body, runId, requestedM
9283
9505
  }
9284
9506
  }
9285
9507
  //#endregion
9508
+ //#region ../../packages/review-harness/src/workspace/notifications.ts
9509
+ /**
9510
+ * What happened while the reviewer was looking at something else, kept until they have seen it.
9511
+ *
9512
+ * **One file per notification, not one list.** Notifications are written by whichever process
9513
+ * drove the work to its end: the CLI for a review typed at a terminal, the workspace server for one
9514
+ * started from the page, the sweep for one whose driver died. A single `notifications.json` read,
9515
+ * modified and written by several processes loses whichever entry was written second, and the lost
9516
+ * one is by definition something the reviewer was not watching. `jobs/` is laid out the same way for
9517
+ * the same reason.
9518
+ *
9519
+ * **Created once, with `wx`.** The id is derived from the transition it records, so a second attempt
9520
+ * to record the same transition fails at the filesystem and returns null, and a caller that only
9521
+ * delivers what it created cannot deliver twice. Nothing that reads a job ever creates one: a page
9522
+ * reloading, a client polling or a review being reopened cannot announce anything.
9523
+ *
9524
+ * Imports only `store_root`, for the cycle `queue.ts` describes.
9525
+ */
9526
+ var NOTIFICATIONS_DIR = join(STORE_ROOT, "notifications");
9527
+ /** As long as a review is kept, so the list never names a review nobody can open for that reason. */
9528
+ var NOTIFICATION_RETENTION_MS = 2592e6;
9529
+ var OPERATION_NAMES = {
9530
+ review: "Review",
9531
+ reanalysis: "Reanalysis",
9532
+ update: "Review update",
9533
+ kiss: "KISS analysis"
9534
+ };
9535
+ /** What happened, in the words the list uses: `Review ready`, `Review update failed`. */
9536
+ function eventName(notification) {
9537
+ return `${OPERATION_NAMES[notification.operation]} ${notification.outcome}`;
9538
+ }
9539
+ /** The whole line, for anything that shows one event on its own: what happened, then to what. */
9540
+ function headline(notification) {
9541
+ return `${eventName(notification)} · ${notification.subject}`;
9542
+ }
9543
+ var OPERATIONS = [
9544
+ "review",
9545
+ "reanalysis",
9546
+ "update",
9547
+ "kiss"
9548
+ ];
9549
+ var OUTCOMES = ["ready", "failed"];
9550
+ /** An id is a file name, so it is held to a shape that cannot leave the directory. */
9551
+ var ID = /^[A-Za-z0-9-]{1,200}$/;
9552
+ function pathOf(id) {
9553
+ return join(NOTIFICATIONS_DIR, `${id}.json`);
9554
+ }
9555
+ /**
9556
+ * Record one event, or return null because it is already recorded or could not be written.
9557
+ *
9558
+ * Never throws. A notification that could not be written is a notification the reviewer does not
9559
+ * get, which is a smaller loss than the review job that was being announced.
9560
+ */
9561
+ function createNotification(input, now = Date.now()) {
9562
+ if (!ID.test(input.id)) return null;
9563
+ const record = {
9564
+ version: 1,
9565
+ kind: "analysis",
9566
+ ...input,
9567
+ createdAt: now
9568
+ };
9569
+ try {
9570
+ mkdirSync(NOTIFICATIONS_DIR, { recursive: true });
9571
+ writeFileSync(pathOf(input.id), JSON.stringify(record), {
9572
+ flag: "wx",
9573
+ mode: 384
9574
+ });
9575
+ return record;
9576
+ } catch {
9577
+ return null;
9578
+ }
9579
+ }
9580
+ /** Every notification, newest first. Unreadable and malformed files are skipped, not reported. */
9581
+ function listNotifications() {
9582
+ let names;
9583
+ try {
9584
+ names = readdirSync(NOTIFICATIONS_DIR);
9585
+ } catch {
9586
+ return [];
9587
+ }
9588
+ const found = [];
9589
+ for (const name of names) {
9590
+ if (!name.endsWith(".json")) continue;
9591
+ const record = readNotification(name.slice(0, -5));
9592
+ if (record) found.push(record);
9593
+ }
9594
+ return found.sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? -1 : 1));
9595
+ }
9596
+ function readNotification(id) {
9597
+ if (!ID.test(id)) return null;
9598
+ try {
9599
+ const parsed = JSON.parse(readFileSync(pathOf(id), "utf8"));
9600
+ return isNotification(parsed) && parsed.id === id ? parsed : null;
9601
+ } catch {
9602
+ return null;
9603
+ }
9604
+ }
9605
+ function unreadCount() {
9606
+ return listNotifications().filter((notification) => notification.readAt === void 0).length;
9607
+ }
9608
+ /**
9609
+ * Mark these read, and say how many changed.
9610
+ *
9611
+ * Only ever by something the reviewer did to the notification itself: opening it, or one of the two
9612
+ * mark buttons. Opening the review it points at is not the same act and does not come through here.
9613
+ */
9614
+ function markRead(ids, now = Date.now()) {
9615
+ let changed = 0;
9616
+ for (const id of ids) {
9617
+ const record = readNotification(id);
9618
+ if (!record || record.readAt !== void 0) continue;
9619
+ try {
9620
+ writeFileSync(pathOf(id), JSON.stringify({
9621
+ ...record,
9622
+ readAt: now
9623
+ }), { mode: 384 });
9624
+ changed += 1;
9625
+ } catch {}
9626
+ }
9627
+ return changed;
9628
+ }
9629
+ function markAllRead(now = Date.now()) {
9630
+ return markRead(listNotifications().filter((notification) => notification.readAt === void 0).map((notification) => notification.id), now);
9631
+ }
9632
+ /**
9633
+ * Forget what is older than the retention, and anything past the cap, oldest first.
9634
+ *
9635
+ * Files that do not parse are removed once they are as old as the retention, judged by their
9636
+ * modification time, so a stray file neither lingers for ever nor disappears the moment it is seen.
9637
+ */
9638
+ function pruneNotifications(now = Date.now()) {
9639
+ let names;
9640
+ try {
9641
+ names = readdirSync(NOTIFICATIONS_DIR);
9642
+ } catch {
9643
+ return 0;
9644
+ }
9645
+ const kept = listNotifications();
9646
+ const keep = new Set(kept.filter((notification) => now - notification.createdAt < NOTIFICATION_RETENTION_MS).slice(0, 200).map((notification) => `${notification.id}.json`));
9647
+ const valid = new Set(kept.map((notification) => `${notification.id}.json`));
9648
+ let removed = 0;
9649
+ for (const name of names) {
9650
+ if (!name.endsWith(".json") || keep.has(name)) continue;
9651
+ const path = join(NOTIFICATIONS_DIR, name);
9652
+ try {
9653
+ if (!valid.has(name) && now - statSync(path).mtimeMs < 2592e6) continue;
9654
+ unlinkSync(path);
9655
+ removed += 1;
9656
+ } catch {}
9657
+ }
9658
+ return removed;
9659
+ }
9660
+ function isNotification(value) {
9661
+ if (!value || typeof value !== "object") return false;
9662
+ const record = value;
9663
+ return record.version === 1 && record.kind === "analysis" && typeof record.id === "string" && OPERATIONS.includes(record.operation) && OUTCOMES.includes(record.outcome) && typeof record.reviewId === "string" && typeof record.subject === "string" && (record.branch === void 0 || typeof record.branch === "string") && typeof record.repo === "string" && typeof record.createdAt === "number" && (record.readAt === void 0 || typeof record.readAt === "number");
9664
+ }
9665
+ //#endregion
9666
+ //#region ../../packages/review-harness/src/workspace/announce.ts
9667
+ /**
9668
+ * Tell the reviewer a job ended, once, after the ending has been written.
9669
+ *
9670
+ * Called by the three places that write an ending: `drive` in `review_job.ts`, `updateReview`, and
9671
+ * the sweep for a job whose driver died. Each calls it with the record it has just saved, in the
9672
+ * process that saved it, so the answer never depends on who happens to be looking.
9673
+ *
9674
+ * The id is the job and the moment it ended, so two calls about the same ending make one record,
9675
+ * and a job picked up again that ends later is a new event with its own.
9676
+ *
9677
+ * The record is the event. The Notifications page and the workspace's toasts both read it. Any
9678
+ * delivery that has to be pushed rather than read belongs here, after `createNotification`, and only
9679
+ * for a record this call created, which carries the once-only guarantee over to it for free. Desktop
9680
+ * delivery is written that way and parked outside the product: see `desktop_preference.ts`.
9681
+ *
9682
+ * Never throws. This runs on the success path of a review that has already been saved, and the
9683
+ * accounting must not break the thing it accounts for.
9684
+ */
9685
+ function announceJob(job) {
9686
+ try {
9687
+ if (!isTerminal(job) || job.failure?.stopped || purposeOf(job) === "ask") return;
9688
+ const workspace = loadWorkspace(job.workspaceId);
9689
+ if (!workspace) return;
9690
+ const branch = branchOf(workspace);
9691
+ createNotification({
9692
+ id: `${job.id}-${endedAt(job)}`,
9693
+ operation: operationOf(job),
9694
+ outcome: job.phase === "done" ? "ready" : "failed",
9695
+ reviewId: workspace.id,
9696
+ subject: subjectOf(workspace),
9697
+ ...branch ? { branch } : {},
9698
+ repo: basename(workspace.repoPath)
9699
+ });
9700
+ } catch {}
9701
+ }
9702
+ function operationOf(job) {
9703
+ if (job.reviewType === "kiss") return "kiss";
9704
+ if (purposeOf(job) === "update") return "update";
9705
+ return job.reanalysis ? "reanalysis" : "review";
9706
+ }
9707
+ /**
9708
+ * What was reviewed, as the reviewer would name it, and nothing that locates it on disk.
9709
+ *
9710
+ * A pull request by its number, spelled the way its forge spells it; a commit by its short sha; a
9711
+ * branch by the ref it follows, which is the same rule the reviews list and the CLI use.
9712
+ */
9713
+ function subjectOf(workspace) {
9714
+ const changeSet = workspace.changeSet;
9715
+ if (changeSet.targetType === "commit") return `commit ${changeSet.data.pullRequest.head.sha.slice(0, 7)}`;
9716
+ const number = changeSet.data.pullRequest?.number || workspace.prContext?.pullRequest.number;
9717
+ if (number) return /\/merge_requests\//.test(workspace.prContext?.pullRequest.url ?? "") ? `MR !${number}` : `PR #${number}`;
9718
+ return followedRefName(changeSet.headRef, changeSet.headResolvedRef) ?? changeSet.headRef;
9719
+ }
9720
+ /**
9721
+ * The branch beside a pull request's number, which alone does not say what the change is. Nothing
9722
+ * for a branch review, whose subject already is the branch, or for a commit, which has none.
9723
+ */
9724
+ function branchOf(workspace) {
9725
+ const changeSet = workspace.changeSet;
9726
+ if (changeSet.targetType === "commit") return void 0;
9727
+ if (!(changeSet.data.pullRequest?.number || workspace.prContext?.pullRequest.number)) return void 0;
9728
+ return (followedRefName(changeSet.headRef, changeSet.headResolvedRef) ?? changeSet.headRef) || void 0;
9729
+ }
9730
+ //#endregion
9286
9731
  //#region ../../packages/review-harness/src/telemetry/consent.ts
9287
9732
  /**
9288
9733
  * Who this machine is counted as, and what it has agreed to.
@@ -9605,6 +10050,7 @@ async function reanalyseReview(workspaceId, agentId, onStarted, requestedModel)
9605
10050
  target,
9606
10051
  env: agent.captureEnv(),
9607
10052
  purpose: "review",
10053
+ reanalysis: true,
9608
10054
  ...model ? { requestedModel: model } : {}
9609
10055
  });
9610
10056
  saveJob(job);
@@ -9748,6 +10194,7 @@ async function drive(jobId, workspaceId, runners, driver, signal) {
9748
10194
  repoPath: job.target.originRepoPath,
9749
10195
  headRef: job.target.branch,
9750
10196
  ...job.target.ref ? { headResolvedRef: job.target.ref } : {},
10197
+ ...commitIdentity(job.target, stored.changeSet),
9751
10198
  checkoutName,
9752
10199
  isLinkedWorktree,
9753
10200
  data: {
@@ -9819,6 +10266,7 @@ async function drive(jobId, workspaceId, runners, driver, signal) {
9819
10266
  else await applyGuide(job, workspaceId, payload, observedModel);
9820
10267
  job = update(job, "done");
9821
10268
  finished(job, workspaceId, startedAt, true);
10269
+ announceJob(job);
9822
10270
  return job;
9823
10271
  } catch (error) {
9824
10272
  if (error instanceof AgentCancelledError || signal?.aborted) {
@@ -9832,6 +10280,7 @@ async function drive(jobId, workspaceId, runners, driver, signal) {
9832
10280
  job = recordFailure(job, explain(job, raw), driver, kind, raw);
9833
10281
  if (job.reviewType === "kiss") failCurrentKissRun(workspaceId, job, job.failure?.message ?? raw, kind, error instanceof KissRejected ? error.answer : void 0);
9834
10282
  finished(job, workspaceId, startedAt, false);
10283
+ announceJob(job);
9835
10284
  return job;
9836
10285
  }
9837
10286
  }
@@ -9916,7 +10365,7 @@ function pendingChangeSet(target) {
9916
10365
  data: {
9917
10366
  pullRequest: {
9918
10367
  number: target.pullRequest?.number ?? 0,
9919
- title: target.branch,
10368
+ title: target.commit?.subject || target.branch,
9920
10369
  body: null,
9921
10370
  base: {
9922
10371
  ref: target.base ?? "",
@@ -9935,6 +10384,7 @@ function pendingChangeSet(target) {
9935
10384
  repoPath: target.originRepoPath,
9936
10385
  headRef: target.branch,
9937
10386
  ...target.ref ? { headResolvedRef: target.ref } : {},
10387
+ ...commitIdentity(target, void 0),
9938
10388
  baseRef: target.base ?? "",
9939
10389
  mergeBase: "",
9940
10390
  includesUncommitted: false,
@@ -9943,6 +10393,27 @@ function pendingChangeSet(target) {
9943
10393
  };
9944
10394
  }
9945
10395
  /**
10396
+ * What makes a stored change set a commit review, carried from wherever it is known.
10397
+ *
10398
+ * Two sources, because a target is rebuilt from a stored review in three places (`reanalyseReview`,
10399
+ * `pinned_checkout.ts`, `kiss_run.ts`) and none of them has the `NamedCommit` the original command
10400
+ * resolved. They do not need it: the stored record already says what it is, and that answer is the
10401
+ * one to keep. A fresh target wins when there is one, since it is where the answer was decided.
10402
+ */
10403
+ function commitIdentity(target, stored) {
10404
+ if (target.commit) return {
10405
+ targetType: "commit",
10406
+ parentSha: target.commit.parentSha,
10407
+ parentCount: target.commit.parentCount
10408
+ };
10409
+ if (stored?.targetType === "commit") return {
10410
+ targetType: "commit",
10411
+ parentSha: stored.parentSha ?? null,
10412
+ ...stored.parentCount !== void 0 ? { parentCount: stored.parentCount } : {}
10413
+ };
10414
+ return {};
10415
+ }
10416
+ /**
9946
10417
  * Rebuild the prompt a phase needs, from what the job and the workspace already hold.
9947
10418
  *
9948
10419
  * Not cached in memory: the daemon can restart between `context` finishing and `analysing`
@@ -10965,6 +11436,8 @@ function explainUpdateFailure(error, env, requestedModel) {
10965
11436
  var RefreshUnavailableError = class extends Error {};
10966
11437
  /** A fetch reaches the network, so it gets a ceiling. Nothing else here can hang. */
10967
11438
  var FETCH_TIMEOUT_MS = 6e4;
11439
+ /** Said wherever an update is asked of a commit review. One sentence, so both surfaces agree. */
11440
+ 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.";
10968
11441
  /**
10969
11442
  * Computes and mutates the workspace in memory; does not persist.
10970
11443
  *
@@ -10973,6 +11446,7 @@ var FETCH_TIMEOUT_MS = 6e4;
10973
11446
  */
10974
11447
  async function updateReview(workspace, runners = {}) {
10975
11448
  const reviewedSha = workspace.session.metadata?.commitSha || null;
11449
+ if (workspace.changeSet.targetType === "commit") return refuse(reviewedSha, false, COMMIT_REVIEW_CANNOT_UPDATE);
10976
11450
  const held = await claimCheckoutJob(workspace, reviewedSha ?? "", {
10977
11451
  purpose: "update",
10978
11452
  ...workspace.prContext ? { conversation: {
@@ -10985,10 +11459,14 @@ async function updateReview(workspace, runners = {}) {
10985
11459
  const step = (phase) => saveJob(advance(current(), phase));
10986
11460
  try {
10987
11461
  const outcome = await bringForward(workspace, runners, reviewedSha, held, step);
10988
- saveJob(outcome.stop === "analysis-failed" ? fail(current(), outcome.message) : advance(current(), "done", outcome.message));
11462
+ const ended = outcome.stop === "analysis-failed" ? fail(current(), outcome.message) : advance(current(), "done", outcome.message);
11463
+ saveJob(ended);
11464
+ announceJob(ended);
10989
11465
  return outcome;
10990
11466
  } catch (error) {
10991
- saveJob(fail(current(), error instanceof Error ? error.message : String(error)));
11467
+ const failed = fail(current(), error instanceof Error ? error.message : String(error));
11468
+ saveJob(failed);
11469
+ announceJob(failed);
10992
11470
  throw error;
10993
11471
  } finally {
10994
11472
  held.claim.release();
@@ -11578,4 +12056,4 @@ function lineageIds(id) {
11578
12056
  return [.../* @__PURE__ */ new Set([id, ...lineage.map((review) => review.id)])];
11579
12057
  }
11580
12058
  //#endregion
11581
- 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 };
12059
+ export { feedbackUrl as $, reviewerDispositions as $t, markRead as A, loadWorkspace as At, modelHelpLines as B, workspaceRevision as Bt, recordTelemetryUploadConsent as C, isClaimed as Ct, headline as D, latestKissRun as Dt, eventName as E, indexReviewGroups as Et, EXPLAIN_SIMPLY_PROMPT as F, reviewedCommit as Ft, ensureServer as G, queueStamp as Gt, readIndexToken as H, dequeueOnComplete as Ht, ReviewBeingDeletedError as I, reviewedRepositories as It, stopServer as J, doneVerb as Jt, readServerRecord as K, readQueue as Kt, MANAGED_ROOT as L, saveWorkspace as Lt, readNotification as M, positionInLineage as Mt, unreadCount as N, previousKissResult as Nt, listNotifications as O, lineageKeyFor as Ot, withUsageRecorded as P, recentWorkspaces as Pt, PACKAGE_NAME as Q, questionsOutstanding as Qt, readMarker as R, summarise as Rt, record as S, driverGone as St, announceJob as T, groupByLineage as Tt, bootstrapUrl as U, queueAdd as Ut, relativeTime as V, workspaceStamps as Vt, clearServerRecord as W, queueRemove as Wt, writeServerRecord as X, isQuestionOutstanding as Xt, workspaceUrl as Y, isIssueOutstanding as Yt, BUILD_VERSION as Z, issuesOutstanding as Zt, startKissJob as _, AgentCancelledError as _n, PHASES as _t, wasBlocked as a, resolveAgent as an, statedWorkspacePort as at, startJob as b, purposeOf as bt, updateReview as c, readAgentPreference as cn, fillFileUrlTemplate as ct, askCheckout as d, STORE_ROOT as dn, allJobs as dt, processDiscussion as en, DEFAULT_WORKSPACE_PORT as et, UpdateAlreadyRunningError as f, AGENT_IDS as fn, endedAt as ft, recordKissRun as g, detectAgents as gn, saveJob as gt, agentEnvOf as h, agentFor as hn, loadJob as ht, liveJobsFor as i, git as in, resolveWorkspacePort as it, pruneNotifications as j, matchingWorkspaceIds as jt, markAllRead as k, lineagePosition as kt, refreshPrContext as l, writeAgentPreference as ln, checkCodeFreshness as lt, runningJobs as m, agentById as mn, isTerminal as mt, deleteReview as n, resolveTarget as nn, MIN_WORKSPACE_PORT as nt, discardJob as o, resolveModel as on, writePortPreference as ot, liveUpdateFor as p, DEFAULT_AGENT_ID as pn, fail as pt, reviewsUrl as q, writeQueue as qt, lineageIds as r, displayRef as rn, parseWorkspacePort as rt, RefreshUnavailableError as s, clearAgentPreference as sn, forgeResolver as st, deleteLineage as t, describeAuthorship as tn, MAX_WORKSPACE_PORT as tt, isUnchanged as u, CONFIG_PATH as un, checkReview as ut, reanalyseReview as v, AgentUnavailableError as vn, phasesFor as vt, telemetryUploadConsent as w, followedRefName as wt, readEvents as x, clearClaim as xt, runJob as y, progressSteps as yt, removeWorktree as z, touchWorkspace as zt };