@fullstackhouse/cerber 0.8.0 → 0.9.1
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 +19 -10
- package/dist/cli/index.js +32 -18
- package/dist/core/config.js +21 -3
- package/dist/core/gh.js +11 -4
- package/dist/core/state.js +15 -0
- package/dist/server/daemon.js +209 -48
- package/dist/server/index.js +35 -5
- package/package.json +1 -1
- package/web/dist/assets/{index-DQG1BhG2.js → index-CsWW0EBv.js} +71 -71
- package/web/dist/assets/{index-D05bU4ds.css → index-Dmz5lQnG.css} +1 -1
- package/web/dist/index.html +2 -2
package/README.md
CHANGED
|
@@ -28,10 +28,19 @@ 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, and PRs in archived repos are never
|
|
42
|
+
picked up at all — the repo is read-only, so a review could never be sent.
|
|
43
|
+
|
|
35
44
|
## What a review looks like
|
|
36
45
|
|
|
37
46
|
Each review is a plain JSON artifact in `~/.cerber/reviews/` (override with
|
|
@@ -75,7 +84,7 @@ If you want the old behaviour — faster, lighter, blind past the changed lines:
|
|
|
75
84
|
|
|
76
85
|
```bash
|
|
77
86
|
cerber review owner/repo#123 --no-source
|
|
78
|
-
cerber serve --
|
|
87
|
+
cerber serve --no-source
|
|
79
88
|
```
|
|
80
89
|
|
|
81
90
|
The cockpit labels every review "read the full source" or "read the diff only",
|
|
@@ -171,9 +180,10 @@ the default is a review that cannot run anything at all.
|
|
|
171
180
|
machine — the branch's scripts, its dependencies, its test suite. That is the
|
|
172
181
|
same exposure as checking the branch out and running the tests yourself, which
|
|
173
182
|
is what you would otherwise do; it is not the same as reading a diff. Trust
|
|
174
|
-
orgs and people, not the whole of GitHub. And note
|
|
175
|
-
unattended: with trust rules set,
|
|
176
|
-
|
|
183
|
+
orgs and people, not the whole of GitHub. And note that `cerber serve` reviews
|
|
184
|
+
unattended by default: with trust rules set, it will run matching PRs' code
|
|
185
|
+
with nobody watching. It warns at startup, the cockpit shows a ⚡ badge while
|
|
186
|
+
it's the case, and `--no-trust` (or `--no-auto-review`) turns it off.
|
|
177
187
|
|
|
178
188
|
### When the PR moves under you
|
|
179
189
|
|
|
@@ -199,9 +209,8 @@ Early, but all six planned phases have shipped.
|
|
|
199
209
|
only GitHub write in the codebase) + `cerber export` to markdown
|
|
200
210
|
3. ✅ `--awaiting-me` discovery, parallel runs (`--parallel`, default 3),
|
|
201
211
|
freshness skip (same head SHA → no re-run; `--force` overrides), queue stats
|
|
202
|
-
4. ✅
|
|
203
|
-
review and reviews them automatically
|
|
204
|
-
non-localhost binds
|
|
212
|
+
4. ✅ Inbox mode (now the default): `cerber serve` polls for PRs awaiting your
|
|
213
|
+
review and reviews them automatically; token auth for non-localhost binds
|
|
205
214
|
5. ✅ Confidence calibration (`cerber stats`), shadow-mode auto-send (default
|
|
206
215
|
in daemon: logs what would be sent), opt-in `--auto-send` (approve-only,
|
|
207
216
|
`--auto-send-threshold`, default 90%)
|
|
@@ -212,11 +221,11 @@ Early, but all six planned phases have shipped.
|
|
|
212
221
|
## Running on a VPS
|
|
213
222
|
|
|
214
223
|
```bash
|
|
215
|
-
cerber serve --
|
|
224
|
+
cerber serve --host 0.0.0.0 --token "$(openssl rand -hex 16)" \
|
|
216
225
|
--repo you/repo-a --repo you/repo-b --interval 10
|
|
217
226
|
```
|
|
218
227
|
|
|
219
|
-
The
|
|
228
|
+
The server polls GitHub, reviews anything new (skipping PRs whose artifact
|
|
220
229
|
already matches the head SHA), and the cockpit is always warm — open it any
|
|
221
230
|
minute and see pending/ready/sent reviews. Auth: `?token=…` once in the
|
|
222
231
|
browser (sets a cookie) or `Authorization: Bearer …`. Binding a non-localhost
|
package/dist/cli/index.js
CHANGED
|
@@ -305,16 +305,19 @@ program
|
|
|
305
305
|
}
|
|
306
306
|
});
|
|
307
307
|
program
|
|
308
|
-
.command("serve")
|
|
309
|
-
.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.")
|
|
310
311
|
.option("-p, --port <port>", "port", "4820")
|
|
311
312
|
.option("-H, --host <host>", "host to bind (0.0.0.0 for VPS — requires --token)", "127.0.0.1")
|
|
312
313
|
.option("-t, --token <token>", "require this token on every request (env: CERBER_TOKEN)")
|
|
313
|
-
.option("-
|
|
314
|
-
.option("-
|
|
315
|
-
.option("-
|
|
316
|
-
.option("-
|
|
317
|
-
.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")
|
|
318
321
|
.option("--no-source", "review the diff alone, with no local checkout of the PR head (applies to daemon runs and cockpit re-reviews)")
|
|
319
322
|
.option("--no-trust", "ignore the configured trust rules: every review here stays read-only, even for repos you trust")
|
|
320
323
|
.option("--auto-send", "ACTUALLY auto-send APPROVE verdicts at/above --auto-send-threshold (default: shadow mode, which only logs what would be sent)")
|
|
@@ -333,27 +336,38 @@ program
|
|
|
333
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` +
|
|
334
337
|
` Every decision is logged to ${cerberHome()}/autosend.ndjson. COMMENT/REQUEST_CHANGES always wait for a human.`);
|
|
335
338
|
}
|
|
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;
|
|
336
347
|
// Trust means "run this PR's code". A human clicking review accepted that
|
|
337
|
-
// per PR;
|
|
338
|
-
if (opts.
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
`automatically, with nobody watching. Pass --no-trust to keep every review read-only.`);
|
|
343
|
-
}
|
|
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.`);
|
|
344
353
|
}
|
|
345
|
-
const daemon = opts.
|
|
354
|
+
const daemon = opts.poll
|
|
346
355
|
? startDaemon({
|
|
347
|
-
repos: opts.repo,
|
|
348
|
-
intervalMs: Math.max(1, Number(opts.interval) ||
|
|
349
|
-
|
|
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),
|
|
350
360
|
model: opts.model,
|
|
351
361
|
withSource: opts.source,
|
|
352
362
|
trust: opts.trust ? undefined : false,
|
|
363
|
+
autoReview: opts.autoReview,
|
|
353
364
|
autoSend: opts.autoSend ? "on" : "shadow",
|
|
354
365
|
autoSendThreshold: threshold,
|
|
355
366
|
})
|
|
356
367
|
: undefined;
|
|
368
|
+
if (!opts.poll) {
|
|
369
|
+
console.log("Polling is off (--no-poll) — the queue only shows what you review by hand.");
|
|
370
|
+
}
|
|
357
371
|
await startServer({
|
|
358
372
|
port: Number(opts.port),
|
|
359
373
|
host: opts.host,
|
package/dist/core/config.js
CHANGED
|
@@ -11,6 +11,22 @@ import { TrustRuleError, parseTrustRule } from "./trust.js";
|
|
|
11
11
|
* Rules stay one-line strings rather than objects: the same syntax you type at
|
|
12
12
|
* `cerber trust`, readable in a diff, and short enough to explain in a tooltip.
|
|
13
13
|
*/
|
|
14
|
+
/**
|
|
15
|
+
* The background loop `cerber serve` runs. Both knobs default ON — the cockpit
|
|
16
|
+
* is an inbox: open it and the PRs awaiting your review are already there,
|
|
17
|
+
* drafts and all. Flip them here (or in the cockpit's Settings), or per-run
|
|
18
|
+
* with `--no-poll` / `--no-auto-review`.
|
|
19
|
+
*/
|
|
20
|
+
export const DaemonConfigSchema = z.object({
|
|
21
|
+
/** Discover PRs awaiting your review and list them as `awaiting`. */
|
|
22
|
+
poll: z.boolean().default(true),
|
|
23
|
+
/** Draft an AI review for whatever lands in the queue. Never sends. */
|
|
24
|
+
autoReview: z.boolean().default(true),
|
|
25
|
+
intervalMinutes: z.number().int().positive().default(5),
|
|
26
|
+
parallel: z.number().int().positive().default(3),
|
|
27
|
+
/** Repos to watch (owner/repo). Empty = everything your gh account can see. */
|
|
28
|
+
repos: z.array(z.string()).default([]),
|
|
29
|
+
});
|
|
14
30
|
export const ConfigSchema = z.object({
|
|
15
31
|
/** Whose PRs may be reviewed by running code. See core/trust.ts for syntax. */
|
|
16
32
|
trust: z
|
|
@@ -30,8 +46,9 @@ export const ConfigSchema = z.object({
|
|
|
30
46
|
}
|
|
31
47
|
});
|
|
32
48
|
}),
|
|
49
|
+
daemon: DaemonConfigSchema.default({}),
|
|
33
50
|
});
|
|
34
|
-
export const DEFAULT_CONFIG = {
|
|
51
|
+
export const DEFAULT_CONFIG = ConfigSchema.parse({});
|
|
35
52
|
export function configPath() {
|
|
36
53
|
return path.join(cerberHome(), "config.json");
|
|
37
54
|
}
|
|
@@ -42,7 +59,7 @@ export async function loadConfig() {
|
|
|
42
59
|
throw err;
|
|
43
60
|
});
|
|
44
61
|
if (raw === null)
|
|
45
|
-
return {
|
|
62
|
+
return ConfigSchema.parse({});
|
|
46
63
|
try {
|
|
47
64
|
return ConfigSchema.parse(JSON.parse(raw));
|
|
48
65
|
}
|
|
@@ -64,7 +81,8 @@ function describeConfigError(err) {
|
|
|
64
81
|
return ` not JSON — ${err.message}`;
|
|
65
82
|
return ` ${err instanceof Error ? err.message : String(err)}`;
|
|
66
83
|
}
|
|
67
|
-
/** Write atomically — the cockpit and a `cerber trust` can both be writing.
|
|
84
|
+
/** Write atomically — the cockpit and a `cerber trust` can both be writing.
|
|
85
|
+
* Accepts schema input (defaults fill the gaps), so callers may omit blocks. */
|
|
68
86
|
export async function saveConfig(config) {
|
|
69
87
|
const file = configPath();
|
|
70
88
|
await fs.mkdir(cerberHome(), { recursive: true });
|
package/dist/core/gh.js
CHANGED
|
@@ -109,25 +109,32 @@ export function mapSearchResults(raw) {
|
|
|
109
109
|
url: r.url,
|
|
110
110
|
updatedAt: r.updatedAt,
|
|
111
111
|
isDraft: r.isDraft,
|
|
112
|
+
author: r.author?.login ?? "",
|
|
112
113
|
},
|
|
113
114
|
];
|
|
114
115
|
});
|
|
115
116
|
}
|
|
116
|
-
|
|
117
|
-
export async function searchAwaitingMe(repoFilter, limit = 50) {
|
|
117
|
+
export function searchAwaitingArgs(repoFilter, limit = 50) {
|
|
118
118
|
const args = [
|
|
119
119
|
"search",
|
|
120
120
|
"prs",
|
|
121
|
+
// An archived repo is read-only: its PRs can never merge, close, or take a
|
|
122
|
+
// review. Without this they'd sit in the inbox forever — nothing retires them.
|
|
123
|
+
"archived:false",
|
|
121
124
|
"--review-requested=@me",
|
|
122
125
|
"--state=open",
|
|
123
126
|
"--limit",
|
|
124
127
|
String(limit),
|
|
125
128
|
"--json",
|
|
126
|
-
"number,title,repository,isDraft,url,updatedAt",
|
|
129
|
+
"number,title,repository,isDraft,url,updatedAt,author",
|
|
127
130
|
];
|
|
128
131
|
if (repoFilter)
|
|
129
132
|
args.push("--repo", repoFilter);
|
|
130
|
-
|
|
133
|
+
return args;
|
|
134
|
+
}
|
|
135
|
+
/** Open, non-draft PRs where the current gh user's review is requested. */
|
|
136
|
+
export async function searchAwaitingMe(repoFilter, limit = 50) {
|
|
137
|
+
const out = await gh(searchAwaitingArgs(repoFilter, limit));
|
|
131
138
|
return mapSearchResults(JSON.parse(out));
|
|
132
139
|
}
|
|
133
140
|
/**
|
package/dist/core/state.js
CHANGED
|
@@ -67,6 +67,21 @@ export async function readAutoSendLog() {
|
|
|
67
67
|
throw err;
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Remove an artifact file. Only ever called on pure discovery stubs (status
|
|
72
|
+
* "awaiting", no comments) — anything a human or an AI run touched stays.
|
|
73
|
+
*/
|
|
74
|
+
export async function deleteArtifact(id) {
|
|
75
|
+
try {
|
|
76
|
+
await fs.unlink(artifactPath(id));
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
if (err.code === "ENOENT")
|
|
81
|
+
return false;
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
70
85
|
/** Load by key, apply a mutation, bump updatedAt, save. Returns the new artifact. */
|
|
71
86
|
export async function updateArtifactByKey(key, mutate) {
|
|
72
87
|
const artifact = await loadArtifactByKey(key);
|
package/dist/server/daemon.js
CHANGED
|
@@ -1,13 +1,63 @@
|
|
|
1
|
-
import { artifactKey } from "../core/artifact.js";
|
|
1
|
+
import { SCHEMA_VERSION, artifactId, artifactKey } from "../core/artifact.js";
|
|
2
2
|
import { evaluateAutoSend } from "../core/autosend.js";
|
|
3
|
-
import {
|
|
3
|
+
import { loadConfig } from "../core/config.js";
|
|
4
|
+
import { fetchPrInfo, searchAwaitingMe, submitReview } from "../core/gh.js";
|
|
4
5
|
import { buildReviewPayload, computeCalibration } from "../core/send.js";
|
|
5
|
-
import { appendAutoSendLog, updateArtifactByKey } from "../core/state.js";
|
|
6
|
+
import { appendAutoSendLog, deleteArtifact, listArtifacts, loadArtifact, saveArtifact, updateArtifactByKey, } from "../core/state.js";
|
|
6
7
|
import { ReviewInProgressError } from "../runner/inflight.js";
|
|
7
8
|
import { pool, reviewPr } from "../runner/review.js";
|
|
8
9
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* A pure discovery stub: enough artifact for the queue to show an awaiting row.
|
|
11
|
+
* The AI run that follows (automatic or clicked) replaces it wholesale.
|
|
12
|
+
*/
|
|
13
|
+
export function stubArtifact(ref) {
|
|
14
|
+
const now = new Date().toISOString();
|
|
15
|
+
return {
|
|
16
|
+
schemaVersion: SCHEMA_VERSION,
|
|
17
|
+
id: artifactId(ref),
|
|
18
|
+
status: "awaiting",
|
|
19
|
+
createdAt: now,
|
|
20
|
+
updatedAt: now,
|
|
21
|
+
pr: {
|
|
22
|
+
owner: ref.owner,
|
|
23
|
+
repo: ref.repo,
|
|
24
|
+
number: ref.number,
|
|
25
|
+
title: ref.title,
|
|
26
|
+
url: ref.url,
|
|
27
|
+
author: ref.author,
|
|
28
|
+
body: "",
|
|
29
|
+
baseRefName: "",
|
|
30
|
+
headRefName: "",
|
|
31
|
+
headSha: "",
|
|
32
|
+
state: "OPEN",
|
|
33
|
+
additions: 0,
|
|
34
|
+
deletions: 0,
|
|
35
|
+
changedFiles: 0,
|
|
36
|
+
},
|
|
37
|
+
diff: "",
|
|
38
|
+
summary: "",
|
|
39
|
+
chapters: [],
|
|
40
|
+
comments: [],
|
|
41
|
+
verdict: null,
|
|
42
|
+
run: null,
|
|
43
|
+
sent: null,
|
|
44
|
+
refresh: null,
|
|
45
|
+
calibration: null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** An artifact nothing has touched yet — safe to delete when its PR leaves the queue. */
|
|
49
|
+
export function isPureStub(artifact) {
|
|
50
|
+
return artifact.status === "awaiting" && artifact.comments.length === 0 && artifact.run === null;
|
|
51
|
+
}
|
|
52
|
+
/** How many open-PR artifacts to re-check against GitHub per poll, and how often each. */
|
|
53
|
+
const STATE_REFRESH_CAP = 10;
|
|
54
|
+
const STATE_REFRESH_MIN_MS = 30 * 60_000;
|
|
55
|
+
/**
|
|
56
|
+
* The inbox loop behind `cerber serve`: poll GitHub for PRs awaiting the
|
|
57
|
+
* user's review, list them as `awaiting`, and (unless auto-review is off)
|
|
58
|
+
* draft a review for each. Merged/closed PRs are archived out of the queue.
|
|
59
|
+
* Reviews stay local; the daemon NEVER sends anything except via the
|
|
60
|
+
* explicitly opted-in auto-send path.
|
|
11
61
|
*/
|
|
12
62
|
export function startDaemon(opts) {
|
|
13
63
|
let timer = null;
|
|
@@ -17,70 +67,181 @@ export function startDaemon(opts) {
|
|
|
17
67
|
polling: false,
|
|
18
68
|
repos: opts.repos,
|
|
19
69
|
intervalMs: opts.intervalMs,
|
|
70
|
+
pollEnabled: true,
|
|
71
|
+
autoReview: opts.autoReview,
|
|
72
|
+
trustedRuns: false,
|
|
20
73
|
polls: 0,
|
|
21
74
|
errors: 0,
|
|
22
75
|
lastPollAt: null,
|
|
23
76
|
nextPollAt: new Date(Date.now()).toISOString(),
|
|
24
77
|
lastSummary: null,
|
|
78
|
+
lastPollError: null,
|
|
25
79
|
autoSend: opts.autoSend,
|
|
26
80
|
autoSendThreshold: opts.autoSendThreshold,
|
|
27
81
|
autoSent: 0,
|
|
28
82
|
autoSendCandidates: 0,
|
|
29
83
|
};
|
|
30
84
|
const log = (m) => console.log(`[daemon] ${m}`);
|
|
85
|
+
/** When GitHub last confirmed an artifact's PR state, by artifact id. In-memory:
|
|
86
|
+
* a restart just re-checks sooner, which is harmless. */
|
|
87
|
+
const stateCheckedAt = new Map();
|
|
88
|
+
async function discover() {
|
|
89
|
+
const filters = opts.repos.length > 0 ? opts.repos : [undefined];
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
return (await Promise.all(filters.map((repo) => searchAwaitingMe(repo))))
|
|
92
|
+
.flat()
|
|
93
|
+
.filter((r) => {
|
|
94
|
+
const id = artifactId(r);
|
|
95
|
+
if (seen.has(id))
|
|
96
|
+
return false;
|
|
97
|
+
seen.add(id);
|
|
98
|
+
return true;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Make the local queue mirror what's actually awaiting: stub artifacts for
|
|
103
|
+
* newly discovered PRs, archive merged/closed ones, drop stubs whose review
|
|
104
|
+
* request went away. Freshness decisions always come from local artifacts —
|
|
105
|
+
* search results lag GitHub by minutes and never override them.
|
|
106
|
+
*/
|
|
107
|
+
async function syncQueue(refs) {
|
|
108
|
+
let discovered = 0;
|
|
109
|
+
for (const ref of refs) {
|
|
110
|
+
if (await loadArtifact(artifactId(ref)))
|
|
111
|
+
continue;
|
|
112
|
+
await saveArtifact(stubArtifact(ref));
|
|
113
|
+
discovered++;
|
|
114
|
+
}
|
|
115
|
+
const awaitingIds = new Set(refs.map((r) => artifactId(r)));
|
|
116
|
+
let refreshed = 0;
|
|
117
|
+
for (const artifact of await listArtifacts()) {
|
|
118
|
+
if (artifact.status === "running")
|
|
119
|
+
continue;
|
|
120
|
+
if (awaitingIds.has(artifact.id))
|
|
121
|
+
continue;
|
|
122
|
+
if (artifact.pr.state !== "OPEN")
|
|
123
|
+
continue;
|
|
124
|
+
if (isPureStub(artifact)) {
|
|
125
|
+
// Discovered earlier, gone from the search now: merged/closed (archive
|
|
126
|
+
// it), or the request was withdrawn / handled elsewhere (drop it — it
|
|
127
|
+
// holds no work, and search lag at worst re-creates it next poll).
|
|
128
|
+
try {
|
|
129
|
+
const pr = await fetchPrInfo(artifact.pr);
|
|
130
|
+
if (pr.state !== "OPEN") {
|
|
131
|
+
await updateArtifactByKey(artifactKey(artifact.id), (a) => ({ ...a, pr }));
|
|
132
|
+
log(`[${artifact.id}] ${pr.state.toLowerCase()} — archived`);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
await deleteArtifact(artifact.id);
|
|
136
|
+
log(`[${artifact.id}] no longer awaiting your review — removed from the queue`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// GitHub unreachable — leave the stub; next poll retries.
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
// Anything with real work in it: periodically confirm the PR is still
|
|
145
|
+
// open, so merged/closed ones stop cluttering the queue. Capped and
|
|
146
|
+
// spaced out — this is book-keeping, not a data feed.
|
|
147
|
+
if (refreshed >= STATE_REFRESH_CAP)
|
|
148
|
+
continue;
|
|
149
|
+
const last = stateCheckedAt.get(artifact.id) ?? 0;
|
|
150
|
+
if (Date.now() - last < STATE_REFRESH_MIN_MS)
|
|
151
|
+
continue;
|
|
152
|
+
refreshed++;
|
|
153
|
+
stateCheckedAt.set(artifact.id, Date.now());
|
|
154
|
+
try {
|
|
155
|
+
const pr = await fetchPrInfo(artifact.pr);
|
|
156
|
+
if (pr.state !== "OPEN") {
|
|
157
|
+
await updateArtifactByKey(artifactKey(artifact.id), (a) => ({
|
|
158
|
+
...a,
|
|
159
|
+
pr: { ...a.pr, state: pr.state },
|
|
160
|
+
}));
|
|
161
|
+
log(`[${artifact.id}] ${pr.state.toLowerCase()} — archived`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// Same: book-keeping can wait for the next poll.
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { discovered };
|
|
169
|
+
}
|
|
170
|
+
async function reviewAll(refs) {
|
|
171
|
+
let reviewed = 0;
|
|
172
|
+
let skipped = 0;
|
|
173
|
+
let failed = 0;
|
|
174
|
+
await pool(refs, opts.parallel, async (ref) => {
|
|
175
|
+
const label = artifactId(ref);
|
|
176
|
+
try {
|
|
177
|
+
const result = await reviewPr(ref, {
|
|
178
|
+
model: opts.model,
|
|
179
|
+
withSource: opts.withSource,
|
|
180
|
+
trust: opts.trust,
|
|
181
|
+
onProgress: (m) => log(`[${label}] ${m}`),
|
|
182
|
+
});
|
|
183
|
+
if (result.skipped)
|
|
184
|
+
skipped++;
|
|
185
|
+
else {
|
|
186
|
+
reviewed++;
|
|
187
|
+
await handleAutoSend(result.artifact);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
if (err instanceof ReviewInProgressError) {
|
|
192
|
+
// The cockpit (or an earlier, slower poll) is already on it.
|
|
193
|
+
skipped++;
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
failed++;
|
|
197
|
+
status.errors++;
|
|
198
|
+
log(`[${label}] failed: ${err instanceof Error ? err.message : err}`);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
return { reviewed, skipped, failed };
|
|
202
|
+
}
|
|
31
203
|
async function poll() {
|
|
32
204
|
if (status.polling)
|
|
33
205
|
return;
|
|
34
206
|
status.polling = true;
|
|
35
207
|
status.lastPollAt = new Date().toISOString();
|
|
36
208
|
try {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
catch (err) {
|
|
68
|
-
if (err instanceof ReviewInProgressError) {
|
|
69
|
-
// The cockpit (or an earlier, slower poll) is already on it.
|
|
70
|
-
skipped++;
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
failed++;
|
|
74
|
-
status.errors++;
|
|
75
|
-
log(`[${label}] failed: ${err instanceof Error ? err.message : err}`);
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
status.lastSummary = `${refs.length} awaiting; ${reviewed} reviewed, ${skipped} up to date${failed > 0 ? `, ${failed} failed` : ""}`;
|
|
209
|
+
// The config is re-read every poll, so the cockpit's Settings toggles
|
|
210
|
+
// take effect on the next tick — no restart. CLI --no-* flags cap it.
|
|
211
|
+
// loadConfig is strict on purpose (a typo must not silently change trust
|
|
212
|
+
// or the knobs), so a broken file skips the tick loudly instead of
|
|
213
|
+
// proceeding on guessed defaults.
|
|
214
|
+
const config = await loadConfig();
|
|
215
|
+
const knobs = config.daemon;
|
|
216
|
+
const autoReview = opts.autoReview && knobs.autoReview;
|
|
217
|
+
status.pollEnabled = knobs.poll;
|
|
218
|
+
status.autoReview = autoReview;
|
|
219
|
+
// Recomputed too, so a trust rule added mid-run shows up in the strip.
|
|
220
|
+
status.trustedRuns = autoReview && opts.trust !== false && config.trust.length > 0;
|
|
221
|
+
if (!knobs.poll) {
|
|
222
|
+
// Deliberately off is not an outage — don't leave a stale error up.
|
|
223
|
+
status.lastPollError = null;
|
|
224
|
+
status.lastSummary = "polling off (settings) — the queue only shows what you review by hand";
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const refs = await discover();
|
|
228
|
+
status.lastPollError = null;
|
|
229
|
+
const { discovered } = await syncQueue(refs);
|
|
230
|
+
if (autoReview) {
|
|
231
|
+
const { reviewed, skipped, failed } = await reviewAll(refs);
|
|
232
|
+
status.lastSummary =
|
|
233
|
+
`${refs.length} awaiting; ${reviewed} reviewed, ${skipped} up to date` +
|
|
234
|
+
`${failed > 0 ? `, ${failed} failed` : ""}`;
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
status.lastSummary = `${refs.length} awaiting${discovered > 0 ? `, ${discovered} new` : ""} (auto-review off)`;
|
|
238
|
+
}
|
|
79
239
|
log(status.lastSummary);
|
|
80
240
|
}
|
|
81
241
|
catch (err) {
|
|
82
242
|
status.errors++;
|
|
83
|
-
status.
|
|
243
|
+
status.lastPollError = err instanceof Error ? err.message : String(err);
|
|
244
|
+
status.lastSummary = `poll failed: ${status.lastPollError}`;
|
|
84
245
|
log(status.lastSummary);
|
|
85
246
|
}
|
|
86
247
|
finally {
|
package/dist/server/index.js
CHANGED
|
@@ -9,7 +9,8 @@ import { randomUUID } from "node:crypto";
|
|
|
9
9
|
import { ArtifactStatusSchema, VerdictSchema, artifactKey } from "../core/artifact.js";
|
|
10
10
|
import { toMarkdown } from "../core/export.js";
|
|
11
11
|
import { fetchPrDiff, fetchPrInfo, submitReview } from "../core/gh.js";
|
|
12
|
-
import {
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { DaemonConfigSchema, configPath, loadConfig, saveConfig } from "../core/config.js";
|
|
13
14
|
import { refreshArtifact } from "../core/refresh.js";
|
|
14
15
|
import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
|
|
15
16
|
import { buildReviewPayload, computeCalibration } from "../core/send.js";
|
|
@@ -51,12 +52,35 @@ export async function buildApp(opts) {
|
|
|
51
52
|
app.get("/api/config", async (c) => {
|
|
52
53
|
try {
|
|
53
54
|
const config = await loadConfig();
|
|
54
|
-
return c.json({ path: configPath(), trust: trustView(config.trust) });
|
|
55
|
+
return c.json({ path: configPath(), trust: trustView(config.trust), daemon: config.daemon });
|
|
55
56
|
}
|
|
56
57
|
catch (err) {
|
|
57
58
|
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
58
59
|
}
|
|
59
60
|
});
|
|
61
|
+
// The inbox knobs. The daemon re-reads the config every poll, so a toggle
|
|
62
|
+
// here applies on the next tick without restarting serve.
|
|
63
|
+
app.post("/api/config/daemon", async (c) => {
|
|
64
|
+
let body;
|
|
65
|
+
try {
|
|
66
|
+
body = await c.req.json();
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return c.json({ error: "body must be JSON" }, 400);
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const config = await loadConfig();
|
|
73
|
+
const daemon = DaemonConfigSchema.parse({ ...config.daemon, ...body });
|
|
74
|
+
await saveConfig({ ...config, daemon });
|
|
75
|
+
return c.json({ path: configPath(), trust: trustView(config.trust), daemon });
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
if (err instanceof z.ZodError) {
|
|
79
|
+
return c.json({ error: err.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ") }, 400);
|
|
80
|
+
}
|
|
81
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
60
84
|
app.post("/api/config/trust", async (c) => {
|
|
61
85
|
let body;
|
|
62
86
|
try {
|
|
@@ -90,7 +114,9 @@ export async function buildApp(opts) {
|
|
|
90
114
|
});
|
|
91
115
|
const trust = remove === true ? without : [...without, canonical];
|
|
92
116
|
await saveConfig({ ...config, trust });
|
|
93
|
-
|
|
117
|
+
// Full ConfigView — the cockpit replaces its config state with this
|
|
118
|
+
// wholesale, so omitting daemon would crash the Settings toggles.
|
|
119
|
+
return c.json({ path: configPath(), trust: trustView(trust), daemon: config.daemon });
|
|
94
120
|
}
|
|
95
121
|
catch (err) {
|
|
96
122
|
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
@@ -110,6 +136,7 @@ export async function buildApp(opts) {
|
|
|
110
136
|
owner: a.pr.owner,
|
|
111
137
|
repo: a.pr.repo,
|
|
112
138
|
number: a.pr.number,
|
|
139
|
+
state: a.pr.state,
|
|
113
140
|
additions: a.pr.additions,
|
|
114
141
|
deletions: a.pr.deletions,
|
|
115
142
|
changedFiles: a.pr.changedFiles,
|
|
@@ -360,7 +387,10 @@ export async function startServer(opts) {
|
|
|
360
387
|
serve({ fetch: app.fetch, port: opts.port, hostname: opts.host }, (info) => {
|
|
361
388
|
const tokenHint = opts.token ? `/?token=${opts.token}` : "";
|
|
362
389
|
console.log(`cerber cockpit: http://${opts.host}:${info.port}${tokenHint}`);
|
|
363
|
-
if (opts.daemon)
|
|
364
|
-
|
|
390
|
+
if (opts.daemon) {
|
|
391
|
+
const s = opts.daemon.status();
|
|
392
|
+
console.log(`inbox: polling every ${Math.round(s.intervalMs / 60_000)}m` +
|
|
393
|
+
`${s.autoReview ? ", drafting a review for each PR awaiting you" : " (auto-review off — click review in the queue)"}`);
|
|
394
|
+
}
|
|
365
395
|
});
|
|
366
396
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fullstackhouse/cerber",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
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
|
"repository": "github:fullstackhouse/cerber",
|