@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 +62 -5
- package/dist/cli/index.js +37 -2
- package/dist/core/artifact.js +117 -0
- package/dist/core/autosend.js +37 -1
- package/dist/core/export.js +8 -2
- package/dist/core/gh.js +250 -3
- package/dist/core/history.js +6 -0
- package/dist/core/raised.js +175 -0
- package/dist/core/refresh.js +5 -0
- package/dist/core/revise.js +15 -1
- package/dist/core/send.js +42 -2
- package/dist/core/state.js +7 -2
- package/dist/runner/claude.js +3 -0
- package/dist/runner/raised.js +218 -0
- package/dist/runner/review.js +10 -4
- package/dist/server/daemon.js +35 -16
- package/dist/server/index.js +358 -18
- package/package.json +1 -2
- package/web/dist/assets/index-BwVeoj7s.js +241 -0
- package/web/dist/assets/{index-BBIhRnAc.css → index-DjRn6xUY.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CMyZArsF.js +0 -241
package/dist/server/daemon.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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)
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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:
|
|
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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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.
|
|
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",
|