@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.
@@ -47,6 +47,8 @@ export function stubArtifact(ref) {
47
47
  sent: null,
48
48
  refresh: null,
49
49
  filed: null,
50
+ handoff: null,
51
+ raisedCheck: null,
50
52
  settledAt: null,
51
53
  // Null, not absent: the poll found this PR, so it owes the machine a tap
52
54
  // for it — once there is something to tap you about (§9.8).
@@ -365,8 +367,14 @@ export function startDaemon(opts) {
365
367
  // those calls took, and neither wants its status rewritten underneath it.
366
368
  const saved = await updateArtifactByKey(artifactKey(artifact.id), (a) => askedAgainAfterSettling(a, at)
367
369
  ? // `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 }
370
+ // this row was settled is not the story of a row that is back. So
371
+ // does `handoff`, and for a sharper reason: every line it prints
372
+ // says GitHub is asking somebody else and no longer asking you,
373
+ // which is the one fact a reopen has just disproved. Left behind it
374
+ // would also tell the next handoff your request was already
375
+ // withdrawn, and that one skips the withdrawal — so the row would
376
+ // claim you were taken off a PR you are still on.
377
+ { ...a, status: reopenedStatus(a), settledAt: null, filed: null, handoff: null }
370
378
  : a);
371
379
  // The status is what says the write landed. `settledAt` cannot: a legacy
372
380
  // row never had one, so a mutation that declined would read as a reopen.
@@ -711,6 +719,27 @@ export function startDaemon(opts) {
711
719
  });
712
720
  return { reviewed, skipped, failed };
713
721
  }
722
+ /**
723
+ * The status fields that follow the config, set from it. The poll calls this
724
+ * with what it just read, and `reconfigure` with what the cockpit just saved.
725
+ */
726
+ function applyKnobs(config) {
727
+ const knobs = config.daemon;
728
+ const autoReview = opts.autoReview && knobs.autoReview;
729
+ status.pollEnabled = knobs.poll;
730
+ // What the cockpit's bell defers to: a loop that isn't polling announces
731
+ // nothing, whatever the notify toggle says.
732
+ const notifyOn = opts.notify && knobs.notify;
733
+ // Only true while all three hold, and the third is only knowable by
734
+ // having tried: a notifier that isn't there is not a bell the cockpit
735
+ // should be standing down for.
736
+ const announcing = () => knobs.poll && notifyOn && notifierWorks !== false;
737
+ status.notify = announcing();
738
+ status.autoReview = autoReview;
739
+ // Recomputed too, so a trust rule added mid-run shows up in the strip.
740
+ status.trustedRuns = autoReview && opts.trust !== false && config.trust.length > 0;
741
+ return { knobs, autoReview, notifyOn, announcing };
742
+ }
714
743
  /** Everything a poll writes is the poll's — including the reviews it starts,
715
744
  * which stamp themselves as runs from inside this. */
716
745
  const poll = () => withWriter({ by: "daemon", cause: "poll" }, runPoll);
@@ -726,20 +755,7 @@ export function startDaemon(opts) {
726
755
  // or the knobs), so a broken file skips the tick loudly instead of
727
756
  // proceeding on guessed defaults.
728
757
  const config = await loadConfig();
729
- const knobs = config.daemon;
730
- const autoReview = opts.autoReview && knobs.autoReview;
731
- status.pollEnabled = knobs.poll;
732
- // What the cockpit's bell defers to: a loop that isn't polling announces
733
- // nothing, whatever the notify toggle says.
734
- const notifyOn = opts.notify && knobs.notify;
735
- // Only true while all three hold, and the third is only knowable by
736
- // having tried: a notifier that isn't there is not a bell the cockpit
737
- // should be standing down for.
738
- const announcing = () => knobs.poll && notifyOn && notifierWorks !== false;
739
- status.notify = announcing();
740
- status.autoReview = autoReview;
741
- // Recomputed too, so a trust rule added mid-run shows up in the strip.
742
- status.trustedRuns = autoReview && opts.trust !== false && config.trust.length > 0;
758
+ const { knobs, autoReview, notifyOn, announcing } = applyKnobs(config);
743
759
  if (!knobs.poll) {
744
760
  // Deliberately off is not an outage — don't leave a stale error up, or
745
761
  // a list of PRs we are no longer checking are still awaiting you.
@@ -840,6 +856,9 @@ export function startDaemon(opts) {
840
856
  cockpitAt: (url) => {
841
857
  cockpitUrl = url;
842
858
  },
859
+ reconfigure: (config) => {
860
+ applyKnobs(config);
861
+ },
843
862
  stop: () => {
844
863
  stopped = true;
845
864
  if (timer)
@@ -9,13 +9,15 @@ 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, postReplies, 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";
16
16
  import { withWriter } from "../core/history.js";
17
17
  import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
18
- import { buildReviewPayload, computeCalibration } from "../core/send.js";
18
+ import { buildReviewPayload, computeCalibration, describeReplyFailures, recordReplies, } from "../core/send.js";
19
+ import { raisedChanged, startRaisedCheck } from "../core/raised.js";
20
+ import { checkAlreadyRaised, claimRaisedCheck, finishRaisedCheck, isRaisedCheckRunning, prepareRaisedCheck, releaseRaisedCheck, } from "../runner/raised.js";
19
21
  import { listArtifacts, loadArtifact, loadArtifactByKey, saveArtifact, updateArtifactByKey, } from "../core/state.js";
20
22
  import { isReviewRunning } from "../runner/inflight.js";
21
23
  import { reviewPr } from "../runner/review.js";
@@ -29,6 +31,35 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
31
  * own decision about a PR. Everything else is a fact some other path owns.
30
32
  */
31
33
  const SETTLEABLE = ["reviewed", "skipped"];
34
+ /** What the user may decide about a finding somebody else already raised. */
35
+ const RaisedDecisionSchema = z.enum(["send", "reply"]).nullable();
36
+ /**
37
+ * What a handoff needs: one person, a note to post, and a confirmation.
38
+ *
39
+ * A login and not a team. `@org/team` reaches GitHub through a different field
40
+ * (`team_reviewers`), and "take your own request off" means nothing next to a
41
+ * room being asked — so it is refused in words rather than half-handled.
42
+ */
43
+ const HandoffSchema = z.object({
44
+ to: z
45
+ .string()
46
+ .transform((s) => s.trim().replace(/^@/, ""))
47
+ .refine((s) => /^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,37}[A-Za-z0-9])?$/.test(s), {
48
+ 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",
49
+ }),
50
+ /** The comment to post on the PR. Blank or absent posts nothing. */
51
+ note: z.string().max(65536).optional(),
52
+ /**
53
+ * Send the draft along with it, as a COMMENT review.
54
+ *
55
+ * Off unless asked for: a draft the poll wrote and nobody has read is exactly
56
+ * what the send path exists to keep off GitHub, and a handoff is reachable
57
+ * from a row you never opened. Asking for it is the human act that makes it
58
+ * a send like any other.
59
+ */
60
+ postReview: z.boolean().optional(),
61
+ confirm: z.boolean().optional(),
62
+ });
32
63
  /**
33
64
  * Is an AI run rewriting this draft right now?
34
65
  *
@@ -38,6 +69,22 @@ const SETTLEABLE = ["reviewed", "skipped"];
38
69
  * that has not written yet.
39
70
  */
40
71
  const inFlight = (a) => a.status === "running" || isReviewRunning(a.id);
72
+ /**
73
+ * What to say when the in-flight claim is why a request was refused. The check
74
+ * for findings others already raised holds the claim too, for up to a minute,
75
+ * and "a review is running" would send the user looking for a run that isn't.
76
+ */
77
+ const busy = (a, otherwise) => isRaisedCheckRunning(a.id)
78
+ ? "cerber is checking which findings other reviewers already raised — give it a minute"
79
+ : otherwise;
80
+ /** Run a check for already-raised findings detached; its failures land on the artifact. */
81
+ function checkInBackground(artifact) {
82
+ return checkAlreadyRaised(artifact, {
83
+ log: (m) => console.log(`[raised ${artifact.id}] ${m}`),
84
+ }).catch((err) => {
85
+ console.error(`[raised ${artifact.id}] ${err instanceof Error ? err.message : String(err)}`);
86
+ });
87
+ }
41
88
  export async function buildApp(opts) {
42
89
  const app = new Hono();
43
90
  // Whatever any route writes to an artifact is stamped with the request that
@@ -84,7 +131,8 @@ export async function buildApp(opts) {
84
131
  }
85
132
  });
86
133
  // The inbox knobs. The daemon re-reads the config every poll, so a toggle
87
- // here applies on the next tick without restarting serve.
134
+ // here applies on the next tick without restarting serve — and what the
135
+ // status reports about them applies at once, since the cockpit reads it.
88
136
  app.post("/api/config/daemon", async (c) => {
89
137
  let body;
90
138
  try {
@@ -97,6 +145,7 @@ export async function buildApp(opts) {
97
145
  const config = await loadConfig();
98
146
  const daemon = DaemonConfigSchema.parse({ ...config.daemon, ...body });
99
147
  await saveConfig({ ...config, daemon });
148
+ opts.daemon?.reconfigure({ ...config, daemon });
100
149
  return c.json({ path: configPath(), trust: trustView(config.trust), daemon });
101
150
  }
102
151
  catch (err) {
@@ -139,6 +188,7 @@ export async function buildApp(opts) {
139
188
  });
140
189
  const trust = remove === true ? without : [...without, canonical];
141
190
  await saveConfig({ ...config, trust });
191
+ opts.daemon?.reconfigure({ ...config, trust });
142
192
  // Full ConfigView — the cockpit replaces its config state with this
143
193
  // wholesale, so omitting daemon would crash the Settings toggles.
144
194
  return c.json({ path: configPath(), trust: trustView(trust), daemon: config.daemon });
@@ -193,6 +243,10 @@ export async function buildApp(opts) {
193
243
  sent: a.sent ? { at: a.sent.at, event: a.sent.event, url: a.sent.url, auto: a.sent.auto ?? false } : null,
194
244
  // Why a settled row is settled, when cerber settled it rather than you.
195
245
  filed: a.filed,
246
+ // And when it was settled by giving it away. Carried in the list because
247
+ // it is what the row's tag reads, and because an open request GitHub has
248
+ // not caught up on yet is explained by this and nothing else.
249
+ handoff: a.handoff,
196
250
  })));
197
251
  });
198
252
  // ---- Pull a PR in: review anything, not just what the inbox found ----
@@ -289,6 +343,8 @@ export async function buildApp(opts) {
289
343
  sent: null,
290
344
  refresh: null,
291
345
  filed: null,
346
+ handoff: null,
347
+ raisedCheck: null,
292
348
  settledAt: null,
293
349
  calibration: null,
294
350
  chat: [],
@@ -382,11 +438,35 @@ export async function buildApp(opts) {
382
438
  app.patch("/api/reviews/:key/comments/:id", async (c) => {
383
439
  const body = await c.req.json();
384
440
  const id = c.req.param("id");
441
+ // What Send does with a finding somebody else already raised: post it
442
+ // anyway, answer in their thread, or — null — the default for where they
443
+ // raised it. Refused rather than coerced when it cannot mean anything: a
444
+ // comment nobody else raised has no thread to answer, and neither does a
445
+ // review body.
446
+ const decision = RaisedDecisionSchema.safeParse(body.raisedDecision);
447
+ if (body.raisedDecision !== undefined) {
448
+ if (!decision.success) {
449
+ return c.json({ error: "raisedDecision must be \"send\", \"reply\", or null for the default" }, 400);
450
+ }
451
+ const artifact = await loadArtifactByKey(c.req.param("key"));
452
+ const target = artifact?.comments.find((cm) => cm.id === id);
453
+ if (!artifact || !target)
454
+ return c.json({ error: "not found" }, 404);
455
+ if (!target.alreadyRaised) {
456
+ return c.json({ error: "nobody else raised this comment, so there is nothing to decide" }, 409);
457
+ }
458
+ if (decision.data === "reply" && target.alreadyRaised.where.kind !== "thread") {
459
+ return c.json({ error: `@${target.alreadyRaised.by} raised it in a review body, which has no thread to reply in` }, 409);
460
+ }
461
+ }
385
462
  const updated = await updateArtifactByKey(c.req.param("key"), (a) => ({
386
463
  ...a,
387
464
  comments: a.comments.map((cm) => cm.id === id
388
465
  ? {
389
466
  ...cm,
467
+ ...(decision.success && body.raisedDecision !== undefined && cm.alreadyRaised
468
+ ? { alreadyRaised: { ...cm.alreadyRaised, decision: decision.data } }
469
+ : {}),
390
470
  ...(body.body !== undefined ? { body: String(body.body) } : {}),
391
471
  ...(body.severity !== undefined
392
472
  ? { severity: SeveritySchema.nullable().parse(body.severity) }
@@ -422,6 +502,7 @@ export async function buildApp(opts) {
422
502
  editedByUser: false,
423
503
  originalLine: null,
424
504
  drifted: false,
505
+ alreadyRaised: null,
425
506
  },
426
507
  ],
427
508
  }));
@@ -504,7 +585,7 @@ export async function buildApp(opts) {
504
585
  if (artifact.sent)
505
586
  return c.json({ error: `already sent at ${artifact.sent.at}` }, 409);
506
587
  if (isReviewRunning(artifact.id)) {
507
- return c.json({ error: "a review of this PR is already running" }, 409);
588
+ return c.json({ error: busy(artifact, "a review of this PR is already running") }, 409);
508
589
  }
509
590
  const ref = { owner: artifact.pr.owner, repo: artifact.pr.repo, number: artifact.pr.number };
510
591
  // Source-backed unless the server was started with --no-source; ?source=1
@@ -568,7 +649,7 @@ export async function buildApp(opts) {
568
649
  if (artifact.sent)
569
650
  return c.json({ error: `already sent at ${artifact.sent.at}` }, 409);
570
651
  if (isReviewRunning(artifact.id) || turnInFlight(artifact)) {
571
- return c.json({ error: "a run for this PR is already in flight" }, 409);
652
+ return c.json({ error: busy(artifact, "a run for this PR is already in flight") }, 409);
572
653
  }
573
654
  let request;
574
655
  try {
@@ -612,10 +693,16 @@ export async function buildApp(opts) {
612
693
  // The cockpit stays live throughout, so fold the result onto whatever
613
694
  // is on disk now rather than overwriting it — an edit the user made
614
695
  // while waiting must not vanish when the answer lands.
615
- await updateArtifactByKey(key, (current) => ({
616
- ...mergeConcurrentEdits(artifact, result.artifact, current),
617
- pendingChat: null,
618
- }));
696
+ // A finding the turn added is compared with what others said, like
697
+ // the review's own were. Marked in the same write that clears the
698
+ // turn, so the cockpit's poll has no gap in which nothing is pending.
699
+ const added = result.artifact.comments.some((cm) => !artifact.comments.some((before) => before.id === cm.id));
700
+ const landed = await updateArtifactByKey(key, (current) => {
701
+ const merged = { ...mergeConcurrentEdits(artifact, result.artifact, current), pendingChat: null };
702
+ return added ? startRaisedCheck(merged, new Date().toISOString()) : merged;
703
+ });
704
+ if (added && landed)
705
+ void checkInBackground(landed);
619
706
  })
620
707
  .catch(async (err) => {
621
708
  const message = err instanceof Error ? err.message : String(err);
@@ -672,7 +759,7 @@ export async function buildApp(opts) {
672
759
  c.header("Content-Disposition", `attachment; filename="review-${artifactKey(artifact.id)}.md"`);
673
760
  return c.body(toMarkdown(artifact));
674
761
  });
675
- // ---- Send: THE ONLY GITHUB WRITE. Explicit user action from the cockpit. ----
762
+ // ---- Send: the only write that speaks as a review. Explicit user action. ----
676
763
  app.post("/api/reviews/:key/send", async (c) => {
677
764
  const { event, confirm } = await c.req.json();
678
765
  if (confirm !== true) {
@@ -692,22 +779,86 @@ export async function buildApp(opts) {
692
779
  // run used to save `sent: null` back over the record when it landed, so the
693
780
  // guard above would wave a *second* submission through afterwards.
694
781
  if (inFlight(artifact)) {
695
- return c.json({ error: "a review of this PR is running — wait for it to finish" }, 409);
782
+ return c.json({ error: busy(artifact, "a review of this PR is running — wait for it to finish") }, 409);
696
783
  }
697
784
  const payload = buildReviewPayload(artifact, event);
785
+ const ref = { owner: artifact.pr.owner, repo: artifact.pr.repo, number: artifact.pr.number };
786
+ let url;
698
787
  try {
699
- const { url } = await submitReview({ owner: artifact.pr.owner, repo: artifact.pr.repo, number: artifact.pr.number }, { event: payload.event, body: payload.body, comments: payload.comments, commitId: payload.commitId });
700
- const updated = await updateArtifactByKey(c.req.param("key"), (a) => ({
701
- ...a,
702
- status: "sent",
703
- sent: { at: new Date().toISOString(), event: payload.event, url, auto: false },
704
- calibration: computeCalibration(a, payload.event),
788
+ ({ url } = await submitReview(ref, {
789
+ event: payload.event,
790
+ body: payload.body,
791
+ comments: payload.comments,
792
+ commitId: payload.commitId,
705
793
  }));
706
- return c.json(updated);
707
794
  }
708
795
  catch (err) {
709
796
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 502);
710
797
  }
798
+ // Recorded before the replies go: the review is up, and a reply failing
799
+ // must not leave the row claiming it isn't — the "already sent" guard is
800
+ // what keeps a retry from posting the review twice.
801
+ await updateArtifactByKey(c.req.param("key"), (a) => ({
802
+ ...a,
803
+ status: "sent",
804
+ sent: { at: new Date().toISOString(), event: payload.event, url, auto: false },
805
+ calibration: computeCalibration(a, payload.event),
806
+ }));
807
+ const outcomes = await postReplies(ref, payload.replies);
808
+ const updated = await updateArtifactByKey(c.req.param("key"), (a) => recordReplies(a, outcomes));
809
+ const replyError = describeReplyFailures(outcomes);
810
+ if (replyError)
811
+ console.error(`[send ${artifact.id}] ${replyError}`);
812
+ return c.json({ ...updated, replyError });
813
+ });
814
+ // ---- Already raised: compare the draft with what others said on the PR ----
815
+ // Read-only against GitHub. Detached, like a chat turn: the cockpit polls
816
+ // `raisedCheck.checkingSince` until it clears.
817
+ app.post("/api/reviews/:key/raised", async (c) => {
818
+ const key = c.req.param("key");
819
+ const artifact = await loadArtifactByKey(key);
820
+ if (!artifact)
821
+ return c.json({ error: "not found" }, 404);
822
+ // A sent review is history, and a running one checks for itself when it lands.
823
+ if (artifact.sent)
824
+ return c.json({ error: `already sent at ${artifact.sent.at}` }, 409);
825
+ // Before the in-flight guard, which a running check also trips: a second
826
+ // open while one is out is answered with what is on disk, not refused.
827
+ if (isRaisedCheckRunning(artifact.id))
828
+ return c.json(artifact, 202);
829
+ if (inFlight(artifact) || turnInFlight(artifact) || !claimRaisedCheck(artifact.id)) {
830
+ return c.json({ error: "a run for this PR is in flight — it checks when it lands" }, 409);
831
+ }
832
+ // The claim is held from here, across the GitHub read as well as any model
833
+ // call: Send waits on it, and a Send landing mid-read would post the
834
+ // duplicate the read was about to find. It is in memory, so a check that
835
+ // finds nothing new still writes nothing.
836
+ let detached = false;
837
+ try {
838
+ const step = await prepareRaisedCheck(artifact, { log: (m) => console.log(`[raised ${artifact.id}] ${m}`) });
839
+ if (!step.needsModel) {
840
+ const current = await loadArtifactByKey(key);
841
+ if (!current)
842
+ return c.json({ error: "not found" }, 404);
843
+ // Nothing new is no write: `updatedAt` is what the queue sorts and ages
844
+ // rows by, and merely opening a review must not move it.
845
+ if (!raisedChanged(current, step.fold(current)))
846
+ return c.json(current);
847
+ return c.json(await updateArtifactByKey(key, step.fold));
848
+ }
849
+ const marked = await updateArtifactByKey(key, (a) => startRaisedCheck(a, new Date().toISOString()));
850
+ if (!marked)
851
+ return c.json({ error: "not found" }, 404);
852
+ detached = true;
853
+ void finishRaisedCheck(marked, step).catch((err) => {
854
+ console.error(`[raised ${artifact.id}] ${err instanceof Error ? err.message : String(err)}`);
855
+ });
856
+ return c.json(marked, 202);
857
+ }
858
+ finally {
859
+ if (!detached)
860
+ releaseRaisedCheck(artifact.id);
861
+ }
711
862
  });
712
863
  // Preview what send would post, without posting anything.
713
864
  app.get("/api/reviews/:key/send-preview", async (c) => {
@@ -717,6 +868,195 @@ export async function buildApp(opts) {
717
868
  const event = (c.req.query("event") ?? "COMMENT");
718
869
  return c.json(buildReviewPayload(artifact, event));
719
870
  });
871
+ /**
872
+ * Who this review could be handed to — the handoff's suggestions.
873
+ *
874
+ * Read-only, and allowed to come back empty: the name box works without it,
875
+ * and a repo cerber cannot list people for is a repo you can still hand a PR
876
+ * to by typing the name. So a failure here is reported as a line in the
877
+ * dialog, not as an error that stops it.
878
+ *
879
+ * Two logins are dropped because GitHub would refuse them anyway: your own,
880
+ * and the PR author's — nobody reviews their own pull request.
881
+ */
882
+ app.get("/api/reviews/:key/reviewers", async (c) => {
883
+ const artifact = await loadArtifactByKey(c.req.param("key"));
884
+ if (!artifact)
885
+ return c.json({ error: "not found" }, 404);
886
+ let logins;
887
+ try {
888
+ logins = await fetchAssignableUsers({
889
+ owner: artifact.pr.owner,
890
+ repo: artifact.pr.repo,
891
+ number: artifact.pr.number,
892
+ });
893
+ }
894
+ catch (err) {
895
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 502);
896
+ }
897
+ // Not knowing who you are costs the exclusion, not the list: you would
898
+ // simply see yourself offered, and the handoff refuses that anyway.
899
+ const me = await currentLogin().catch(() => null);
900
+ const drop = new Set([me, artifact.pr.author].filter(Boolean).map((l) => l.toLowerCase()));
901
+ return c.json({
902
+ logins: logins.filter((l) => !drop.has(l.toLowerCase())).sort((a, b) => a.localeCompare(b)),
903
+ });
904
+ });
905
+ /**
906
+ * Give this review to somebody else: GitHub's review request moves to them, a
907
+ * note in the conversation says so, and the row settles here.
908
+ *
909
+ * Up to three writes. Two are never a review: the request swap, and the note,
910
+ * which is a plain issue comment. The third is the draft, and only when
911
+ * `postReview` asked for it — composed and submitted by the send path itself
912
+ * rather than a second composition, and always as COMMENT, because handing a
913
+ * PR over hands the judgement over with it.
914
+ */
915
+ app.post("/api/reviews/:key/handoff", async (c) => {
916
+ const parsed = HandoffSchema.safeParse(await c.req.json().catch(() => null));
917
+ if (!parsed.success) {
918
+ return c.json({ error: parsed.error.issues.map((i) => i.message).join("; ") }, 400);
919
+ }
920
+ const { to, confirm } = parsed.data;
921
+ const note = parsed.data.note?.trim() ?? "";
922
+ const postReview = parsed.data.postReview === true;
923
+ if (confirm !== true) {
924
+ return c.json({ error: "a handoff requires an explicit confirm: true" }, 400);
925
+ }
926
+ const key = c.req.param("key");
927
+ const artifact = await loadArtifactByKey(key);
928
+ if (!artifact)
929
+ return c.json({ error: "not found" }, 404);
930
+ // Not while a run is rewriting the draft. The handoff settles the row, and
931
+ // settling one out from under a run that is about to report on it is the
932
+ // same race the send path refuses.
933
+ if (inFlight(artifact)) {
934
+ return c.json({ error: busy(artifact, "a review of this PR is running — wait for it to finish") }, 409);
935
+ }
936
+ // Nor while a chat turn is being answered. A turn's result is folded onto
937
+ // whatever the artifact says when it lands, and the fold is written for a
938
+ // row nobody gave away mid-turn: every field a handoff sets would have to
939
+ // survive it. One of them does now, and refusing here is what keeps the
940
+ // rest — the send record above all — from needing to.
941
+ if (turnInFlight(artifact)) {
942
+ return c.json({ error: "the reviewer is answering a question about this review — wait for it to land" }, 409);
943
+ }
944
+ let me;
945
+ try {
946
+ me = await currentLogin();
947
+ }
948
+ catch (err) {
949
+ // Without this there is nobody to take off the PR, and a handoff that
950
+ // only adds a reviewer leaves you on the hook while the row says
951
+ // otherwise — so it does not happen at all.
952
+ return c.json({ error: `cerber could not ask gh who you are: ${err instanceof Error ? err.message : String(err)}` }, 502);
953
+ }
954
+ if (to.toLowerCase() === me.toLowerCase()) {
955
+ return c.json({ error: `@${to} is you — a handoff needs somebody else` }, 400);
956
+ }
957
+ const ref = { owner: artifact.pr.owner, repo: artifact.pr.repo, number: artifact.pr.number };
958
+ let swap;
959
+ try {
960
+ swap = await handOffReview(ref, to, artifact.handoff?.withdrewYours ? null : me);
961
+ }
962
+ catch (err) {
963
+ // Nothing was written here and nothing was posted: the request swap goes
964
+ // first precisely so its refusal — not a collaborator, no permission —
965
+ // leaves the PR and this row exactly as they were.
966
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 502);
967
+ }
968
+ if (swap.withdrawError) {
969
+ console.error(`[handoff ${artifact.id}] could not withdraw your request: ${swap.withdrawError}`);
970
+ }
971
+ // The note goes last, and is allowed to fail on its own — as is the review
972
+ // above it. The request has already moved by now: they are being asked for
973
+ // this review whether or not the conversation says why, so a failure in
974
+ // either is recorded and reported rather than rolled back into a lie about
975
+ // where the PR is.
976
+ // The draft, when it was asked for, and always as a COMMENT: handing a PR
977
+ // over is handing over the judgement with it, so an approve or a change
978
+ // request here would be cerber ruling on a review it is giving away. The
979
+ // grades stay on the findings, which is where they were anyway — the
980
+ // verdict is the part that is now @to's to make.
981
+ //
982
+ // Re-read from disk first, and not out of caution: the artifact above was
983
+ // loaded before the request swap, which is two GitHub calls ago, and a Send
984
+ // in another tab or an auto-send landing in that window would make this the
985
+ // second review on the PR. The snapshot cannot see that; the file can.
986
+ let sendError = null;
987
+ let sent = null;
988
+ let payload = null;
989
+ const alreadySent = postReview ? ((await loadArtifactByKey(key))?.sent ?? artifact.sent) : null;
990
+ // And there has to be a review to post. The dialog only offers the box on a
991
+ // row that has one, but the dialog is not the guard: a stale tab or a direct
992
+ // call would otherwise submit a body that is nothing but cerber's footer,
993
+ // under the user's name, on a PR nobody has drafted a word about.
994
+ const somethingToSay = artifact.summary !== "" || artifact.comments.some((cm) => cm.status !== "dropped");
995
+ if (postReview && !alreadySent && somethingToSay) {
996
+ payload = buildReviewPayload(artifact, "COMMENT");
997
+ try {
998
+ const { url } = await submitReview(ref, {
999
+ event: payload.event,
1000
+ body: payload.body,
1001
+ comments: payload.comments,
1002
+ commitId: payload.commitId,
1003
+ });
1004
+ sent = { at: new Date().toISOString(), event: payload.event, url, auto: false };
1005
+ }
1006
+ catch (err) {
1007
+ sendError = err instanceof Error ? err.message : String(err);
1008
+ console.error(`[handoff ${artifact.id}] the review did not post: ${sendError}`);
1009
+ }
1010
+ }
1011
+ // The draft carries its replies too, when it went: they are part of the
1012
+ // composition the box sends, and only ever ones the user chose to make.
1013
+ const replies = sent && payload ? await postReplies(ref, payload.replies) : [];
1014
+ const replyError = describeReplyFailures(replies);
1015
+ if (replyError)
1016
+ console.error(`[handoff ${artifact.id}] ${replyError}`);
1017
+ let noteError = null;
1018
+ let posted = null;
1019
+ if (note) {
1020
+ try {
1021
+ const { url } = await postIssueComment(ref, note);
1022
+ posted = { body: note, url };
1023
+ }
1024
+ catch (err) {
1025
+ noteError = err instanceof Error ? err.message : String(err);
1026
+ console.error(`[handoff ${artifact.id}] the note did not post: ${noteError}`);
1027
+ }
1028
+ }
1029
+ const at = new Date().toISOString();
1030
+ const updated = await updateArtifactByKey(key, (a) => {
1031
+ // Three ways this row can end, and the status follows what actually
1032
+ // reached GitHub. A review that went is the strongest thing true about
1033
+ // it; a row already sent keeps the standing it had; anything else is you
1034
+ // deciding not to review this one, which is a skip.
1035
+ const settles = !sent && !a.sent;
1036
+ return {
1037
+ ...recordReplies(a, replies),
1038
+ status: sent ? "sent" : a.sent ? a.status : "skipped",
1039
+ settledAt: settles ? at : a.settledAt,
1040
+ // Cerber's account of why a row is filed away cannot stand next to your
1041
+ // own: you did this, and `handoff` is what the queue tags the row from.
1042
+ filed: a.sent ? a.filed : null,
1043
+ // The disk's record wins: a send that landed while this handoff was
1044
+ // talking to GitHub is a review that went, and ours — if there is one
1045
+ // at all — came after it.
1046
+ sent: a.sent ?? sent,
1047
+ calibration: sent && payload ? computeCalibration(a, payload.event) : a.calibration,
1048
+ handoff: {
1049
+ at,
1050
+ to,
1051
+ withdrewYours: swap.withdrewYours,
1052
+ note: posted,
1053
+ },
1054
+ };
1055
+ });
1056
+ if (!updated)
1057
+ return c.json({ error: "not found" }, 404);
1058
+ return c.json({ artifact: updated, noteError, sendError, replyError });
1059
+ });
720
1060
  // Static cockpit build. In the published package web/dist ships alongside dist/.
721
1061
  // __dirname is src/server (dev via tsx) or dist/server (built) — both are two levels below the repo root.
722
1062
  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.35.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": [
@@ -51,7 +51,6 @@
51
51
  "zod": "^3.24.1"
52
52
  },
53
53
  "devDependencies": {
54
- "@semantic-release/git": "^11.0.1",
55
54
  "@types/node": "^22.10.5",
56
55
  "@types/react": "^19.0.4",
57
56
  "@types/react-dom": "^19.0.2",