prreviewbuddy 0.56.0 → 0.57.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,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.57.0
4
+
5
+ **Reviews you marked reviewed now find out when their pull request merges.** Until now the
6
+ workspace server only checked the pull requests of reviews that were queued, saved, Waiting, Done or
7
+ not yet marked reviewed. A review you finished while its pull request was still open stopped being
8
+ checked, so if the pull request merged later, its card kept saying nothing. The server now keeps
9
+ checking those in the background too, for whether each pull request is open, closed or merged and
10
+ nothing else. The checks run every hour and take a limited number of pull requests each time, so
11
+ with many reviews, or a pull request GitHub keeps failing to answer for, it can take a few hours to
12
+ come round to a particular one. A card that had already missed a merge picks it up the same way, and
13
+ shows **Merged** with the time it was found, so it may read "Merged just now" for a pull request that
14
+ merged days ago. A merged pull request is never checked again. One closed without merging keeps
15
+ being checked, since it can be reopened. These background checks never send a notification, and
16
+ never take a turn from the checks of reviews you are still working on.
17
+
18
+ **Check for updates asks GitHub too.** Pressing **Check for updates** on a review page now also asks
19
+ GitHub whether its pull request is open, closed or merged, and the label beside the pull request
20
+ number updates at once. It does not wait for any background check that happens to be running. It works on any review, including one marked **Done**, and leaves the review
21
+ where it is: still Done, not queued, not checked any more often than before. The checks the page makes
22
+ by itself, every minute and when you come back to the tab, still never contact GitHub.
23
+
24
+ **A deleted checkout no longer stops other pull requests being checked.** If a review's folder had
25
+ been moved or deleted, the server took the failure to mean the GitHub CLI was missing, and skipped
26
+ every other pull request on GitHub for the rest of that check. Now only that one review's check
27
+ fails, and everything else is checked as normal.
28
+
29
+ **Done reviews no longer notify when their pull request merges or closes.** A Done review is still
30
+ checked, so its card still says **Changed since done** after a push, a merge, a close or a reopen, and
31
+ a push still sends **PR updated**. A merge, close or reopen is now recorded without a **PR merged** or
32
+ **PR closed** notification. Reviews that are not Done notify as before.
33
+
3
34
  ## 0.56.0
4
35
 
5
36
  **Work in one focused tab, with Back and Forward that know where you were.** The workspace now has
@@ -6471,8 +6471,17 @@ function authStatusArgs(host) {
6471
6471
  *
6472
6472
  * `host` defaults for the one caller that has no repository in hand yet. Both callers that are
6473
6473
  * about to make a real request pass the host they are about to make it against.
6474
+ *
6475
+ * **A directory that is not there is not a missing `gh`, and is thrown rather than answered.** Node
6476
+ * reports a spawn into a missing `cwd` as `ENOENT`, exactly as it reports a missing executable, so
6477
+ * without this check a review whose checkout had been deleted read as "`gh` is not installed". The
6478
+ * watcher takes that verdict as true of every request reached the same way, and one stale checkout
6479
+ * then silenced every other check for the rest of the pass. Neither verdict this function returns is
6480
+ * about one directory, so it says neither: the caller gets a failure that belongs to the one
6481
+ * review whose checkout it is.
6474
6482
  */
6475
6483
  async function ghAvailable(cwd, env, host = "github.com") {
6484
+ if (!existsSync(cwd)) throw new GhError(`The checkout at ${cwd} no longer exists.`);
6476
6485
  try {
6477
6486
  await run(["--version"], cwd, env);
6478
6487
  } catch {
@@ -6947,7 +6956,15 @@ function readHeadSha(raw) {
6947
6956
  };
6948
6957
  }
6949
6958
  async function headOf(repo, number, env, cwd) {
6950
- const availability = await ghAvailable(cwd, env, repo.host);
6959
+ let availability;
6960
+ try {
6961
+ availability = await ghAvailable(cwd, env, repo.host);
6962
+ } catch (error) {
6963
+ return {
6964
+ kind: "failed",
6965
+ detail: error.message
6966
+ };
6967
+ }
6951
6968
  if (!availability.ok) return {
6952
6969
  kind: availability.kind,
6953
6970
  cli: availability.cli
package/dist/main.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { $n as resolveAgent, $t as driverGone, At as statedWorkspacePort, Ct as PACKAGE_NAME, Dt as MIN_WORKSPACE_PORT, E as telemetryUploadConsent, Et as MAX_WORKSPACE_PORT, Gt as loadJob, Jn as kissOutcome, Nt as forgeResolver, Ot as parseWorkspacePort, Qn as git, Qt as clearClaim, Rt as checkReview, S as startJob, St as BUILD_VERSION, T as recordTelemetryUploadConsent, Tt as DEFAULT_WORKSPACE_PORT, Vt as allJobs, Wt as isTerminal, Xn as displayRef, Xt as progressSteps, Yn as resolveTarget, Zt as purposeOf, _n as recentWorkspaces, a as wasBlocked, ar as STORE_ROOT, at as modelHelpLines, b as reanalyseReview, bn as summarise, bt as workspaceUrl, cr as agentById, dn as loadWorkspace, dt as readIndexToken, en as isClaimed, er as resolveModel, fn as matchingWorkspaceIds, ft as bootstrapUrl, g as runningJobs, gt as reviewsUrl, h as liveUpdateFor, i as liveJobsFor, ir as CONFIG_PATH, it as removeWorktree, jt as writePortPreference, l as mergeUpdateInto, ln as lineageKeyFor, lt as queueForReview, m as UpdateAlreadyRunningError, mn as positionInLineage, mt as ensureServer, n as deleteReview$1, nn as followedRefName, nr as readAgentPreference, nt as MANAGED_ROOT, or as AGENT_IDS, ot as relativeTime, pn as mutateWorkspace, qn as WITHHELD_REASON, qt as PHASES, rn as groupByLineage, rr as writeAgentPreference, rt as readMarker, sr as DEFAULT_AGENT_ID, u as updateReview, ur as detectAgents, v as recordKissRun, w as record$1, x as runJob, yn as reviewedRepositories, yt as stopServer } from "./delete_review-C5bz0Pts.js";
2
+ import { $n as resolveAgent, $t as driverGone, At as statedWorkspacePort, Ct as PACKAGE_NAME, Dt as MIN_WORKSPACE_PORT, E as telemetryUploadConsent, Et as MAX_WORKSPACE_PORT, Gt as loadJob, Jn as kissOutcome, Nt as forgeResolver, Ot as parseWorkspacePort, Qn as git, Qt as clearClaim, Rt as checkReview, S as startJob, St as BUILD_VERSION, T as recordTelemetryUploadConsent, Tt as DEFAULT_WORKSPACE_PORT, Vt as allJobs, Wt as isTerminal, Xn as displayRef, Xt as progressSteps, Yn as resolveTarget, Zt as purposeOf, _n as recentWorkspaces, a as wasBlocked, ar as STORE_ROOT, at as modelHelpLines, b as reanalyseReview, bn as summarise, bt as workspaceUrl, cr as agentById, dn as loadWorkspace, dt as readIndexToken, en as isClaimed, er as resolveModel, fn as matchingWorkspaceIds, ft as bootstrapUrl, g as runningJobs, gt as reviewsUrl, h as liveUpdateFor, i as liveJobsFor, ir as CONFIG_PATH, it as removeWorktree, jt as writePortPreference, l as mergeUpdateInto, ln as lineageKeyFor, lt as queueForReview, m as UpdateAlreadyRunningError, mn as positionInLineage, mt as ensureServer, n as deleteReview$1, nn as followedRefName, nr as readAgentPreference, nt as MANAGED_ROOT, or as AGENT_IDS, ot as relativeTime, pn as mutateWorkspace, qn as WITHHELD_REASON, qt as PHASES, rn as groupByLineage, rr as writeAgentPreference, rt as readMarker, sr as DEFAULT_AGENT_ID, u as updateReview, ur as detectAgents, v as recordKissRun, w as record$1, x as runJob, yn as reviewedRepositories, yt as stopServer } from "./delete_review-B3VPeozh.js";
3
3
  import { basename, dirname, join, resolve } from "node:path";
4
4
  import { spawn } from "node:child_process";
5
5
  import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
package/dist/server.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as asString, A as createPrState, An as offersLifecycle, At as statedWorkspacePort, B as toneOf, Bn as isQuestionOutstanding, Bt as reviewedShaOf, C as readEvents, Cn as workspaceStamps, D as announceJob, Dn as lifecycleAction, Dt as MIN_WORKSPACE_PORT, E as telemetryUploadConsent, En as isLifecycle, Et as MAX_WORKSPACE_PORT, F as markAllRead, Fn as queueRemove, Ft as remoteHost, G as authorPreview, Gn as recommendationsOf, Gt as loadJob, H as withUsageRecorded, Hn as questionsOutstanding, Ht as endedAt, I as markRead, In as queueStamp, It as fillFileUrlTemplate, J as renderMarkdown, Jn as kissOutcome, Jt as isLiveJob, K as hasAuthorContent, Kn as describeAuthorship, Kt as saveJob, L as pruneNotifications, Ln as readQueue, Lt as checkCodeFreshness, M as eventName, Mn as sectionOf, Mt as claimRequestUrl, N as headline, Nn as queueAdd, O as branchOf, On as lifecycleChip, Ot as parseWorkspacePort, P as listNotifications, Pn as queueAddMany, Pt as forgeFor, Q as renderInlineText, Qn as git, R as readNotification, Rn as doneVerb, Rt as checkReview, Sn as workspaceRevision, St as BUILD_VERSION, T as recordTelemetryUploadConsent, Tn as activityLabel, Tt as DEFAULT_WORKSPACE_PORT, U as EXPLAIN_SIMPLY_PROMPT, Un as reviewerDispositions, Ut as fail, V as unreadCount$1, Vn as issuesOutstanding, Vt as allJobs, W as UNESTABLISHED_MOTIVATION, Wn as processDiscussion, Wt as isTerminal, X as CODE_COPY_CLASS, Xt as progressSteps, Y as CODE_BLOCK_CLASS, Yt as phasesFor, Z as escapeHtml$1, Zn as isCommitReview, Zt as purposeOf, _ as agentEnvOf, _n as recentWorkspaces, _t as serverEntryExists, a as wasBlocked, an as indexReviewGroups, ar as STORE_ROOT, b as reanalyseReview, c as RefreshUnavailableError, cn as latestKissRun, ct as compactQueue, d as refreshPrContext, dn as loadWorkspace, dr as AgentCancelledError, dt as readIndexToken, en as isClaimed, er as resolveModel, et as extractJsonText, f as isUnchanged, fr as AgentUnavailableError, gn as previousKissResult, h as liveUpdateFor, hn as presentedKissRunOf, ht as readServerRecord, i as liveJobsFor, in as indexKeptIds, it as removeWorktree, j as createPrUpdate, jn as parseLifecycle, jt as writePortPreference, k as subjectOf, kn as lifecyclePill, kt as resolveWorkspacePort, l as mergeUpdateInto, lr as agentFor, m as UpdateAlreadyRunningError, n as deleteReview, nn as followedRefName, nr as readAgentPreference, nt as MANAGED_ROOT, o as discardJob, on as kissDecisionRefusal, or as AGENT_IDS, ot as relativeTime$1, p as askCheckout, pn as mutateWorkspace, pt as clearServerRecord, q as GITHUB_MARKDOWN, qn as WITHHELD_REASON, r as lineageIds, rn as groupByLineage, rr as writeAgentPreference, rt as readMarker, s as FETCH_TIMEOUT_MS, sn as kissDecisionsOf, st as activateQueued, t as deleteLineage, tn as decideKissRecommendation, tr as clearAgentPreference, tt as ReviewBeingDeletedError, u as updateReview, un as lineagePosition, ut as settleQueue, vn as reviewedCommit, vt as spawnServer, w as record, wn as LIFECYCLES, wt as feedbackUrl, x as runJob, xn as touchWorkspace, xt as writeServerRecord, y as startKissJob, z as removeNotification, zn as isIssueOutstanding, zt as followedBranch } from "./delete_review-C5bz0Pts.js";
1
+ import { $ as asString, A as createPrState, An as offersLifecycle, At as statedWorkspacePort, B as toneOf, Bn as isQuestionOutstanding, Bt as reviewedShaOf, C as readEvents, Cn as workspaceStamps, D as announceJob, Dn as lifecycleAction, Dt as MIN_WORKSPACE_PORT, E as telemetryUploadConsent, En as isLifecycle, Et as MAX_WORKSPACE_PORT, F as markAllRead, Fn as queueRemove, Ft as remoteHost, G as authorPreview, Gn as recommendationsOf, Gt as loadJob, H as withUsageRecorded, Hn as questionsOutstanding, Ht as endedAt, I as markRead, In as queueStamp, It as fillFileUrlTemplate, J as renderMarkdown, Jn as kissOutcome, Jt as isLiveJob, K as hasAuthorContent, Kn as describeAuthorship, Kt as saveJob, L as pruneNotifications, Ln as readQueue, Lt as checkCodeFreshness, M as eventName, Mn as sectionOf, Mt as claimRequestUrl, N as headline, Nn as queueAdd, O as branchOf, On as lifecycleChip, Ot as parseWorkspacePort, P as listNotifications, Pn as queueAddMany, Pt as forgeFor, Q as renderInlineText, Qn as git, R as readNotification, Rn as doneVerb, Rt as checkReview, Sn as workspaceRevision, St as BUILD_VERSION, T as recordTelemetryUploadConsent, Tn as activityLabel, Tt as DEFAULT_WORKSPACE_PORT, U as EXPLAIN_SIMPLY_PROMPT, Un as reviewerDispositions, Ut as fail, V as unreadCount$1, Vn as issuesOutstanding, Vt as allJobs, W as UNESTABLISHED_MOTIVATION, Wn as processDiscussion, Wt as isTerminal, X as CODE_COPY_CLASS, Xt as progressSteps, Y as CODE_BLOCK_CLASS, Yt as phasesFor, Z as escapeHtml$1, Zn as isCommitReview, Zt as purposeOf, _ as agentEnvOf, _n as recentWorkspaces, _t as serverEntryExists, a as wasBlocked, an as indexReviewGroups, ar as STORE_ROOT, b as reanalyseReview, c as RefreshUnavailableError, cn as latestKissRun, ct as compactQueue, d as refreshPrContext, dn as loadWorkspace, dr as AgentCancelledError, dt as readIndexToken, en as isClaimed, er as resolveModel, et as extractJsonText, f as isUnchanged, fr as AgentUnavailableError, gn as previousKissResult, h as liveUpdateFor, hn as presentedKissRunOf, ht as readServerRecord, i as liveJobsFor, in as indexKeptIds, it as removeWorktree, j as createPrUpdate, jn as parseLifecycle, jt as writePortPreference, k as subjectOf, kn as lifecyclePill, kt as resolveWorkspacePort, l as mergeUpdateInto, lr as agentFor, m as UpdateAlreadyRunningError, n as deleteReview, nn as followedRefName, nr as readAgentPreference, nt as MANAGED_ROOT, o as discardJob, on as kissDecisionRefusal, or as AGENT_IDS, ot as relativeTime$1, p as askCheckout, pn as mutateWorkspace, pt as clearServerRecord, q as GITHUB_MARKDOWN, qn as WITHHELD_REASON, r as lineageIds, rn as groupByLineage, rr as writeAgentPreference, rt as readMarker, s as FETCH_TIMEOUT_MS, sn as kissDecisionsOf, st as activateQueued, t as deleteLineage, tn as decideKissRecommendation, tr as clearAgentPreference, tt as ReviewBeingDeletedError, u as updateReview, un as lineagePosition, ut as settleQueue, vn as reviewedCommit, vt as spawnServer, w as record, wn as LIFECYCLES, wt as feedbackUrl, x as runJob, xn as touchWorkspace, xt as writeServerRecord, y as startKissJob, z as removeNotification, zn as isIssueOutstanding, zt as followedBranch } from "./delete_review-B3VPeozh.js";
2
2
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { createHash, timingSafeEqual } from "node:crypto";
@@ -6371,12 +6371,14 @@ function writeJsonAtomically(path, value) {
6371
6371
  * Keyed by request (`requestKey` in `pr_watch.ts`) for the reason `watch.json` is: Update review
6372
6372
  * writes a new workspace record, and the request is the thing that persists across them.
6373
6373
  *
6374
- * Written only by the watcher, so single-writer, and atomically, because the read below treats
6375
- * anything unparsable as nothing known -- and a store that forgot every state would then record the
6374
+ * Three writers, all in `pr_watch.ts`: the five-minute watcher, the hourly pass for reviews it does
6375
+ * not check, and a press of Check for updates. All three go through `persistPrStates`, which reads
6376
+ * the file, keeps the newer observation of each request and writes, with nothing awaited between,
6377
+ * so none can take back a later one's answer. Written atomically, because the read below treats anything unparsable as nothing known -- and a store that forgot every state would then record the
6376
6378
  * next observation of each as a first one, which is silent. Losing it costs badges, not duplicates.
6377
6379
  */
6378
6380
  var PR_STATES_PATH = join(STORE_ROOT, "pr_states.json");
6379
- var VERSION$1 = 1;
6381
+ var VERSION$2 = 1;
6380
6382
  var STATES = [
6381
6383
  "open",
6382
6384
  "merged",
@@ -6387,7 +6389,7 @@ function readPrStates() {
6387
6389
  try {
6388
6390
  const parsed = JSON.parse(readFileSync(PR_STATES_PATH, "utf8"));
6389
6391
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
6390
- if (parsed.version !== VERSION$1) return {};
6392
+ if (parsed.version !== VERSION$2) return {};
6391
6393
  const entries = parsed.entries;
6392
6394
  if (!entries || typeof entries !== "object" || Array.isArray(entries)) return {};
6393
6395
  return normalise$1(entries);
@@ -6397,7 +6399,7 @@ function readPrStates() {
6397
6399
  }
6398
6400
  function writePrStates(states) {
6399
6401
  return writeJsonAtomically(PR_STATES_PATH, {
6400
- version: VERSION$1,
6402
+ version: VERSION$2,
6401
6403
  entries: states
6402
6404
  });
6403
6405
  }
@@ -6431,6 +6433,59 @@ function normalise$1(entries) {
6431
6433
  return states;
6432
6434
  }
6433
6435
  //#endregion
6436
+ //#region ../../packages/review-harness/src/workspace/pr_state_checks_store.ts
6437
+ /**
6438
+ * When the slow pull request state pass last tried each request, and how often it has failed.
6439
+ *
6440
+ * The rotation's bookkeeping for `reconcileCycle` in `pr_watch.ts`, and nothing else. In the
6441
+ * `watch.json` entry shape, using only `polledAt` and `failures`, so `orderForCycle` and
6442
+ * `isBackedOff` rotate and back off this pass by exactly the rules the five-minute watcher uses.
6443
+ *
6444
+ * **Its own file**, because neither existing one can hold it. `watch.json` is pruned to the reviews
6445
+ * the five-minute watcher checks, which are exactly the ones this pass does not. `pr_states.json`
6446
+ * says when the forge last *answered* (`observedAt`), and ordering by success would park a request
6447
+ * whose checks keep failing at the front for ever, starving everything behind it. Last attempted
6448
+ * and last succeeded are different questions, so they live in different places.
6449
+ *
6450
+ * Losing it costs nothing worse than one pass asking in a different order. Best-effort read,
6451
+ * atomic write, like its neighbours.
6452
+ */
6453
+ var PR_STATE_CHECKS_PATH = join(STORE_ROOT, "pr_state_checks.json");
6454
+ var VERSION$1 = 1;
6455
+ /** Keyed by `requestKey`. Only `polledAt` and `failures` are ever written. */
6456
+ function readPrStateChecks() {
6457
+ try {
6458
+ const parsed = JSON.parse(readFileSync(PR_STATE_CHECKS_PATH, "utf8"));
6459
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
6460
+ if (parsed.version !== VERSION$1) return {};
6461
+ const entries = parsed.entries;
6462
+ if (!entries || typeof entries !== "object" || Array.isArray(entries)) return {};
6463
+ const checks = {};
6464
+ for (const [key, value] of Object.entries(entries)) {
6465
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
6466
+ const raw = value;
6467
+ checks[key] = {
6468
+ ...typeof raw.polledAt === "number" ? { polledAt: raw.polledAt } : {},
6469
+ ...typeof raw.failures === "number" ? { failures: raw.failures } : {}
6470
+ };
6471
+ }
6472
+ return checks;
6473
+ } catch {
6474
+ return {};
6475
+ }
6476
+ }
6477
+ function writePrStateChecks(checks) {
6478
+ const entries = {};
6479
+ for (const [key, entry] of Object.entries(checks)) entries[key] = {
6480
+ ...entry.polledAt === void 0 ? {} : { polledAt: entry.polledAt },
6481
+ ...entry.failures === void 0 ? {} : { failures: entry.failures }
6482
+ };
6483
+ return writeJsonAtomically(PR_STATE_CHECKS_PATH, {
6484
+ version: VERSION$1,
6485
+ entries
6486
+ });
6487
+ }
6488
+ //#endregion
6434
6489
  //#region ../../packages/review-harness/src/workspace/watch_store.ts
6435
6490
  /**
6436
6491
  * What the PR watcher has already seen, and already said.
@@ -6594,6 +6649,14 @@ function normalise(entries) {
6594
6649
  * feel real-time about an event that is not urgent.
6595
6650
  */
6596
6651
  var WATCH_INTERVAL_MS = 3e5;
6652
+ /**
6653
+ * How often the slow pull request state pass runs. See `reconcileCycle`.
6654
+ *
6655
+ * Chosen by what being late costs, the way `WATCH_INTERVAL_MS` is. This pass covers reviews the
6656
+ * reviewer has finished with, so the only cost of lateness is a card saying "open" for up to an hour
6657
+ * after a merge. An hour is short enough that nobody tidying their list finds it stale.
6658
+ */
6659
+ var RECONCILE_INTERVAL_MS = 36e5;
6597
6660
  function isBranchCandidate(candidate) {
6598
6661
  return candidate.request === void 0;
6599
6662
  }
@@ -6846,6 +6909,7 @@ async function watchCycle(deps = defaultDeps(), options = {}) {
6846
6909
  const state = migrated(deps);
6847
6910
  if (state === null) return;
6848
6911
  const prStates = readPrStates();
6912
+ const observedStates = {};
6849
6913
  const byKey = new Map(dedupeByKey(candidates).map((candidate) => [watchKey(candidate), candidate]));
6850
6914
  const inScope = [...byKey.keys()].filter((key) => (options.scope?.(byKey.get(key)) ?? true) && prStates[key]?.state !== "merged");
6851
6915
  const budget = options.budget ?? {
@@ -6879,7 +6943,10 @@ async function watchCycle(deps = defaultDeps(), options = {}) {
6879
6943
  continue;
6880
6944
  }
6881
6945
  if (observed.kind === "unresolved") continue;
6882
- if (observed.prState !== void 0 && !isBranchCandidate(candidate)) recordPrState(candidate, key, observed.sha, observed.prState, prStates, now);
6946
+ if (observed.prState !== void 0 && !isBranchCandidate(candidate)) {
6947
+ recordPrState(candidate, key, observed.sha, observed.prState, prStates, seenAt, candidate.done);
6948
+ if (prStates[key]) observedStates[key] = prStates[key];
6949
+ }
6883
6950
  const lastSeen = state[key]?.observedSha;
6884
6951
  state[key] = {
6885
6952
  ...state[key],
@@ -6900,7 +6967,45 @@ async function watchCycle(deps = defaultDeps(), options = {}) {
6900
6967
  if (deps.cancelled?.() === true) return;
6901
6968
  writeWatch(state);
6902
6969
  pruneWatch([...byKey.keys()]);
6903
- writePrStates(prunedPrStates(prStates, deps));
6970
+ persistPrStates(observedStates, deps);
6971
+ }
6972
+ /**
6973
+ * Put what a pass or a press observed into `pr_states.json`, keeping whichever observation of each
6974
+ * request is newer.
6975
+ *
6976
+ * **Merged with what is on disk now, never written over it.** A pass reads the file when it starts
6977
+ * and can run for minutes; a press of Check for updates writes the moment its answer arrives,
6978
+ * without waiting for any pass. Writing a pass's whole snapshot back at the end would take back
6979
+ * whatever a press wrote meanwhile, and queueing the press behind the pass instead would make a
6980
+ * press wait out up to twenty slow requests and then write an answer older than the pass's.
6981
+ *
6982
+ * So each writer contributes only the requests it asked about, and per request **the later
6983
+ * `observedAt` wins**. That is the only ordering that means anything: whatever GitHub said last is
6984
+ * true. A merge cannot be taken back by an older "open", because nothing can be observed open after
6985
+ * it merged.
6986
+ *
6987
+ * Synchronous from the read to the write, so in this process no other writer can come between
6988
+ * them. `changedAt` is worked out against what is on disk, not against the writer's snapshot, since
6989
+ * that is the state this observation actually follows.
6990
+ */
6991
+ function persistPrStates(observed, prune) {
6992
+ const onDisk = readPrStates();
6993
+ for (const [key, mine] of Object.entries(observed)) {
6994
+ const theirs = onDisk[key];
6995
+ if (theirs === void 0) onDisk[key] = mine;
6996
+ else if (theirs.observedAt > mine.observedAt) continue;
6997
+ else if (theirs.state === mine.state) {
6998
+ const { changedAt: _ours, ...rest } = mine;
6999
+ onDisk[key] = {
7000
+ ...rest,
7001
+ ...theirs.changedAt === void 0 ? {} : { changedAt: theirs.changedAt }
7002
+ };
7003
+ } else onDisk[key] = {
7004
+ ...mine,
7005
+ changedAt: mine.observedAt
7006
+ };
7007
+ }
7008
+ writePrStates(prune ? prunedPrStates(onDisk, prune) : onDisk);
6904
7009
  }
6905
7010
  /**
6906
7011
  * The facts to keep: every request a stored review still refers to. If the store cannot be listed
@@ -6925,13 +7030,19 @@ function prunedPrStates(prStates, deps) {
6925
7030
  * `null` -- the forge answered but did not say -- leaves the stored state alone. An unreadable
6926
7031
  * answer never erases a fact.
6927
7032
  *
7033
+ * **`quiet` records the same fact and says nothing.** For a Done review, for the slow pass and for
7034
+ * an explicit Check for updates: the reviewer is either finished with the review or looking at it.
7035
+ * `changedAt` still moves, deliberately. It is what dates the card and what raises "Changed since
7036
+ * done" on a Done one, and "the pull request merged since you marked it done" is a true and useful
7037
+ * thing for that card to say. It is only not worth an interruption.
7038
+ *
6928
7039
  * Nothing about the review changes. `completedAt` is the reviewer's decision and only Mark reviewed
6929
7040
  * writes it; this module does not load or save a workspace record at all.
6930
7041
  */
6931
- function recordPrState(candidate, key, headSha, observed, prStates, now) {
7042
+ function recordPrState(candidate, key, headSha, observed, prStates, now, quiet) {
6932
7043
  if (observed === null) return;
6933
7044
  const previous = prStates[key]?.state;
6934
- if (previous === "open" && observed !== "open") {
7045
+ if (!quiet && previous === "open" && observed !== "open") {
6935
7046
  const id = `pr-${observed}-${candidate.reviewId}-${headSha}`;
6936
7047
  if (createPrState({
6937
7048
  id,
@@ -7106,7 +7217,12 @@ async function announce(candidate, key, observed, state, deps, now) {
7106
7217
  };
7107
7218
  }
7108
7219
  /**
7109
- * Which reviews are worth watching, from state the product already keeps.
7220
+ * Which reviews the five-minute watcher checks for new commits, from state the product already keeps.
7221
+ *
7222
+ * **One of two eligibility rules, and not the one that decides whether a pull request's state is
7223
+ * kept accurate.** That is `needsPrStateTracking`. This one is about the head moving: whether a push
7224
+ * is something the reviewer wants to hear about. The two used to be one rule, and a review marked
7225
+ * reviewed while its request was open fell out of both, so its merge was never seen.
7110
7226
  *
7111
7227
  * No `watched` flag, because the reviewer has already said what they are working on three times
7112
7228
  * over: the queue is the set they are working through, Saved is the set they mean to come back to,
@@ -7124,10 +7240,35 @@ async function announce(candidate, key, observed, state, deps, now) {
7124
7240
  * no pull request used to be excluded too, having nothing to ask a forge about. It is watched
7125
7241
  * through its branch instead, locally; see `BranchCandidate`.
7126
7242
  */
7127
- function isWatchable(workspace, queued) {
7243
+ function needsChangeWatching(workspace, queued) {
7128
7244
  if (isCommitReview(workspace.changeSet)) return false;
7129
7245
  return queued.has(workspace.id) || workspace.pinned === true || workspace.completedAt === void 0 || parseLifecycle(workspace.lifecycle) !== void 0;
7130
7246
  }
7247
+ /**
7248
+ * Whether this review's pull request state should still be kept accurate: whether it is open, closed
7249
+ * or merged.
7250
+ *
7251
+ * **Lifecycle eligibility, not membership of any one pass.** Who actually asks is decided elsewhere:
7252
+ *
7253
+ * needsChangeWatching yes -> five-minute watcher (head moves, and state with them)
7254
+ * needsPrStateTracking yes, not the above -> hourly pass (`reconcileCycle`, state only)
7255
+ *
7256
+ * Most reviews this says yes to are already covered by the first row, which records the state from
7257
+ * the same request it makes for the head. `prStateCandidates` is the second row.
7258
+ *
7259
+ * Nothing about the review's workflow enters into it: reviewed, queued, saved, Waiting and Done all
7260
+ * leave it unchanged. Whether a review is finished is the reviewer's business; whether its pull
7261
+ * request merged is a fact about the world, and the card shows it either way.
7262
+ *
7263
+ * Merged is the end. A merged request cannot move or reopen, so asking again would only spend
7264
+ * somebody's rate limit. Closed is not the end, because a closed request can be reopened.
7265
+ */
7266
+ function needsPrStateTracking(workspace, prStates) {
7267
+ if (isCommitReview(workspace.changeSet)) return false;
7268
+ const request = requestOf(workspace);
7269
+ if (!request) return false;
7270
+ return prStates[requestKey(workspace.repoPath, request.number)]?.state !== "merged";
7271
+ }
7131
7272
  /** The request a review is attached to, by URL where there is one and by number otherwise. */
7132
7273
  function requestOf(workspace) {
7133
7274
  const pr = workspace.prContext?.pullRequest;
@@ -7148,7 +7289,8 @@ function candidateOf(workspace) {
7148
7289
  ...branch ? { branch } : {},
7149
7290
  repo: basename(workspace.repoPath),
7150
7291
  createdAt: workspace.createdAt,
7151
- env: agentEnvOf(workspace)
7292
+ env: agentEnvOf(workspace),
7293
+ done: parseLifecycle(workspace.lifecycle)?.state === "done"
7152
7294
  };
7153
7295
  if (request) return {
7154
7296
  ...base,
@@ -7193,12 +7335,36 @@ function watchedCandidates() {
7193
7335
  const found = [];
7194
7336
  for (const [id] of workspaceStamps()) {
7195
7337
  const workspace = loadWorkspace(id);
7196
- if (!workspace || !isWatchable(workspace, queued)) continue;
7338
+ if (!workspace || !needsChangeWatching(workspace, queued)) continue;
7197
7339
  const candidate = candidateOf(workspace);
7198
7340
  if (candidate) found.push(candidate);
7199
7341
  }
7200
7342
  return found;
7201
7343
  }
7344
+ /**
7345
+ * The requests the hourly pass asks about: those whose state needs keeping accurate
7346
+ * (`needsPrStateTracking`) and that the five-minute watcher does not already cover.
7347
+ *
7348
+ * The subtraction is by request, not by review. If any analysis of a request is change-watched, the
7349
+ * watcher's request for the head carries the state too, and asking again here would double the
7350
+ * calls for nothing. In practice what is left is the reviews marked reviewed and left Active,
7351
+ * unqueued and unsaved, which is the set nothing used to ask about at all.
7352
+ */
7353
+ function prStateCandidates() {
7354
+ const queued = new Set(readQueue());
7355
+ const prStates = readPrStates();
7356
+ const changeWatched = /* @__PURE__ */ new Set();
7357
+ const tracked = [];
7358
+ for (const [id] of workspaceStamps()) {
7359
+ const workspace = loadWorkspace(id);
7360
+ if (!workspace) continue;
7361
+ const candidate = candidateOf(workspace);
7362
+ if (!candidate || isBranchCandidate(candidate)) continue;
7363
+ if (needsChangeWatching(workspace, queued)) changeWatched.add(watchKey(candidate));
7364
+ else if (needsPrStateTracking(workspace, prStates)) tracked.push(candidate);
7365
+ }
7366
+ return dedupeByKey(tracked).filter((candidate) => !isBranchCandidate(candidate) && !changeWatched.has(watchKey(candidate)));
7367
+ }
7202
7368
  /** Every stored review's request, watched or not. See `WatchDeps.requestKeys`. */
7203
7369
  function storedRequestKeys() {
7204
7370
  const keys = /* @__PURE__ */ new Set();
@@ -7262,8 +7428,8 @@ function reviewActivity(groups, watch = readWatch(), prStates = readPrStates())
7262
7428
  }
7263
7429
  /**
7264
7430
  * One review's pull request state, for its page, filed under the same key the list looks up. Null
7265
- * for a review with no request or one never observed: unknown, not open. Only ever what the watcher
7266
- * last saw; nothing here reaches the forge.
7431
+ * for a review with no request or one never observed: unknown, not open. Only ever what was last
7432
+ * stored; nothing here reaches the forge.
7267
7433
  */
7268
7434
  function prStateOf$1(workspace, states = readPrStates()) {
7269
7435
  const request = requestOf(workspace);
@@ -7372,6 +7538,133 @@ function startWatching(makeDeps = defaultDeps) {
7372
7538
  clearInterval(timer);
7373
7539
  };
7374
7540
  }
7541
+ function defaultReconcileDeps() {
7542
+ const { fetchHead, requestKeys, now } = defaultDeps();
7543
+ return {
7544
+ candidates: prStateCandidates,
7545
+ fetchHead,
7546
+ requestKeys,
7547
+ now
7548
+ };
7549
+ }
7550
+ /**
7551
+ * One slow pass: keep pull request state accurate for the reviews the five-minute watcher does not
7552
+ * check. See `needsPrStateTracking` for who those are and why.
7553
+ *
7554
+ * **State only, and always quiet.** It reads no head, writes nothing to `watch.json`, counts no
7555
+ * commits and creates no notification of any kind. What it finds is bookkeeping for reviews the
7556
+ * reviewer has finished with: the card's badge and its time change, nothing interrupts.
7557
+ *
7558
+ * Rotated and backed off by `orderForCycle` and `isBackedOff`, the five-minute watcher's own rules,
7559
+ * over its own bookkeeping (`pr_state_checks.json`). A failed attempt still moves its request to the
7560
+ * back, so requests that keep failing cannot hold the front and starve the rest.
7561
+ *
7562
+ * Never throws. Cancellation is checked before each request, so a stood-down pass stops asking, and
7563
+ * before the writes, so it changes nothing. Unlike `watchCycle` there is no check between the two:
7564
+ * nothing in the loop reaches outside memory, because nothing here notifies.
7565
+ */
7566
+ async function reconcileCycle(deps = defaultReconcileDeps(), budget = 20) {
7567
+ let candidates;
7568
+ try {
7569
+ candidates = deps.candidates();
7570
+ } catch {
7571
+ return;
7572
+ }
7573
+ const byKey = new Map(dedupeByKey(candidates).map((candidate) => [watchKey(candidate), candidate]));
7574
+ const checks = readPrStateChecks();
7575
+ const prStates = readPrStates();
7576
+ const observedStates = {};
7577
+ const now = deps.now();
7578
+ const unreachable = /* @__PURE__ */ new Set();
7579
+ for (const key of orderForCycle([...byKey.keys()], checks, budget, now, RECONCILE_INTERVAL_MS)) {
7580
+ if (deps.cancelled?.() === true) return;
7581
+ const candidate = byKey.get(key);
7582
+ if (unreachable.has(reachKey(candidate))) continue;
7583
+ const observed = await observeRequest(candidate, deps, unreachable);
7584
+ if (observed.kind !== "head") {
7585
+ checks[key] = {
7586
+ polledAt: now,
7587
+ failures: (checks[key]?.failures ?? 0) + 1
7588
+ };
7589
+ continue;
7590
+ }
7591
+ checks[key] = {
7592
+ polledAt: now,
7593
+ failures: 0
7594
+ };
7595
+ if (observed.prState !== void 0) {
7596
+ recordPrState(candidate, key, observed.sha, observed.prState, prStates, deps.now(), true);
7597
+ if (prStates[key]) observedStates[key] = prStates[key];
7598
+ }
7599
+ }
7600
+ if (deps.cancelled?.() === true) return;
7601
+ persistPrStates(observedStates, deps);
7602
+ writePrStateChecks(Object.fromEntries(Object.entries(checks).filter(([key]) => byKey.has(key))));
7603
+ }
7604
+ /**
7605
+ * Run the slow pass on a timer, and return the stop. The shape of `startWatching`.
7606
+ *
7607
+ * **Once immediately**, and not only because a server that has been down missed things. The server
7608
+ * restarts itself whenever it is rebuilt or upgraded, often more than once an hour, and a pass that
7609
+ * waited a full interval after each start could go days without running at all.
7610
+ *
7611
+ * Through `exclusive`, so it never runs beside `watchCycle` and the two never ask the forge at the
7612
+ * same time. `pr_states.json` does not depend on it: see `persistPrStates`.
7613
+ */
7614
+ function startReconciling(makeDeps = defaultReconcileDeps) {
7615
+ let running = false;
7616
+ let stopped = false;
7617
+ const run = () => {
7618
+ if (running || stopped) return;
7619
+ running = true;
7620
+ exclusive(() => reconcileCycle({
7621
+ ...makeDeps(),
7622
+ cancelled: () => stopped
7623
+ })).catch(() => {}).finally(() => {
7624
+ running = false;
7625
+ });
7626
+ };
7627
+ run();
7628
+ const timer = setInterval(run, RECONCILE_INTERVAL_MS);
7629
+ timer.unref();
7630
+ return () => {
7631
+ stopped = true;
7632
+ clearInterval(timer);
7633
+ };
7634
+ }
7635
+ /**
7636
+ * Ask the forge for one review's pull request state now, because the reviewer pressed Check for
7637
+ * updates.
7638
+ *
7639
+ * **Whatever the review's workflow state.** Done, reviewed, unqueued: an explicit question gets an
7640
+ * answer, and still gets one when the stored state is merged. Workflow decides what happens in the
7641
+ * background, never what the reviewer may ask.
7642
+ *
7643
+ * **It changes nothing but the stored state.** No lifecycle, no queue, no `watch.json` and no
7644
+ * rotation bookkeeping, so it neither starts nor stops anything watching the review. It is quiet:
7645
+ * the reviewer is looking at the answer.
7646
+ *
7647
+ * **Never behind a pass.** Not through `exclusive`: a press waits for one answer from the forge and
7648
+ * nothing else, however slow the pass running beside it. That is safe because of
7649
+ * `persistPrStates`, which writes this one answer the moment it arrives and keeps whichever
7650
+ * observation of the request is newer, so a pass that finishes afterwards cannot take it back, and
7651
+ * this cannot take back anything a pass saw later. Never throws.
7652
+ */
7653
+ async function checkPrStateNow(workspace, deps = defaultDeps()) {
7654
+ try {
7655
+ if (isCommitReview(workspace.changeSet)) return;
7656
+ const candidate = candidateOf(workspace);
7657
+ if (!candidate || isBranchCandidate(candidate)) return;
7658
+ const observed = await observeRequest(candidate, deps, /* @__PURE__ */ new Set());
7659
+ if (observed.kind !== "head" || observed.prState === void 0) return;
7660
+ if ((deps.cancelled ?? standingDown)() === true) return;
7661
+ const seenAt = deps.now();
7662
+ const key = watchKey(candidate);
7663
+ const prStates = readPrStates();
7664
+ recordPrState(candidate, key, observed.sha, observed.prState, prStates, seenAt, true);
7665
+ if (prStates[key]) persistPrStates({ [key]: prStates[key] });
7666
+ } catch {}
7667
+ }
7375
7668
  //#endregion
7376
7669
  //#region ../../packages/review-harness/src/workspace/reviews_page.ts
7377
7670
  function renderReviewsPage(view) {
@@ -9972,7 +10265,7 @@ async function handle(req, res, options) {
9972
10265
  if (action === "/job") return send(res, 200, jobReport(workspace));
9973
10266
  if (action === "/retry") return handleRetry(res, workspace);
9974
10267
  if (action === "/reanalyse") return handleReanalyse(req, res, workspace);
9975
- if (action === "/freshness") return handleFreshness(res, workspace, url.searchParams.get("quiet") === "1");
10268
+ if (action === "/freshness") return handleFreshness(res, workspace, url.searchParams.get("quiet") === "1", options.checkPrState ?? checkPrStateNow);
9976
10269
  if (action === "/fetch") return handleFetchOwn(req, res, workspace);
9977
10270
  if (action === "/refresh") return handleRefreshPr(res, workspace);
9978
10271
  if (action === "/update") return handleUpdate(res, workspace);
@@ -10515,9 +10808,10 @@ async function handleFetchUpdates(req, res, indexToken) {
10515
10808
  * Fetch remote updates, from one review's own page, on its own token.
10516
10809
  *
10517
10810
  * The list's fetch scoped to this review: `GET` says whether it applies (the page draws the item
10518
- * only if so, asked after load so rendering never waits on git), `POST` runs it. The review page's
10519
- * Check for updates stays read-only; this is the one explicit act on that page that reaches the
10520
- * network, and it writes remote-tracking refs and nothing else.
10811
+ * only if so, asked after load so rendering never waits on git), `POST` runs it. It writes
10812
+ * remote-tracking refs and nothing else. The page's other network act is a press of Check for
10813
+ * updates, which asks the forge one question, whether the pull request is still open, and writes
10814
+ * only the answer.
10521
10815
  */
10522
10816
  async function handleFetchOwn(req, res, workspace) {
10523
10817
  if (req.method === "GET") return send(res, 200, { fetchable: await isFetchTarget(workspace.id) });
@@ -10959,15 +11253,19 @@ function jobReport(workspace) {
10959
11253
  * The three comparisons, on demand.
10960
11254
  *
10961
11255
  * Separate from the page render so a tab that has been open for an hour can ask again without
10962
- * losing its place — which is the case the button exists for. Read-only and cheap: four git
10963
- * plumbing commands against the local object store, no network anywhere in it.
10964
- */
10965
- /**
11256
+ * losing its place — which is the case the button exists for. The comparisons are read-only and
11257
+ * cheap: four git plumbing commands against the local object store.
11258
+ *
10966
11259
  * `quiet` is the page checking on its own, on tab return and on a slow timer. It answers exactly as a
10967
- * press does and records nothing: `freshness_checked` says the reviewer asked, and a page left open
10968
- * all afternoon has not asked sixty times.
11260
+ * press does, records nothing and **reaches no network**: `freshness_checked` says the reviewer
11261
+ * asked, and a page left open all afternoon has not asked sixty times.
11262
+ *
11263
+ * A press is the reviewer asking, so it is allowed one call to the forge: `checkPrState`, for
11264
+ * whether the pull request is open, closed or merged. That is the one network question here, and
11265
+ * it is asked before the answer is assembled so the answer carries it.
10969
11266
  */
10970
- async function handleFreshness(res, workspace, quiet) {
11267
+ async function handleFreshness(res, workspace, quiet, checkPrState) {
11268
+ if (!quiet) await checkPrState(workspace);
10971
11269
  const kiss = await kissFreshnessOrNull(workspace);
10972
11270
  const standing = await standingOrNull(workspace);
10973
11271
  const report = standing?.freshness ?? null;
@@ -11831,6 +12129,7 @@ function beginRestart() {
11831
12129
  function handOver() {
11832
12130
  stopSweeping();
11833
12131
  stopWatching();
12132
+ stopReconciling();
11834
12133
  clearServerRecord();
11835
12134
  server.close(() => void replaceThisProcess());
11836
12135
  server.closeAllConnections();
@@ -11848,6 +12147,7 @@ async function replaceThisProcess() {
11848
12147
  });
11849
12148
  stopSweeping = startSweeping();
11850
12149
  stopWatching = startWatching();
12150
+ stopReconciling = startReconciling();
11851
12151
  }
11852
12152
  /**
11853
12153
  * The replacement is a different process, so a different `startedAt` is what proves it is there.
@@ -11890,9 +12190,11 @@ server.listen(resolveWorkspacePort(), "127.0.0.1", () => {
11890
12190
  server.on("error", () => process.exit(1));
11891
12191
  var stopSweeping = startSweeping();
11892
12192
  var stopWatching = startWatching();
12193
+ var stopReconciling = startReconciling();
11893
12194
  for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => {
11894
12195
  stopSweeping();
11895
12196
  stopWatching();
12197
+ stopReconciling();
11896
12198
  clearServerRecord();
11897
12199
  server.close(() => process.exit(0));
11898
12200
  server.closeAllConnections();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prreviewbuddy",
3
- "version": "0.56.0",
3
+ "version": "0.57.0",
4
4
  "description": "Review a branch or pull request in an isolated checkout, from your terminal, with the coding agent you already have installed.",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://prreviewbuddy.com",