@fullstackhouse/cerber 0.29.1 โ 0.31.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 +145 -80
- package/dist/cli/index.js +57 -2
- package/dist/core/artifact.js +26 -0
- package/dist/core/config.js +4 -3
- package/dist/core/gh.js +7 -2
- package/dist/core/notify.js +104 -15
- package/dist/core/preflight.js +82 -0
- package/dist/core/refresh.js +2 -0
- package/dist/core/revise.js +3 -0
- package/dist/runner/review.js +31 -1
- package/dist/server/daemon.js +101 -22
- package/dist/server/index.js +49 -6
- package/package.json +13 -1
- package/web/dist/assets/{index-D0STDydg.js โ index-LX8NvM54.js} +64 -64
- package/web/dist/index.html +1 -1
package/README.md
CHANGED
|
@@ -1,18 +1,38 @@
|
|
|
1
1
|
# cerber ๐
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@fullstackhouse/cerber)
|
|
4
|
+
[](https://github.com/fullstackhouse/cerber/actions/workflows/ci.yml)
|
|
4
5
|
[](./LICENSE)
|
|
5
6
|
|
|
6
|
-
**AI code-review cockpit.** Claude reviews your pull requests into local
|
|
7
|
-
a summary, a chaptered walkthrough of the changes, draft inline
|
|
8
|
-
verdict with a confidence score. You
|
|
7
|
+
**AI code-review cockpit.** Claude reviews your pull requests into local
|
|
8
|
+
artifacts โ a summary, a chaptered walkthrough of the changes, draft inline
|
|
9
|
+
comments, and a verdict with a confidence score. You read it in a local web
|
|
10
|
+
cockpit, keep or rewrite or drop each comment, then press Send when you agree
|
|
11
|
+
with it.
|
|
9
12
|
|
|
10
13
|
**Nothing reaches GitHub until you explicitly say so.** Cerber's only GitHub
|
|
11
14
|
write is the Send button (plus opt-in daemon auto-send you turn on yourself) โ
|
|
12
|
-
reviewing is 100% local and read-only.
|
|
15
|
+
reviewing is 100% local and read-only. Reviews go out under your own account,
|
|
16
|
+
as your review, because you decided each line of it should.
|
|
17
|
+
|
|
18
|
+
That gate is the whole point, and the reason this is not another review bot:
|
|
19
|
+
|
|
20
|
+
> Reviewing pull requests with an AI model creates a specific danger: a system
|
|
21
|
+
> that can both draft opinions and hold GitHub credentials will eventually post
|
|
22
|
+
> one without a human deciding it should.
|
|
13
23
|
|
|
14
24
|
Named after Cerberus, the gatekeeper: cerber guards what gets merged.
|
|
15
25
|
|
|
26
|
+
**A drafted review, as you read it** โ verdict and confidence beside the PR
|
|
27
|
+
title, the walkthrough rail on the left, a summary written for someone who has
|
|
28
|
+
not read the diff:
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+
|
|
32
|
+
**And the queue they arrive in** โ `cerber serve` polls GitHub for PRs awaiting
|
|
33
|
+
your review and drafts one for each, so they are written by the time you get
|
|
34
|
+
there:
|
|
35
|
+
|
|
16
36
|

|
|
17
37
|
|
|
18
38
|
*Every screenshot in this README is cerber reviewing cerber's own pull
|
|
@@ -20,11 +40,25 @@ requests.*
|
|
|
20
40
|
|
|
21
41
|
## Quick start
|
|
22
42
|
|
|
23
|
-
Requires Node 20+, an authenticated [`gh`](https://cli.github.com/) and
|
|
24
|
-
logged-in [`claude`](https://claude.com/claude-code) CLI. No API keys, no
|
|
43
|
+
Requires Node 20+, an authenticated [`gh`](https://cli.github.com/), `git`, and
|
|
44
|
+
a logged-in [`claude`](https://claude.com/claude-code) CLI. No API keys, no
|
|
45
|
+
config, no database.
|
|
46
|
+
|
|
47
|
+
**Claude Code is a hard requirement, and a paid one.** Cerber drafts reviews by
|
|
48
|
+
running `claude` as you: it rides your existing Claude Code login and draws on
|
|
49
|
+
that plan's usage limits, so you need a Claude subscription that includes Claude
|
|
50
|
+
Code. There is no `ANTHROPIC_API_KEY` path โ not as a fallback, not at all.
|
|
51
|
+
GitHub only; no GitLab or Bitbucket. Notifications are macOS and Linux.
|
|
52
|
+
|
|
53
|
+
Run `cerber doctor` first โ it checks all three tools and tells you which one to
|
|
54
|
+
fix, instead of letting a missing login turn into an empty cockpit later.
|
|
25
55
|
|
|
26
56
|
```bash
|
|
27
|
-
#
|
|
57
|
+
# Open the cockpit โ this is the whole product
|
|
58
|
+
npx @fullstackhouse/cerber serve # โ http://127.0.0.1:4820
|
|
59
|
+
|
|
60
|
+
# Or review one PR from the terminal (writes a local artifact to ~/.cerber,
|
|
61
|
+
# nothing else)
|
|
28
62
|
npx @fullstackhouse/cerber review https://github.com/owner/repo/pull/123
|
|
29
63
|
|
|
30
64
|
# Review several
|
|
@@ -33,22 +67,79 @@ npx @fullstackhouse/cerber review 123 124 --repo owner/repo
|
|
|
33
67
|
|
|
34
68
|
# See what's in the queue
|
|
35
69
|
npx @fullstackhouse/cerber list
|
|
36
|
-
|
|
37
|
-
# Open the cockpit โ it's an inbox: it polls GitHub for PRs awaiting your
|
|
38
|
-
# review and drafts a review for each, so they're ready when you arrive
|
|
39
|
-
npx @fullstackhouse/cerber serve # โ http://127.0.0.1:4820
|
|
40
70
|
```
|
|
41
71
|
|
|
42
72
|
`serve` does the whole loop by default: discover โ draft โ wait for you.
|
|
43
73
|
Sending stays a human click. Tame it with `--no-auto-review` (list awaiting
|
|
44
74
|
PRs, review on click) or `--no-poll` (no GitHub polling at all) โ or flip those
|
|
45
75
|
switches in the cockpit's Settings, which persist in `~/.cerber/config.json`
|
|
46
|
-
and apply on the next poll.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
76
|
+
and apply on the next poll.
|
|
77
|
+
|
|
78
|
+
## Contents
|
|
79
|
+
|
|
80
|
+
- [What a review looks like](#what-a-review-looks-like) โ what Claude writes and how you walk it
|
|
81
|
+
- [The inbox](#the-inbox) โ what the queue shows, what settles a row, the notifications
|
|
82
|
+
- [It reviews the code, not just the diff](#it-reviews-the-code-not-just-the-diff)
|
|
83
|
+
- [Trusted PRs: reviews that can run things](#trusted-prs-reviews-that-can-run-things)
|
|
84
|
+
- [When the PR moves under you](#when-the-pr-moves-under-you)
|
|
85
|
+
- [Arguing with the review before you send it](#arguing-with-the-review-before-you-send-it)
|
|
86
|
+
- [Every row remembers what happened to it](#every-row-remembers-what-happened-to-it)
|
|
87
|
+
- [Status](#status) ยท [Running on a VPS](#running-on-a-vps) ยท [How it works](#how-it-works) ยท [Development](#development)
|
|
88
|
+
|
|
89
|
+
## What a review looks like
|
|
90
|
+
|
|
91
|
+
Each review is a plain JSON artifact in `~/.cerber/reviews/` (override with
|
|
92
|
+
`CERBER_HOME`) containing:
|
|
93
|
+
|
|
94
|
+
- **Summary** โ what the PR actually does and why, written top-down
|
|
95
|
+
- **Chapters** โ the changed files grouped into a logical walkthrough, each
|
|
96
|
+
with a title, an explanation, and its slice of the diff
|
|
97
|
+
- **Draft comments** โ inline, anchored to file/line, only things worth a
|
|
98
|
+
human's time. Findings are graded **blocker / minor / nit** โ the words mean
|
|
99
|
+
what they mean everywhere, and only a blocker stands between the PR and
|
|
100
|
+
approval; a question or note carries no grade at all
|
|
101
|
+
- **Verdict** โ approve / comment / request changes, with a 0โ100 confidence
|
|
102
|
+
score and reasoning that names the worst finding still standing
|
|
103
|
+
- **Run metadata** โ model, whether it read the source or only the diff, and
|
|
104
|
+
the token spend as an API-rate equivalent
|
|
105
|
+
|
|
106
|
+
The cockpit (`cerber serve`) renders the queue and the per-PR walkthrough with
|
|
107
|
+
diffs. Draft comments sit inline in the diff, anchored to the line they're
|
|
108
|
+
about, each waiting for you to keep, rewrite, or drop it:
|
|
109
|
+
|
|
110
|
+

|
|
111
|
+
|
|
112
|
+
Any line of the diff is one click away from being talked about: hover it and a
|
|
113
|
+
`+` appears in the gutter, the way it does on GitHub. What opens is one box
|
|
114
|
+
with two exits โ write the comment yourself, or ask the reviewer about that
|
|
115
|
+
line and get the answer in the conversation below. A line the PR removes can be
|
|
116
|
+
asked about too; a comment on one posts on the file, since GitHub only takes
|
|
117
|
+
inline comments on the new side of a diff.
|
|
118
|
+
|
|
119
|
+
A markdown file the PR **adds** opens as the document it is, not as a thousand
|
|
120
|
+
rows of `+ ## Heading` โ because reviewing a new spec means reading it, and
|
|
121
|
+
every line being an addition means the diff's markers say nothing. It is still
|
|
122
|
+
the review: the draft comments sit in the document under the paragraph they
|
|
123
|
+
point at, and any paragraph takes a comment or a question where it stands. A
|
|
124
|
+
markdown file the PR only *edits* stays a diff โ there the change is the point
|
|
125
|
+
โ with "read as a document" in its header when you want the prose instead.
|
|
126
|
+
|
|
127
|
+
Artifacts are plain JSON you can `cat`, edit, or pipe into anything.
|
|
128
|
+
|
|
129
|
+
The queue is meant to be walked, not clicked through: `j`/`k` move the cursor
|
|
130
|
+
and the strip under the table explains whatever it lands on โ the verdict's own
|
|
131
|
+
reasoning, what the run read, how it sits against the auto-send bar โ so most
|
|
132
|
+
rows can be judged without opening them. `โต` opens one, `r` drafts (or
|
|
133
|
+
re-drafts) it. Inside a review, `[` and `]` walk to the previous/next PR still
|
|
134
|
+
awaiting you, `n` steps through the chapters, and `s` sends.
|
|
135
|
+
|
|
136
|
+
## The inbox
|
|
137
|
+
|
|
138
|
+
The queue only lists what still wants you: anything you have settled moves to
|
|
139
|
+
the filed tabs right of the thin rule in the filter row โ reviews you sent,
|
|
140
|
+
ones you marked reviewed or skipped, merged and closed PRs โ each tab appearing
|
|
141
|
+
only while it holds something. PRs in archived repos are never picked up at
|
|
142
|
+
all, since the repo is read-only and a review could never be sent.
|
|
52
143
|
|
|
53
144
|
### Settled means you settled it
|
|
54
145
|
|
|
@@ -88,13 +179,20 @@ who did is the one mistake worth designing against.
|
|
|
88
179
|
|
|
89
180
|
### It taps you on the shoulder
|
|
90
181
|
|
|
91
|
-
When a PR
|
|
92
|
-
into one popup when several
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
182
|
+
When a PR is ready for you, you get a desktop notification naming it, folded
|
|
183
|
+
into one popup when several land at once. Ready means *there is something to
|
|
184
|
+
read*: cerber drafts the review itself, so the tap waits for the draft and says
|
|
185
|
+
what it found โ "widgets#7 draft ready ยท requests changes ยท 2 blockers" โ rather
|
|
186
|
+
than walking you back to a row that says "no run yet". A PR nobody is going to
|
|
187
|
+
draft (auto-review off, or a run that failed) is announced as it arrives, since
|
|
188
|
+
that is all the news there will be โ and if a retry drafts it after all, that is
|
|
189
|
+
new news and you are told. Nothing else is: a PR whose draft you have been told
|
|
190
|
+
about stays quiet however its next re-review ends. The tap comes from the poll, so it reaches
|
|
191
|
+
you with the cockpit closed, in another Space, or after a browser restart โ
|
|
192
|
+
`cerber serve` is the only thing that has to be running. It goes to whatever
|
|
193
|
+
this machine already has (Notification Centre on macOS, `notify-send` on Linux)
|
|
194
|
+
and stays quiet where there is neither. Off with `daemon.notify` in
|
|
195
|
+
`~/.cerber/config.json`, or the checkbox in Settings.
|
|
98
196
|
|
|
99
197
|
**On macOS, clicking it opens that review** in the cockpit. It takes a small app
|
|
100
198
|
cerber builds once under `~/.cerber` โ the first tap asks you to allow
|
|
@@ -122,56 +220,7 @@ anything, the favicon wears a red dot, and it goes away when the last review is
|
|
|
122
220
|
dealt with. No permission, no switch โ just the paw with a mark on it, so a
|
|
123
221
|
glance at the tab strip answers "is there anything for me?".
|
|
124
222
|
|
|
125
|
-
##
|
|
126
|
-
|
|
127
|
-
Each review is a plain JSON artifact in `~/.cerber/reviews/` (override with
|
|
128
|
-
`CERBER_HOME`) containing:
|
|
129
|
-
|
|
130
|
-
- **Summary** โ what the PR actually does and why, written top-down
|
|
131
|
-
- **Chapters** โ the changed files grouped into a logical walkthrough, each
|
|
132
|
-
with a title, an explanation, and its slice of the diff
|
|
133
|
-
- **Draft comments** โ inline, anchored to file/line, only things worth a
|
|
134
|
-
human's time. Findings are graded **blocker / minor / nit** โ the words mean
|
|
135
|
-
what they mean everywhere, and only a blocker stands between the PR and
|
|
136
|
-
approval; a question or note carries no grade at all
|
|
137
|
-
- **Verdict** โ approve / comment / request changes, with a 0โ100 confidence
|
|
138
|
-
score and reasoning that names the worst finding still standing
|
|
139
|
-
- **Run metadata** โ model, whether it read the source or only the diff, and
|
|
140
|
-
the token spend as an API-rate equivalent
|
|
141
|
-
|
|
142
|
-

|
|
143
|
-
|
|
144
|
-
The cockpit (`cerber serve`) renders the queue and the per-PR walkthrough with
|
|
145
|
-
diffs. Draft comments sit inline in the diff, anchored to the line they're
|
|
146
|
-
about, each waiting for you to keep, rewrite, or drop it:
|
|
147
|
-
|
|
148
|
-

|
|
149
|
-
|
|
150
|
-
Any line of the diff is one click away from being talked about: hover it and a
|
|
151
|
-
`+` appears in the gutter, the way it does on GitHub. What opens is one box
|
|
152
|
-
with two exits โ write the comment yourself, or ask the reviewer about that
|
|
153
|
-
line and get the answer in the conversation below. A line the PR removes can be
|
|
154
|
-
asked about too; a comment on one posts on the file, since GitHub only takes
|
|
155
|
-
inline comments on the new side of a diff.
|
|
156
|
-
|
|
157
|
-
A markdown file the PR **adds** opens as the document it is, not as a thousand
|
|
158
|
-
rows of `+ ## Heading` โ because reviewing a new spec means reading it, and
|
|
159
|
-
every line being an addition means the diff's markers say nothing. It is still
|
|
160
|
-
the review: the draft comments sit in the document under the paragraph they
|
|
161
|
-
point at, and any paragraph takes a comment or a question where it stands. A
|
|
162
|
-
markdown file the PR only *edits* stays a diff โ there the change is the point
|
|
163
|
-
โ with "read as a document" in its header when you want the prose instead.
|
|
164
|
-
|
|
165
|
-
Artifacts are plain JSON you can `cat`, edit, or pipe into anything.
|
|
166
|
-
|
|
167
|
-
The queue is meant to be walked, not clicked through: `j`/`k` move the cursor
|
|
168
|
-
and the strip under the table explains whatever it lands on โ the verdict's own
|
|
169
|
-
reasoning, what the run read, how it sits against the auto-send bar โ so most
|
|
170
|
-
rows can be judged without opening them. `โต` opens one, `r` drafts (or
|
|
171
|
-
re-drafts) it. Inside a review, `[` and `]` walk to the previous/next PR still
|
|
172
|
-
awaiting you, `n` steps through the chapters, and `s` sends.
|
|
173
|
-
|
|
174
|
-
### It reviews the code, not just the diff
|
|
223
|
+
## It reviews the code, not just the diff
|
|
175
224
|
|
|
176
225
|
Cerber checks the PR's head out locally and lets the review read it. That
|
|
177
226
|
matters because a reviewer holding only a diff hedges over things it could have
|
|
@@ -228,7 +277,7 @@ Details worth knowing:
|
|
|
228
277
|
review is currently reading. `cerber prune` reclaims the space now;
|
|
229
278
|
`cerber prune --all` takes the ones for reviews still awaiting you too.
|
|
230
279
|
|
|
231
|
-
|
|
280
|
+
## Trusted PRs: reviews that can run things
|
|
232
281
|
|
|
233
282
|
Reading beats guessing, but running beats reading. A review that ran the test
|
|
234
283
|
covering the change reports what happened; one that only read it guesses. So
|
|
@@ -297,7 +346,7 @@ unattended by default: with trust rules set, it will run matching PRs' code
|
|
|
297
346
|
with nobody watching. It warns at startup, the cockpit shows a โก badge while
|
|
298
347
|
it's the case, and `--no-trust` (or `--no-auto-review`) turns it off.
|
|
299
348
|
|
|
300
|
-
|
|
349
|
+
## When the PR moves under you
|
|
301
350
|
|
|
302
351
|
A review is written against one commit, but authors keep pushing. Opening a
|
|
303
352
|
review checks the PR's head and pulls the review forward: comments follow their
|
|
@@ -308,10 +357,17 @@ commit's SHA, so inline comments land where they were written.
|
|
|
308
357
|
|
|
309
358
|
The AI's summary and verdict still describe the commit that was reviewed. To
|
|
310
359
|
get its opinion of the new code, hit **Re-review at the new head** in the
|
|
311
|
-
cockpit (or `cerber review <pr> --force`)
|
|
312
|
-
|
|
360
|
+
cockpit (or `cerber review <pr> --force`).
|
|
361
|
+
|
|
362
|
+
> โ ๏ธ **A re-review replaces the whole draft โ including comments you wrote or
|
|
363
|
+
> rewrote, and a send body you wrote yourself.** They are dropped when the run
|
|
364
|
+
> starts and they do not come back, on success or on failure. This is a
|
|
365
|
+
> decision, not a bug ([`docs/lifecycle.md`](docs/lifecycle.md)): the run owns
|
|
366
|
+
> the draft. If you have written comments you want to keep, send the review or
|
|
367
|
+
> copy them out first. Your *decisions* do survive โ a send, a `reviewed` or
|
|
368
|
+
> `skipped` you set, and the chat transcript are all kept.
|
|
313
369
|
|
|
314
|
-
|
|
370
|
+
## Arguing with the review before you send it
|
|
315
371
|
|
|
316
372
|
A draft you disagree with used to leave two options: rewrite it by hand, or
|
|
317
373
|
re-review and hope. Neither lets you say *what was wrong*. So every review has
|
|
@@ -369,7 +425,7 @@ the summary, comments and verdict alone, and is still one deliberate click. A
|
|
|
369
425
|
review that has already been sent can't be argued with: that artifact is the
|
|
370
426
|
record of what GitHub has.
|
|
371
427
|
|
|
372
|
-
|
|
428
|
+
## Every row remembers what happened to it
|
|
373
429
|
|
|
374
430
|
A review keeps one "last updated" time, which means every write erases the
|
|
375
431
|
answer to *when did I skip this, and did they ask again afterwards?* So each
|
|
@@ -448,6 +504,15 @@ pnpm build # dist/ (CLI+server) + web/dist (cockpit)
|
|
|
448
504
|
Web cockpit dev with hot reload: `pnpm dev serve` in one terminal,
|
|
449
505
|
`npx vite --config web/vite.config.ts` in another (proxies `/api`).
|
|
450
506
|
|
|
507
|
+
## Contributing and security
|
|
508
|
+
|
|
509
|
+
Patches welcome โ [`CONTRIBUTING.md`](./CONTRIBUTING.md) has the gate
|
|
510
|
+
(`pnpm typecheck && pnpm test`) and the one rule that is not negotiable.
|
|
511
|
+
|
|
512
|
+
Cerber holds your GitHub credentials and, on a trusted PR, runs that PR's code.
|
|
513
|
+
[`SECURITY.md`](./SECURITY.md) says exactly what that means, what is stripped
|
|
514
|
+
from a trusted run, and where to report a vulnerability privately.
|
|
515
|
+
|
|
451
516
|
## License
|
|
452
517
|
|
|
453
518
|
MIT ยฉ [Full Stack House](https://fullstack.house)
|
package/dist/cli/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
3
4
|
import readline from "node:readline/promises";
|
|
4
5
|
import { artifactId, artifactKey } from "../core/artifact.js";
|
|
5
6
|
import { listCheckouts, removeCheckout } from "../core/checkout.js";
|
|
6
7
|
import { toMarkdown } from "../core/export.js";
|
|
7
8
|
import { configPath, loadConfig, saveConfig } from "../core/config.js";
|
|
9
|
+
import { formatBlockers, isBlocking, preflight } from "../core/preflight.js";
|
|
8
10
|
import { withWriter } from "../core/history.js";
|
|
9
11
|
import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
|
|
10
12
|
import { parsePrRef, searchAwaitingMe, submitReview } from "../core/gh.js";
|
|
@@ -15,10 +17,34 @@ import { isReviewRunning } from "../runner/inflight.js";
|
|
|
15
17
|
import { startDaemon } from "../server/daemon.js";
|
|
16
18
|
import { cockpitUrl, startServer } from "../server/index.js";
|
|
17
19
|
const program = new Command();
|
|
20
|
+
/**
|
|
21
|
+
* The published version, read from the manifest rather than typed here โ a
|
|
22
|
+
* hand-maintained copy drifted to 0.5.0 while 0.30.0 shipped, and `--version`
|
|
23
|
+
* is the first thing anyone runs to decide whether a project is alive.
|
|
24
|
+
*/
|
|
25
|
+
const { version } = createRequire(import.meta.url)("../../package.json");
|
|
26
|
+
/**
|
|
27
|
+
* Stop before the work starts when a tool cerber shells out to is missing or
|
|
28
|
+
* logged out, rather than letting it surface as an empty queue or a retrying
|
|
29
|
+
* poll. `allowDegraded` is for `serve --no-poll`, which is still useful for
|
|
30
|
+
* reading artifacts already on disk.
|
|
31
|
+
*/
|
|
32
|
+
async function requireTools(allowDegraded = false) {
|
|
33
|
+
const checks = await preflight();
|
|
34
|
+
if (!checks.some(isBlocking))
|
|
35
|
+
return;
|
|
36
|
+
const message = formatBlockers(checks);
|
|
37
|
+
if (allowDegraded) {
|
|
38
|
+
console.warn(`\n${message}\n`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
console.error(`\n${message}\n`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
18
44
|
program
|
|
19
45
|
.name("cerber")
|
|
20
46
|
.description("AI code-review cockpit. Claude reviews PRs into local artifacts; nothing reaches GitHub until you explicitly send it.")
|
|
21
|
-
.version(
|
|
47
|
+
.version(version);
|
|
22
48
|
program
|
|
23
49
|
.command("review")
|
|
24
50
|
.description("Run an AI review of one or more PRs (local artifact only โ no GitHub writes)")
|
|
@@ -32,6 +58,7 @@ program
|
|
|
32
58
|
.option("-t, --trust", "let the review run commands in the checkout (tests, typecheck, git log) whatever the trust config says")
|
|
33
59
|
.option("--no-trust", "keep the review read-only even if a trust rule matches")
|
|
34
60
|
.action(async (prs, opts) => {
|
|
61
|
+
await requireTools();
|
|
35
62
|
let refs;
|
|
36
63
|
if (opts.awaitingMe) {
|
|
37
64
|
const found = await searchAwaitingMe(opts.repo);
|
|
@@ -349,6 +376,31 @@ program
|
|
|
349
376
|
`${wouldSend} shadow candidate(s), ${autoSent} auto-sent.`);
|
|
350
377
|
}
|
|
351
378
|
});
|
|
379
|
+
program
|
|
380
|
+
.command("doctor")
|
|
381
|
+
.description("Check that everything cerber needs is installed and logged in")
|
|
382
|
+
.action(async () => {
|
|
383
|
+
const checks = await preflight();
|
|
384
|
+
for (const c of checks) {
|
|
385
|
+
if (c.status === "ok") {
|
|
386
|
+
console.log(` โ ${c.name.padEnd(22)} ${c.detail ?? ""}`.trimEnd());
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
console.log(` โ ${c.name.padEnd(22)} ${c.status === "missing" ? "not installed" : "not logged in"}`);
|
|
390
|
+
if (c.fix)
|
|
391
|
+
console.log(` ${c.fix}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const bad = checks.filter(isBlocking);
|
|
395
|
+
console.log("");
|
|
396
|
+
if (bad.length === 0) {
|
|
397
|
+
console.log("Ready. `cerber serve` opens the cockpit.");
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
console.log(`${bad.length} thing(s) to fix before cerber can review anything.`);
|
|
401
|
+
process.exitCode = 1;
|
|
402
|
+
}
|
|
403
|
+
});
|
|
352
404
|
program
|
|
353
405
|
.command("serve", { isDefault: true })
|
|
354
406
|
.description("Start the review cockpit โ an inbox: it polls GitHub for PRs awaiting your review and drafts " +
|
|
@@ -358,7 +410,7 @@ program
|
|
|
358
410
|
.option("-t, --token <token>", "require this token on every request (env: CERBER_TOKEN)")
|
|
359
411
|
.option("--no-poll", "don't poll GitHub for PRs awaiting your review (also disables auto-review)")
|
|
360
412
|
.option("--no-auto-review", "list awaiting PRs but don't review them until you click review")
|
|
361
|
-
.option("--no-notify", "don't notify this machine when a
|
|
413
|
+
.option("--no-notify", "don't notify this machine when a review is ready for you")
|
|
362
414
|
.option("-d, --daemon", "(deprecated no-op โ polling and auto-review are now the default)")
|
|
363
415
|
.option("-i, --interval <minutes>", "poll interval (default: config daemon.intervalMinutes, 5)")
|
|
364
416
|
.option("-R, --repo <owner/repo>", "repo(s) to watch (repeatable; default: config daemon.repos, all)", collect, [])
|
|
@@ -377,6 +429,9 @@ program
|
|
|
377
429
|
`Pass --token <secret> (or CERBER_TOKEN), or --insecure if the network is trusted.`);
|
|
378
430
|
process.exit(1);
|
|
379
431
|
}
|
|
432
|
+
// Without `gh` the cockpit binds, polls into an error loop and shows an
|
|
433
|
+
// empty queue; `--no-poll` is the one shape that still works offline.
|
|
434
|
+
await requireTools(!opts.poll);
|
|
380
435
|
const threshold = Math.min(100, Math.max(50, Number(opts.autoSendThreshold) || 90));
|
|
381
436
|
if (opts.autoSend) {
|
|
382
437
|
console.log(`โ auto-send is ON: APPROVE verdicts with confidence โฅ ${threshold}% will be sent to GitHub as you, without a per-review confirmation.\n` +
|
package/dist/core/artifact.js
CHANGED
|
@@ -304,6 +304,32 @@ export const ArtifactSchema = z.object({
|
|
|
304
304
|
* field forward long after the decision it is meant to date.
|
|
305
305
|
*/
|
|
306
306
|
settledAt: z.string().nullable().default(null),
|
|
307
|
+
/**
|
|
308
|
+
* The announcement ledger: what this machine has already told you about this
|
|
309
|
+
* row โ see `pendingNews` in `src/core/notify.ts` for what is worth telling.
|
|
310
|
+
*
|
|
311
|
+
* Three states, not two. `null` is "the poll found this and owes you a tap";
|
|
312
|
+
* a record is "already told". **Absent** is neither: nobody ever meant to
|
|
313
|
+
* announce this row โ a review you pulled in by hand, or one written before
|
|
314
|
+
* the ledger existed โ so it is never news, and an upgrade doesn't announce
|
|
315
|
+
* a queue the user has been looking at for weeks. Only `stubArtifact` writes
|
|
316
|
+
* the null.
|
|
317
|
+
*
|
|
318
|
+
* It records *what* was said, not just that something was, because the two
|
|
319
|
+
* things cerber can say about a row are different news: "nobody is drafting
|
|
320
|
+
* this" and "here is the draft". A run that fails is announced as the first,
|
|
321
|
+
* is retried on the next poll, and may then succeed โ and a ledger that only
|
|
322
|
+
* remembered "told" would swallow the draft-ready tap, leaving the user with
|
|
323
|
+
* the one notification that had nothing to read behind it.
|
|
324
|
+
*/
|
|
325
|
+
notified: z
|
|
326
|
+
.object({
|
|
327
|
+
at: z.string(),
|
|
328
|
+
/** True when what was announced was a finished draft. */
|
|
329
|
+
drafted: z.boolean(),
|
|
330
|
+
})
|
|
331
|
+
.nullable()
|
|
332
|
+
.optional(),
|
|
307
333
|
/** Last time this review was pulled forward onto a newer head commit. */
|
|
308
334
|
refresh: RefreshInfoSchema.nullable().default(null),
|
|
309
335
|
calibration: CalibrationSchema.nullable().default(null),
|
package/dist/core/config.js
CHANGED
|
@@ -23,9 +23,10 @@ export const DaemonConfigSchema = z.object({
|
|
|
23
23
|
/** Draft an AI review for whatever lands in the queue. Never sends. */
|
|
24
24
|
autoReview: z.boolean().default(true),
|
|
25
25
|
/**
|
|
26
|
-
* Tap this machine's notification centre when a PR
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* Tap this machine's notification centre when a PR is worth coming back for
|
|
27
|
+
* โ the draft landing, or the arrival itself when nothing is going to be
|
|
28
|
+
* drafted (`isNews` in `core/notify.ts`). The cockpit's bell needs a tab open
|
|
29
|
+
* and permitted; this one only needs `serve` running, which is the point.
|
|
29
30
|
*/
|
|
30
31
|
notify: z.boolean().default(true),
|
|
31
32
|
intervalMinutes: z.number().int().positive().default(5),
|
package/dist/core/gh.js
CHANGED
|
@@ -68,8 +68,13 @@ export async function fetchPrInfo(ref) {
|
|
|
68
68
|
}
|
|
69
69
|
/**
|
|
70
70
|
* Org and team membership, for `@org/*` and `@org/team` trust rules. A 404 is
|
|
71
|
-
* GitHub's "no"
|
|
72
|
-
*
|
|
71
|
+
* GitHub's "no"; anything else (an outage, a rate limit) throws. Either way the
|
|
72
|
+
* answer is never "trusted", which is the property that matters.
|
|
73
|
+
*
|
|
74
|
+
* Note that a token without `read:org` also gets a 404 rather than a 403 โ so a
|
|
75
|
+
* missing scope reads as "not a member" and silently narrows trust instead of
|
|
76
|
+
* announcing itself. Fail-closed, but quiet: if a rule you expect to match
|
|
77
|
+
* doesn't, check the scope before the rule.
|
|
73
78
|
*/
|
|
74
79
|
export async function isOrgMember(org, login) {
|
|
75
80
|
return membershipCheck(["api", `orgs/${org}/members/${login}`, "--silent"]);
|
package/dist/core/notify.js
CHANGED
|
@@ -4,8 +4,16 @@
|
|
|
4
4
|
// one when it can ring: it names the PR and opens that review when you click
|
|
5
5
|
// it. But it is a page notification, so it needs a tab that is open, alive and
|
|
6
6
|
// permitted โ which is exactly what you don't have while you're in an editor
|
|
7
|
-
// all afternoon. This half rides the poll
|
|
8
|
-
//
|
|
7
|
+
// all afternoon. This half rides the poll instead, so the tap survives a closed
|
|
8
|
+
// cockpit, a denied permission and a browser restart.
|
|
9
|
+
//
|
|
10
|
+
// It taps you once per piece of news, at the moment that news is worth walking
|
|
11
|
+
// back for. With auto-review on โ the default โ that is when the draft is
|
|
12
|
+
// written, not when the PR lands: a tap that leads to "no run yet, press r" is
|
|
13
|
+
// a tap that costs you the walk back and gives you nothing. `isNews` decides
|
|
14
|
+
// whether there is news at all, `pendingNews` whether it is news you have
|
|
15
|
+
// already been told. A PR has at most two: "nobody is drafting this", and
|
|
16
|
+
// "here is the draft" โ and it only gets the second if it got the first.
|
|
9
17
|
//
|
|
10
18
|
// A click has to land in the same place either way, which on macOS is the whole
|
|
11
19
|
// difference between the two paths below. `osascript -e 'display notification'`
|
|
@@ -40,6 +48,22 @@ const execFileAsync = promisify(execFile);
|
|
|
40
48
|
*/
|
|
41
49
|
export const NOTIFY_TIMEOUT_MS = 5_000;
|
|
42
50
|
const slug = (a) => `${a.repo}#${a.number}`;
|
|
51
|
+
const VERDICT_WORDS = {
|
|
52
|
+
approve: "approves",
|
|
53
|
+
comment: "comments",
|
|
54
|
+
request_changes: "requests changes",
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* What a finished draft says, in the few words a popup gets: the verdict, and
|
|
58
|
+
* the count the verdict rests on. A draft that graded nothing and left no
|
|
59
|
+
* verdict still gets a line โ "drafted" is the news there.
|
|
60
|
+
*/
|
|
61
|
+
function draftLine(d) {
|
|
62
|
+
const verdict = d.recommendation ? VERDICT_WORDS[d.recommendation] : "drafted";
|
|
63
|
+
if (d.blockers === 0)
|
|
64
|
+
return verdict;
|
|
65
|
+
return `${verdict} ยท ${d.blockers} blocker${d.blockers === 1 ? "" : "s"}`;
|
|
66
|
+
}
|
|
43
67
|
/**
|
|
44
68
|
* Text a notification body or an AppleScript literal can actually hold. A PR
|
|
45
69
|
* title is somebody else's text and can carry anything; control characters
|
|
@@ -48,7 +72,7 @@ const slug = (a) => `${a.repo}#${a.number}`;
|
|
|
48
72
|
*/
|
|
49
73
|
const flatten = (value) => value.replace(/[\u0000-\u001f\u007f]+/g, " ");
|
|
50
74
|
/**
|
|
51
|
-
* One popup for one poll's
|
|
75
|
+
* One popup for one poll's news โ a batch is one interruption, not five.
|
|
52
76
|
* Deliberately the same shape and wording the cockpit's bell uses, so the two
|
|
53
77
|
* channels never read as two different pieces of news about one PR.
|
|
54
78
|
*
|
|
@@ -57,27 +81,92 @@ const flatten = (value) => value.replace(/[\u0000-\u001f\u007f]+/g, " ");
|
|
|
57
81
|
* origin with no fragment of its own โ the deep link is a fragment, and the
|
|
58
82
|
* cockpit is a hash-routed page.
|
|
59
83
|
*/
|
|
60
|
-
export function notice(
|
|
61
|
-
if (
|
|
84
|
+
export function notice(news, cockpit) {
|
|
85
|
+
if (news.length === 0)
|
|
62
86
|
return null;
|
|
63
87
|
const base = cockpit || null;
|
|
64
|
-
if (
|
|
65
|
-
const a =
|
|
88
|
+
if (news.length === 1) {
|
|
89
|
+
const a = news[0];
|
|
66
90
|
const key = artifactKey(artifactId(a));
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
91
|
+
const url = base && `${base}#/r/${encodeURIComponent(key)}`;
|
|
92
|
+
// The draft is the news when there is one, so the popup leads with what it
|
|
93
|
+
// found rather than with the PR having arrived โ which, by then, it did
|
|
94
|
+
// minutes ago.
|
|
95
|
+
if (a.draft)
|
|
96
|
+
return { title: `${slug(a)} draft ready`, body: `${draftLine(a.draft)} โ ${a.title}`, url };
|
|
97
|
+
return { title: `${slug(a)} awaits your review`, body: `${a.title} โ ${a.author}`, url };
|
|
72
98
|
}
|
|
73
|
-
const named =
|
|
74
|
-
const rest =
|
|
99
|
+
const named = news.slice(0, 3).map(slug);
|
|
100
|
+
const rest = news.length - named.length;
|
|
75
101
|
return {
|
|
76
|
-
|
|
102
|
+
// A drafted PR awaits you too, so the general wording is true of any batch;
|
|
103
|
+
// the better headline is only claimed when every one of them is drafted.
|
|
104
|
+
title: news.every((n) => n.draft) ? `${news.length} drafts ready` : `${news.length} PRs await your review`,
|
|
77
105
|
body: rest > 0 ? `${named.join(", ")} and ${rest} more` : named.join(", "),
|
|
78
106
|
url: base,
|
|
79
107
|
};
|
|
80
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Whether this row is news *yet*.
|
|
111
|
+
*
|
|
112
|
+
* The one rule behind the tap's timing, and the cockpit's bell mirrors it
|
|
113
|
+
* (`web/src/notify.ts`). A row cerber is about to draft, or is drafting, is not
|
|
114
|
+
* news: the tap would arrive minutes before there is anything to read, and the
|
|
115
|
+
* one after it would never come. So it waits โ unless nothing is coming, which
|
|
116
|
+
* is the case when auto-review is off (nobody will draft this until you say so)
|
|
117
|
+
* or the run failed (nobody will draft it at all).
|
|
118
|
+
*
|
|
119
|
+
* A row you have settled is not news either, whatever else happened to it: you
|
|
120
|
+
* already answered it.
|
|
121
|
+
*/
|
|
122
|
+
export function isNews(a, autoReview) {
|
|
123
|
+
if (a.pr.state !== "OPEN")
|
|
124
|
+
return false;
|
|
125
|
+
if (a.status === "running")
|
|
126
|
+
return false;
|
|
127
|
+
if (a.status === "awaiting")
|
|
128
|
+
return !autoReview;
|
|
129
|
+
return a.status === "ready" || a.status === "failed";
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* What this row has to say that it has not said already, or null for silence.
|
|
133
|
+
*
|
|
134
|
+
* `isNews` answers "is there news at all"; this one also weighs it against the
|
|
135
|
+
* ledger, which is where the two things cerber can say about a row stop being
|
|
136
|
+
* interchangeable. A run that failed is announced as "nobody is drafting this",
|
|
137
|
+
* is retried on the next poll, and may then succeed โ so the draft is news
|
|
138
|
+
* *again*, to someone who was told the opposite five minutes ago. The reverse
|
|
139
|
+
* is not: a row that had a draft and now has a broken re-review is a PR the
|
|
140
|
+
* user already knows about, and a second tap there is noise.
|
|
141
|
+
*/
|
|
142
|
+
export function pendingNews(a, autoReview) {
|
|
143
|
+
// Absent, not null: nobody ever meant to announce this row.
|
|
144
|
+
if (a.notified === undefined)
|
|
145
|
+
return null;
|
|
146
|
+
if (!isNews(a, autoReview))
|
|
147
|
+
return null;
|
|
148
|
+
const news = newsOf(a);
|
|
149
|
+
if (a.notified === null)
|
|
150
|
+
return news;
|
|
151
|
+
return news.draft && !a.notified.drafted ? news : null;
|
|
152
|
+
}
|
|
153
|
+
/** What that row would say, for the one notice a poll sends. */
|
|
154
|
+
export function newsOf(a) {
|
|
155
|
+
return {
|
|
156
|
+
// The click target is built from the ref, so the owner travels with it.
|
|
157
|
+
owner: a.pr.owner,
|
|
158
|
+
repo: a.pr.repo,
|
|
159
|
+
number: a.pr.number,
|
|
160
|
+
title: a.pr.title,
|
|
161
|
+
author: a.pr.author,
|
|
162
|
+
draft: a.status === "ready"
|
|
163
|
+
? {
|
|
164
|
+
recommendation: a.verdict?.recommendation ?? null,
|
|
165
|
+
blockers: a.comments.filter((c) => c.status !== "dropped" && c.severity === "blocker").length,
|
|
166
|
+
}
|
|
167
|
+
: null,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
81
170
|
/**
|
|
82
171
|
* A PR title, as an AppleScript string literal.
|
|
83
172
|
*
|