@fullstackhouse/cerber 0.25.0 → 0.27.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.
@@ -1,10 +1,11 @@
1
1
  import { SCHEMA_VERSION, artifactId, artifactKey } from "../core/artifact.js";
2
2
  import { evaluateAutoSend } from "../core/autosend.js";
3
3
  import { loadConfig } from "../core/config.js";
4
- import { classifyReply, currentLogin, fetchConversation, fetchLastReviewRequest, fetchOwnReview, fetchPrInfo, fetchReviewRequests, lastWordOfYours, searchAwaitingMe, stillRequested, submitReview, } from "../core/gh.js";
4
+ import { classifyReply, currentLogin, fetchConversation, fetchLastReviewRequest, fetchOwnReview, fetchPrInfo, fetchReviewRequests, lastMentionOfYou, lastWordOfYours, searchAwaitingMe, stillRequested, submitReview, } from "../core/gh.js";
5
+ import { withWriter } from "../core/history.js";
5
6
  import { notice, notify } from "../core/notify.js";
6
7
  import { buildReviewPayload, computeCalibration } from "../core/send.js";
7
- import { appendAutoSendLog, deleteArtifact, listArtifacts, loadArtifact, saveArtifact, updateArtifactByKey, } from "../core/state.js";
8
+ import { appendAutoSendLog, deleteArtifact, listArtifacts, loadArtifact, noteHistory, saveArtifact, updateArtifactByKey, } from "../core/state.js";
8
9
  import { ReviewInProgressError } from "../runner/inflight.js";
9
10
  import { SETTLED_BY_YOU, pool, reviewPr } from "../runner/review.js";
10
11
  /**
@@ -144,25 +145,40 @@ export function settledAtOf(a) {
144
145
  return a.settledAt ?? a.filed?.at ?? a.run?.finishedAt ?? null;
145
146
  }
146
147
  /**
147
- * Whether a review request is a *new* ask rather than the one you settled.
148
+ * Whether an ask is a *new* one rather than the one you settled.
148
149
  *
149
150
  * A skip says "I am done with this PR", and a push does not undo that — the
150
151
  * whole point of `SETTLED_BY_YOU`. Somebody asking you again is a different
151
- * event: your skip answered the request that was open when you made it, and it
152
- * cannot have answered one that came after. Withdrawn-then-re-added is the
153
- * shape this catches, and it is not exotic — an author who pulls the request
154
- * while you are mid-look and puts it back with the next push leaves the inbox
155
- * silent about a review someone is now actively waiting on.
152
+ * event: your skip answered whatever was open when you made it, and it cannot
153
+ * have answered something that came after. Withdrawn-then-re-added is one shape
154
+ * this catches, and it is not exotic — an author who pulls the request while
155
+ * you are mid-look and puts it back with the next push leaves the inbox silent
156
+ * about a review someone is now actively waiting on. The other shape is the
157
+ * same author typing it instead (`Ask`).
156
158
  *
157
159
  * Parsed, not compared as text, for the reason `filedByYourAct` gives: our
158
160
  * timestamps carry milliseconds and GitHub's do not, and "…:00.500Z" sorts
159
161
  * before "…:00Z".
160
162
  */
161
- export function askedAgainAfterSettling(a, requestedAt) {
162
- if (requestedAt === null)
163
+ export function askedAgainAfterSettling(a, askedAt) {
164
+ if (askedAt === null)
163
165
  return false;
164
166
  const settled = settledAtOf(a);
165
- return settled !== null && Date.parse(requestedAt) > Date.parse(settled);
167
+ return settled !== null && Date.parse(askedAt) > Date.parse(settled);
168
+ }
169
+ /**
170
+ * What the daemon says about a reopen, in the log and in the row's history.
171
+ *
172
+ * One place, so the line the user reads in the terminal and the line they read
173
+ * on the review months later cannot tell different stories about why a decision
174
+ * of theirs was undone — which is the only thing that makes undoing it fair.
175
+ */
176
+ export function askWording(ask) {
177
+ const day = ask.at.slice(0, 10);
178
+ const what = ask.how === "requested"
179
+ ? `your review was requested again on ${day}`
180
+ : `${ask.by} asked for you by name on ${day}`;
181
+ return { log: `${what} — back in the inbox`, history: `back in the inbox: ${what}, after you settled it` };
166
182
  }
167
183
  /**
168
184
  * What a reopened row goes back to being: the draft it has, or a row waiting
@@ -244,6 +260,11 @@ export function startDaemon(opts) {
244
260
  // needs it. Undefined is "not asked yet"; null is "gh could not say", which
245
261
  // attributes nothing to you rather than guessing.
246
262
  let login;
263
+ async function who() {
264
+ if (login === undefined)
265
+ login = await currentLogin().catch(() => null);
266
+ return login;
267
+ }
247
268
  /**
248
269
  * Put a settled row back in the inbox when somebody has asked you again.
249
270
  *
@@ -252,9 +273,17 @@ export function startDaemon(opts) {
252
273
  * moved *to* it. Both exist because the queue's picture of who is waiting
253
274
  * on what is local, and the thing it is a picture of lives on github.com.
254
275
  *
255
- * Only rows GitHub is asking about right now get here at all — the caller
256
- * is looping over the awaiting search — so the extra read is one call for
257
- * one question: was that request made after you settled?
276
+ * Two ways to be asked, because people use both. GitHub's re-request button
277
+ * leaves a dated event and is read first: it is the strongest evidence and
278
+ * the cheapest call. Failing that, the conversation is read for the last
279
+ * comment naming you — "@you this is ready for another look", which is the
280
+ * same ask with no event behind it. Reading only the button was the bug:
281
+ * a settled row sat invisible while its author waited, having said so in
282
+ * plain words on the PR.
283
+ *
284
+ * Only rows GitHub is still asking about get here — the caller is looping
285
+ * over the awaiting search. A row whose request is gone (you reviewed on
286
+ * github.com) is caught by `reopenIfAskedInWords` on the other loop instead.
258
287
  */
259
288
  async function reopenIfAskedAgain(artifact) {
260
289
  if (settledAtOf(artifact) === null)
@@ -264,26 +293,72 @@ export function startDaemon(opts) {
264
293
  return;
265
294
  reopenChecks++;
266
295
  requestCheckedAt.set(artifact.id, Date.now());
267
- if (login === undefined)
268
- login = await currentLogin().catch(() => null);
269
- if (login === null)
296
+ const me = await who();
297
+ if (me === null)
270
298
  return;
271
- let requestedAt;
299
+ let ask;
272
300
  try {
273
- requestedAt = await fetchLastReviewRequest(artifact.pr, login);
301
+ const requestedAt = await fetchLastReviewRequest(artifact.pr, me);
302
+ const request = requestedAt === null ? null : { at: requestedAt, how: "requested" };
303
+ // The words are only worth a second call when the button did not
304
+ // already answer: a live re-request says everything a comment could.
305
+ ask = askedAgainAfterSettling(artifact, request?.at ?? null)
306
+ ? request
307
+ : await askedInWords(artifact, me);
274
308
  }
275
309
  catch {
276
310
  // A read that failed is not evidence of anything, so nothing happens to
277
311
  // the row. It is not re-asked about until the leash above expires: the
278
312
  // stamp is spent before the call on purpose, so gh being down cannot
279
- // turn every settled row into a retry on every poll.
313
+ // turn every settled row into a retry on every poll. Only the reads are
314
+ // guarded: a write that fails is a real fault and belongs to the caller.
280
315
  return;
281
316
  }
282
- if (requestedAt === null || !askedAgainAfterSettling(artifact, requestedAt))
317
+ await reopen(artifact, ask);
318
+ }
319
+ /**
320
+ * The same reopen for a settled row GitHub is *not* asking about.
321
+ *
322
+ * The case is ordinary: you reviewed on github.com, which clears the
323
+ * request and drops the PR out of the awaiting search, and the author then
324
+ * pushes and writes "@you ready for another look". No request exists to
325
+ * re-read, so the words are the whole of the evidence — and without this
326
+ * the mention check would only work for the rows that happen to still carry
327
+ * an open request, which is a fix that half-works.
328
+ *
329
+ * Rides the state-refresh leash rather than one of its own: the caller has
330
+ * already spent that artifact's slot for this half-hour, and a settled row
331
+ * never reaches `fileIfSettledElsewhere`, so this costs the poll one call
332
+ * on rows that were costing it one already.
333
+ */
334
+ async function reopenIfAskedInWords(artifact) {
335
+ if (settledAtOf(artifact) === null)
283
336
  return;
284
- const at = requestedAt;
337
+ const me = await who();
338
+ if (me === null)
339
+ return;
340
+ let ask;
341
+ try {
342
+ ask = await askedInWords(artifact, me);
343
+ }
344
+ catch {
345
+ // Same as above: a failed read changes nothing and waits for the leash.
346
+ return;
347
+ }
348
+ await reopen(artifact, ask);
349
+ }
350
+ /** The conversation's answer to "has anybody asked for me again", or null. */
351
+ async function askedInWords(artifact, me) {
352
+ const mention = lastMentionOfYou(await fetchConversation(artifact.pr), me);
353
+ return mention === null ? null : { at: mention.at, how: "mentioned", by: mention.author };
354
+ }
355
+ /** Undo the settle, if the ask really is newer than it. */
356
+ async function reopen(artifact, ask) {
357
+ if (ask === null || !askedAgainAfterSettling(artifact, ask.at))
358
+ return;
359
+ const at = ask.at;
285
360
  // Re-read from disk: a re-review or a send may have started in the time
286
- // that call took, and neither wants its status rewritten underneath it.
361
+ // those calls took, and neither wants its status rewritten underneath it.
287
362
  const saved = await updateArtifactByKey(artifactKey(artifact.id), (a) => askedAgainAfterSettling(a, at)
288
363
  ? // `filed` goes with the status it explained: cerber's account of why
289
364
  // this row was settled is not the story of a row that is back.
@@ -292,7 +367,11 @@ export function startDaemon(opts) {
292
367
  // The status is what says the write landed. `settledAt` cannot: a legacy
293
368
  // row never had one, so a mutation that declined would read as a reopen.
294
369
  if (saved && !SETTLED_BY_YOU.has(saved.status)) {
295
- log(`[${artifact.id}] your review was requested again on ${at.slice(0, 10)} — back in the inbox`);
370
+ const said = askWording(ask);
371
+ log(`[${artifact.id}] ${said.log}`);
372
+ // The status change alone says a row moved; this says why it moved,
373
+ // which is the whole reason a settle is allowed to be undone at all.
374
+ await noteHistory(artifact.id, said.history);
296
375
  }
297
376
  }
298
377
  for (const ref of refs) {
@@ -341,11 +420,9 @@ export function startDaemon(opts) {
341
420
  // that could never qualify costs no GitHub call at all.
342
421
  if (artifact.status !== "ready" || artifact.sent)
343
422
  return;
344
- if (login === undefined)
345
- login = await currentLogin().catch(() => null);
346
- if (login === null)
423
+ const me = await who();
424
+ if (me === null)
347
425
  return;
348
- const me = login;
349
426
  /** Re-reads from disk: a run or a send may have started in the seconds
350
427
  * these reads took, and neither wants filing out from under it. */
351
428
  async function file(filed, note, stillFilable) {
@@ -374,15 +451,21 @@ export function startDaemon(opts) {
374
451
  }
375
452
  // Anything but silence from you leaves the row alone: `them` is somebody
376
453
  // answering a comment of yours, which is the opposite of settled.
377
- if (classifyReply(conversation, me) !== "none")
454
+ if (classifyReply(conversation, me) !== "none") {
455
+ await noteHistory(artifact.id, "someone has answered you on the PR — the draft stays out for you");
378
456
  return;
457
+ }
379
458
  if (!filedByWithdrawnRequest(artifact))
380
459
  return;
381
460
  // The awaiting search says nobody is asking, and here that is the entire
382
461
  // case — so confirm it against the PR itself before acting on it. The
383
462
  // other two reasons stand on a fact of their own and need no such check.
384
- if (stillRequested(await fetchReviewRequests(artifact.pr), me))
463
+ if (stillRequested(await fetchReviewRequests(artifact.pr), me)) {
464
+ // The one fact GitHub cannot be asked for later: what its search index
465
+ // said at this minute, and that the PR itself disagreed with it.
466
+ await noteHistory(artifact.id, "gone from the awaiting search, but GitHub still lists you as a requested reviewer — left alone");
385
467
  return;
468
+ }
386
469
  await file({ at: new Date().toISOString(), reason: "request-withdrawn", review: null, reply: null }, "nobody is asking for this review any more", filedByWithdrawnRequest);
387
470
  }
388
471
  const awaitingIds = new Set(refs.map((r) => artifactId(r)));
@@ -437,8 +520,14 @@ export function startDaemon(opts) {
437
520
  }
438
521
  if (pr.state !== "OPEN")
439
522
  log(`[${artifact.id}] ${pr.state.toLowerCase()} — archived`);
440
- else
523
+ else {
524
+ // Exactly one of these two spends a call: the first only looks at
525
+ // settled rows, the second only at finished drafts. They are the same
526
+ // question from opposite sides — has GitHub moved past this, or back
527
+ // to it — and a row can only be on one side of it.
528
+ await reopenIfAskedInWords(artifact);
441
529
  await fileIfSettledElsewhere(artifact);
530
+ }
442
531
  }
443
532
  catch {
444
533
  // Same: book-keeping can wait for the next poll.
@@ -543,7 +632,10 @@ export function startDaemon(opts) {
543
632
  });
544
633
  return { reviewed, skipped, failed };
545
634
  }
546
- async function poll() {
635
+ /** Everything a poll writes is the poll's — including the reviews it starts,
636
+ * which stamp themselves as runs from inside this. */
637
+ const poll = () => withWriter({ by: "daemon", cause: "poll" }, runPoll);
638
+ async function runPoll() {
547
639
  if (status.polling)
548
640
  return;
549
641
  status.polling = true;
@@ -13,6 +13,7 @@ import { fetchPrDiff, fetchPrInfo, parsePrRef, submitReview } from "../core/gh.j
13
13
  import { z } from "zod";
14
14
  import { DaemonConfigSchema, configPath, loadConfig, saveConfig } from "../core/config.js";
15
15
  import { refreshArtifact } from "../core/refresh.js";
16
+ import { withWriter } from "../core/history.js";
16
17
  import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
17
18
  import { buildReviewPayload, computeCalibration } from "../core/send.js";
18
19
  import { listArtifacts, loadArtifact, loadArtifactByKey, saveArtifact, updateArtifactByKey, } from "../core/state.js";
@@ -39,6 +40,11 @@ const SETTLEABLE = ["reviewed", "skipped"];
39
40
  const inFlight = (a) => a.status === "running" || isReviewRunning(a.id);
40
41
  export async function buildApp(opts) {
41
42
  const app = new Hono();
43
+ // Whatever any route writes to an artifact is stamped with the request that
44
+ // caused it — once, here, rather than route by route, so a route added later
45
+ // is labelled without knowing that history exists. A detached run started by
46
+ // a request re-labels its own writes for itself.
47
+ app.use("*", (c, next) => withWriter({ by: "cockpit", cause: `${c.req.method} ${new URL(c.req.url).pathname}` }, next));
42
48
  if (opts.token) {
43
49
  const token = opts.token;
44
50
  app.use("*", async (c, next) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackhouse/cerber",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "description": "AI code-review cockpit: Claude reviews your PRs into local artifacts; nothing reaches GitHub until you say so.",
5
5
  "license": "MIT",
6
6
  "author": "Full Stack House (https://fullstack.house)",