@fullstackhouse/cerber 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
 
@@ -86,6 +88,7 @@ and apply on the next poll.
86
88
 
87
89
  - [What a review looks like](#what-a-review-looks-like) — what Claude writes and how you walk it
88
90
  - [The inbox](#the-inbox) — what the queue shows, what settles a row, the notifications
91
+ - [Handing a review to somebody else](#handing-a-review-to-somebody-else)
89
92
  - [It reviews the code, not just the diff](#it-reviews-the-code-not-just-the-diff)
90
93
  - [Trusted PRs: reviews that can run things](#trusted-prs-reviews-that-can-run-things)
91
94
  - [When the PR moves under you](#when-the-pr-moves-under-you)
@@ -152,7 +155,9 @@ all, since the repo is read-only and a review could never be sent.
152
155
 
153
156
  Settling a review is your decision, not GitHub's: skip a PR, or mark one
154
157
  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
158
+ because nothing in that decision reaches it. (The one decision that does reach
159
+ it is [handing the review to somebody else](#handing-a-review-to-somebody-else),
160
+ which moves the request rather than leaving it behind.) Those rows stay marked where they
156
161
  sit, counted on the tab they went into, and named in place of the empty
157
162
  inbox — cerber never tells you nothing awaits you while its own poll says
158
163
  otherwise. It is a report, not a nag: what you decided here stands, and the
@@ -227,6 +232,55 @@ anything, the favicon wears a red dot, and it goes away when the last review is
227
232
  dealt with. No permission, no switch — just the paw with a mark on it, so a
228
233
  glance at the tab strip answers "is there anything for me?".
229
234
 
235
+ ## Handing a review to somebody else
236
+
237
+ Some reviews are not yours. Wrong part of the stack, wrong week, somebody else
238
+ wrote half of it — and the honest answer is a name, not a skip. **Hand off** is
239
+ in the send panel next to *mark reviewed* and *skip*. It opens with the people
240
+ who can take it already listed — whoever this repo assigns work to, with anyone
241
+ you have handed to before at the top — so in the ordinary case you pick a name
242
+ rather than typing one. Then:
243
+
244
+ - they are added as a requested reviewer and you are taken off,
245
+ - a short note goes on the PR saying so — prefilled with their name so they get
246
+ the notification, editable, and clearable if you would rather say nothing,
247
+ - the drafted review goes up with it, if you tick the box for it (below),
248
+ - the row is filed under settled here, tagged `handed to @them` — or under
249
+ sent, if you sent the review with it.
250
+
251
+ That is the whole of it, and it is the one thing on the queue that genuinely
252
+ *finishes* a review request instead of just filing it: a skip leaves GitHub
253
+ asking you forever, and this does not. If they run cerber too, their next poll
254
+ picks the PR up and drafts a review for them — nothing has to be sent anywhere
255
+ for that to work.
256
+
257
+ **You can send the draft with it.** Tick *also post the review* and what Claude
258
+ found goes up at the same time — the same body and the same inline comments the
259
+ Send button would post, so the next reviewer and the author start from something
260
+ rather than nothing.
261
+
262
+ It posts as a **comment**, never an approval or a change request. You are handing
263
+ the judgement over, so making it on the way out would be odd. The findings keep
264
+ their grades, so a blocker still reads as a blocker — what that means for the
265
+ merge is now the next reviewer's call.
266
+
267
+ That box starts unticked, and it is the one thing here that isn't on by default.
268
+ You can hand off a PR you never opened, where the draft is whatever the overnight
269
+ poll wrote, and publishing that under your name is the thing the Send button
270
+ exists to prevent. Ticking it shows you the body first. Leave it alone and the
271
+ draft just stays here, openable and still sendable.
272
+
273
+ If GitHub refuses the swap — they are not a collaborator, or you cannot request
274
+ reviews on that repo — nothing happens at all: no note on the PR, no change
275
+ here. And if it moves them on but cannot take you off, the row says that too,
276
+ rather than claiming you are done with it.
277
+
278
+ The list is a convenience, not a gate: it is GitHub's assignable-users list, not
279
+ its rule for who may review, so you can always type a login it doesn't mention —
280
+ and on a repo cerber can't read that list for, the box says so and still works.
281
+
282
+ Handing to a team isn't supported — a room being asked is not somebody taking it.
283
+
230
284
  ## It reviews the code, not just the diff
231
285
 
232
286
  Cerber checks the PR's head out locally and lets the review read it. That
@@ -149,6 +149,36 @@ export const FiledInfoSchema = z.object({
149
149
  .nullable()
150
150
  .default(null),
151
151
  });
152
+ /**
153
+ * You gave this review to somebody else.
154
+ *
155
+ * The opposite of `filed` in every way that matters: that one is a record of
156
+ * cerber noticing GitHub had moved past a draft, this one is a decision of
157
+ * yours that *moved* GitHub — the review request is theirs now and no longer
158
+ * yours. It is why the row is settled, so it outranks the status wherever the
159
+ * queue tags one.
160
+ *
161
+ * `note` is the comment that went on the PR announcing it, and null when none
162
+ * did — either you cleared the text, or posting it failed after the request had
163
+ * already moved, which is one of the half-done states a handoff can end in.
164
+ *
165
+ * The draft may go with it, if you ticked the box for that, in which case the
166
+ * row carries a `sent` record too. It posts as a COMMENT and never anything
167
+ * else: handing a PR over hands the judgement over with it, so a handoff forms
168
+ * no verdict of its own.
169
+ */
170
+ export const HandoffInfoSchema = z.object({
171
+ at: z.string(),
172
+ /** The login GitHub now asks for. */
173
+ to: z.string(),
174
+ /** Whether cerber also took your own review request off the PR. */
175
+ withdrewYours: z.boolean().default(true),
176
+ /** The note posted on the PR, if one was. */
177
+ note: z
178
+ .object({ body: z.string(), url: z.string().nullable().default(null) })
179
+ .nullable()
180
+ .default(null),
181
+ });
152
182
  /** Result of pulling a review forward onto a newer head commit. */
153
183
  export const RefreshInfoSchema = z.object({
154
184
  at: z.string(),
@@ -292,6 +322,8 @@ export const ArtifactSchema = z.object({
292
322
  sent: SentInfoSchema.nullable().default(null),
293
323
  /** Set when cerber filed this draft away itself, and why. Never on a sent one. */
294
324
  filed: FiledInfoSchema.nullable().default(null),
325
+ /** Set when you gave this review to somebody else. Mutually exclusive with `filed`. */
326
+ handoff: HandoffInfoSchema.nullable().default(null),
295
327
  /**
296
328
  * When this review was settled — marked `reviewed` or `skipped`, by you or by
297
329
  * cerber filing it. Null on anything not settled.
package/dist/core/gh.js CHANGED
@@ -448,9 +448,107 @@ export async function searchAwaitingMe(repoFilter, limit = 50) {
448
448
  return mapSearchResults(JSON.parse(out));
449
449
  }
450
450
  /**
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).
451
+ * Who this repo will let you put on a PR — the list behind the handoff's
452
+ * suggestions.
453
+ *
454
+ * The `assignees` endpoint rather than `collaborators`: they answer nearly the
455
+ * same question, but collaborators needs push access, which is exactly what a
456
+ * reviewer on somebody else's repo does not have. This one needs only read, so
457
+ * the suggestions work wherever cerber can see the PR at all.
458
+ *
459
+ * Bots are dropped. A bot cannot take a review off you, and a list that offers
460
+ * one is a list you have to read past every time.
461
+ *
462
+ * Not authoritative, and nothing treats it as such: GitHub's own rule for who
463
+ * may be *requested* is its own, so the name box stays free text and a login
464
+ * this never mentions can still be handed to. These are suggestions.
465
+ */
466
+ export async function fetchAssignableUsers(ref) {
467
+ const out = await gh([
468
+ "api",
469
+ // The page size goes in the path, not in a `-f`/`-F` field: `gh api`
470
+ // switches the method to POST the moment any field is present, and
471
+ // `POST …/assignees` is a 404. A query string keeps this the GET it is,
472
+ // and `--paginate` carries it onto every following page.
473
+ `repos/${ref.owner}/${ref.repo}/assignees?per_page=100`,
474
+ "--paginate",
475
+ "--jq",
476
+ '.[] | select(.type != "Bot") | .login',
477
+ ]);
478
+ return out
479
+ .split("\n")
480
+ .map((line) => line.trim())
481
+ .filter(Boolean);
482
+ }
483
+ /**
484
+ * Move the review request to somebody else: ask GitHub for their review, then
485
+ * take yours off.
486
+ *
487
+ * Order matters and is the whole of the error handling. Asking first means a
488
+ * refusal — not a collaborator, no permission to request reviews — leaves the
489
+ * PR exactly as it was, with you still on the hook and nothing to undo. The
490
+ * reverse order could drop the request on the floor: you off it, them never on
491
+ * it, and nobody looking at the PR at all.
492
+ *
493
+ * Withdrawing yours is then allowed to fail on its own, and says so rather than
494
+ * throwing. Two requested reviewers is a state GitHub already understands and
495
+ * the person you asked is asked either way, so unpicking a request that worked
496
+ * would be undoing the half that succeeded to tidy up the half that didn't.
497
+ */
498
+ export async function handOffReview(ref, to,
499
+ /**
500
+ * Your login, to take off the PR — or null when it is already off, which is
501
+ * the case on a second handoff. Asking GitHub to withdraw a request that does
502
+ * not exist would fail, and this would then report you still listed when the
503
+ * truth is that you were taken off the first time.
504
+ */
505
+ from) {
506
+ const endpoint = `repos/${ref.owner}/${ref.repo}/pulls/${ref.number}/requested_reviewers`;
507
+ await gh(["api", endpoint, "--method", "POST", "-f", `reviewers[]=${to}`]);
508
+ if (from === null)
509
+ return { withdrewYours: true, withdrawError: null };
510
+ try {
511
+ await gh(["api", endpoint, "--method", "DELETE", "-f", `reviewers[]=${from}`]);
512
+ return { withdrewYours: true, withdrawError: null };
513
+ }
514
+ catch (err) {
515
+ return { withdrewYours: false, withdrawError: err instanceof Error ? err.message : String(err) };
516
+ }
517
+ }
518
+ /**
519
+ * Post a plain comment in the PR conversation.
520
+ *
521
+ * The `issues` endpoint on purpose: that is the one shape of writing on a PR
522
+ * that GitHub does not count as a review. A `pulls/…/reviews` POST with an
523
+ * event of COMMENT would look the same in the thread and be a review
524
+ * everywhere it matters — in the PR's review list, in `fetchOwnReview`, in
525
+ * whether the request is satisfied — and the only thing that may speak for a
526
+ * review here is Send.
527
+ */
528
+ export async function postIssueComment(ref, body) {
529
+ const out = await gh([
530
+ "api",
531
+ `repos/${ref.owner}/${ref.repo}/issues/${ref.number}/comments`,
532
+ "--method",
533
+ "POST",
534
+ "-f",
535
+ `body=${body}`,
536
+ ]);
537
+ try {
538
+ return { url: JSON.parse(out).html_url ?? null };
539
+ }
540
+ catch {
541
+ return { url: null };
542
+ }
543
+ }
544
+ /**
545
+ * THE ONLY GITHUB WRITE THAT SPEAKS AS A REVIEW.
546
+ * Submits a review in one shot. Must only ever be called from a human act:
547
+ * the cockpit's Send button, `cerber send` after confirmation, opt-in
548
+ * auto-send, or a handoff that was ticked to carry the draft — which composes
549
+ * through this same path and always as COMMENT.
550
+ * The two writes above are the other things that reach GitHub — a handoff's
551
+ * request swap and its note — and neither of those is a review.
454
552
  */
455
553
  export async function submitReview(ref, payload) {
456
554
  const args = [
@@ -177,6 +177,12 @@ export function describeChange(before, after) {
177
177
  if (after.filed && after.filed.at !== before?.filed?.at) {
178
178
  lines.push(`filed under settled — ${FILED_PHRASE[after.filed.reason] ?? after.filed.reason}`);
179
179
  }
180
+ if (after.handoff && after.handoff.at !== before?.handoff?.at) {
181
+ const h = after.handoff;
182
+ lines.push(`handed to @${h.to} — GitHub asks them for this review now` +
183
+ `${h.withdrewYours ? "" : ", and still asks you too"}` +
184
+ `${h.note ? ", with a note on the PR" : ""}`);
185
+ }
180
186
  if (after.refresh && after.refresh.at !== before?.refresh?.at) {
181
187
  const r = after.refresh;
182
188
  lines.push(`pulled forward onto ${short(r.toSha)} — ${r.moved} comment(s) followed the code` +
@@ -80,6 +80,11 @@ export function mergeRunResult(fresh, current) {
80
80
  notified: current.notified,
81
81
  calibration: current.calibration,
82
82
  filed: current.filed,
83
+ // A decision of the user's, like the status above it: a handoff that landed
84
+ // while the run worked has already moved the request on GitHub, and a run
85
+ // that wrote `null` over it would leave the row reading "skipped" with
86
+ // nothing anywhere saying who has the PR now.
87
+ handoff: current.handoff,
83
88
  chat: current.chat,
84
89
  preChat: current.preChat,
85
90
  pendingChat: current.pendingChat,
@@ -66,6 +66,10 @@ export function mergeConcurrentEdits(before, after, current) {
66
66
  // Nor does a turn announce anything: the ledger is the poll's, and a turn
67
67
  // takes minutes, which is long enough for a poll to have stamped it.
68
68
  notified: current.notified,
69
+ // Nor does it hand a review to anybody. A handoff that landed while the
70
+ // turn ran has already moved the request on GitHub, so restoring the
71
+ // pre-turn `null` would erase the only local record of where the PR went.
72
+ handoff: current.handoff,
69
73
  };
70
74
  }
71
75
  export function applyRevisions(artifact, revisions, opts = {}) {
@@ -193,6 +193,7 @@ async function performReview(ref, pr, existing, opts, log) {
193
193
  sent: null,
194
194
  refresh: null,
195
195
  filed: null,
196
+ handoff: null,
196
197
  settledAt: null,
197
198
  calibration: null,
198
199
  // The conversation is the user's writing, so a re-review keeps it — the
@@ -47,6 +47,7 @@ export function stubArtifact(ref) {
47
47
  sent: null,
48
48
  refresh: null,
49
49
  filed: null,
50
+ handoff: null,
50
51
  settledAt: null,
51
52
  // Null, not absent: the poll found this PR, so it owes the machine a tap
52
53
  // for it — once there is something to tap you about (§9.8).
@@ -365,8 +366,14 @@ export function startDaemon(opts) {
365
366
  // those calls took, and neither wants its status rewritten underneath it.
366
367
  const saved = await updateArtifactByKey(artifactKey(artifact.id), (a) => askedAgainAfterSettling(a, at)
367
368
  ? // `filed` goes with the status it explained: cerber's account of why
368
- // this row was settled is not the story of a row that is back.
369
- { ...a, status: reopenedStatus(a), settledAt: null, filed: null }
369
+ // this row was settled is not the story of a row that is back. So
370
+ // does `handoff`, and for a sharper reason: every line it prints
371
+ // says GitHub is asking somebody else and no longer asking you,
372
+ // which is the one fact a reopen has just disproved. Left behind it
373
+ // would also tell the next handoff your request was already
374
+ // withdrawn, and that one skips the withdrawal — so the row would
375
+ // claim you were taken off a PR you are still on.
376
+ { ...a, status: reopenedStatus(a), settledAt: null, filed: null, handoff: null }
370
377
  : a);
371
378
  // The status is what says the write landed. `settledAt` cannot: a legacy
372
379
  // row never had one, so a mutation that declined would read as a reopen.
@@ -9,7 +9,7 @@ import { randomUUID } from "node:crypto";
9
9
  import { isPureStub } from "./daemon.js";
10
10
  import { ArtifactStatusSchema, SCHEMA_VERSION, SeveritySchema, VerdictSchema, artifactId, artifactKey, } from "../core/artifact.js";
11
11
  import { toMarkdown } from "../core/export.js";
12
- import { fetchPrDiff, fetchPrInfo, parsePrRef, submitReview } from "../core/gh.js";
12
+ import { currentLogin, fetchAssignableUsers, fetchPrDiff, fetchPrInfo, handOffReview, parsePrRef, postIssueComment, submitReview, } from "../core/gh.js";
13
13
  import { z } from "zod";
14
14
  import { DaemonConfigSchema, configPath, loadConfig, saveConfig } from "../core/config.js";
15
15
  import { refreshArtifact, userOwnsStatus } from "../core/refresh.js";
@@ -29,6 +29,33 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
29
  * own decision about a PR. Everything else is a fact some other path owns.
30
30
  */
31
31
  const SETTLEABLE = ["reviewed", "skipped"];
32
+ /**
33
+ * What a handoff needs: one person, a note to post, and a confirmation.
34
+ *
35
+ * A login and not a team. `@org/team` reaches GitHub through a different field
36
+ * (`team_reviewers`), and "take your own request off" means nothing next to a
37
+ * room being asked — so it is refused in words rather than half-handled.
38
+ */
39
+ const HandoffSchema = z.object({
40
+ to: z
41
+ .string()
42
+ .transform((s) => s.trim().replace(/^@/, ""))
43
+ .refine((s) => /^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,37}[A-Za-z0-9])?$/.test(s), {
44
+ message: "`to` must be one GitHub login — a handoff names a person, and asking a team is a different thing cerber does not do yet",
45
+ }),
46
+ /** The comment to post on the PR. Blank or absent posts nothing. */
47
+ note: z.string().max(65536).optional(),
48
+ /**
49
+ * Send the draft along with it, as a COMMENT review.
50
+ *
51
+ * Off unless asked for: a draft the poll wrote and nobody has read is exactly
52
+ * what the send path exists to keep off GitHub, and a handoff is reachable
53
+ * from a row you never opened. Asking for it is the human act that makes it
54
+ * a send like any other.
55
+ */
56
+ postReview: z.boolean().optional(),
57
+ confirm: z.boolean().optional(),
58
+ });
32
59
  /**
33
60
  * Is an AI run rewriting this draft right now?
34
61
  *
@@ -193,6 +220,10 @@ export async function buildApp(opts) {
193
220
  sent: a.sent ? { at: a.sent.at, event: a.sent.event, url: a.sent.url, auto: a.sent.auto ?? false } : null,
194
221
  // Why a settled row is settled, when cerber settled it rather than you.
195
222
  filed: a.filed,
223
+ // And when it was settled by giving it away. Carried in the list because
224
+ // it is what the row's tag reads, and because an open request GitHub has
225
+ // not caught up on yet is explained by this and nothing else.
226
+ handoff: a.handoff,
196
227
  })));
197
228
  });
198
229
  // ---- Pull a PR in: review anything, not just what the inbox found ----
@@ -289,6 +320,7 @@ export async function buildApp(opts) {
289
320
  sent: null,
290
321
  refresh: null,
291
322
  filed: null,
323
+ handoff: null,
292
324
  settledAt: null,
293
325
  calibration: null,
294
326
  chat: [],
@@ -672,7 +704,7 @@ export async function buildApp(opts) {
672
704
  c.header("Content-Disposition", `attachment; filename="review-${artifactKey(artifact.id)}.md"`);
673
705
  return c.body(toMarkdown(artifact));
674
706
  });
675
- // ---- Send: THE ONLY GITHUB WRITE. Explicit user action from the cockpit. ----
707
+ // ---- Send: the only write that speaks as a review. Explicit user action. ----
676
708
  app.post("/api/reviews/:key/send", async (c) => {
677
709
  const { event, confirm } = await c.req.json();
678
710
  if (confirm !== true) {
@@ -717,6 +749,189 @@ export async function buildApp(opts) {
717
749
  const event = (c.req.query("event") ?? "COMMENT");
718
750
  return c.json(buildReviewPayload(artifact, event));
719
751
  });
752
+ /**
753
+ * Who this review could be handed to — the handoff's suggestions.
754
+ *
755
+ * Read-only, and allowed to come back empty: the name box works without it,
756
+ * and a repo cerber cannot list people for is a repo you can still hand a PR
757
+ * to by typing the name. So a failure here is reported as a line in the
758
+ * dialog, not as an error that stops it.
759
+ *
760
+ * Two logins are dropped because GitHub would refuse them anyway: your own,
761
+ * and the PR author's — nobody reviews their own pull request.
762
+ */
763
+ app.get("/api/reviews/:key/reviewers", async (c) => {
764
+ const artifact = await loadArtifactByKey(c.req.param("key"));
765
+ if (!artifact)
766
+ return c.json({ error: "not found" }, 404);
767
+ let logins;
768
+ try {
769
+ logins = await fetchAssignableUsers({
770
+ owner: artifact.pr.owner,
771
+ repo: artifact.pr.repo,
772
+ number: artifact.pr.number,
773
+ });
774
+ }
775
+ catch (err) {
776
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 502);
777
+ }
778
+ // Not knowing who you are costs the exclusion, not the list: you would
779
+ // simply see yourself offered, and the handoff refuses that anyway.
780
+ const me = await currentLogin().catch(() => null);
781
+ const drop = new Set([me, artifact.pr.author].filter(Boolean).map((l) => l.toLowerCase()));
782
+ return c.json({
783
+ logins: logins.filter((l) => !drop.has(l.toLowerCase())).sort((a, b) => a.localeCompare(b)),
784
+ });
785
+ });
786
+ /**
787
+ * Give this review to somebody else: GitHub's review request moves to them, a
788
+ * note in the conversation says so, and the row settles here.
789
+ *
790
+ * Up to three writes. Two are never a review: the request swap, and the note,
791
+ * which is a plain issue comment. The third is the draft, and only when
792
+ * `postReview` asked for it — composed and submitted by the send path itself
793
+ * rather than a second composition, and always as COMMENT, because handing a
794
+ * PR over hands the judgement over with it.
795
+ */
796
+ app.post("/api/reviews/:key/handoff", async (c) => {
797
+ const parsed = HandoffSchema.safeParse(await c.req.json().catch(() => null));
798
+ if (!parsed.success) {
799
+ return c.json({ error: parsed.error.issues.map((i) => i.message).join("; ") }, 400);
800
+ }
801
+ const { to, confirm } = parsed.data;
802
+ const note = parsed.data.note?.trim() ?? "";
803
+ const postReview = parsed.data.postReview === true;
804
+ if (confirm !== true) {
805
+ return c.json({ error: "a handoff requires an explicit confirm: true" }, 400);
806
+ }
807
+ const key = c.req.param("key");
808
+ const artifact = await loadArtifactByKey(key);
809
+ if (!artifact)
810
+ return c.json({ error: "not found" }, 404);
811
+ // Not while a run is rewriting the draft. The handoff settles the row, and
812
+ // settling one out from under a run that is about to report on it is the
813
+ // same race the send path refuses.
814
+ if (inFlight(artifact)) {
815
+ return c.json({ error: "a review of this PR is running — wait for it to finish" }, 409);
816
+ }
817
+ // Nor while a chat turn is being answered. A turn's result is folded onto
818
+ // whatever the artifact says when it lands, and the fold is written for a
819
+ // row nobody gave away mid-turn: every field a handoff sets would have to
820
+ // survive it. One of them does now, and refusing here is what keeps the
821
+ // rest — the send record above all — from needing to.
822
+ if (turnInFlight(artifact)) {
823
+ return c.json({ error: "the reviewer is answering a question about this review — wait for it to land" }, 409);
824
+ }
825
+ let me;
826
+ try {
827
+ me = await currentLogin();
828
+ }
829
+ catch (err) {
830
+ // Without this there is nobody to take off the PR, and a handoff that
831
+ // only adds a reviewer leaves you on the hook while the row says
832
+ // otherwise — so it does not happen at all.
833
+ return c.json({ error: `cerber could not ask gh who you are: ${err instanceof Error ? err.message : String(err)}` }, 502);
834
+ }
835
+ if (to.toLowerCase() === me.toLowerCase()) {
836
+ return c.json({ error: `@${to} is you — a handoff needs somebody else` }, 400);
837
+ }
838
+ const ref = { owner: artifact.pr.owner, repo: artifact.pr.repo, number: artifact.pr.number };
839
+ let swap;
840
+ try {
841
+ swap = await handOffReview(ref, to, artifact.handoff?.withdrewYours ? null : me);
842
+ }
843
+ catch (err) {
844
+ // Nothing was written here and nothing was posted: the request swap goes
845
+ // first precisely so its refusal — not a collaborator, no permission —
846
+ // leaves the PR and this row exactly as they were.
847
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 502);
848
+ }
849
+ if (swap.withdrawError) {
850
+ console.error(`[handoff ${artifact.id}] could not withdraw your request: ${swap.withdrawError}`);
851
+ }
852
+ // The note goes last, and is allowed to fail on its own — as is the review
853
+ // above it. The request has already moved by now: they are being asked for
854
+ // this review whether or not the conversation says why, so a failure in
855
+ // either is recorded and reported rather than rolled back into a lie about
856
+ // where the PR is.
857
+ // The draft, when it was asked for, and always as a COMMENT: handing a PR
858
+ // over is handing over the judgement with it, so an approve or a change
859
+ // request here would be cerber ruling on a review it is giving away. The
860
+ // grades stay on the findings, which is where they were anyway — the
861
+ // verdict is the part that is now @to's to make.
862
+ //
863
+ // Re-read from disk first, and not out of caution: the artifact above was
864
+ // loaded before the request swap, which is two GitHub calls ago, and a Send
865
+ // in another tab or an auto-send landing in that window would make this the
866
+ // second review on the PR. The snapshot cannot see that; the file can.
867
+ let sendError = null;
868
+ let sent = null;
869
+ let payload = null;
870
+ const alreadySent = postReview ? ((await loadArtifactByKey(key))?.sent ?? artifact.sent) : null;
871
+ // And there has to be a review to post. The dialog only offers the box on a
872
+ // row that has one, but the dialog is not the guard: a stale tab or a direct
873
+ // call would otherwise submit a body that is nothing but cerber's footer,
874
+ // under the user's name, on a PR nobody has drafted a word about.
875
+ const somethingToSay = artifact.summary !== "" || artifact.comments.some((cm) => cm.status !== "dropped");
876
+ if (postReview && !alreadySent && somethingToSay) {
877
+ payload = buildReviewPayload(artifact, "COMMENT");
878
+ try {
879
+ const { url } = await submitReview(ref, {
880
+ event: payload.event,
881
+ body: payload.body,
882
+ comments: payload.comments,
883
+ commitId: payload.commitId,
884
+ });
885
+ sent = { at: new Date().toISOString(), event: payload.event, url, auto: false };
886
+ }
887
+ catch (err) {
888
+ sendError = err instanceof Error ? err.message : String(err);
889
+ console.error(`[handoff ${artifact.id}] the review did not post: ${sendError}`);
890
+ }
891
+ }
892
+ let noteError = null;
893
+ let posted = null;
894
+ if (note) {
895
+ try {
896
+ const { url } = await postIssueComment(ref, note);
897
+ posted = { body: note, url };
898
+ }
899
+ catch (err) {
900
+ noteError = err instanceof Error ? err.message : String(err);
901
+ console.error(`[handoff ${artifact.id}] the note did not post: ${noteError}`);
902
+ }
903
+ }
904
+ const at = new Date().toISOString();
905
+ const updated = await updateArtifactByKey(key, (a) => {
906
+ // Three ways this row can end, and the status follows what actually
907
+ // reached GitHub. A review that went is the strongest thing true about
908
+ // it; a row already sent keeps the standing it had; anything else is you
909
+ // deciding not to review this one, which is a skip.
910
+ const settles = !sent && !a.sent;
911
+ return {
912
+ ...a,
913
+ status: sent ? "sent" : a.sent ? a.status : "skipped",
914
+ settledAt: settles ? at : a.settledAt,
915
+ // Cerber's account of why a row is filed away cannot stand next to your
916
+ // own: you did this, and `handoff` is what the queue tags the row from.
917
+ filed: a.sent ? a.filed : null,
918
+ // The disk's record wins: a send that landed while this handoff was
919
+ // talking to GitHub is a review that went, and ours — if there is one
920
+ // at all — came after it.
921
+ sent: a.sent ?? sent,
922
+ calibration: sent && payload ? computeCalibration(a, payload.event) : a.calibration,
923
+ handoff: {
924
+ at,
925
+ to,
926
+ withdrewYours: swap.withdrewYours,
927
+ note: posted,
928
+ },
929
+ };
930
+ });
931
+ if (!updated)
932
+ return c.json({ error: "not found" }, 404);
933
+ return c.json({ artifact: updated, noteError, sendError });
934
+ });
720
935
  // Static cockpit build. In the published package web/dist ships alongside dist/.
721
936
  // __dirname is src/server (dev via tsx) or dist/server (built) — both are two levels below the repo root.
722
937
  const webDist = path.resolve(__dirname, "../../web/dist");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackhouse/cerber",
3
- "version": "0.33.0",
3
+ "version": "0.34.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
  "keywords": [