@fullstackhouse/cerber 0.33.0 → 0.35.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/README.md CHANGED
@@ -10,10 +10,12 @@ comments, and a verdict with a confidence score. You read it in a local web
10
10
  cockpit, keep or rewrite or drop each comment, then press Send when you agree
11
11
  with it.
12
12
 
13
- **Nothing reaches GitHub until you explicitly say so.** Cerber's only GitHub
14
- write is the Send button (plus opt-in daemon auto-send you turn on yourself) —
15
- reviewing is 100% local and read-only. Reviews go out under your own account,
16
- as your review, because you decided each line of it should.
13
+ **Nothing reaches GitHub until you explicitly say so.** Reviewing is 100% local
14
+ and read-only; three things can write, each one a click you make — the Send
15
+ button, opt-in daemon auto-send you turn on yourself, and handing a review to
16
+ somebody else. A review only ever goes out under your own account because you
17
+ decided it should: Send posts the one you vouched for, and a handoff posts the
18
+ draft only if you ticked the box to send it along, always as a comment.
17
19
 
18
20
  That gate is the whole point, and the reason this is not another review bot:
19
21
 
@@ -23,6 +25,9 @@ That gate is the whole point, and the reason this is not another review bot:
23
25
 
24
26
  Named after Cerberus, the gatekeeper: cerber guards what gets merged.
25
27
 
28
+ Why it exists, and the four things I tried first:
29
+ [AI drafts the review. I decide what gets sent.](https://www.fullstack.house/blog/cerber?utm_source=github&utm_medium=readme)
30
+
26
31
  **The whole loop in seventeen seconds** — five PRs already drafted when you sit
27
32
  down, the one that isn't an approval, straight to its blocker, arguing with the
28
33
  reviewer until it rewrites its own finding, dropping the comment you disagree
@@ -86,6 +91,7 @@ and apply on the next poll.
86
91
 
87
92
  - [What a review looks like](#what-a-review-looks-like) — what Claude writes and how you walk it
88
93
  - [The inbox](#the-inbox) — what the queue shows, what settles a row, the notifications
94
+ - [Handing a review to somebody else](#handing-a-review-to-somebody-else)
89
95
  - [It reviews the code, not just the diff](#it-reviews-the-code-not-just-the-diff)
90
96
  - [Trusted PRs: reviews that can run things](#trusted-prs-reviews-that-can-run-things)
91
97
  - [When the PR moves under you](#when-the-pr-moves-under-you)
@@ -152,7 +158,9 @@ all, since the repo is read-only and a review could never be sent.
152
158
 
153
159
  Settling a review is your decision, not GitHub's: skip a PR, or mark one
154
160
  reviewed without sending, and GitHub still holds its review request open,
155
- because nothing cerber does here reaches it. Those rows stay marked where they
161
+ because nothing in that decision reaches it. (The one decision that does reach
162
+ it is [handing the review to somebody else](#handing-a-review-to-somebody-else),
163
+ which moves the request rather than leaving it behind.) Those rows stay marked where they
156
164
  sit, counted on the tab they went into, and named in place of the empty
157
165
  inbox — cerber never tells you nothing awaits you while its own poll says
158
166
  otherwise. It is a report, not a nag: what you decided here stands, and the
@@ -227,6 +235,55 @@ anything, the favicon wears a red dot, and it goes away when the last review is
227
235
  dealt with. No permission, no switch — just the paw with a mark on it, so a
228
236
  glance at the tab strip answers "is there anything for me?".
229
237
 
238
+ ## Handing a review to somebody else
239
+
240
+ Some reviews are not yours. Wrong part of the stack, wrong week, somebody else
241
+ wrote half of it — and the honest answer is a name, not a skip. **Hand off** is
242
+ in the send panel next to *mark reviewed* and *skip*. It opens with the people
243
+ who can take it already listed — whoever this repo assigns work to, with anyone
244
+ you have handed to before at the top — so in the ordinary case you pick a name
245
+ rather than typing one. Then:
246
+
247
+ - they are added as a requested reviewer and you are taken off,
248
+ - a short note goes on the PR saying so — prefilled with their name so they get
249
+ the notification, editable, and clearable if you would rather say nothing,
250
+ - the drafted review goes up with it, if you tick the box for it (below),
251
+ - the row is filed under settled here, tagged `handed to @them` — or under
252
+ sent, if you sent the review with it.
253
+
254
+ That is the whole of it, and it is the one thing on the queue that genuinely
255
+ *finishes* a review request instead of just filing it: a skip leaves GitHub
256
+ asking you forever, and this does not. If they run cerber too, their next poll
257
+ picks the PR up and drafts a review for them — nothing has to be sent anywhere
258
+ for that to work.
259
+
260
+ **You can send the draft with it.** Tick *also post the review* and what Claude
261
+ found goes up at the same time — the same body and the same inline comments the
262
+ Send button would post, so the next reviewer and the author start from something
263
+ rather than nothing.
264
+
265
+ It posts as a **comment**, never an approval or a change request. You are handing
266
+ the judgement over, so making it on the way out would be odd. The findings keep
267
+ their grades, so a blocker still reads as a blocker — what that means for the
268
+ merge is now the next reviewer's call.
269
+
270
+ That box starts unticked, and it is the one thing here that isn't on by default.
271
+ You can hand off a PR you never opened, where the draft is whatever the overnight
272
+ poll wrote, and publishing that under your name is the thing the Send button
273
+ exists to prevent. Ticking it shows you the body first. Leave it alone and the
274
+ draft just stays here, openable and still sendable.
275
+
276
+ If GitHub refuses the swap — they are not a collaborator, or you cannot request
277
+ reviews on that repo — nothing happens at all: no note on the PR, no change
278
+ here. And if it moves them on but cannot take you off, the row says that too,
279
+ rather than claiming you are done with it.
280
+
281
+ The list is a convenience, not a gate: it is GitHub's assignable-users list, not
282
+ its rule for who may review, so you can always type a login it doesn't mention —
283
+ and on a repo cerber can't read that list for, the box says so and still works.
284
+
285
+ Handing to a team isn't supported — a room being asked is not somebody taking it.
286
+
230
287
  ## It reviews the code, not just the diff
231
288
 
232
289
  Cerber checks the PR's head out locally and lets the review read it. That
package/dist/cli/index.js CHANGED
@@ -9,8 +9,8 @@ import { configPath, loadConfig, saveConfig } from "../core/config.js";
9
9
  import { formatBlockers, isBlocking, preflight } from "../core/preflight.js";
10
10
  import { withWriter } from "../core/history.js";
11
11
  import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
12
- import { parsePrRef, searchAwaitingMe, submitReview } from "../core/gh.js";
13
- import { buildReviewPayload, computeCalibration, eventForRecommendation } from "../core/send.js";
12
+ import { parsePrRef, postReplies, searchAwaitingMe, submitReview } from "../core/gh.js";
13
+ import { buildReviewPayload, computeCalibration, describeReplyFailures, eventForRecommendation, recordReplies, } from "../core/send.js";
14
14
  import { cerberHome, listArtifacts, loadArtifact, readAutoSendLog, reconcileRunning, updateArtifactByKey, } from "../core/state.js";
15
15
  import { pool, reviewPr } from "../runner/review.js";
16
16
  import { isReviewRunning } from "../runner/inflight.js";
@@ -21,6 +21,13 @@ const program = new Command();
21
21
  * The published version, read from the manifest rather than typed here — a
22
22
  * hand-maintained copy drifted to 0.5.0 while 0.30.0 shipped, and `--version`
23
23
  * is the first thing anyone runs to decide whether a project is alive.
24
+ *
25
+ * In the published package that manifest is the one the release bumped, so this
26
+ * is the real version wherever anyone installed cerber. In a clone it reads
27
+ * `0.0.0-development`, because nothing commits the bump back to `main` and a
28
+ * number left behind there would quietly describe the release before last. A
29
+ * sentinel cannot drift: it says "built from source", which is true, instead of
30
+ * naming a version this build is not.
24
31
  */
25
32
  const { version } = createRequire(import.meta.url)("../../package.json");
26
33
  /**
@@ -208,6 +215,14 @@ program
208
215
  `either of which clears the stale status.`);
209
216
  process.exit(1);
210
217
  }
218
+ // The same wait the cockpit makes. A check run by `serve` leaves the status
219
+ // `ready`, so the guard above cannot see it — and sending now would post
220
+ // the duplicates it is about to find.
221
+ if (artifact.raisedCheck?.checkingSince) {
222
+ console.error(`cerber is checking which findings of ${id} other reviewers already raised — send once it finishes. ` +
223
+ `If nothing is running, restart \`cerber serve\`, which clears a check that died mid-flight.`);
224
+ process.exit(1);
225
+ }
211
226
  const event = (opts.event ??
212
227
  (artifact.verdict ? eventForRecommendation(artifact.verdict.recommendation) : "COMMENT")).toUpperCase();
213
228
  if (!["APPROVE", "COMMENT", "REQUEST_CHANGES"].includes(event)) {
@@ -221,6 +236,16 @@ program
221
236
  if (payload.folded.length > 0) {
222
237
  console.log(` comments folded into body (no diff anchor): ${payload.folded.length}`);
223
238
  }
239
+ if (payload.replies.length > 0) {
240
+ console.log(` replies in another reviewer's thread: ${payload.replies.length}`);
241
+ }
242
+ if (payload.held.length > 0) {
243
+ console.log(` left out — already raised by another reviewer: ${payload.held.length}` +
244
+ ` (decide in the cockpit to post or reply instead)`);
245
+ }
246
+ if (artifact.raisedCheck?.error) {
247
+ console.log(` ⚠ could not check what others already raised: ${artifact.raisedCheck.error}`);
248
+ }
224
249
  console.log(` body: ${payload.body.length} chars\n`);
225
250
  if (!opts.yes) {
226
251
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -244,6 +269,16 @@ program
244
269
  calibration: computeCalibration(a, event),
245
270
  }));
246
271
  console.log(`✔ Review sent${url ? `: ${url}` : "."}`);
272
+ const outcomes = await postReplies(ref, payload.replies);
273
+ await updateArtifactByKey(artifactKey(id), (a) => recordReplies(a, outcomes));
274
+ const replyError = describeReplyFailures(outcomes);
275
+ if (replyError) {
276
+ console.error(`✖ ${replyError}`);
277
+ process.exitCode = 1;
278
+ }
279
+ else if (outcomes.length > 0) {
280
+ console.log(`✔ ${outcomes.length} ${outcomes.length === 1 ? "reply" : "replies"} posted in existing threads.`);
281
+ }
247
282
  });
248
283
  program
249
284
  .command("trust")
@@ -8,6 +8,63 @@ export const SCHEMA_VERSION = 1;
8
8
  * is not a finding at all — a question, a note, praise — not "unknown".
9
9
  */
10
10
  export const SeveritySchema = z.enum(["blocker", "minor", "nit"]);
11
+ /**
12
+ * Where on the PR somebody else already made a point: an inline thread, or the
13
+ * body of a review — some bots put their findings there and nowhere else.
14
+ */
15
+ export const RaisedPlaceSchema = z.discriminatedUnion("kind", [
16
+ z.object({
17
+ kind: z.literal("thread"),
18
+ path: z.string(),
19
+ /** Null once GitHub can no longer place the thread on the current diff. */
20
+ line: z.number().int().positive().nullable(),
21
+ /** Outdated: the code it was on has changed since. Resolved wins over outdated. */
22
+ state: z.enum(["open", "resolved", "outdated"]),
23
+ /** REST id of the thread's first comment — what a reply is posted against. */
24
+ replyTo: z.string(),
25
+ }),
26
+ z.object({ kind: z.literal("review") }),
27
+ ]);
28
+ /**
29
+ * Another reviewer — a person or a bot, never you — already raised the same
30
+ * defect on the PR.
31
+ *
32
+ * Found after the review, never shown to it: a review that has read earlier
33
+ * comments anchors on them, and they are third-party text the prompt would
34
+ * have to treat as hostile. So the draft is written blind, and this is the
35
+ * comparison afterwards (`src/runner/raised.ts`).
36
+ *
37
+ * It changes what Send does with the comment and nothing else — the comment
38
+ * stays in the draft, as written. What Send does is `decision`, and while it
39
+ * is null the default for the place applies (`sendTreatment` in
40
+ * `src/core/raised.ts`): left out, unless the thread was resolved, because a
41
+ * problem somebody marked settled that is still in the code is news.
42
+ */
43
+ export const AlreadyRaisedSchema = z.object({
44
+ /** GitHub node id of the thread or review — how the next check finds it again. */
45
+ remarkId: z.string(),
46
+ by: z.string(),
47
+ at: z.string(),
48
+ url: z.string().nullable().default(null),
49
+ where: RaisedPlaceSchema,
50
+ /** Why the two are the same defect, in a sentence — and what this one adds, if anything. */
51
+ reason: z.string(),
52
+ /**
53
+ * What you chose: post it as a comment of its own anyway, or answer in their
54
+ * thread. Null is "not decided", which Send reads as the default above. Drop
55
+ * is not here: dropping is the comment's own `status`, as for any comment.
56
+ */
57
+ decision: z.enum(["send", "reply"]).nullable().default(null),
58
+ /** The reply Send posted into their thread. */
59
+ replied: z.object({ at: z.string(), url: z.string().nullable().default(null) }).nullable().default(null),
60
+ /**
61
+ * The other remarks that raised the same thing, behind the one shown. Kept
62
+ * because each was compared once and will not be asked about again: if the
63
+ * one shown is deleted, the next best takes its place instead of the
64
+ * finding quietly posting as new.
65
+ */
66
+ others: z.array(z.object({ remarkId: z.string(), reason: z.string() })).default([]),
67
+ });
11
68
  /** A draft inline comment. Never sent to GitHub unless the user explicitly sends the review. */
12
69
  export const CommentSchema = z.object({
13
70
  id: z.string(),
@@ -26,6 +83,32 @@ export const CommentSchema = z.object({
26
83
  originalLine: z.number().int().positive().nullable().default(null),
27
84
  /** The commented line no longer exists in the diff — it can't post inline. */
28
85
  drifted: z.boolean().default(false),
86
+ /** Somebody else already raised this on the PR. AI comments only. */
87
+ alreadyRaised: AlreadyRaisedSchema.nullable().default(null),
88
+ });
89
+ /**
90
+ * The last comparison of the draft against what others already said on the PR.
91
+ *
92
+ * It is incremental, and these lists are why: a check compares only what is
93
+ * new since the last one — a remark nobody has looked at yet, or a finding a
94
+ * chat turn added — so opening a review costs one read of GitHub and no model
95
+ * call unless something actually changed.
96
+ */
97
+ export const RaisedCheckSchema = z.object({
98
+ /**
99
+ * When the last check that changed anything finished, or failed. A check
100
+ * that found nothing new writes nothing (`raisedChanged`). Null while the
101
+ * first is still running.
102
+ */
103
+ at: z.string().nullable().default(null),
104
+ /** Set while a check runs detached, so the cockpit knows to wait for it. */
105
+ checkingSince: z.string().nullable().default(null),
106
+ /** GitHub node ids of every remark compared so far. */
107
+ remarks: z.array(z.string()).default([]),
108
+ /** Ids of every finding compared so far. */
109
+ findings: z.array(z.string()).default([]),
110
+ /** Why the last check could not finish. The matches from before it stand. */
111
+ error: z.string().nullable().default(null),
29
112
  });
30
113
  /** A logical group of changes — the unit of the review walkthrough. */
31
114
  export const ChapterSchema = z.object({
@@ -149,6 +232,36 @@ export const FiledInfoSchema = z.object({
149
232
  .nullable()
150
233
  .default(null),
151
234
  });
235
+ /**
236
+ * You gave this review to somebody else.
237
+ *
238
+ * The opposite of `filed` in every way that matters: that one is a record of
239
+ * cerber noticing GitHub had moved past a draft, this one is a decision of
240
+ * yours that *moved* GitHub — the review request is theirs now and no longer
241
+ * yours. It is why the row is settled, so it outranks the status wherever the
242
+ * queue tags one.
243
+ *
244
+ * `note` is the comment that went on the PR announcing it, and null when none
245
+ * did — either you cleared the text, or posting it failed after the request had
246
+ * already moved, which is one of the half-done states a handoff can end in.
247
+ *
248
+ * The draft may go with it, if you ticked the box for that, in which case the
249
+ * row carries a `sent` record too. It posts as a COMMENT and never anything
250
+ * else: handing a PR over hands the judgement over with it, so a handoff forms
251
+ * no verdict of its own.
252
+ */
253
+ export const HandoffInfoSchema = z.object({
254
+ at: z.string(),
255
+ /** The login GitHub now asks for. */
256
+ to: z.string(),
257
+ /** Whether cerber also took your own review request off the PR. */
258
+ withdrewYours: z.boolean().default(true),
259
+ /** The note posted on the PR, if one was. */
260
+ note: z
261
+ .object({ body: z.string(), url: z.string().nullable().default(null) })
262
+ .nullable()
263
+ .default(null),
264
+ });
152
265
  /** Result of pulling a review forward onto a newer head commit. */
153
266
  export const RefreshInfoSchema = z.object({
154
267
  at: z.string(),
@@ -292,6 +405,8 @@ export const ArtifactSchema = z.object({
292
405
  sent: SentInfoSchema.nullable().default(null),
293
406
  /** Set when cerber filed this draft away itself, and why. Never on a sent one. */
294
407
  filed: FiledInfoSchema.nullable().default(null),
408
+ /** Set when you gave this review to somebody else. Mutually exclusive with `filed`. */
409
+ handoff: HandoffInfoSchema.nullable().default(null),
295
410
  /**
296
411
  * When this review was settled — marked `reviewed` or `skipped`, by you or by
297
412
  * cerber filing it. Null on anything not settled.
@@ -332,6 +447,8 @@ export const ArtifactSchema = z.object({
332
447
  .optional(),
333
448
  /** Last time this review was pulled forward onto a newer head commit. */
334
449
  refresh: RefreshInfoSchema.nullable().default(null),
450
+ /** The last check for findings somebody else already raised. Null: never checked. */
451
+ raisedCheck: RaisedCheckSchema.nullable().default(null),
335
452
  calibration: CalibrationSchema.nullable().default(null),
336
453
  /** The conversation about this review. Never sent to GitHub. */
337
454
  chat: z.array(ChatTurnSchema).default([]),
@@ -1,7 +1,14 @@
1
+ import { sendTreatment } from "./raised.js";
1
2
  /**
2
3
  * Auto-send policy — deliberately narrow:
3
4
  * only APPROVE verdicts, at or above the confidence threshold, never a
4
5
  * re-send. COMMENT and REQUEST_CHANGES always wait for a human.
6
+ *
7
+ * Findings somebody else already raised go the way Send's default sends them:
8
+ * an undecided one is left out, and the reason says how many were. Nobody is
9
+ * there to decide, and the default is the side that posts less. A reply into
10
+ * someone's thread is never auto-sent — that is a decision only a person makes,
11
+ * and auto-send posts one review and nothing else.
5
12
  */
6
13
  export function evaluateAutoSend(artifact, threshold) {
7
14
  if (artifact.sent)
@@ -31,5 +38,34 @@ export function evaluateAutoSend(artifact, threshold) {
31
38
  reason: `confidence ${artifact.verdict.confidence}% < threshold ${threshold}%`,
32
39
  };
33
40
  }
34
- return { eligible: true, reason: `approve at ${artifact.verdict.confidence}% ≥ ${threshold}%` };
41
+ // Nobody is watching an auto-send, so a check that never ran, is still
42
+ // running, or failed is not the same as one that found nothing: each would
43
+ // post every duplicate it missed. Only a finished, clean check will do.
44
+ const check = artifact.raisedCheck;
45
+ if (check?.error) {
46
+ return {
47
+ eligible: false,
48
+ reason: `could not check which findings other reviewers already raised (${check.error})`,
49
+ };
50
+ }
51
+ if (check == null || check.at == null || check.checkingSince != null) {
52
+ return {
53
+ eligible: false,
54
+ reason: "the check for findings other reviewers already raised has not finished",
55
+ };
56
+ }
57
+ const live = artifact.comments.filter((c) => c.status !== "dropped");
58
+ const replies = live.filter((c) => sendTreatment(c) === "reply").length;
59
+ if (replies > 0) {
60
+ return {
61
+ eligible: false,
62
+ reason: `${replies} finding(s) marked to reply in another reviewer's thread — only a human Send posts replies`,
63
+ };
64
+ }
65
+ const held = live.filter((c) => sendTreatment(c) === "hold").length;
66
+ return {
67
+ eligible: true,
68
+ reason: `approve at ${artifact.verdict.confidence}% ≥ ${threshold}%` +
69
+ (held > 0 ? `; ${held} finding(s) already raised by another reviewer left out` : ""),
70
+ };
35
71
  }
@@ -38,7 +38,7 @@ export function toMarkdown(artifact) {
38
38
  lines.push("");
39
39
  const comments = artifact.comments.filter((c) => c.chapterId === ch.id && c.status !== "dropped");
40
40
  for (const c of comments) {
41
- lines.push(`> **${c.path}${c.line != null ? `:${c.line}` : ""}** — ${withGrade(c.body, c.severity).replace(/\n/g, "\n> ")}`);
41
+ lines.push(renderComment(c));
42
42
  lines.push("");
43
43
  }
44
44
  }
@@ -49,9 +49,15 @@ export function toMarkdown(artifact) {
49
49
  lines.push("## Other comments");
50
50
  lines.push("");
51
51
  for (const c of orphans) {
52
- lines.push(`> **${c.path}${c.line != null ? `:${c.line}` : ""}** — ${withGrade(c.body, c.severity).replace(/\n/g, "\n> ")}`);
52
+ lines.push(renderComment(c));
53
53
  lines.push("");
54
54
  }
55
55
  }
56
56
  return lines.join("\n");
57
57
  }
58
+ function renderComment(c) {
59
+ const text = `> **${c.path}${c.line != null ? `:${c.line}` : ""}** — ${withGrade(c.body, c.severity).replace(/\n/g, "\n> ")}`;
60
+ // Said here too, so a reader of the export does not take a repeat for news.
61
+ const raised = c.alreadyRaised;
62
+ return raised ? `${text}\n>\n> _Already raised by @${raised.by}: ${raised.reason}_` : text;
63
+ }
package/dist/core/gh.js CHANGED
@@ -424,6 +424,105 @@ export async function fetchLastReviewRequest(ref, login) {
424
424
  const raw = JSON.parse(out);
425
425
  return lastRequestOf(raw.data?.repository?.pullRequest?.timelineItems?.nodes ?? [], login);
426
426
  }
427
+ /**
428
+ * Everything others have said on the PR in review form: the inline threads, by
429
+ * their first comment, and the review bodies.
430
+ *
431
+ * Yours are left out — what you already said is not something a draft of yours
432
+ * repeats from somebody else — and so are the author's: a thread the author
433
+ * opens on their own PR is an explanation, not a finding. Bots stay in. A bot
434
+ * is very often the first to raise a thing, and some put their findings only in
435
+ * the review body, which is why bodies are read at all.
436
+ *
437
+ * Replies inside a thread are not read. The finding is what opened the thread;
438
+ * the rest is the conversation about it.
439
+ */
440
+ export function priorRemarks(raw, people) {
441
+ const skip = new Set([people.you.toLowerCase(), people.author.toLowerCase()]);
442
+ const remarks = [];
443
+ for (const t of raw.threads) {
444
+ const first = t.comments.nodes[0];
445
+ // GitHub's own deleted-user placeholder, so a thread whose author left is
446
+ // still a thing somebody raised.
447
+ const by = first?.author?.login ?? "ghost";
448
+ if (!first || first.fullDatabaseId == null || skip.has(by.toLowerCase()))
449
+ continue;
450
+ remarks.push({
451
+ kind: "thread",
452
+ id: t.id,
453
+ by,
454
+ bot: first.author?.__typename === "Bot",
455
+ at: first.createdAt,
456
+ body: first.body ?? "",
457
+ url: first.url,
458
+ path: t.path,
459
+ line: t.line,
460
+ state: t.isResolved ? "resolved" : t.isOutdated ? "outdated" : "open",
461
+ replyTo: String(first.fullDatabaseId),
462
+ });
463
+ }
464
+ for (const r of raw.reviews) {
465
+ const by = r.author?.login ?? "ghost";
466
+ // An empty body is a review whose whole say is in its threads, read above.
467
+ // A pending one is a draft nobody else can see.
468
+ if (!r.body?.trim() || r.submittedAt == null || skip.has(by.toLowerCase()))
469
+ continue;
470
+ remarks.push({
471
+ kind: "review",
472
+ id: r.id,
473
+ by,
474
+ bot: r.author?.__typename === "Bot",
475
+ at: r.submittedAt,
476
+ body: r.body,
477
+ url: r.url,
478
+ });
479
+ }
480
+ return remarks;
481
+ }
482
+ // The last 100 of each, in one call. A PR past 100 threads loses its oldest
483
+ // from the comparison — accepted rather than paginated, because the failure is
484
+ // the conservative one: a duplicate that posts, which is what happened before
485
+ // there was any comparison at all.
486
+ const PRIOR_REMARKS_QUERY = `query($owner:String!,$repo:String!,$number:Int!){
487
+ repository(owner:$owner,name:$repo){
488
+ pullRequest(number:$number){
489
+ reviewThreads(last:100){
490
+ nodes{ id isResolved isOutdated path line
491
+ comments(first:1){ nodes{ fullDatabaseId url createdAt body author{ login __typename } } }
492
+ }
493
+ }
494
+ reviews(last:100){
495
+ nodes{ id state submittedAt url body author{ login __typename } }
496
+ }
497
+ }
498
+ }
499
+ }`;
500
+ /**
501
+ * Read what others have already said on a PR in review form.
502
+ *
503
+ * GraphQL because REST has no thread resolution: `pulls/N/comments` returns the
504
+ * comments but not whether anybody resolved the thread they are in, and that is
505
+ * the fact that decides what Send does with a match.
506
+ */
507
+ export async function fetchPriorRemarks(ref, people) {
508
+ const out = await gh([
509
+ "api",
510
+ "graphql",
511
+ "-f",
512
+ `query=${PRIOR_REMARKS_QUERY}`,
513
+ "-F",
514
+ `owner=${ref.owner}`,
515
+ "-F",
516
+ `repo=${ref.repo}`,
517
+ "-F",
518
+ `number=${ref.number}`,
519
+ ]);
520
+ const raw = JSON.parse(out);
521
+ const pr = raw.data?.repository?.pullRequest;
522
+ if (!pr)
523
+ throw new Error(`GitHub returned no pull request for ${ref.owner}/${ref.repo}#${ref.number}`);
524
+ return priorRemarks({ threads: pr.reviewThreads?.nodes ?? [], reviews: pr.reviews?.nodes ?? [] }, people);
525
+ }
427
526
  export function searchAwaitingArgs(repoFilter, limit = 50) {
428
527
  const args = [
429
528
  "search",
@@ -448,9 +547,157 @@ export async function searchAwaitingMe(repoFilter, limit = 50) {
448
547
  return mapSearchResults(JSON.parse(out));
449
548
  }
450
549
  /**
451
- * THE ONLY GITHUB WRITE IN CERBER.
452
- * Submits a review in one shot. Must only ever be called from an explicit
453
- * user action (cockpit Send button / `cerber send` after confirmation).
550
+ * Who this repo will let you put on a PR — the list behind the handoff's
551
+ * suggestions.
552
+ *
553
+ * The `assignees` endpoint rather than `collaborators`: they answer nearly the
554
+ * same question, but collaborators needs push access, which is exactly what a
555
+ * reviewer on somebody else's repo does not have. This one needs only read, so
556
+ * the suggestions work wherever cerber can see the PR at all.
557
+ *
558
+ * Bots are dropped. A bot cannot take a review off you, and a list that offers
559
+ * one is a list you have to read past every time.
560
+ *
561
+ * Not authoritative, and nothing treats it as such: GitHub's own rule for who
562
+ * may be *requested* is its own, so the name box stays free text and a login
563
+ * this never mentions can still be handed to. These are suggestions.
564
+ */
565
+ export async function fetchAssignableUsers(ref) {
566
+ const out = await gh([
567
+ "api",
568
+ // The page size goes in the path, not in a `-f`/`-F` field: `gh api`
569
+ // switches the method to POST the moment any field is present, and
570
+ // `POST …/assignees` is a 404. A query string keeps this the GET it is,
571
+ // and `--paginate` carries it onto every following page.
572
+ `repos/${ref.owner}/${ref.repo}/assignees?per_page=100`,
573
+ "--paginate",
574
+ "--jq",
575
+ '.[] | select(.type != "Bot") | .login',
576
+ ]);
577
+ return out
578
+ .split("\n")
579
+ .map((line) => line.trim())
580
+ .filter(Boolean);
581
+ }
582
+ /**
583
+ * Move the review request to somebody else: ask GitHub for their review, then
584
+ * take yours off.
585
+ *
586
+ * Order matters and is the whole of the error handling. Asking first means a
587
+ * refusal — not a collaborator, no permission to request reviews — leaves the
588
+ * PR exactly as it was, with you still on the hook and nothing to undo. The
589
+ * reverse order could drop the request on the floor: you off it, them never on
590
+ * it, and nobody looking at the PR at all.
591
+ *
592
+ * Withdrawing yours is then allowed to fail on its own, and says so rather than
593
+ * throwing. Two requested reviewers is a state GitHub already understands and
594
+ * the person you asked is asked either way, so unpicking a request that worked
595
+ * would be undoing the half that succeeded to tidy up the half that didn't.
596
+ */
597
+ export async function handOffReview(ref, to,
598
+ /**
599
+ * Your login, to take off the PR — or null when it is already off, which is
600
+ * the case on a second handoff. Asking GitHub to withdraw a request that does
601
+ * not exist would fail, and this would then report you still listed when the
602
+ * truth is that you were taken off the first time.
603
+ */
604
+ from) {
605
+ const endpoint = `repos/${ref.owner}/${ref.repo}/pulls/${ref.number}/requested_reviewers`;
606
+ await gh(["api", endpoint, "--method", "POST", "-f", `reviewers[]=${to}`]);
607
+ if (from === null)
608
+ return { withdrewYours: true, withdrawError: null };
609
+ try {
610
+ await gh(["api", endpoint, "--method", "DELETE", "-f", `reviewers[]=${from}`]);
611
+ return { withdrewYours: true, withdrawError: null };
612
+ }
613
+ catch (err) {
614
+ return { withdrewYours: false, withdrawError: err instanceof Error ? err.message : String(err) };
615
+ }
616
+ }
617
+ /**
618
+ * Post a plain comment in the PR conversation.
619
+ *
620
+ * The `issues` endpoint on purpose: that is the one shape of writing on a PR
621
+ * that GitHub does not count as a review. A `pulls/…/reviews` POST with an
622
+ * event of COMMENT would look the same in the thread and be a review
623
+ * everywhere it matters — in the PR's review list, in `fetchOwnReview`, in
624
+ * whether the request is satisfied — and the only thing that may speak for a
625
+ * review here is Send.
626
+ */
627
+ export async function postIssueComment(ref, body) {
628
+ const out = await gh([
629
+ "api",
630
+ `repos/${ref.owner}/${ref.repo}/issues/${ref.number}/comments`,
631
+ "--method",
632
+ "POST",
633
+ "-f",
634
+ `body=${body}`,
635
+ ]);
636
+ try {
637
+ return { url: JSON.parse(out).html_url ?? null };
638
+ }
639
+ catch {
640
+ return { url: null };
641
+ }
642
+ }
643
+ /**
644
+ * Answer an existing review thread — a finding of yours that somebody else
645
+ * already raised, posted where they raised it instead of as a second thread.
646
+ *
647
+ * Part of Send and never anything on its own: it is called only by
648
+ * `postReplies`, after `submitReview` has posted the review it goes with, and
649
+ * only for comments the user marked "reply in their thread". GitHub files each
650
+ * reply under a small review of its own, which is GitHub's bookkeeping and not
651
+ * a second verdict: it carries no event, and the review that carries yours has
652
+ * already landed.
653
+ */
654
+ export async function replyInThread(ref, replyTo, body) {
655
+ const out = await gh([
656
+ "api",
657
+ `repos/${ref.owner}/${ref.repo}/pulls/${ref.number}/comments/${replyTo}/replies`,
658
+ "--method",
659
+ "POST",
660
+ "-f",
661
+ `body=${body}`,
662
+ ]);
663
+ try {
664
+ return { url: JSON.parse(out).html_url ?? null };
665
+ }
666
+ catch {
667
+ return { url: null };
668
+ }
669
+ }
670
+ /**
671
+ * Post a Send's replies, one by one, each allowed to fail on its own.
672
+ *
673
+ * After the review, never before it: the review is the thing the user vouched
674
+ * for, and a review that failed must leave nothing behind for the retry to post
675
+ * twice. A reply that fails once the review is up is reported against its
676
+ * comment rather than rolled back into a claim that nothing was sent.
677
+ */
678
+ export async function postReplies(ref, replies) {
679
+ const outcomes = [];
680
+ for (const r of replies) {
681
+ const at = new Date().toISOString();
682
+ try {
683
+ const { url } = await replyInThread(ref, r.replyTo, r.body);
684
+ outcomes.push({ commentId: r.commentId, at, url, error: null });
685
+ }
686
+ catch (err) {
687
+ outcomes.push({ commentId: r.commentId, at, url: null, error: err instanceof Error ? err.message : String(err) });
688
+ }
689
+ }
690
+ return outcomes;
691
+ }
692
+ /**
693
+ * THE ONLY GITHUB WRITE THAT SPEAKS AS A REVIEW.
694
+ * Submits a review in one shot. Must only ever be called from a human act:
695
+ * the cockpit's Send button, `cerber send` after confirmation, opt-in
696
+ * auto-send, or a handoff that was ticked to carry the draft — which composes
697
+ * through this same path and always as COMMENT.
698
+ * The other writes are a handoff's request swap and its note, neither of them
699
+ * a review, and the thread replies a Send carries for findings somebody else
700
+ * already raised (`replyInThread`).
454
701
  */
455
702
  export async function submitReview(ref, payload) {
456
703
  const args = [