@fullstackhouse/cerber 0.26.0 → 0.27.1

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/README.md CHANGED
@@ -415,6 +415,12 @@ above: the seven statuses and what sets each, which tab a PR lands in and why,
415
415
  when the poll files or archives a row on its own, when a re-review happens and
416
416
  what it will not overwrite, and what gets written to disk at each step.
417
417
 
418
+ [**SPEC.md**](SPEC.md) is the full service specification — the artifact
419
+ schema, every lifecycle and filing rule, the runner's tool policy and
420
+ credential hygiene, the send path — written precisely enough to reimplement
421
+ cerber from. lifecycle.md is the operator's view of the product; SPEC.md is
422
+ the engineer's.
423
+
418
424
  ## Development
419
425
 
420
426
  ```bash
package/dist/core/gh.js CHANGED
@@ -90,8 +90,88 @@ async function membershipCheck(args) {
90
90
  throw err;
91
91
  }
92
92
  }
93
+ /**
94
+ * GitHub refuses to render a diff past 300 changed files: `gh pr diff` comes
95
+ * back 406 and the review dies before it starts. The files API answers for ten
96
+ * times as many, so a too-large diff is re-assembled from it rather than
97
+ * reported as a broken review — see {@link assembleDiff}.
98
+ */
93
99
  export async function fetchPrDiff(ref) {
94
- return gh(["pr", "diff", String(ref.number), "--repo", `${ref.owner}/${ref.repo}`]);
100
+ try {
101
+ return await gh(["pr", "diff", String(ref.number), "--repo", `${ref.owner}/${ref.repo}`]);
102
+ }
103
+ catch (err) {
104
+ // Matched on GitHub's own `too_large` signature rather than the bare 406:
105
+ // 406 is "Not Acceptable" generally, while `too_large` is emitted for this
106
+ // refusal and nothing else. Matching the meaning survives a status change;
107
+ // matching the status would fall back on some unrelated 406 one day.
108
+ const message = err instanceof Error ? err.message : String(err);
109
+ if (!/too_large|exceeded the maximum number of files/i.test(message))
110
+ throw err;
111
+ const files = await fetchPrFiles(ref);
112
+ const diff = assembleDiff(files);
113
+ // The files API stops at 3000 too, and says nothing when it does. Hitting
114
+ // the cap exactly is not proof of truncation — a PR can change exactly
115
+ // 3000 files — and there is nothing here to tell the two apart, so the
116
+ // note reports the doubt rather than asserting a loss that may not exist.
117
+ return files.length < FILES_API_CAP
118
+ ? diff
119
+ : `${diff}[GitHub's files API returns at most ${FILES_API_CAP} files and returned exactly that many — if this PR changes more, the rest is missing from this diff. Read the checkout.]\n`;
120
+ }
121
+ }
122
+ const FILES_API_CAP = 3000;
123
+ async function fetchPrFiles(ref) {
124
+ const out = await gh([
125
+ "api",
126
+ "--paginate",
127
+ `repos/${ref.owner}/${ref.repo}/pulls/${ref.number}/files?per_page=100`,
128
+ "--jq",
129
+ ".[] | {filename, status, previous_filename, additions, deletions, patch}",
130
+ ]);
131
+ // --jq streams one object per line rather than a JSON array.
132
+ return out
133
+ .split("\n")
134
+ .filter((line) => line.trim() !== "")
135
+ .map((line) => JSON.parse(line));
136
+ }
137
+ /**
138
+ * Put the `diff --git` / `---` / `+++` headers back on the per-file hunks the
139
+ * files API returns, so the result parses exactly like `gh pr diff` output —
140
+ * `splitDiffByFile` and the line anchoring read both the same way.
141
+ *
142
+ * A file whose patch GitHub withheld still gets its headers and a line saying
143
+ * so: an unexplained gap would read as "nothing changed here".
144
+ */
145
+ export function assembleDiff(files) {
146
+ const out = [];
147
+ for (const file of files) {
148
+ const renamed = file.status === "renamed" && file.previous_filename;
149
+ const oldPath = file.status === "added" ? null : renamed ? file.previous_filename : file.filename;
150
+ const newPath = file.status === "removed" ? null : file.filename;
151
+ // The side a file does not have is /dev/null, in the headers and in the
152
+ // binary line alike — that is how git names it.
153
+ const a = oldPath ? `a/${oldPath}` : "/dev/null";
154
+ const b = newPath ? `b/${newPath}` : "/dev/null";
155
+ out.push(`diff --git a/${oldPath ?? file.filename} b/${newPath ?? file.filename}`);
156
+ if (renamed)
157
+ out.push(`rename from ${oldPath}`, `rename to ${newPath}`);
158
+ if (file.patch) {
159
+ out.push(`--- ${a}`, `+++ ${b}`, file.patch.replace(/\n$/, ""));
160
+ }
161
+ else if (file.additions > 0 || file.deletions > 0) {
162
+ // GitHub counted the lines and then withheld the patch: a text file it
163
+ // decided was too big. Say so — an empty file block reads as "unchanged".
164
+ out.push(`--- ${a}`, `+++ ${b}`, `[GitHub withheld this file's patch — ${file.additions} addition(s), ${file.deletions} deletion(s). Read the file in the checkout.]`);
165
+ }
166
+ else if (!renamed) {
167
+ out.push(`Binary files ${a} and ${b} differ`);
168
+ }
169
+ // A pure rename ends here, with no hunks and no ---/+++ pair: nothing about
170
+ // the content changed, and `rename from`/`rename to` have already said
171
+ // everything. Calling it binary — which counting lines alone would — is
172
+ // both wrong and the more alarming of the two ways to be wrong.
173
+ }
174
+ return out.length === 0 ? "" : out.join("\n") + "\n";
95
175
  }
96
176
  /**
97
177
  * Map `gh search prs` JSON output to PR refs.
@@ -132,7 +212,7 @@ export async function fetchConversation(ref) {
132
212
  "--paginate",
133
213
  `repos/${ref.owner}/${ref.repo}/issues/${ref.number}/comments`,
134
214
  "--jq",
135
- '.[] | {author: .user.login, at: .created_at, bot: (.user.type == "Bot"), url: .html_url}',
215
+ '.[] | {author: .user.login, at: .created_at, body: (.body // ""), bot: (.user.type == "Bot"), url: .html_url}',
136
216
  ]);
137
217
  // --jq streams one object per line rather than a JSON array.
138
218
  return out
@@ -173,6 +253,39 @@ export function lastWordOfYours(comments, login) {
173
253
  const mine = comments.filter((c) => !isBot(c) && c.author === login);
174
254
  return mine[mine.length - 1] ?? null;
175
255
  }
256
+ /**
257
+ * Whether a comment speaks to you by name.
258
+ *
259
+ * GitHub's own rule, near enough: an `@login` that isn't part of a longer word.
260
+ * The boundary is "not a login character" rather than `\b`, because logins may
261
+ * contain hyphens — `@me-bot` is a different account from `@me`, and
262
+ * `you@me.dev` is an email address, not an ask.
263
+ *
264
+ * Deliberately not team mentions. `@org/team` is a room being addressed, and
265
+ * this is used to undo a decision of yours, which wants somebody naming *you*.
266
+ */
267
+ export function mentionsYou(body, login) {
268
+ const name = login.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
269
+ return new RegExp(`(^|[^\\w-])@${name}(?![\\w-])`, "i").test(body);
270
+ }
271
+ /**
272
+ * The last comment somebody addressed to you by name, or null if nobody has.
273
+ *
274
+ * The other half of what the conversation can say. `classifyReply` reads it for
275
+ * whose move it is; this reads the same comments for the ask GitHub's own API
276
+ * has no event for — "@you this is ready for another look", typed instead of
277
+ * clicking the re-request button. Your own comments never count, and bots never
278
+ * do: a bot that @-mentions the assignee on every push would otherwise be an
279
+ * ask on every push.
280
+ */
281
+ export function lastMentionOfYou(comments, login) {
282
+ const theirs = comments.filter((c) => !isBot(c) && c.author !== login && mentionsYou(c.body, login));
283
+ if (theirs.length === 0)
284
+ return null;
285
+ // By date, not by position: this undoes a settle, so it does not lean on the
286
+ // API happening to return the conversation in order.
287
+ return theirs.reduce((a, b) => (b.at > a.at ? b : a));
288
+ }
176
289
  /**
177
290
  * The last review of your own that still stands, or null.
178
291
  *
@@ -1,7 +1,7 @@
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
5
  import { withWriter } from "../core/history.js";
6
6
  import { notice, notify } from "../core/notify.js";
7
7
  import { buildReviewPayload, computeCalibration } from "../core/send.js";
@@ -145,25 +145,40 @@ export function settledAtOf(a) {
145
145
  return a.settledAt ?? a.filed?.at ?? a.run?.finishedAt ?? null;
146
146
  }
147
147
  /**
148
- * 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.
149
149
  *
150
150
  * A skip says "I am done with this PR", and a push does not undo that — the
151
151
  * whole point of `SETTLED_BY_YOU`. Somebody asking you again is a different
152
- * event: your skip answered the request that was open when you made it, and it
153
- * cannot have answered one that came after. Withdrawn-then-re-added is the
154
- * shape this catches, and it is not exotic — an author who pulls the request
155
- * while you are mid-look and puts it back with the next push leaves the inbox
156
- * 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`).
157
158
  *
158
159
  * Parsed, not compared as text, for the reason `filedByYourAct` gives: our
159
160
  * timestamps carry milliseconds and GitHub's do not, and "…:00.500Z" sorts
160
161
  * before "…:00Z".
161
162
  */
162
- export function askedAgainAfterSettling(a, requestedAt) {
163
- if (requestedAt === null)
163
+ export function askedAgainAfterSettling(a, askedAt) {
164
+ if (askedAt === null)
164
165
  return false;
165
166
  const settled = settledAtOf(a);
166
- 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` };
167
182
  }
168
183
  /**
169
184
  * What a reopened row goes back to being: the draft it has, or a row waiting
@@ -245,6 +260,11 @@ export function startDaemon(opts) {
245
260
  // needs it. Undefined is "not asked yet"; null is "gh could not say", which
246
261
  // attributes nothing to you rather than guessing.
247
262
  let login;
263
+ async function who() {
264
+ if (login === undefined)
265
+ login = await currentLogin().catch(() => null);
266
+ return login;
267
+ }
248
268
  /**
249
269
  * Put a settled row back in the inbox when somebody has asked you again.
250
270
  *
@@ -253,9 +273,17 @@ export function startDaemon(opts) {
253
273
  * moved *to* it. Both exist because the queue's picture of who is waiting
254
274
  * on what is local, and the thing it is a picture of lives on github.com.
255
275
  *
256
- * Only rows GitHub is asking about right now get here at all — the caller
257
- * is looping over the awaiting search — so the extra read is one call for
258
- * 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.
259
287
  */
260
288
  async function reopenIfAskedAgain(artifact) {
261
289
  if (settledAtOf(artifact) === null)
@@ -265,26 +293,72 @@ export function startDaemon(opts) {
265
293
  return;
266
294
  reopenChecks++;
267
295
  requestCheckedAt.set(artifact.id, Date.now());
268
- if (login === undefined)
269
- login = await currentLogin().catch(() => null);
270
- if (login === null)
296
+ const me = await who();
297
+ if (me === null)
271
298
  return;
272
- let requestedAt;
299
+ let ask;
273
300
  try {
274
- 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);
275
308
  }
276
309
  catch {
277
310
  // A read that failed is not evidence of anything, so nothing happens to
278
311
  // the row. It is not re-asked about until the leash above expires: the
279
312
  // stamp is spent before the call on purpose, so gh being down cannot
280
- // 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.
281
315
  return;
282
316
  }
283
- 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)
336
+ return;
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))
284
358
  return;
285
- const at = requestedAt;
359
+ const at = ask.at;
286
360
  // Re-read from disk: a re-review or a send may have started in the time
287
- // that call took, and neither wants its status rewritten underneath it.
361
+ // those calls took, and neither wants its status rewritten underneath it.
288
362
  const saved = await updateArtifactByKey(artifactKey(artifact.id), (a) => askedAgainAfterSettling(a, at)
289
363
  ? // `filed` goes with the status it explained: cerber's account of why
290
364
  // this row was settled is not the story of a row that is back.
@@ -293,10 +367,11 @@ export function startDaemon(opts) {
293
367
  // The status is what says the write landed. `settledAt` cannot: a legacy
294
368
  // row never had one, so a mutation that declined would read as a reopen.
295
369
  if (saved && !SETTLED_BY_YOU.has(saved.status)) {
296
- 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}`);
297
372
  // The status change alone says a row moved; this says why it moved,
298
373
  // which is the whole reason a settle is allowed to be undone at all.
299
- await noteHistory(artifact.id, `back in the inbox: your review was requested again on ${at.slice(0, 10)}, after you settled it`);
374
+ await noteHistory(artifact.id, said.history);
300
375
  }
301
376
  }
302
377
  for (const ref of refs) {
@@ -345,11 +420,9 @@ export function startDaemon(opts) {
345
420
  // that could never qualify costs no GitHub call at all.
346
421
  if (artifact.status !== "ready" || artifact.sent)
347
422
  return;
348
- if (login === undefined)
349
- login = await currentLogin().catch(() => null);
350
- if (login === null)
423
+ const me = await who();
424
+ if (me === null)
351
425
  return;
352
- const me = login;
353
426
  /** Re-reads from disk: a run or a send may have started in the seconds
354
427
  * these reads took, and neither wants filing out from under it. */
355
428
  async function file(filed, note, stillFilable) {
@@ -447,8 +520,14 @@ export function startDaemon(opts) {
447
520
  }
448
521
  if (pr.state !== "OPEN")
449
522
  log(`[${artifact.id}] ${pr.state.toLowerCase()} — archived`);
450
- 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);
451
529
  await fileIfSettledElsewhere(artifact);
530
+ }
452
531
  }
453
532
  catch {
454
533
  // Same: book-keeping can wait for the next poll.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackhouse/cerber",
3
- "version": "0.26.0",
3
+ "version": "0.27.1",
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)",