@fullstackhouse/cerber 0.7.0 → 0.9.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 +141 -6
- package/dist/cli/index.js +146 -13
- package/dist/core/artifact.js +4 -0
- package/dist/core/checkout.js +201 -0
- package/dist/core/config.js +100 -0
- package/dist/core/gh.js +26 -1
- package/dist/core/state.js +15 -0
- package/dist/core/trust.js +91 -0
- package/dist/runner/claude.js +84 -4
- package/dist/runner/prompt.js +31 -4
- package/dist/runner/review.js +122 -20
- package/dist/server/daemon.js +209 -46
- package/dist/server/index.js +113 -4
- package/package.json +1 -1
- package/web/dist/assets/index-CsWW0EBv.js +236 -0
- package/web/dist/assets/{index-CwjpyotK.css → index-Dmz5lQnG.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-Bq-5Sr6-.js +0 -236
package/README.md
CHANGED
|
@@ -28,10 +28,18 @@ npx @fullstackhouse/cerber review 123 124 --repo owner/repo
|
|
|
28
28
|
# See what's in the queue
|
|
29
29
|
npx @fullstackhouse/cerber list
|
|
30
30
|
|
|
31
|
-
# Open the cockpit
|
|
31
|
+
# Open the cockpit — it's an inbox: it polls GitHub for PRs awaiting your
|
|
32
|
+
# review and drafts a review for each, so they're ready when you arrive
|
|
32
33
|
npx @fullstackhouse/cerber serve # → http://127.0.0.1:4820
|
|
33
34
|
```
|
|
34
35
|
|
|
36
|
+
`serve` does the whole loop by default: discover → draft → wait for you.
|
|
37
|
+
Sending stays a human click. Tame it with `--no-auto-review` (list awaiting
|
|
38
|
+
PRs, review on click) or `--no-poll` (no GitHub polling at all) — or flip the
|
|
39
|
+
same two switches in the cockpit's Settings, which persist in
|
|
40
|
+
`~/.cerber/config.json` and apply on the next poll. Merged and closed PRs are
|
|
41
|
+
archived out of the queue automatically.
|
|
42
|
+
|
|
35
43
|
## What a review looks like
|
|
36
44
|
|
|
37
45
|
Each review is a plain JSON artifact in `~/.cerber/reviews/` (override with
|
|
@@ -44,10 +52,138 @@ Each review is a plain JSON artifact in `~/.cerber/reviews/` (override with
|
|
|
44
52
|
human's time
|
|
45
53
|
- **Verdict** — approve / comment / request changes, with a 0–100 confidence
|
|
46
54
|
score and reasoning
|
|
55
|
+
- **Run metadata** — model, whether it read the source or only the diff, and
|
|
56
|
+
the token spend as an API-rate equivalent
|
|
47
57
|
|
|
48
58
|
The cockpit (`cerber serve`) renders the queue and the per-PR walkthrough with
|
|
49
59
|
diffs. Artifacts are plain JSON you can `cat`, edit, or pipe into anything.
|
|
50
60
|
|
|
61
|
+
### It reviews the code, not just the diff
|
|
62
|
+
|
|
63
|
+
Cerber checks the PR's head out locally and lets the review read it. That
|
|
64
|
+
matters because a reviewer holding only a diff hedges over things it could have
|
|
65
|
+
just looked up — *"I can't see the enclosing function, so this could be
|
|
66
|
+
wrong"* — and burns its confidence score on missing context instead of on real
|
|
67
|
+
uncertainty. With the source there, it opens the enclosing function, follows
|
|
68
|
+
callers of a changed signature, checks whether a helper already exists, and
|
|
69
|
+
sees whether a test covers the new path.
|
|
70
|
+
|
|
71
|
+
On one real PR, same commit, the difference was: a speculative "this *looks
|
|
72
|
+
like* it sits after an early return" became a concrete finding; a permission
|
|
73
|
+
check the diff showed as a tidy gate turned out to hide a value the API
|
|
74
|
+
response still ships; confidence went 72% → 82%. It burned 3.3× the tokens and
|
|
75
|
+
took three minutes instead of one.
|
|
76
|
+
|
|
77
|
+
Cerber rides your `claude` login, so a review isn't billed per run: on a Claude
|
|
78
|
+
subscription it draws on your usage limits. The `≈$` figures in the CLI and
|
|
79
|
+
cockpit are what those tokens would cost at API token rates — read them as
|
|
80
|
+
"how much of the plan did this eat", not as an invoice.
|
|
81
|
+
|
|
82
|
+
If you want the old behaviour — faster, lighter, blind past the changed lines:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
cerber review owner/repo#123 --no-source
|
|
86
|
+
cerber serve --no-source
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The cockpit labels every review "read the full source" or "read the diff only",
|
|
90
|
+
and offers a one-click **Re-review with full source** on the diff-only ones.
|
|
91
|
+
|
|
92
|
+
Details worth knowing:
|
|
93
|
+
|
|
94
|
+
- The checkout is a shallow (`--depth=1`) fetch of `refs/pull/N/head` from the
|
|
95
|
+
base repo, so fork PRs work with no extra remotes. Auth rides `gh`'s
|
|
96
|
+
credential helper, set on that clone only — your git config is untouched.
|
|
97
|
+
- If git or `gh` can't produce a checkout, the run says so and reviews the diff
|
|
98
|
+
alone. A missing checkout never fails a review.
|
|
99
|
+
- The run is read-only unless you have trusted the PR (see below): `Read`,
|
|
100
|
+
`Grep` and `Glob` are the only tools it gets. Without a checkout it gets none,
|
|
101
|
+
and runs in an empty directory, so it can never read whatever project cerber
|
|
102
|
+
happens to be started from and mistake it for the PR.
|
|
103
|
+
- PR content is untrusted, and `claude -p` skips the workspace-trust prompt, so
|
|
104
|
+
cerber does the distrusting itself: a `.claude/settings.json` or `.mcp.json`
|
|
105
|
+
in the checkout is renamed aside (still readable, no longer loaded), hooks
|
|
106
|
+
and non-cerber MCP servers are off, and the prompt tells the reviewer that
|
|
107
|
+
instructions found in files are material to review, not directions to follow.
|
|
108
|
+
What a hostile PR can still do is skew the review text — it cannot run
|
|
109
|
+
anything, write anything, or reach the network. Read the verdict, don't
|
|
110
|
+
rubber-stamp it. (All of this applies to PRs you have *not* trusted; a
|
|
111
|
+
trusted one is deliberately given the run of the place.)
|
|
112
|
+
- Checkouts live in `~/.cerber/src/<owner>__<repo>__<n>` and are reused by later
|
|
113
|
+
re-reviews. A big monorepo is a few hundred MB per PR, so the cache keeps only
|
|
114
|
+
the 8 most recently reviewed and evicts the rest as it goes — never one a
|
|
115
|
+
review is currently reading. `cerber prune` reclaims the space now;
|
|
116
|
+
`cerber prune --all` takes the ones for reviews still awaiting you too.
|
|
117
|
+
|
|
118
|
+
### Trusted PRs: reviews that can run things
|
|
119
|
+
|
|
120
|
+
Reading beats guessing, but running beats reading. A review that ran the test
|
|
121
|
+
covering the change reports what happened; one that only read it guesses. So
|
|
122
|
+
for PRs you already trust — a teammate's, or anything in your own repos —
|
|
123
|
+
cerber can let the review run commands in the checkout: the test suite, a
|
|
124
|
+
typecheck, `git log`/`git blame`, a build.
|
|
125
|
+
|
|
126
|
+
Trust is about the *people*, and only about people — cerber has no way to
|
|
127
|
+
trust a repository, because anyone can open a PR against one:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
cerber trust @fullstackhouse/* # anyone in the org
|
|
131
|
+
cerber trust @fullstackhouse/devs # anyone on that GitHub team
|
|
132
|
+
cerber trust @teammate # one person, by login
|
|
133
|
+
cerber trust # show who is trusted today
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Membership is resolved against GitHub when the review runs (your `gh` login
|
|
137
|
+
needs `read:org`), and a lookup that fails counts as "not a member" — a check
|
|
138
|
+
cerber could not complete never reads as trust. Writing a repo instead of a
|
|
139
|
+
person is rejected, in the CLI, the cockpit, and on config load:
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
$ cerber trust acme/widgets
|
|
143
|
+
"acme/widgets" is not a trust rule. Trust is about people, not repositories —
|
|
144
|
+
anyone can open a PR against a repo you own, so trusting the repo would trust
|
|
145
|
+
them too. Use @acme/* for everyone in that org, @acme/team for one team, or
|
|
146
|
+
@login for a person.
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Rules live in `~/.cerber/config.json`, and the cockpit has a **settings**
|
|
150
|
+
screen that reads and writes the same file — each rule shown with what it
|
|
151
|
+
actually grants. A `!` rule denies and beats every grant, so `@fullstackhouse/*`
|
|
152
|
+
plus `!@fullstackhouse/contractors` does what it looks like. `--trust` and
|
|
153
|
+
`--no-trust` override the config for one run; `cerber trust <pattern> --delete`
|
|
154
|
+
removes a rule.
|
|
155
|
+
|
|
156
|
+
A trusted review gets `Bash`, `WebFetch` and `WebSearch` on top of reading, and
|
|
157
|
+
the repo's own `.claude` config applies — it behaves as if you had checked the
|
|
158
|
+
branch out and opened it yourself. The cockpit labels it "ran the code" so you
|
|
159
|
+
know which kind you are reading.
|
|
160
|
+
|
|
161
|
+
It is handed no GitHub credentials, and that part is *enforced* rather than
|
|
162
|
+
requested. `GH_CONFIG_DIR` points at a fresh empty directory, `GH_TOKEN` and
|
|
163
|
+
`GITHUB_TOKEN` are blanked, the fetch credential rides the fetch command
|
|
164
|
+
instead of being stored in the checkout, git's global and system config are
|
|
165
|
+
switched off so a keychain helper cannot stand in, and the ssh route is closed
|
|
166
|
+
too — no agent, no `~/.ssh/config`, no default identity — since otherwise a run
|
|
167
|
+
could point the remote at `git@github.com` and ride your keys. Measured from
|
|
168
|
+
inside a checkout: `git push` over https and over ssh, and `gh pr comment`, all
|
|
169
|
+
fail to authenticate, while `git log` still works.
|
|
170
|
+
|
|
171
|
+
**It is not a sandbox, and you should not read it as one.** A trusted run has
|
|
172
|
+
`Bash` and your filesystem: it can write files in the checkout, and a
|
|
173
|
+
determined one could read a key straight out of `~/.ssh` and use it itself.
|
|
174
|
+
What cerber guarantees is that it *hands* the run nothing. The rest is what
|
|
175
|
+
trusting a person means — which is why trust is spelled `@org/team`, and why
|
|
176
|
+
the default is a review that cannot run anything at all.
|
|
177
|
+
|
|
178
|
+
**What you are accepting.** A trusted review executes code from that PR on your
|
|
179
|
+
machine — the branch's scripts, its dependencies, its test suite. That is the
|
|
180
|
+
same exposure as checking the branch out and running the tests yourself, which
|
|
181
|
+
is what you would otherwise do; it is not the same as reading a diff. Trust
|
|
182
|
+
orgs and people, not the whole of GitHub. And note that `cerber serve` reviews
|
|
183
|
+
unattended by default: with trust rules set, it will run matching PRs' code
|
|
184
|
+
with nobody watching. It warns at startup, the cockpit shows a ⚡ badge while
|
|
185
|
+
it's the case, and `--no-trust` (or `--no-auto-review`) turns it off.
|
|
186
|
+
|
|
51
187
|
### When the PR moves under you
|
|
52
188
|
|
|
53
189
|
A review is written against one commit, but authors keep pushing. Opening a
|
|
@@ -72,9 +208,8 @@ Early, but all six planned phases have shipped.
|
|
|
72
208
|
only GitHub write in the codebase) + `cerber export` to markdown
|
|
73
209
|
3. ✅ `--awaiting-me` discovery, parallel runs (`--parallel`, default 3),
|
|
74
210
|
freshness skip (same head SHA → no re-run; `--force` overrides), queue stats
|
|
75
|
-
4. ✅
|
|
76
|
-
review and reviews them automatically
|
|
77
|
-
non-localhost binds
|
|
211
|
+
4. ✅ Inbox mode (now the default): `cerber serve` polls for PRs awaiting your
|
|
212
|
+
review and reviews them automatically; token auth for non-localhost binds
|
|
78
213
|
5. ✅ Confidence calibration (`cerber stats`), shadow-mode auto-send (default
|
|
79
214
|
in daemon: logs what would be sent), opt-in `--auto-send` (approve-only,
|
|
80
215
|
`--auto-send-threshold`, default 90%)
|
|
@@ -85,11 +220,11 @@ Early, but all six planned phases have shipped.
|
|
|
85
220
|
## Running on a VPS
|
|
86
221
|
|
|
87
222
|
```bash
|
|
88
|
-
cerber serve --
|
|
223
|
+
cerber serve --host 0.0.0.0 --token "$(openssl rand -hex 16)" \
|
|
89
224
|
--repo you/repo-a --repo you/repo-b --interval 10
|
|
90
225
|
```
|
|
91
226
|
|
|
92
|
-
The
|
|
227
|
+
The server polls GitHub, reviews anything new (skipping PRs whose artifact
|
|
93
228
|
already matches the head SHA), and the cockpit is always warm — open it any
|
|
94
229
|
minute and see pending/ready/sent reviews. Auth: `?token=…` once in the
|
|
95
230
|
browser (sets a cookie) or `Authorization: Bearer …`. Binding a non-localhost
|
package/dist/cli/index.js
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import readline from "node:readline/promises";
|
|
4
4
|
import { artifactId, artifactKey } from "../core/artifact.js";
|
|
5
|
+
import { listCheckouts, removeCheckout } from "../core/checkout.js";
|
|
5
6
|
import { toMarkdown } from "../core/export.js";
|
|
7
|
+
import { configPath, loadConfig, saveConfig } from "../core/config.js";
|
|
8
|
+
import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
|
|
6
9
|
import { parsePrRef, searchAwaitingMe, submitReview } from "../core/gh.js";
|
|
7
10
|
import { buildReviewPayload, computeCalibration, eventForRecommendation } from "../core/send.js";
|
|
8
11
|
import { cerberHome, listArtifacts, loadArtifact, readAutoSendLog, updateArtifactByKey, } from "../core/state.js";
|
|
@@ -23,6 +26,9 @@ program
|
|
|
23
26
|
.option("-a, --awaiting-me", "discover and review all open PRs awaiting your review")
|
|
24
27
|
.option("-P, --parallel <n>", "how many reviews to run concurrently", "3")
|
|
25
28
|
.option("-f, --force", "re-review even if the artifact is up to date with the PR head")
|
|
29
|
+
.option("--no-source", "review the diff alone: skip the local checkout of the PR head. Faster and cheaper, but the AI cannot see past the changed lines")
|
|
30
|
+
.option("-t, --trust", "let the review run commands in the checkout (tests, typecheck, git log) whatever the trust config says")
|
|
31
|
+
.option("--no-trust", "keep the review read-only even if a trust rule matches")
|
|
26
32
|
.action(async (prs, opts) => {
|
|
27
33
|
let refs;
|
|
28
34
|
if (opts.awaitingMe) {
|
|
@@ -52,6 +58,8 @@ program
|
|
|
52
58
|
const { artifact, skipped } = await reviewPr(ref, {
|
|
53
59
|
model: opts.model,
|
|
54
60
|
force: opts.force,
|
|
61
|
+
withSource: opts.source,
|
|
62
|
+
trust: opts.trust,
|
|
55
63
|
onProgress: (m) => console.log(`[${label}] ${m}`),
|
|
56
64
|
});
|
|
57
65
|
if (skipped) {
|
|
@@ -61,7 +69,7 @@ program
|
|
|
61
69
|
const v = artifact.verdict;
|
|
62
70
|
rows.push(`✔ ${label.padEnd(40)} ${v ? `${v.recommendation} ${v.confidence}%` : "?"}`.padEnd(65) +
|
|
63
71
|
`${artifact.comments.length} comment(s)` +
|
|
64
|
-
(artifact.run?.costUsd != null ? `,
|
|
72
|
+
(artifact.run?.costUsd != null ? `, ≈$${artifact.run.costUsd.toFixed(2)}` : ""));
|
|
65
73
|
}
|
|
66
74
|
catch (err) {
|
|
67
75
|
failures++;
|
|
@@ -74,6 +82,9 @@ program
|
|
|
74
82
|
if (skips > 0)
|
|
75
83
|
console.log(`(${skips} already up to date — use --force to re-review)`);
|
|
76
84
|
console.log(`\nArtifacts in ${cerberHome()}/reviews — view with: cerber serve`);
|
|
85
|
+
if (rows.some((r) => r.includes("≈$"))) {
|
|
86
|
+
console.log("(≈$ is what the tokens would cost at API rates — on a Claude subscription they draw on your usage limits, not your card.)");
|
|
87
|
+
}
|
|
77
88
|
if (failures > 0)
|
|
78
89
|
process.exitCode = 1;
|
|
79
90
|
});
|
|
@@ -161,6 +172,94 @@ program
|
|
|
161
172
|
}));
|
|
162
173
|
console.log(`✔ Review sent${url ? `: ${url}` : "."}`);
|
|
163
174
|
});
|
|
175
|
+
program
|
|
176
|
+
.command("trust")
|
|
177
|
+
.description("Show, add, or remove the people whose PRs a review may run (~/.cerber/config.json)")
|
|
178
|
+
.argument("[pattern]", "@org/*, @org/team, @login, or !pattern to deny — omit to list what is trusted today")
|
|
179
|
+
.option("-d, --delete", "remove this rule instead of adding it")
|
|
180
|
+
.action(async (pattern, opts) => {
|
|
181
|
+
const config = await loadConfig();
|
|
182
|
+
if (pattern) {
|
|
183
|
+
let rule;
|
|
184
|
+
try {
|
|
185
|
+
rule = parseTrustRule(pattern);
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
console.error(err instanceof TrustRuleError ? err.message : String(err));
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
if (!rule) {
|
|
192
|
+
console.error(`Not a trust rule: "${pattern}"`);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
const canonical = describeRule(rule);
|
|
196
|
+
const without = config.trust.filter((line) => {
|
|
197
|
+
const parsed = parseTrustRule(line);
|
|
198
|
+
return !parsed || describeRule(parsed) !== canonical;
|
|
199
|
+
});
|
|
200
|
+
if (opts.delete) {
|
|
201
|
+
if (without.length === config.trust.length) {
|
|
202
|
+
console.error(`Not in the trust list: ${canonical}`);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
await saveConfig({ ...config, trust: without });
|
|
206
|
+
console.log(`Removed ${canonical}. Those PRs are reviewed read-only again.`);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
await saveConfig({ ...config, trust: [...without, canonical] });
|
|
210
|
+
console.log(`Trusted: ${explainRule(rule)}\n → ${configPath()}`);
|
|
211
|
+
console.log("Reviews of matching PRs may run tests, typechecks and git commands in the checkout.");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (config.trust.length === 0) {
|
|
215
|
+
console.log(`Nothing is trusted: every review reads the checkout and runs nothing.\n` +
|
|
216
|
+
`Trust people — never a repo, since anyone can open a PR against one:\n` +
|
|
217
|
+
` cerber trust @your-org/* # everyone in the org\n` +
|
|
218
|
+
` cerber trust @your-org/devs # one GitHub team\n` +
|
|
219
|
+
` cerber trust @teammate # one person\n` +
|
|
220
|
+
`Rules live in ${configPath()}, and in the cockpit under Settings.`);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
console.log(`Trusted people (${configPath()}):`);
|
|
224
|
+
for (const line of config.trust) {
|
|
225
|
+
const rule = parseTrustRule(line);
|
|
226
|
+
if (rule)
|
|
227
|
+
console.log(` ${describeRule(rule).padEnd(28)} ${explainRule(rule)}`);
|
|
228
|
+
}
|
|
229
|
+
console.log("\nEveryone else's PRs are reviewed read-only. Remove one with: cerber trust <pattern> --delete");
|
|
230
|
+
});
|
|
231
|
+
program
|
|
232
|
+
.command("prune")
|
|
233
|
+
.description("Delete cached source checkouts in ~/.cerber/src (they re-fetch on the next --with-source review)")
|
|
234
|
+
.option("--all", "delete every checkout, including ones for reviews still awaiting you")
|
|
235
|
+
.action(async (opts) => {
|
|
236
|
+
const checkouts = await listCheckouts();
|
|
237
|
+
if (checkouts.length === 0) {
|
|
238
|
+
console.log(`No source checkouts in ${cerberHome()}/src.`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const artifacts = new Map((await listArtifacts()).map((a) => [a.id, a]));
|
|
242
|
+
const mb = (bytes) => {
|
|
243
|
+
const value = bytes / 1024 / 1024;
|
|
244
|
+
return `${value.toFixed(value < 10 ? 1 : 0)} MB`;
|
|
245
|
+
};
|
|
246
|
+
let freed = 0;
|
|
247
|
+
let kept = 0;
|
|
248
|
+
for (const checkout of checkouts) {
|
|
249
|
+
const artifact = artifacts.get(checkout.id);
|
|
250
|
+
// A review you have not sent yet is still live work — its checkout makes
|
|
251
|
+
// the next re-review cheap, so only --all takes it.
|
|
252
|
+
const done = !artifact || artifact.sent != null || artifact.pr.state !== "OPEN";
|
|
253
|
+
if (!opts.all && !done) {
|
|
254
|
+
kept++;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
await removeCheckout(checkout.dir);
|
|
258
|
+
freed += checkout.bytes;
|
|
259
|
+
console.log(`removed ${checkout.id.padEnd(40)} ${mb(checkout.bytes)}`);
|
|
260
|
+
}
|
|
261
|
+
console.log(`\nFreed ${mb(freed)}${kept > 0 ? `; kept ${kept} for open reviews (--all removes them too)` : ""}.`);
|
|
262
|
+
});
|
|
164
263
|
program
|
|
165
264
|
.command("stats")
|
|
166
265
|
.description("How well-calibrated the AI reviews are: verdict agreement and comment survival by confidence")
|
|
@@ -206,16 +305,21 @@ program
|
|
|
206
305
|
}
|
|
207
306
|
});
|
|
208
307
|
program
|
|
209
|
-
.command("serve")
|
|
210
|
-
.description("Start the review cockpit
|
|
308
|
+
.command("serve", { isDefault: true })
|
|
309
|
+
.description("Start the review cockpit — an inbox: it polls GitHub for PRs awaiting your review and drafts " +
|
|
310
|
+
"a review for each, so they're ready when you open it. --no-poll / --no-auto-review to tame it.")
|
|
211
311
|
.option("-p, --port <port>", "port", "4820")
|
|
212
312
|
.option("-H, --host <host>", "host to bind (0.0.0.0 for VPS — requires --token)", "127.0.0.1")
|
|
213
313
|
.option("-t, --token <token>", "require this token on every request (env: CERBER_TOKEN)")
|
|
214
|
-
.option("-
|
|
215
|
-
.option("-
|
|
216
|
-
.option("-
|
|
217
|
-
.option("-
|
|
218
|
-
.option("-
|
|
314
|
+
.option("--no-poll", "don't poll GitHub for PRs awaiting your review (also disables auto-review)")
|
|
315
|
+
.option("--no-auto-review", "list awaiting PRs but don't review them until you click review")
|
|
316
|
+
.option("-d, --daemon", "(deprecated no-op — polling and auto-review are now the default)")
|
|
317
|
+
.option("-i, --interval <minutes>", "poll interval (default: config daemon.intervalMinutes, 5)")
|
|
318
|
+
.option("-R, --repo <owner/repo>", "repo(s) to watch (repeatable; default: config daemon.repos, all)", collect, [])
|
|
319
|
+
.option("-P, --parallel <n>", "review concurrency (default: config daemon.parallel, 3)")
|
|
320
|
+
.option("-m, --model <model>", "Claude model override for unattended reviews")
|
|
321
|
+
.option("--no-source", "review the diff alone, with no local checkout of the PR head (applies to daemon runs and cockpit re-reviews)")
|
|
322
|
+
.option("--no-trust", "ignore the configured trust rules: every review here stays read-only, even for repos you trust")
|
|
219
323
|
.option("--auto-send", "ACTUALLY auto-send APPROVE verdicts at/above --auto-send-threshold (default: shadow mode, which only logs what would be sent)")
|
|
220
324
|
.option("--auto-send-threshold <pct>", "confidence needed to auto-send", "90")
|
|
221
325
|
.option("--insecure", "allow binding a non-localhost host without a token (NOT recommended)")
|
|
@@ -232,17 +336,46 @@ program
|
|
|
232
336
|
console.log(`⚠ auto-send is ON: APPROVE verdicts with confidence ≥ ${threshold}% will be sent to GitHub as you, without a per-review confirmation.\n` +
|
|
233
337
|
` Every decision is logged to ${cerberHome()}/autosend.ndjson. COMMENT/REQUEST_CHANGES always wait for a human.`);
|
|
234
338
|
}
|
|
235
|
-
|
|
339
|
+
if (opts.daemon) {
|
|
340
|
+
console.log("Note: --daemon is deprecated — polling and auto-review are on by default now.");
|
|
341
|
+
}
|
|
342
|
+
// Config holds the defaults and is re-read by the daemon on every poll
|
|
343
|
+
// (so the cockpit's Settings toggles apply live); a --no-* flag caps
|
|
344
|
+
// this run only, and --no-poll skips the loop entirely.
|
|
345
|
+
const config = await loadConfig();
|
|
346
|
+
const d = config.daemon;
|
|
347
|
+
// Trust means "run this PR's code". A human clicking review accepted that
|
|
348
|
+
// per PR; auto-review does it unattended, on whatever lands in the queue.
|
|
349
|
+
if (opts.poll && d.poll && opts.autoReview && d.autoReview && opts.trust && config.trust.length > 0) {
|
|
350
|
+
console.log(`⚠ ${config.trust.length} trust rule(s) in ${configPath()}: unattended reviews will run commands from ` +
|
|
351
|
+
`matching PRs, with nobody watching. Pass --no-trust to keep every review read-only, ` +
|
|
352
|
+
`or --no-auto-review to only review on click.`);
|
|
353
|
+
}
|
|
354
|
+
const daemon = opts.poll
|
|
236
355
|
? startDaemon({
|
|
237
|
-
repos: opts.repo,
|
|
238
|
-
intervalMs: Math.max(1, Number(opts.interval) ||
|
|
239
|
-
|
|
356
|
+
repos: opts.repo.length > 0 ? opts.repo : d.repos,
|
|
357
|
+
intervalMs: Math.max(1, opts.interval !== undefined ? Number(opts.interval) || d.intervalMinutes : d.intervalMinutes) *
|
|
358
|
+
60_000,
|
|
359
|
+
parallel: Math.max(1, opts.parallel !== undefined ? Number(opts.parallel) || d.parallel : d.parallel),
|
|
240
360
|
model: opts.model,
|
|
361
|
+
withSource: opts.source,
|
|
362
|
+
trust: opts.trust ? undefined : false,
|
|
363
|
+
autoReview: opts.autoReview,
|
|
241
364
|
autoSend: opts.autoSend ? "on" : "shadow",
|
|
242
365
|
autoSendThreshold: threshold,
|
|
243
366
|
})
|
|
244
367
|
: undefined;
|
|
245
|
-
|
|
368
|
+
if (!opts.poll) {
|
|
369
|
+
console.log("Polling is off (--no-poll) — the queue only shows what you review by hand.");
|
|
370
|
+
}
|
|
371
|
+
await startServer({
|
|
372
|
+
port: Number(opts.port),
|
|
373
|
+
host: opts.host,
|
|
374
|
+
token,
|
|
375
|
+
daemon,
|
|
376
|
+
withSource: opts.source,
|
|
377
|
+
trust: opts.trust ? undefined : false,
|
|
378
|
+
});
|
|
246
379
|
});
|
|
247
380
|
function collect(value, previous) {
|
|
248
381
|
return [...previous, value];
|
package/dist/core/artifact.js
CHANGED
|
@@ -62,6 +62,10 @@ export const RunInfoSchema = z.object({
|
|
|
62
62
|
finishedAt: z.string().nullable().default(null),
|
|
63
63
|
costUsd: z.number().nullable().default(null),
|
|
64
64
|
error: z.string().nullable().default(null),
|
|
65
|
+
/** The reviewer could read the repo at the PR head, not just the diff. */
|
|
66
|
+
withSource: z.boolean().default(false),
|
|
67
|
+
/** The user vouched for this PR, so the reviewer could also run commands. */
|
|
68
|
+
trusted: z.boolean().default(false),
|
|
65
69
|
});
|
|
66
70
|
export const SentInfoSchema = z.object({
|
|
67
71
|
at: z.string(),
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { cerberHome } from "./state.js";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
/**
|
|
8
|
+
* Config files a checked-out repo could use to make the reviewing agent run
|
|
9
|
+
* code. `claude -p` skips the workspace-trust prompt, so a PR that adds a hook
|
|
10
|
+
* here would otherwise execute on the reviewer's machine.
|
|
11
|
+
*/
|
|
12
|
+
export const AGENT_CONFIG_FILES = [
|
|
13
|
+
".claude/settings.json",
|
|
14
|
+
".claude/settings.local.json",
|
|
15
|
+
".mcp.json",
|
|
16
|
+
];
|
|
17
|
+
const QUARANTINE_SUFFIX = ".cerber-quarantined";
|
|
18
|
+
/**
|
|
19
|
+
* Auth for the fetch, passed per command and never written to the clone.
|
|
20
|
+
* Persisting it would leave a working push credential lying inside a directory
|
|
21
|
+
* a trusted review is about to run commands in.
|
|
22
|
+
*/
|
|
23
|
+
const FETCH_AUTH = ["-c", "credential.helper=!gh auth git-credential"];
|
|
24
|
+
const realDeps = {
|
|
25
|
+
git: async (args, cwd) => {
|
|
26
|
+
try {
|
|
27
|
+
const { stdout } = await execFileAsync("git", args, { cwd, maxBuffer: 16 * 1024 * 1024 });
|
|
28
|
+
return stdout;
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
const e = err;
|
|
32
|
+
throw new Error(`git ${args.slice(0, 2).join(" ")} failed: ${e.stderr?.trim() || e.message}`);
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
isRepo: (dir) => fs
|
|
36
|
+
.access(path.join(dir, ".git"))
|
|
37
|
+
.then(() => true)
|
|
38
|
+
.catch(() => false),
|
|
39
|
+
mkdir: async (dir) => {
|
|
40
|
+
await fs.mkdir(dir, { recursive: true });
|
|
41
|
+
},
|
|
42
|
+
quarantine: async (from, to) => {
|
|
43
|
+
try {
|
|
44
|
+
await fs.rename(from, to);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
if (err.code === "ENOENT")
|
|
49
|
+
return false;
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
touch: async (dir) => {
|
|
54
|
+
const now = new Date();
|
|
55
|
+
await fs.utimes(dir, now, now).catch(() => { });
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
/** One shallow working tree per PR, under ~/.cerber/src — a cache, safe to delete. */
|
|
59
|
+
export function sourceDir(ref) {
|
|
60
|
+
return path.join(cerberHome(), "src", `${ref.owner}__${ref.repo}__${ref.number}`);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A fresh empty directory for one review run: the cwd of a diff-only run, and
|
|
64
|
+
* the throwaway `GH_CONFIG_DIR` of every run.
|
|
65
|
+
*
|
|
66
|
+
* Per run, not shared, because both uses assume "empty" and a run can write.
|
|
67
|
+
* A trusted run has `Bash`; given one directory reused forever, it could leave
|
|
68
|
+
* a `CLAUDE.md` there that every later diff-only run would then load as its
|
|
69
|
+
* own memory. Delete it with {@link removeRunDir} when the run ends.
|
|
70
|
+
*/
|
|
71
|
+
export async function createRunDir() {
|
|
72
|
+
const root = path.join(cerberHome(), "run");
|
|
73
|
+
await fs.mkdir(root, { recursive: true });
|
|
74
|
+
await sweepStaleRunDirs(root);
|
|
75
|
+
return fs.mkdtemp(path.join(root, "run-"));
|
|
76
|
+
}
|
|
77
|
+
export async function removeRunDir(dir) {
|
|
78
|
+
await fs.rm(dir, { recursive: true, force: true }).catch(() => { });
|
|
79
|
+
}
|
|
80
|
+
/** A crashed run leaves its directory behind; don't accumulate them forever. */
|
|
81
|
+
async function sweepStaleRunDirs(root) {
|
|
82
|
+
const cutoff = Date.now() - 24 * 60 * 60_000;
|
|
83
|
+
const entries = await fs.readdir(root).catch(() => []);
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
if (!entry.startsWith("run-"))
|
|
86
|
+
continue;
|
|
87
|
+
const full = path.join(root, entry);
|
|
88
|
+
const stat = await fs.stat(full).catch(() => null);
|
|
89
|
+
if (stat && stat.mtimeMs < cutoff)
|
|
90
|
+
await removeRunDir(full);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Put the PR's head commit on disk so the reviewer can read the code around the
|
|
95
|
+
* diff. Fetches `refs/pull/N/head` from the base repo, which resolves fork PRs
|
|
96
|
+
* too, at depth 1 — cerber never needs the history, only the tree.
|
|
97
|
+
*
|
|
98
|
+
* Read-only towards GitHub. Auth rides `gh`'s credential helper, set locally on
|
|
99
|
+
* this clone so nothing in the user's git config is touched.
|
|
100
|
+
*/
|
|
101
|
+
export async function prepareCheckout(ref, opts = {}) {
|
|
102
|
+
const deps = { ...realDeps, ...opts.deps };
|
|
103
|
+
const log = opts.log ?? (() => { });
|
|
104
|
+
const dir = sourceDir(ref);
|
|
105
|
+
if (!(await deps.isRepo(dir))) {
|
|
106
|
+
log(`Preparing a source checkout in ${dir}…`);
|
|
107
|
+
await deps.mkdir(dir);
|
|
108
|
+
await deps.git(["init", "-q"], dir);
|
|
109
|
+
await deps.git(["remote", "add", "origin", `https://github.com/${ref.owner}/${ref.repo}.git`], dir);
|
|
110
|
+
}
|
|
111
|
+
// Clones made before the credential moved out of the config still carry one.
|
|
112
|
+
await deps.git(["config", "--unset-all", "credential.helper"], dir).catch(() => "");
|
|
113
|
+
await deps.git([...FETCH_AUTH, "fetch", "-q", "--depth=1", "--no-tags", "origin", `refs/pull/${ref.number}/head`], dir);
|
|
114
|
+
await deps.git(["checkout", "-q", "--force", "--detach", "FETCH_HEAD"], dir);
|
|
115
|
+
// A previous head may have left untracked files behind; they would read as
|
|
116
|
+
// part of this commit's tree. This also wipes any quarantine an earlier run
|
|
117
|
+
// left, which is why the loop below never has to undo one: every run starts
|
|
118
|
+
// from the commit's own files, whether or not the last one was trusted.
|
|
119
|
+
await deps.git(["clean", "-qfdx"], dir);
|
|
120
|
+
if (!opts.trusted) {
|
|
121
|
+
// Renamed rather than deleted: the reviewer can still read and comment on
|
|
122
|
+
// these files, but Claude Code won't load them as its own configuration.
|
|
123
|
+
for (const file of AGENT_CONFIG_FILES) {
|
|
124
|
+
const live = path.join(dir, file);
|
|
125
|
+
if (await deps.quarantine(live, live + QUARANTINE_SUFFIX)) {
|
|
126
|
+
log(`Quarantined ${file} from the checkout — PR config never configures the reviewer.`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const sha = (await deps.git(["rev-parse", "HEAD"], dir)).trim();
|
|
131
|
+
await deps.touch(dir);
|
|
132
|
+
return { dir, sha };
|
|
133
|
+
}
|
|
134
|
+
async function listCheckoutDirs() {
|
|
135
|
+
const root = path.join(cerberHome(), "src");
|
|
136
|
+
let entries;
|
|
137
|
+
try {
|
|
138
|
+
entries = await fs.readdir(root);
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
if (err.code === "ENOENT")
|
|
142
|
+
return [];
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
const found = [];
|
|
146
|
+
for (const entry of entries) {
|
|
147
|
+
const parsed = entry.match(/^(.+)__(.+)__(\d+)$/);
|
|
148
|
+
if (!parsed)
|
|
149
|
+
continue;
|
|
150
|
+
const dir = path.join(root, entry);
|
|
151
|
+
const usedAt = await fs.stat(dir).then((s) => s.mtimeMs, () => 0);
|
|
152
|
+
found.push({ id: `${parsed[1]}/${parsed[2]}#${parsed[3]}`, dir, usedAt });
|
|
153
|
+
}
|
|
154
|
+
return found;
|
|
155
|
+
}
|
|
156
|
+
/** Every checkout currently on disk, with what it costs. Walks each tree. */
|
|
157
|
+
export async function listCheckouts() {
|
|
158
|
+
const entries = await listCheckoutDirs();
|
|
159
|
+
const found = [];
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
found.push({ id: entry.id, dir: entry.dir, bytes: await dirSize(entry.dir) });
|
|
162
|
+
}
|
|
163
|
+
return found.sort((a, b) => b.bytes - a.bytes);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Checkouts are the default now, so the cache has to stop growing on its own —
|
|
167
|
+
* a daemon watching a busy repo would otherwise clone every PR it ever sees and
|
|
168
|
+
* keep them all. Drops the least recently used, and never one being reviewed.
|
|
169
|
+
*/
|
|
170
|
+
export const MAX_CACHED_CHECKOUTS = 8;
|
|
171
|
+
export async function evictOldCheckouts(opts = {}) {
|
|
172
|
+
const keep = opts.keep ?? MAX_CACHED_CHECKOUTS;
|
|
173
|
+
const inUse = opts.inUse ?? (() => false);
|
|
174
|
+
const entries = (await listCheckoutDirs()).sort((a, b) => b.usedAt - a.usedAt);
|
|
175
|
+
const evicted = [];
|
|
176
|
+
let kept = 0;
|
|
177
|
+
for (const entry of entries) {
|
|
178
|
+
if (inUse(entry.id) || kept < keep) {
|
|
179
|
+
kept++;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
await removeCheckout(entry.dir);
|
|
183
|
+
evicted.push(entry.id);
|
|
184
|
+
}
|
|
185
|
+
return evicted;
|
|
186
|
+
}
|
|
187
|
+
export async function removeCheckout(dir) {
|
|
188
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
189
|
+
}
|
|
190
|
+
async function dirSize(dir) {
|
|
191
|
+
let total = 0;
|
|
192
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
193
|
+
for (const entry of entries) {
|
|
194
|
+
const full = path.join(dir, entry.name);
|
|
195
|
+
if (entry.isDirectory())
|
|
196
|
+
total += await dirSize(full);
|
|
197
|
+
else if (entry.isFile())
|
|
198
|
+
total += await fs.stat(full).then((s) => s.size, () => 0);
|
|
199
|
+
}
|
|
200
|
+
return total;
|
|
201
|
+
}
|