@fullstackhouse/cerber 0.25.0 → 0.26.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 +25 -1
- package/dist/cli/index.js +39 -1
- package/dist/core/artifact.js +11 -0
- 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 +17 -4
- 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/README.md
CHANGED
|
@@ -355,12 +355,36 @@ the summary, comments and verdict alone, and is still one deliberate click. A
|
|
|
355
355
|
review that has already been sent can't be argued with: that artifact is the
|
|
356
356
|
record of what GitHub has.
|
|
357
357
|
|
|
358
|
+
### Every row remembers what happened to it
|
|
359
|
+
|
|
360
|
+
A review keeps one "last updated" time, which means every write erases the
|
|
361
|
+
answer to *when did I skip this, and did they ask again afterwards?* So each
|
|
362
|
+
one also keeps a history: the status changes with their timestamps, every push
|
|
363
|
+
it saw, each run and what it cost, sends, refreshes — and which part of cerber
|
|
364
|
+
did it, whether that was you in the cockpit, the CLI, an AI run or the poll.
|
|
365
|
+
|
|
366
|
+
The poll's silences are in there too. When it looks at a row and deliberately
|
|
367
|
+
leaves it alone — you settled it, so a new push does not reopen it; or GitHub
|
|
368
|
+
still lists you as a requested reviewer even though its own search has stopped
|
|
369
|
+
saying so — it writes that down instead of passing without a trace. That is
|
|
370
|
+
usually the answer when a PR is not where you expected it to be.
|
|
371
|
+
|
|
372
|
+
It's at the foot of every review in the cockpit, and:
|
|
373
|
+
|
|
374
|
+
```bash
|
|
375
|
+
cerber history owner/repo#123
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Nothing about GitHub's own timeline is copied here — GitHub keeps that, and
|
|
379
|
+
`gh` can be asked for it again. This is cerber's side of the story.
|
|
380
|
+
|
|
358
381
|
## Status
|
|
359
382
|
|
|
360
383
|
Early, but whole: everything described above has shipped — reviewing,
|
|
361
384
|
editing and the gated Send, inbox discovery with parallel runs, confidence
|
|
362
385
|
calibration (`cerber stats`), shadow-mode and opt-in auto-send, re-anchoring
|
|
363
|
-
onto new commits, source-backed and trusted runs,
|
|
386
|
+
onto new commits, source-backed and trusted runs, the reviewer chat, and a
|
|
387
|
+
per-review history of everything that touched it (`cerber history`).
|
|
364
388
|
`cerber export` writes a review out as markdown if you want it elsewhere.
|
|
365
389
|
|
|
366
390
|
## Running on a VPS
|
package/dist/cli/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { artifactId, artifactKey } from "../core/artifact.js";
|
|
|
5
5
|
import { listCheckouts, removeCheckout } from "../core/checkout.js";
|
|
6
6
|
import { toMarkdown } from "../core/export.js";
|
|
7
7
|
import { configPath, loadConfig, saveConfig } from "../core/config.js";
|
|
8
|
+
import { withWriter } from "../core/history.js";
|
|
8
9
|
import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
|
|
9
10
|
import { parsePrRef, searchAwaitingMe, submitReview } from "../core/gh.js";
|
|
10
11
|
import { buildReviewPayload, computeCalibration, eventForRecommendation } from "../core/send.js";
|
|
@@ -103,6 +104,40 @@ program
|
|
|
103
104
|
console.log(`${a.status.padEnd(8)} ${a.id.padEnd(40)} ${v.padEnd(20)} ${a.pr.title}`);
|
|
104
105
|
}
|
|
105
106
|
});
|
|
107
|
+
program
|
|
108
|
+
.command("history")
|
|
109
|
+
.description("Everything that has happened to a review: what changed, when, which part of cerber did it — and the decisions the poll took to leave it alone")
|
|
110
|
+
.argument("<pr>", "PR URL, owner/repo#number, or number (with --repo)")
|
|
111
|
+
.option("-R, --repo <owner/repo>", "repository for bare PR numbers")
|
|
112
|
+
.action(async (input, opts) => {
|
|
113
|
+
const ref = parsePrRef(input, opts.repo);
|
|
114
|
+
const artifact = await loadArtifact(artifactId(ref));
|
|
115
|
+
if (!artifact) {
|
|
116
|
+
console.error(`No review found for ${artifactId(ref)}. Run: cerber review ${input}`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
console.log(`${artifact.id} — ${artifact.pr.title}\n`);
|
|
120
|
+
const history = artifact.history ?? [];
|
|
121
|
+
if (history.length === 0) {
|
|
122
|
+
console.log("Nothing recorded. This review predates cerber keeping a history — it starts at the next thing that happens to it.");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const stamp = (at) => {
|
|
126
|
+
const d = new Date(at);
|
|
127
|
+
return Number.isNaN(d.getTime())
|
|
128
|
+
? at
|
|
129
|
+
: `${d.toLocaleDateString(undefined, { month: "short", day: "2-digit" })} ${d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}`;
|
|
130
|
+
};
|
|
131
|
+
// The stamp's width is the locale's business, not ours — measure it.
|
|
132
|
+
const stampWidth = Math.max(...history.map((e) => stamp(e.at).length)) + 2;
|
|
133
|
+
for (const entry of history) {
|
|
134
|
+
// The cause names the path that made the change — which endpoint, which
|
|
135
|
+
// command — and is the whole point on a row two paths could have written.
|
|
136
|
+
console.log(`${stamp(entry.at).padEnd(stampWidth)}${entry.by.padEnd(9)}${entry.what}` +
|
|
137
|
+
`${entry.cause ? ` · ${entry.cause}` : ""}`);
|
|
138
|
+
}
|
|
139
|
+
console.log(`\n(times are local · ${history.length} entr${history.length === 1 ? "y" : "ies"})`);
|
|
140
|
+
});
|
|
106
141
|
program
|
|
107
142
|
.command("export")
|
|
108
143
|
.description("Print a review as markdown (never touches GitHub)")
|
|
@@ -399,7 +434,10 @@ program
|
|
|
399
434
|
function collect(value, previous) {
|
|
400
435
|
return [...previous, value];
|
|
401
436
|
}
|
|
402
|
-
|
|
437
|
+
// Every artifact write under this command is stamped with the command that
|
|
438
|
+
// made it. Nested contexts win, so `serve` labels its requests, its poll and
|
|
439
|
+
// its runs for themselves rather than all of them "cli".
|
|
440
|
+
withWriter({ by: "cli", cause: process.argv[2] ?? null }, () => program.parseAsync()).catch((err) => {
|
|
403
441
|
console.error(err instanceof Error ? err.message : err);
|
|
404
442
|
process.exit(1);
|
|
405
443
|
});
|
package/dist/core/artifact.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { HistoryEntrySchema } from "./history.js";
|
|
2
3
|
export const SCHEMA_VERSION = 1;
|
|
3
4
|
/**
|
|
4
5
|
* How hard a finding presses on the merge. The words carry their own
|
|
@@ -299,6 +300,16 @@ export const ArtifactSchema = z.object({
|
|
|
299
300
|
pendingChat: PendingChatSchema.nullable().default(null),
|
|
300
301
|
/** The review as it stood before the first chat turn — "reset" restores this. */
|
|
301
302
|
preChat: ReviewSnapshotSchema.nullable().default(null),
|
|
303
|
+
/**
|
|
304
|
+
* Everything that has happened to this review, oldest first.
|
|
305
|
+
*
|
|
306
|
+
* Optional, and with no default: nothing outside `saveArtifact` writes this,
|
|
307
|
+
* so absent means absent — an artifact from before it was kept, which the
|
|
308
|
+
* cockpit and the CLI say so about rather than showing as an empty history.
|
|
309
|
+
* Hand one in and it is ignored; the log on disk is the only current copy.
|
|
310
|
+
* See `history.ts` for what is recorded and what is deliberately left out.
|
|
311
|
+
*/
|
|
312
|
+
history: z.array(HistoryEntrySchema).optional(),
|
|
302
313
|
});
|
|
303
314
|
/** What the AI must return. Subset of the artifact; cerber owns the rest. */
|
|
304
315
|
export const AiReviewSchema = z.object({
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
/**
|
|
4
|
+
* What happened to a review, and when — cerber's side of the story.
|
|
5
|
+
*
|
|
6
|
+
* An artifact keeps one `updatedAt`, so every write erases the answer to "when
|
|
7
|
+
* did this become skipped?". This is the record that survives: one line per
|
|
8
|
+
* thing that changed, appended by `saveArtifact` itself rather than by its
|
|
9
|
+
* callers, so no write path can forget to keep it.
|
|
10
|
+
*
|
|
11
|
+
* It deliberately records only what cerber did and saw. GitHub keeps its own
|
|
12
|
+
* timeline of pushes, requests and reviews, and `gh` can always be asked for it
|
|
13
|
+
* again — mirroring it here would be a database wearing a different hat. The
|
|
14
|
+
* one thing worth writing down is what the poll *saw* at a given minute, since
|
|
15
|
+
* a search index cannot be asked what it said an hour ago.
|
|
16
|
+
*
|
|
17
|
+
* The chat is the other deliberate omission: a conversation already carries its
|
|
18
|
+
* own turns, timestamps and revisions.
|
|
19
|
+
*/
|
|
20
|
+
export const HistoryActorSchema = z.enum(["daemon", "cockpit", "cli", "runner", "unknown"]);
|
|
21
|
+
export const HistoryEntrySchema = z.object({
|
|
22
|
+
at: z.string(),
|
|
23
|
+
/** Which part of cerber wrote it. "unknown" when nothing claimed the write. */
|
|
24
|
+
by: HistoryActorSchema.default("unknown"),
|
|
25
|
+
/** What happened, in plain words: "status ready → skipped". */
|
|
26
|
+
what: z.string(),
|
|
27
|
+
/** What was being done at the time: "PATCH /api/reviews/…", "poll", "review". */
|
|
28
|
+
cause: z.string().nullable().default(null),
|
|
29
|
+
});
|
|
30
|
+
/**
|
|
31
|
+
* How many entries one review keeps.
|
|
32
|
+
*
|
|
33
|
+
* Entries are ~100 bytes next to a diff that is routinely a hundred times
|
|
34
|
+
* that, and a watchlist plus note de-duplication keeps a busy PR to a few
|
|
35
|
+
* dozen — so this is a backstop against a pathological row, not a budget.
|
|
36
|
+
*/
|
|
37
|
+
export const MAX_ENTRIES = 500;
|
|
38
|
+
/**
|
|
39
|
+
* Who is writing right now.
|
|
40
|
+
*
|
|
41
|
+
* Ambient rather than a parameter on every write: there are twenty-odd call
|
|
42
|
+
* sites and the useful answer is the same for all the writes one request, poll
|
|
43
|
+
* or run makes. Set it once at each entry point — the HTTP middleware, the
|
|
44
|
+
* poll, the CLI, an AI run — and every artifact write underneath is labelled,
|
|
45
|
+
* including ones added later that never think about history at all. Nesting
|
|
46
|
+
* works the obvious way: a run started by a request labels its own writes.
|
|
47
|
+
*/
|
|
48
|
+
const writer = new AsyncLocalStorage();
|
|
49
|
+
export function withWriter(w, fn) {
|
|
50
|
+
return writer.run({ by: w.by, cause: w.cause ?? null }, fn);
|
|
51
|
+
}
|
|
52
|
+
export function currentWriter() {
|
|
53
|
+
return writer.getStore() ?? { by: "unknown", cause: null };
|
|
54
|
+
}
|
|
55
|
+
const short = (sha) => (sha.length > 7 ? sha.slice(0, 7) : sha);
|
|
56
|
+
const FILED_PHRASE = {
|
|
57
|
+
"own-review": "you had already reviewed it on GitHub",
|
|
58
|
+
"own-reply": "you answered on the PR and nobody has answered back",
|
|
59
|
+
"request-withdrawn": "nobody is asking for this review any more",
|
|
60
|
+
};
|
|
61
|
+
/** The shape of a run, said once at the top of it: what it could read, who asked. */
|
|
62
|
+
function runShape(run) {
|
|
63
|
+
const parts = [run.model ?? "default model", run.withSource ? "reading the source" : "diff only"];
|
|
64
|
+
if (run.trusted)
|
|
65
|
+
parts.push("trusted — may run commands");
|
|
66
|
+
if (run.trigger)
|
|
67
|
+
parts.push(run.trigger === "daemon" ? "asked for by the poll" : "asked for by you");
|
|
68
|
+
return parts.join(", ");
|
|
69
|
+
}
|
|
70
|
+
/** Comment churn as one line: what a re-review, a chat turn or your own edits did. */
|
|
71
|
+
function describeComments(before, after) {
|
|
72
|
+
const was = new Map(before.map((c) => [c.id, c]));
|
|
73
|
+
const is = new Map(after.map((c) => [c.id, c]));
|
|
74
|
+
let fromReview = 0;
|
|
75
|
+
let yours = 0;
|
|
76
|
+
let edited = 0;
|
|
77
|
+
let regraded = 0;
|
|
78
|
+
let dropped = 0;
|
|
79
|
+
let restored = 0;
|
|
80
|
+
for (const c of after) {
|
|
81
|
+
const old = was.get(c.id);
|
|
82
|
+
if (!old) {
|
|
83
|
+
if (c.origin === "user")
|
|
84
|
+
yours++;
|
|
85
|
+
else
|
|
86
|
+
fromReview++;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (old.body !== c.body)
|
|
90
|
+
edited++;
|
|
91
|
+
if (old.severity !== c.severity)
|
|
92
|
+
regraded++;
|
|
93
|
+
if (old.status !== "dropped" && c.status === "dropped")
|
|
94
|
+
dropped++;
|
|
95
|
+
if (old.status === "dropped" && c.status !== "dropped")
|
|
96
|
+
restored++;
|
|
97
|
+
}
|
|
98
|
+
const gone = before.filter((c) => !is.has(c.id)).length;
|
|
99
|
+
const parts = [];
|
|
100
|
+
if (fromReview)
|
|
101
|
+
parts.push(`+${fromReview} from the review`);
|
|
102
|
+
if (yours)
|
|
103
|
+
parts.push(`+${yours} you wrote`);
|
|
104
|
+
if (edited)
|
|
105
|
+
parts.push(`${edited} edited`);
|
|
106
|
+
if (regraded)
|
|
107
|
+
parts.push(`${regraded} re-graded`);
|
|
108
|
+
if (dropped)
|
|
109
|
+
parts.push(`${dropped} dropped`);
|
|
110
|
+
if (restored)
|
|
111
|
+
parts.push(`${restored} restored`);
|
|
112
|
+
if (gone)
|
|
113
|
+
parts.push(`${gone} gone`);
|
|
114
|
+
return parts.length > 0 ? `comments: ${parts.join(", ")}` : null;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* What changed between two versions of a review, in plain words.
|
|
118
|
+
*
|
|
119
|
+
* A watchlist, not a deep diff. A generic comparison would bury the timeline
|
|
120
|
+
* under a running turn's narration, which is rewritten to the artifact every
|
|
121
|
+
* couple of seconds and says nothing about where the review got to.
|
|
122
|
+
*/
|
|
123
|
+
export function describeChange(before, after) {
|
|
124
|
+
const lines = [];
|
|
125
|
+
if (!before) {
|
|
126
|
+
lines.push(after.status === "awaiting"
|
|
127
|
+
? "appeared in the inbox — GitHub is asking you for a review"
|
|
128
|
+
: `first written here (${after.status})`);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
if (before.status !== after.status)
|
|
132
|
+
lines.push(`status ${before.status} → ${after.status}`);
|
|
133
|
+
if (before.pr.headSha !== after.pr.headSha && after.pr.headSha) {
|
|
134
|
+
lines.push(before.pr.headSha
|
|
135
|
+
? `head moved ${short(before.pr.headSha)} → ${short(after.pr.headSha)}`
|
|
136
|
+
: `head is ${short(after.pr.headSha)}`);
|
|
137
|
+
}
|
|
138
|
+
if (before.pr.state !== after.pr.state) {
|
|
139
|
+
lines.push(after.pr.state === "OPEN" ? "PR reopened" : `PR ${after.pr.state.toLowerCase()}`);
|
|
140
|
+
}
|
|
141
|
+
if (before.pr.isDraft !== after.pr.isDraft) {
|
|
142
|
+
lines.push(after.pr.isDraft ? "turned back into a draft" : "marked ready for review");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const wasRun = before?.run ?? null;
|
|
146
|
+
const run = after.run;
|
|
147
|
+
if (run && run.startedAt !== wasRun?.startedAt)
|
|
148
|
+
lines.push(`review started (${runShape(run)})`);
|
|
149
|
+
if (run?.finishedAt && run.finishedAt !== wasRun?.finishedAt && !run.error) {
|
|
150
|
+
lines.push(`review finished${run.reviewedSha ? ` at ${short(run.reviewedSha)}` : ""}` +
|
|
151
|
+
`${run.costUsd != null ? ` (≈$${run.costUsd.toFixed(2)} at API rates)` : ""}`);
|
|
152
|
+
}
|
|
153
|
+
if (run?.error && run.error !== wasRun?.error)
|
|
154
|
+
lines.push(`review failed: ${run.error}`);
|
|
155
|
+
const wasVerdict = before?.verdict ?? null;
|
|
156
|
+
const verdict = after.verdict;
|
|
157
|
+
if (verdict &&
|
|
158
|
+
(!wasVerdict ||
|
|
159
|
+
wasVerdict.recommendation !== verdict.recommendation ||
|
|
160
|
+
wasVerdict.confidence !== verdict.confidence)) {
|
|
161
|
+
lines.push(`verdict ${wasVerdict ? "changed to" : "set to"} ${verdict.recommendation.replace("_", " ")}` +
|
|
162
|
+
` (${verdict.confidence}% sure of the findings)`);
|
|
163
|
+
}
|
|
164
|
+
const comments = describeComments(before?.comments ?? [], after.comments);
|
|
165
|
+
if (comments)
|
|
166
|
+
lines.push(comments);
|
|
167
|
+
if (after.sent && after.sent.at !== before?.sent?.at) {
|
|
168
|
+
lines.push(`sent to GitHub as ${after.sent.event.toLowerCase().replace("_", " ")}` +
|
|
169
|
+
`${after.sent.auto ? ", by auto-send" : ""}`);
|
|
170
|
+
}
|
|
171
|
+
if (after.filed && after.filed.at !== before?.filed?.at) {
|
|
172
|
+
lines.push(`filed under settled — ${FILED_PHRASE[after.filed.reason] ?? after.filed.reason}`);
|
|
173
|
+
}
|
|
174
|
+
if (after.refresh && after.refresh.at !== before?.refresh?.at) {
|
|
175
|
+
const r = after.refresh;
|
|
176
|
+
lines.push(`pulled forward onto ${short(r.toSha)} — ${r.moved} comment(s) followed the code` +
|
|
177
|
+
`${r.drifted > 0 ? `, ${r.drifted} drifted` : ""}`);
|
|
178
|
+
}
|
|
179
|
+
return lines;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Would this note be dropped as a repeat of the last thing said?
|
|
183
|
+
*
|
|
184
|
+
* The rule belongs to `appendHistory`, which drops such a note; this is the
|
|
185
|
+
* same question asked ahead of time, so a caller can skip a write that would
|
|
186
|
+
* change nothing. Exported rather than inferred from the result: at the cap an
|
|
187
|
+
* appended entry trims an older one, so the log's *length* is not evidence of
|
|
188
|
+
* anything having been added, and a caller comparing lengths would silently
|
|
189
|
+
* stop recording notes the moment a review reached `MAX_ENTRIES`.
|
|
190
|
+
*/
|
|
191
|
+
export function noteIsRepeat(prior, note) {
|
|
192
|
+
return (prior?.history ?? []).at(-1)?.what === note;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The history to write, given what is on disk and what is about to replace it.
|
|
196
|
+
*
|
|
197
|
+
* Whatever history the caller is holding is ignored: several write paths
|
|
198
|
+
* legitimately hand over an artifact built minutes ago, and their copy of the
|
|
199
|
+
* log is stale by definition. Disk is the only source.
|
|
200
|
+
*/
|
|
201
|
+
export function appendHistory(prior, next, opts = {}) {
|
|
202
|
+
const kept = prior?.history ?? [];
|
|
203
|
+
const { by, cause } = currentWriter();
|
|
204
|
+
const at = new Date().toISOString();
|
|
205
|
+
const what = opts.unreadable
|
|
206
|
+
? ["history restarts here — the previous file could not be read"]
|
|
207
|
+
: opts.note
|
|
208
|
+
? // A note explains a decision the poll re-takes every few minutes. Said
|
|
209
|
+
// once: repeating it until something else happens is noise, and the
|
|
210
|
+
// entry it would duplicate already says the same thing.
|
|
211
|
+
noteIsRepeat(prior, opts.note)
|
|
212
|
+
? []
|
|
213
|
+
: [opts.note]
|
|
214
|
+
: describeChange(prior, next);
|
|
215
|
+
const all = [...kept, ...what.map((w) => ({ at, by, cause, what: w }))];
|
|
216
|
+
if (all.length <= MAX_ENTRIES)
|
|
217
|
+
return all;
|
|
218
|
+
const [oldest, ...rest] = all.slice(-(MAX_ENTRIES - 1));
|
|
219
|
+
if (!oldest)
|
|
220
|
+
return all;
|
|
221
|
+
return [
|
|
222
|
+
{ at: oldest.at, by: "unknown", cause: null, what: "… earlier entries dropped" },
|
|
223
|
+
oldest,
|
|
224
|
+
...rest,
|
|
225
|
+
];
|
|
226
|
+
}
|
package/dist/core/state.js
CHANGED
|
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { ArtifactSchema, artifactKey } from "./artifact.js";
|
|
5
|
+
import { appendHistory, noteIsRepeat } from "./history.js";
|
|
5
6
|
export function cerberHome() {
|
|
6
7
|
return process.env.CERBER_HOME ?? path.join(os.homedir(), ".cerber");
|
|
7
8
|
}
|
|
@@ -11,14 +12,91 @@ function reviewsDir() {
|
|
|
11
12
|
function artifactPath(id) {
|
|
12
13
|
return path.join(reviewsDir(), `${artifactKey(id)}.json`);
|
|
13
14
|
}
|
|
14
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Read whatever is on disk, tolerating anything.
|
|
17
|
+
*
|
|
18
|
+
* These files are the user's to edit, so a broken one has to be survivable:
|
|
19
|
+
* before, a save simply overwrote it. It still does — but the timeline says so
|
|
20
|
+
* rather than quietly claiming the review began at that moment.
|
|
21
|
+
*/
|
|
22
|
+
async function readPrior(file) {
|
|
23
|
+
let raw;
|
|
24
|
+
try {
|
|
25
|
+
raw = await fs.readFile(file, "utf8");
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
if (err.code === "ENOENT")
|
|
29
|
+
return { artifact: null, unreadable: false };
|
|
30
|
+
return { artifact: null, unreadable: true };
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
return { artifact: ArtifactSchema.parse(JSON.parse(raw)), unreadable: false };
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return { artifact: null, unreadable: true };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Write an artifact, and record what changed about it.
|
|
41
|
+
*
|
|
42
|
+
* The history is appended here rather than by the caller on purpose. Around
|
|
43
|
+
* twenty places write artifacts and several of them overwrite one wholesale
|
|
44
|
+
* from a copy built minutes earlier — a log any of them had to remember to
|
|
45
|
+
* carry would be lost by the first one that didn't. Appending at the one place
|
|
46
|
+
* every write goes through makes the record a property of writing.
|
|
47
|
+
*
|
|
48
|
+
* The read here is not skippable, even for a caller that has just done one of
|
|
49
|
+
* its own. Two writers share these files — the poll's timer and the cockpit's
|
|
50
|
+
* button — so a caller's copy can be out of date by the time it writes, and
|
|
51
|
+
* appending to *that* would drop whatever the other one recorded in between.
|
|
52
|
+
* The rest of the artifact is lost in that race either way; the history need
|
|
53
|
+
* not be, and the extra read is one file next to a write of the same file.
|
|
54
|
+
*
|
|
55
|
+
* `note` records a decision that changed nothing, which is the only kind of
|
|
56
|
+
* history a diff cannot see. It does not touch `updatedAt` — that belongs to
|
|
57
|
+
* `updateArtifactByKey`.
|
|
58
|
+
*/
|
|
59
|
+
export async function saveArtifact(artifact, opts = {}) {
|
|
15
60
|
await fs.mkdir(reviewsDir(), { recursive: true });
|
|
16
61
|
const file = artifactPath(artifact.id);
|
|
62
|
+
const prior = await readPrior(file);
|
|
63
|
+
const next = {
|
|
64
|
+
...artifact,
|
|
65
|
+
history: appendHistory(prior.artifact, artifact, {
|
|
66
|
+
note: opts.note,
|
|
67
|
+
unreadable: prior.unreadable,
|
|
68
|
+
}),
|
|
69
|
+
};
|
|
17
70
|
const tmp = `${file}.tmp`;
|
|
18
|
-
await fs.writeFile(tmp, JSON.stringify(
|
|
71
|
+
await fs.writeFile(tmp, JSON.stringify(next, null, 2));
|
|
19
72
|
await fs.rename(tmp, file);
|
|
20
73
|
return file;
|
|
21
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Write down a decision that changed nothing.
|
|
77
|
+
*
|
|
78
|
+
* The poll's silences are the hardest thing to debug about it — it looks at a
|
|
79
|
+
* settled row, decides deliberately to leave it alone, and leaves no trace of
|
|
80
|
+
* having looked. This is that trace. It is not a change to the review, so
|
|
81
|
+
* `updatedAt` stays put: a note must not reorder the queue.
|
|
82
|
+
*
|
|
83
|
+
* Missing artifacts are ignored, and a note identical to the last entry is
|
|
84
|
+
* dropped, so a decision re-taken every poll is recorded once.
|
|
85
|
+
*/
|
|
86
|
+
export async function noteHistory(id, what) {
|
|
87
|
+
const prior = await loadArtifact(id).catch(() => null);
|
|
88
|
+
if (!prior)
|
|
89
|
+
return;
|
|
90
|
+
// Nothing to add means nothing to write: the poll re-takes these decisions
|
|
91
|
+
// every few minutes, and rewriting a whole artifact, diff and all, to change
|
|
92
|
+
// nothing is the expensive half of saying it again. The rule for "nothing to
|
|
93
|
+
// add" is `appendHistory`'s, so ask it rather than knowing it twice — and ask
|
|
94
|
+
// it directly, because at the cap an appended note trims an older entry and
|
|
95
|
+
// leaves the length exactly as it was.
|
|
96
|
+
if (noteIsRepeat(prior, what))
|
|
97
|
+
return;
|
|
98
|
+
await saveArtifact(prior, { note: what });
|
|
99
|
+
}
|
|
22
100
|
export async function loadArtifact(id) {
|
|
23
101
|
try {
|
|
24
102
|
const raw = await fs.readFile(artifactPath(id), "utf8");
|
package/dist/runner/review.js
CHANGED
|
@@ -3,7 +3,8 @@ import { AiReviewSchema, SCHEMA_VERSION, artifactId, artifactKey, } from "../cor
|
|
|
3
3
|
import { createRunDir, evictOldCheckouts, prepareCheckout, removeRunDir } from "../core/checkout.js";
|
|
4
4
|
import { fetchPrDiff, fetchPrInfo, isOrgMember, isTeamMember } from "../core/gh.js";
|
|
5
5
|
import { mergeRunResult, userOwnsStatus } from "../core/refresh.js";
|
|
6
|
-
import { loadArtifact, saveArtifact, updateArtifactByKey } from "../core/state.js";
|
|
6
|
+
import { loadArtifact, noteHistory, saveArtifact, updateArtifactByKey } from "../core/state.js";
|
|
7
|
+
import { withWriter } from "../core/history.js";
|
|
7
8
|
import { loadConfig } from "../core/config.js";
|
|
8
9
|
import { decideTrust, membershipQueries, parseTrustRules } from "../core/trust.js";
|
|
9
10
|
import { extractJson, runClaude, unauthenticatedEnv } from "./claude.js";
|
|
@@ -80,15 +81,28 @@ async function resolveTrust(pr, opts, log) {
|
|
|
80
81
|
log(`Trusted (${decision.reason}): the review may run commands in the checkout.`);
|
|
81
82
|
return decision.trusted;
|
|
82
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Decide whether to run, then run.
|
|
86
|
+
*
|
|
87
|
+
* The two halves are deliberately not in the same writer context. Everything
|
|
88
|
+
* down to the guards belongs to whoever asked — the poll's timer, you at a
|
|
89
|
+
* terminal, the cockpit's button — and that is the whole value of the notes
|
|
90
|
+
* they write: a review that did not happen has no runner to blame, and "who
|
|
91
|
+
* wanted one" is the fact worth keeping. Only the run itself is the runner's.
|
|
92
|
+
*/
|
|
83
93
|
async function runReview(ref, opts) {
|
|
84
94
|
const log = opts.onProgress ?? (() => { });
|
|
85
|
-
const now = () => new Date().toISOString();
|
|
86
95
|
log(`Fetching ${ref.owner}/${ref.repo}#${ref.number}…`);
|
|
87
96
|
const pr = await fetchPrInfo(ref);
|
|
88
97
|
const existing = await loadArtifact(artifactId(pr));
|
|
89
98
|
if (existing && !opts.force) {
|
|
90
99
|
if (SETTLED_BY_YOU.has(existing.status)) {
|
|
91
100
|
log(`You marked this ${existing.status} — leaving it alone. Use --force to re-review.`);
|
|
101
|
+
// Written down because it is the poll's most confusing silence: a row
|
|
102
|
+
// the author keeps pushing to, that never comes back into the inbox.
|
|
103
|
+
// Only a push, now — somebody asking again does reopen it, and says so
|
|
104
|
+
// for itself (`reopenIfAskedAgain`).
|
|
105
|
+
await noteHistory(existing.id, `left alone: you marked it ${existing.status}, so a new push does not reopen it`);
|
|
92
106
|
return { artifact: existing, skipped: true };
|
|
93
107
|
}
|
|
94
108
|
// The sha the AI *read*, not the one the artifact happens to mention.
|
|
@@ -100,9 +114,18 @@ async function runReview(ref, opts) {
|
|
|
100
114
|
const reviewedSha = existing.run?.reviewedSha ?? existing.pr.headSha;
|
|
101
115
|
if (HEAD_SENSITIVE.has(existing.status) && reviewedSha !== "" && reviewedSha === pr.headSha) {
|
|
102
116
|
log(`Up to date (reviewed at ${reviewedSha.slice(0, 7)}, status ${existing.status}) — skipping. Use --force to re-review.`);
|
|
117
|
+
// Carries the sha, so it says itself again the next time the head moves
|
|
118
|
+
// and this guard stops being the reason nothing happened.
|
|
119
|
+
await noteHistory(existing.id, `already reviewed at ${reviewedSha.slice(0, 7)} — not re-reviewed`);
|
|
103
120
|
return { artifact: existing, skipped: true };
|
|
104
121
|
}
|
|
105
122
|
}
|
|
123
|
+
// The run owns its writes from here, whoever asked for it: a re-review
|
|
124
|
+
// started from a cockpit click is still the runner rewriting the draft.
|
|
125
|
+
return await withWriter({ by: "runner", cause: "review" }, () => performReview(ref, pr, existing, opts, log));
|
|
126
|
+
}
|
|
127
|
+
async function performReview(ref, pr, existing, opts, log) {
|
|
128
|
+
const now = () => new Date().toISOString();
|
|
106
129
|
const diff = await fetchPrDiff(ref);
|
|
107
130
|
const trusted = await resolveTrust(pr, opts, log);
|
|
108
131
|
// A checkout is an optimisation, never a precondition: if git or gh can't
|
package/dist/server/daemon.js
CHANGED
|
@@ -2,9 +2,10 @@ 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
4
|
import { classifyReply, currentLogin, fetchConversation, fetchLastReviewRequest, fetchOwnReview, fetchPrInfo, fetchReviewRequests, 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
|
/**
|
|
@@ -293,6 +294,9 @@ export function startDaemon(opts) {
|
|
|
293
294
|
// row never had one, so a mutation that declined would read as a reopen.
|
|
294
295
|
if (saved && !SETTLED_BY_YOU.has(saved.status)) {
|
|
295
296
|
log(`[${artifact.id}] your review was requested again on ${at.slice(0, 10)} — back in the inbox`);
|
|
297
|
+
// The status change alone says a row moved; this says why it moved,
|
|
298
|
+
// which is the whole reason a settle is allowed to be undone at all.
|
|
299
|
+
await noteHistory(artifact.id, `back in the inbox: your review was requested again on ${at.slice(0, 10)}, after you settled it`);
|
|
296
300
|
}
|
|
297
301
|
}
|
|
298
302
|
for (const ref of refs) {
|
|
@@ -374,15 +378,21 @@ export function startDaemon(opts) {
|
|
|
374
378
|
}
|
|
375
379
|
// Anything but silence from you leaves the row alone: `them` is somebody
|
|
376
380
|
// answering a comment of yours, which is the opposite of settled.
|
|
377
|
-
if (classifyReply(conversation, me) !== "none")
|
|
381
|
+
if (classifyReply(conversation, me) !== "none") {
|
|
382
|
+
await noteHistory(artifact.id, "someone has answered you on the PR — the draft stays out for you");
|
|
378
383
|
return;
|
|
384
|
+
}
|
|
379
385
|
if (!filedByWithdrawnRequest(artifact))
|
|
380
386
|
return;
|
|
381
387
|
// The awaiting search says nobody is asking, and here that is the entire
|
|
382
388
|
// case — so confirm it against the PR itself before acting on it. The
|
|
383
389
|
// other two reasons stand on a fact of their own and need no such check.
|
|
384
|
-
if (stillRequested(await fetchReviewRequests(artifact.pr), me))
|
|
390
|
+
if (stillRequested(await fetchReviewRequests(artifact.pr), me)) {
|
|
391
|
+
// The one fact GitHub cannot be asked for later: what its search index
|
|
392
|
+
// said at this minute, and that the PR itself disagreed with it.
|
|
393
|
+
await noteHistory(artifact.id, "gone from the awaiting search, but GitHub still lists you as a requested reviewer — left alone");
|
|
385
394
|
return;
|
|
395
|
+
}
|
|
386
396
|
await file({ at: new Date().toISOString(), reason: "request-withdrawn", review: null, reply: null }, "nobody is asking for this review any more", filedByWithdrawnRequest);
|
|
387
397
|
}
|
|
388
398
|
const awaitingIds = new Set(refs.map((r) => artifactId(r)));
|
|
@@ -543,7 +553,10 @@ export function startDaemon(opts) {
|
|
|
543
553
|
});
|
|
544
554
|
return { reviewed, skipped, failed };
|
|
545
555
|
}
|
|
546
|
-
|
|
556
|
+
/** Everything a poll writes is the poll's — including the reviews it starts,
|
|
557
|
+
* which stamp themselves as runs from inside this. */
|
|
558
|
+
const poll = () => withWriter({ by: "daemon", cause: "poll" }, runPoll);
|
|
559
|
+
async function runPoll() {
|
|
547
560
|
if (status.polling)
|
|
548
561
|
return;
|
|
549
562
|
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.26.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)",
|