@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 +59 -5
- package/dist/core/artifact.js +32 -0
- package/dist/core/gh.js +101 -3
- package/dist/core/history.js +6 -0
- package/dist/core/refresh.js +5 -0
- package/dist/core/revise.js +4 -0
- package/dist/runner/review.js +1 -0
- package/dist/server/daemon.js +9 -2
- package/dist/server/index.js +217 -2
- package/package.json +1 -1
- package/web/dist/assets/index-D-kgeiDO.js +241 -0
- package/web/dist/assets/{index-BBIhRnAc.css → index-Deoiz__g.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CMyZArsF.js +0 -241
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.**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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
|
package/dist/core/artifact.js
CHANGED
|
@@ -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
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
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 = [
|
package/dist/core/history.js
CHANGED
|
@@ -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` +
|
package/dist/core/refresh.js
CHANGED
|
@@ -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,
|
package/dist/core/revise.js
CHANGED
|
@@ -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 = {}) {
|
package/dist/runner/review.js
CHANGED
package/dist/server/daemon.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/dist/server/index.js
CHANGED
|
@@ -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:
|
|
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