@fullstackhouse/cerber 0.25.0 → 0.27.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 +31 -1
- package/dist/cli/index.js +39 -1
- package/dist/core/artifact.js +11 -0
- package/dist/core/gh.js +34 -1
- package/dist/core/history.js +226 -0
- package/dist/core/state.js +80 -2
- package/dist/runner/review.js +25 -2
- package/dist/server/daemon.js +124 -32
- package/dist/server/index.js +6 -0
- package/package.json +1 -1
- package/web/dist/assets/index-B8yOhacn.js +236 -0
- package/web/dist/assets/{index-VLT71mrB.css → index-BAwkRYWm.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DFhsdnD2.js +0 -236
package/dist/server/daemon.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { SCHEMA_VERSION, artifactId, artifactKey } from "../core/artifact.js";
|
|
2
2
|
import { evaluateAutoSend } from "../core/autosend.js";
|
|
3
3
|
import { loadConfig } from "../core/config.js";
|
|
4
|
-
import { classifyReply, currentLogin, fetchConversation, fetchLastReviewRequest, fetchOwnReview, fetchPrInfo, fetchReviewRequests, lastWordOfYours, searchAwaitingMe, stillRequested, submitReview, } from "../core/gh.js";
|
|
4
|
+
import { classifyReply, currentLogin, fetchConversation, fetchLastReviewRequest, fetchOwnReview, fetchPrInfo, fetchReviewRequests, lastMentionOfYou, lastWordOfYours, searchAwaitingMe, stillRequested, submitReview, } from "../core/gh.js";
|
|
5
|
+
import { withWriter } from "../core/history.js";
|
|
5
6
|
import { notice, notify } from "../core/notify.js";
|
|
6
7
|
import { buildReviewPayload, computeCalibration } from "../core/send.js";
|
|
7
|
-
import { appendAutoSendLog, deleteArtifact, listArtifacts, loadArtifact, saveArtifact, updateArtifactByKey, } from "../core/state.js";
|
|
8
|
+
import { appendAutoSendLog, deleteArtifact, listArtifacts, loadArtifact, noteHistory, saveArtifact, updateArtifactByKey, } from "../core/state.js";
|
|
8
9
|
import { ReviewInProgressError } from "../runner/inflight.js";
|
|
9
10
|
import { SETTLED_BY_YOU, pool, reviewPr } from "../runner/review.js";
|
|
10
11
|
/**
|
|
@@ -144,25 +145,40 @@ export function settledAtOf(a) {
|
|
|
144
145
|
return a.settledAt ?? a.filed?.at ?? a.run?.finishedAt ?? null;
|
|
145
146
|
}
|
|
146
147
|
/**
|
|
147
|
-
* Whether
|
|
148
|
+
* Whether an ask is a *new* one rather than the one you settled.
|
|
148
149
|
*
|
|
149
150
|
* A skip says "I am done with this PR", and a push does not undo that — the
|
|
150
151
|
* whole point of `SETTLED_BY_YOU`. Somebody asking you again is a different
|
|
151
|
-
* event: your skip answered
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
152
|
+
* event: your skip answered whatever was open when you made it, and it cannot
|
|
153
|
+
* have answered something that came after. Withdrawn-then-re-added is one shape
|
|
154
|
+
* this catches, and it is not exotic — an author who pulls the request while
|
|
155
|
+
* you are mid-look and puts it back with the next push leaves the inbox silent
|
|
156
|
+
* about a review someone is now actively waiting on. The other shape is the
|
|
157
|
+
* same author typing it instead (`Ask`).
|
|
156
158
|
*
|
|
157
159
|
* Parsed, not compared as text, for the reason `filedByYourAct` gives: our
|
|
158
160
|
* timestamps carry milliseconds and GitHub's do not, and "…:00.500Z" sorts
|
|
159
161
|
* before "…:00Z".
|
|
160
162
|
*/
|
|
161
|
-
export function askedAgainAfterSettling(a,
|
|
162
|
-
if (
|
|
163
|
+
export function askedAgainAfterSettling(a, askedAt) {
|
|
164
|
+
if (askedAt === null)
|
|
163
165
|
return false;
|
|
164
166
|
const settled = settledAtOf(a);
|
|
165
|
-
return settled !== null && Date.parse(
|
|
167
|
+
return settled !== null && Date.parse(askedAt) > Date.parse(settled);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* What the daemon says about a reopen, in the log and in the row's history.
|
|
171
|
+
*
|
|
172
|
+
* One place, so the line the user reads in the terminal and the line they read
|
|
173
|
+
* on the review months later cannot tell different stories about why a decision
|
|
174
|
+
* of theirs was undone — which is the only thing that makes undoing it fair.
|
|
175
|
+
*/
|
|
176
|
+
export function askWording(ask) {
|
|
177
|
+
const day = ask.at.slice(0, 10);
|
|
178
|
+
const what = ask.how === "requested"
|
|
179
|
+
? `your review was requested again on ${day}`
|
|
180
|
+
: `${ask.by} asked for you by name on ${day}`;
|
|
181
|
+
return { log: `${what} — back in the inbox`, history: `back in the inbox: ${what}, after you settled it` };
|
|
166
182
|
}
|
|
167
183
|
/**
|
|
168
184
|
* What a reopened row goes back to being: the draft it has, or a row waiting
|
|
@@ -244,6 +260,11 @@ export function startDaemon(opts) {
|
|
|
244
260
|
// needs it. Undefined is "not asked yet"; null is "gh could not say", which
|
|
245
261
|
// attributes nothing to you rather than guessing.
|
|
246
262
|
let login;
|
|
263
|
+
async function who() {
|
|
264
|
+
if (login === undefined)
|
|
265
|
+
login = await currentLogin().catch(() => null);
|
|
266
|
+
return login;
|
|
267
|
+
}
|
|
247
268
|
/**
|
|
248
269
|
* Put a settled row back in the inbox when somebody has asked you again.
|
|
249
270
|
*
|
|
@@ -252,9 +273,17 @@ export function startDaemon(opts) {
|
|
|
252
273
|
* moved *to* it. Both exist because the queue's picture of who is waiting
|
|
253
274
|
* on what is local, and the thing it is a picture of lives on github.com.
|
|
254
275
|
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
276
|
+
* Two ways to be asked, because people use both. GitHub's re-request button
|
|
277
|
+
* leaves a dated event and is read first: it is the strongest evidence and
|
|
278
|
+
* the cheapest call. Failing that, the conversation is read for the last
|
|
279
|
+
* comment naming you — "@you this is ready for another look", which is the
|
|
280
|
+
* same ask with no event behind it. Reading only the button was the bug:
|
|
281
|
+
* a settled row sat invisible while its author waited, having said so in
|
|
282
|
+
* plain words on the PR.
|
|
283
|
+
*
|
|
284
|
+
* Only rows GitHub is still asking about get here — the caller is looping
|
|
285
|
+
* over the awaiting search. A row whose request is gone (you reviewed on
|
|
286
|
+
* github.com) is caught by `reopenIfAskedInWords` on the other loop instead.
|
|
258
287
|
*/
|
|
259
288
|
async function reopenIfAskedAgain(artifact) {
|
|
260
289
|
if (settledAtOf(artifact) === null)
|
|
@@ -264,26 +293,72 @@ export function startDaemon(opts) {
|
|
|
264
293
|
return;
|
|
265
294
|
reopenChecks++;
|
|
266
295
|
requestCheckedAt.set(artifact.id, Date.now());
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
if (login === null)
|
|
296
|
+
const me = await who();
|
|
297
|
+
if (me === null)
|
|
270
298
|
return;
|
|
271
|
-
let
|
|
299
|
+
let ask;
|
|
272
300
|
try {
|
|
273
|
-
requestedAt = await fetchLastReviewRequest(artifact.pr,
|
|
301
|
+
const requestedAt = await fetchLastReviewRequest(artifact.pr, me);
|
|
302
|
+
const request = requestedAt === null ? null : { at: requestedAt, how: "requested" };
|
|
303
|
+
// The words are only worth a second call when the button did not
|
|
304
|
+
// already answer: a live re-request says everything a comment could.
|
|
305
|
+
ask = askedAgainAfterSettling(artifact, request?.at ?? null)
|
|
306
|
+
? request
|
|
307
|
+
: await askedInWords(artifact, me);
|
|
274
308
|
}
|
|
275
309
|
catch {
|
|
276
310
|
// A read that failed is not evidence of anything, so nothing happens to
|
|
277
311
|
// the row. It is not re-asked about until the leash above expires: the
|
|
278
312
|
// stamp is spent before the call on purpose, so gh being down cannot
|
|
279
|
-
// turn every settled row into a retry on every poll.
|
|
313
|
+
// turn every settled row into a retry on every poll. Only the reads are
|
|
314
|
+
// guarded: a write that fails is a real fault and belongs to the caller.
|
|
280
315
|
return;
|
|
281
316
|
}
|
|
282
|
-
|
|
317
|
+
await reopen(artifact, ask);
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* The same reopen for a settled row GitHub is *not* asking about.
|
|
321
|
+
*
|
|
322
|
+
* The case is ordinary: you reviewed on github.com, which clears the
|
|
323
|
+
* request and drops the PR out of the awaiting search, and the author then
|
|
324
|
+
* pushes and writes "@you ready for another look". No request exists to
|
|
325
|
+
* re-read, so the words are the whole of the evidence — and without this
|
|
326
|
+
* the mention check would only work for the rows that happen to still carry
|
|
327
|
+
* an open request, which is a fix that half-works.
|
|
328
|
+
*
|
|
329
|
+
* Rides the state-refresh leash rather than one of its own: the caller has
|
|
330
|
+
* already spent that artifact's slot for this half-hour, and a settled row
|
|
331
|
+
* never reaches `fileIfSettledElsewhere`, so this costs the poll one call
|
|
332
|
+
* on rows that were costing it one already.
|
|
333
|
+
*/
|
|
334
|
+
async function reopenIfAskedInWords(artifact) {
|
|
335
|
+
if (settledAtOf(artifact) === null)
|
|
283
336
|
return;
|
|
284
|
-
const
|
|
337
|
+
const me = await who();
|
|
338
|
+
if (me === null)
|
|
339
|
+
return;
|
|
340
|
+
let ask;
|
|
341
|
+
try {
|
|
342
|
+
ask = await askedInWords(artifact, me);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
// Same as above: a failed read changes nothing and waits for the leash.
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
await reopen(artifact, ask);
|
|
349
|
+
}
|
|
350
|
+
/** The conversation's answer to "has anybody asked for me again", or null. */
|
|
351
|
+
async function askedInWords(artifact, me) {
|
|
352
|
+
const mention = lastMentionOfYou(await fetchConversation(artifact.pr), me);
|
|
353
|
+
return mention === null ? null : { at: mention.at, how: "mentioned", by: mention.author };
|
|
354
|
+
}
|
|
355
|
+
/** Undo the settle, if the ask really is newer than it. */
|
|
356
|
+
async function reopen(artifact, ask) {
|
|
357
|
+
if (ask === null || !askedAgainAfterSettling(artifact, ask.at))
|
|
358
|
+
return;
|
|
359
|
+
const at = ask.at;
|
|
285
360
|
// Re-read from disk: a re-review or a send may have started in the time
|
|
286
|
-
//
|
|
361
|
+
// those calls took, and neither wants its status rewritten underneath it.
|
|
287
362
|
const saved = await updateArtifactByKey(artifactKey(artifact.id), (a) => askedAgainAfterSettling(a, at)
|
|
288
363
|
? // `filed` goes with the status it explained: cerber's account of why
|
|
289
364
|
// this row was settled is not the story of a row that is back.
|
|
@@ -292,7 +367,11 @@ export function startDaemon(opts) {
|
|
|
292
367
|
// The status is what says the write landed. `settledAt` cannot: a legacy
|
|
293
368
|
// row never had one, so a mutation that declined would read as a reopen.
|
|
294
369
|
if (saved && !SETTLED_BY_YOU.has(saved.status)) {
|
|
295
|
-
|
|
370
|
+
const said = askWording(ask);
|
|
371
|
+
log(`[${artifact.id}] ${said.log}`);
|
|
372
|
+
// The status change alone says a row moved; this says why it moved,
|
|
373
|
+
// which is the whole reason a settle is allowed to be undone at all.
|
|
374
|
+
await noteHistory(artifact.id, said.history);
|
|
296
375
|
}
|
|
297
376
|
}
|
|
298
377
|
for (const ref of refs) {
|
|
@@ -341,11 +420,9 @@ export function startDaemon(opts) {
|
|
|
341
420
|
// that could never qualify costs no GitHub call at all.
|
|
342
421
|
if (artifact.status !== "ready" || artifact.sent)
|
|
343
422
|
return;
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
if (login === null)
|
|
423
|
+
const me = await who();
|
|
424
|
+
if (me === null)
|
|
347
425
|
return;
|
|
348
|
-
const me = login;
|
|
349
426
|
/** Re-reads from disk: a run or a send may have started in the seconds
|
|
350
427
|
* these reads took, and neither wants filing out from under it. */
|
|
351
428
|
async function file(filed, note, stillFilable) {
|
|
@@ -374,15 +451,21 @@ export function startDaemon(opts) {
|
|
|
374
451
|
}
|
|
375
452
|
// Anything but silence from you leaves the row alone: `them` is somebody
|
|
376
453
|
// answering a comment of yours, which is the opposite of settled.
|
|
377
|
-
if (classifyReply(conversation, me) !== "none")
|
|
454
|
+
if (classifyReply(conversation, me) !== "none") {
|
|
455
|
+
await noteHistory(artifact.id, "someone has answered you on the PR — the draft stays out for you");
|
|
378
456
|
return;
|
|
457
|
+
}
|
|
379
458
|
if (!filedByWithdrawnRequest(artifact))
|
|
380
459
|
return;
|
|
381
460
|
// The awaiting search says nobody is asking, and here that is the entire
|
|
382
461
|
// case — so confirm it against the PR itself before acting on it. The
|
|
383
462
|
// other two reasons stand on a fact of their own and need no such check.
|
|
384
|
-
if (stillRequested(await fetchReviewRequests(artifact.pr), me))
|
|
463
|
+
if (stillRequested(await fetchReviewRequests(artifact.pr), me)) {
|
|
464
|
+
// The one fact GitHub cannot be asked for later: what its search index
|
|
465
|
+
// said at this minute, and that the PR itself disagreed with it.
|
|
466
|
+
await noteHistory(artifact.id, "gone from the awaiting search, but GitHub still lists you as a requested reviewer — left alone");
|
|
385
467
|
return;
|
|
468
|
+
}
|
|
386
469
|
await file({ at: new Date().toISOString(), reason: "request-withdrawn", review: null, reply: null }, "nobody is asking for this review any more", filedByWithdrawnRequest);
|
|
387
470
|
}
|
|
388
471
|
const awaitingIds = new Set(refs.map((r) => artifactId(r)));
|
|
@@ -437,8 +520,14 @@ export function startDaemon(opts) {
|
|
|
437
520
|
}
|
|
438
521
|
if (pr.state !== "OPEN")
|
|
439
522
|
log(`[${artifact.id}] ${pr.state.toLowerCase()} — archived`);
|
|
440
|
-
else
|
|
523
|
+
else {
|
|
524
|
+
// Exactly one of these two spends a call: the first only looks at
|
|
525
|
+
// settled rows, the second only at finished drafts. They are the same
|
|
526
|
+
// question from opposite sides — has GitHub moved past this, or back
|
|
527
|
+
// to it — and a row can only be on one side of it.
|
|
528
|
+
await reopenIfAskedInWords(artifact);
|
|
441
529
|
await fileIfSettledElsewhere(artifact);
|
|
530
|
+
}
|
|
442
531
|
}
|
|
443
532
|
catch {
|
|
444
533
|
// Same: book-keeping can wait for the next poll.
|
|
@@ -543,7 +632,10 @@ export function startDaemon(opts) {
|
|
|
543
632
|
});
|
|
544
633
|
return { reviewed, skipped, failed };
|
|
545
634
|
}
|
|
546
|
-
|
|
635
|
+
/** Everything a poll writes is the poll's — including the reviews it starts,
|
|
636
|
+
* which stamp themselves as runs from inside this. */
|
|
637
|
+
const poll = () => withWriter({ by: "daemon", cause: "poll" }, runPoll);
|
|
638
|
+
async function runPoll() {
|
|
547
639
|
if (status.polling)
|
|
548
640
|
return;
|
|
549
641
|
status.polling = true;
|
package/dist/server/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { fetchPrDiff, fetchPrInfo, parsePrRef, submitReview } from "../core/gh.j
|
|
|
13
13
|
import { z } from "zod";
|
|
14
14
|
import { DaemonConfigSchema, configPath, loadConfig, saveConfig } from "../core/config.js";
|
|
15
15
|
import { refreshArtifact } from "../core/refresh.js";
|
|
16
|
+
import { withWriter } from "../core/history.js";
|
|
16
17
|
import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
|
|
17
18
|
import { buildReviewPayload, computeCalibration } from "../core/send.js";
|
|
18
19
|
import { listArtifacts, loadArtifact, loadArtifactByKey, saveArtifact, updateArtifactByKey, } from "../core/state.js";
|
|
@@ -39,6 +40,11 @@ const SETTLEABLE = ["reviewed", "skipped"];
|
|
|
39
40
|
const inFlight = (a) => a.status === "running" || isReviewRunning(a.id);
|
|
40
41
|
export async function buildApp(opts) {
|
|
41
42
|
const app = new Hono();
|
|
43
|
+
// Whatever any route writes to an artifact is stamped with the request that
|
|
44
|
+
// caused it — once, here, rather than route by route, so a route added later
|
|
45
|
+
// is labelled without knowing that history exists. A detached run started by
|
|
46
|
+
// a request re-labels its own writes for itself.
|
|
47
|
+
app.use("*", (c, next) => withWriter({ by: "cockpit", cause: `${c.req.method} ${new URL(c.req.url).pathname}` }, next));
|
|
42
48
|
if (opts.token) {
|
|
43
49
|
const token = opts.token;
|
|
44
50
|
app.use("*", async (c, next) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fullstackhouse/cerber",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.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
|
"author": "Full Stack House (https://fullstack.house)",
|