@fullstackhouse/cerber 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -12
- package/dist/cli/index.js +2 -0
- package/dist/core/artifact.js +22 -7
- package/dist/core/config.js +6 -0
- package/dist/core/gh.js +49 -1
- package/dist/core/notify.js +105 -0
- package/dist/runner/review.js +23 -5
- package/dist/server/daemon.js +153 -33
- package/package.json +1 -1
- package/web/dist/assets/{index-DvH4pObZ.js → index-DFhsdnD2.js} +64 -64
- package/web/dist/assets/{index-BG0S9CJ-.css → index-VLT71mrB.css} +1 -1
- package/web/dist/index.html +2 -2
package/README.md
CHANGED
|
@@ -41,9 +41,9 @@ npx @fullstackhouse/cerber serve # → http://127.0.0.1:4820
|
|
|
41
41
|
|
|
42
42
|
`serve` does the whole loop by default: discover → draft → wait for you.
|
|
43
43
|
Sending stays a human click. Tame it with `--no-auto-review` (list awaiting
|
|
44
|
-
PRs, review on click) or `--no-poll` (no GitHub polling at all) — or flip
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
PRs, review on click) or `--no-poll` (no GitHub polling at all) — or flip those
|
|
45
|
+
switches in the cockpit's Settings, which persist in `~/.cerber/config.json`
|
|
46
|
+
and apply on the next poll. The queue only lists what
|
|
47
47
|
still wants you: anything you have settled moves to the filed tabs right of
|
|
48
48
|
the thin rule in the filter row — reviews you sent, ones you marked reviewed
|
|
49
49
|
or skipped, merged and closed PRs — each tab appearing only while it holds
|
|
@@ -88,15 +88,28 @@ who did is the one mistake worth designing against.
|
|
|
88
88
|
|
|
89
89
|
### It taps you on the shoulder
|
|
90
90
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
91
|
+
When a PR lands in the queue you get a desktop notification naming it, folded
|
|
92
|
+
into one popup when several arrive at once. The tap comes from the poll that
|
|
93
|
+
found the PR, so it reaches you with the cockpit closed, in another Space, or
|
|
94
|
+
after a browser restart — `cerber serve` is the only thing that has to be
|
|
95
|
+
running. It goes to whatever this machine already has (Notification Centre on
|
|
96
|
+
macOS, `notify-send` on Linux) and stays quiet where there is neither. Off with
|
|
97
|
+
`daemon.notify` in `~/.cerber/config.json`, or the checkbox in Settings.
|
|
98
|
+
|
|
99
|
+
The cockpit has a bell of its own, and it is the better one where it can ring:
|
|
100
|
+
its notification **opens that review when you click it**. But it is a page
|
|
101
|
+
notification — it needs a tab that is open, alive and permitted, which is
|
|
102
|
+
exactly what you don't have on the afternoons this feature is for. So it stands
|
|
103
|
+
down while the machine's own tap is on, rather than making one PR two popups,
|
|
104
|
+
and the bell in the top bar says so. Untick the machine one and this browser
|
|
105
|
+
takes over: click the bell, grant the permission, and click it again to stop.
|
|
106
|
+
The switch and the record of what has been announced are per-browser — the
|
|
107
|
+
permission they gate is the browser's, so a second browser starts its own
|
|
108
|
+
record — and it stays quiet while you're looking straight at cerber.
|
|
109
|
+
|
|
110
|
+
A cockpit served from somewhere else (`cerber serve -H 0.0.0.0 --token` on a
|
|
111
|
+
VPS) keeps its bell either way: the machine's tap would land on the VPS, where
|
|
112
|
+
nobody is looking.
|
|
100
113
|
|
|
101
114
|
The tab itself says it too, without asking anyone: while the inbox holds
|
|
102
115
|
anything, the favicon wears a red dot, and it goes away when the last review is
|
package/dist/cli/index.js
CHANGED
|
@@ -313,6 +313,7 @@ program
|
|
|
313
313
|
.option("-t, --token <token>", "require this token on every request (env: CERBER_TOKEN)")
|
|
314
314
|
.option("--no-poll", "don't poll GitHub for PRs awaiting your review (also disables auto-review)")
|
|
315
315
|
.option("--no-auto-review", "list awaiting PRs but don't review them until you click review")
|
|
316
|
+
.option("--no-notify", "don't notify this machine when a PR lands in the queue")
|
|
316
317
|
.option("-d, --daemon", "(deprecated no-op — polling and auto-review are now the default)")
|
|
317
318
|
.option("-i, --interval <minutes>", "poll interval (default: config daemon.intervalMinutes, 5)")
|
|
318
319
|
.option("-R, --repo <owner/repo>", "repo(s) to watch (repeatable; default: config daemon.repos, all)", collect, [])
|
|
@@ -361,6 +362,7 @@ program
|
|
|
361
362
|
withSource: opts.source,
|
|
362
363
|
trust: opts.trust ? undefined : false,
|
|
363
364
|
autoReview: opts.autoReview,
|
|
365
|
+
notify: opts.notify,
|
|
364
366
|
autoSend: opts.autoSend ? "on" : "shadow",
|
|
365
367
|
autoSendThreshold: threshold,
|
|
366
368
|
})
|
package/dist/core/artifact.js
CHANGED
|
@@ -108,19 +108,34 @@ export const SentInfoSchema = z.object({
|
|
|
108
108
|
/**
|
|
109
109
|
* Why a draft was filed away without ever being sent.
|
|
110
110
|
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
111
|
+
* All three causes are the same shape of fact: the inbox was still holding a
|
|
112
|
+
* row out for you that GitHub had already moved past. You reviewed the PR
|
|
113
|
+
* through GitHub itself; you answered in the conversation and nobody has
|
|
114
|
+
* answered back; or whoever asked for the review took the request away. It is
|
|
115
|
+
* a record, not a decision to defend — the review is untouched, still openable
|
|
116
|
+
* and still sendable.
|
|
117
|
+
*
|
|
118
|
+
* `reason` defaults to the one that shipped first, so artifacts written before
|
|
119
|
+
* the other two existed load unchanged and keep saying what they always said.
|
|
115
120
|
*/
|
|
121
|
+
export const FiledReasonSchema = z.enum(["own-review", "own-reply", "request-withdrawn"]);
|
|
116
122
|
export const FiledInfoSchema = z.object({
|
|
117
123
|
at: z.string(),
|
|
118
|
-
|
|
119
|
-
review
|
|
124
|
+
reason: FiledReasonSchema.default("own-review"),
|
|
125
|
+
/** The review of yours GitHub had. Set when `reason` is "own-review". */
|
|
126
|
+
review: z
|
|
127
|
+
.object({
|
|
120
128
|
at: z.string(),
|
|
121
129
|
state: z.enum(["APPROVED", "CHANGES_REQUESTED", "COMMENTED"]),
|
|
122
130
|
url: z.string().nullable().default(null),
|
|
123
|
-
})
|
|
131
|
+
})
|
|
132
|
+
.nullable()
|
|
133
|
+
.default(null),
|
|
134
|
+
/** The comment that gave you the last word. Set when `reason` is "own-reply". */
|
|
135
|
+
reply: z
|
|
136
|
+
.object({ at: z.string(), url: z.string().nullable().default(null) })
|
|
137
|
+
.nullable()
|
|
138
|
+
.default(null),
|
|
124
139
|
});
|
|
125
140
|
/** Result of pulling a review forward onto a newer head commit. */
|
|
126
141
|
export const RefreshInfoSchema = z.object({
|
package/dist/core/config.js
CHANGED
|
@@ -22,6 +22,12 @@ export const DaemonConfigSchema = z.object({
|
|
|
22
22
|
poll: z.boolean().default(true),
|
|
23
23
|
/** Draft an AI review for whatever lands in the queue. Never sends. */
|
|
24
24
|
autoReview: z.boolean().default(true),
|
|
25
|
+
/**
|
|
26
|
+
* Tap this machine's notification centre when a PR lands in the queue. The
|
|
27
|
+
* cockpit's bell needs a tab open and permitted; this one only needs `serve`
|
|
28
|
+
* running, which is the point of it.
|
|
29
|
+
*/
|
|
30
|
+
notify: z.boolean().default(true),
|
|
25
31
|
intervalMinutes: z.number().int().positive().default(5),
|
|
26
32
|
parallel: z.number().int().positive().default(3),
|
|
27
33
|
/** Repos to watch (owner/repo). Empty = everything your gh account can see. */
|
package/dist/core/gh.js
CHANGED
|
@@ -132,7 +132,7 @@ export async function fetchConversation(ref) {
|
|
|
132
132
|
"--paginate",
|
|
133
133
|
`repos/${ref.owner}/${ref.repo}/issues/${ref.number}/comments`,
|
|
134
134
|
"--jq",
|
|
135
|
-
'.[] | {author: .user.login, at: .created_at, bot: (.user.type == "Bot")}',
|
|
135
|
+
'.[] | {author: .user.login, at: .created_at, bot: (.user.type == "Bot"), url: .html_url}',
|
|
136
136
|
]);
|
|
137
137
|
// --jq streams one object per line rather than a JSON array.
|
|
138
138
|
return out
|
|
@@ -158,6 +158,21 @@ export function classifyReply(comments, login) {
|
|
|
158
158
|
// answer to it, so only strictly-later comments hand the move back.
|
|
159
159
|
return human.some((c) => c.author !== login && c.at > lastMine) ? "them" : "you";
|
|
160
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* The comment that gave you the last word, or null if it isn't yours.
|
|
163
|
+
*
|
|
164
|
+
* Answers "you have said your piece and nobody has answered since" with the
|
|
165
|
+
* comment that says it, so a draft filed on the strength of it can point at
|
|
166
|
+
* the thing on GitHub it was filed for. Routed through `classifyReply` rather
|
|
167
|
+
* than re-deriving the rule, so the tag on a row and the reason it was filed
|
|
168
|
+
* can never disagree about who spoke last.
|
|
169
|
+
*/
|
|
170
|
+
export function lastWordOfYours(comments, login) {
|
|
171
|
+
if (classifyReply(comments, login) !== "you")
|
|
172
|
+
return null;
|
|
173
|
+
const mine = comments.filter((c) => !isBot(c) && c.author === login);
|
|
174
|
+
return mine[mine.length - 1] ?? null;
|
|
175
|
+
}
|
|
161
176
|
/**
|
|
162
177
|
* The last review of your own that still stands, or null.
|
|
163
178
|
*
|
|
@@ -206,6 +221,39 @@ export function currentLogin() {
|
|
|
206
221
|
export function resetLoginCache() {
|
|
207
222
|
cachedLogin = null;
|
|
208
223
|
}
|
|
224
|
+
export async function fetchReviewRequests(ref) {
|
|
225
|
+
const out = await gh([
|
|
226
|
+
"pr",
|
|
227
|
+
"view",
|
|
228
|
+
String(ref.number),
|
|
229
|
+
"--repo",
|
|
230
|
+
`${ref.owner}/${ref.repo}`,
|
|
231
|
+
"--json",
|
|
232
|
+
"reviewRequests",
|
|
233
|
+
]);
|
|
234
|
+
const raw = JSON.parse(out);
|
|
235
|
+
const users = [];
|
|
236
|
+
const teams = [];
|
|
237
|
+
for (const r of raw.reviewRequests ?? []) {
|
|
238
|
+
if (r.login)
|
|
239
|
+
users.push(r.login);
|
|
240
|
+
else if (r.slug ?? r.name)
|
|
241
|
+
teams.push((r.slug ?? r.name));
|
|
242
|
+
}
|
|
243
|
+
return { users, teams };
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Whether GitHub might still be asking *you*, specifically.
|
|
247
|
+
*
|
|
248
|
+
* A team request names the team and not its members, so it can never rule you
|
|
249
|
+
* out — and a membership lookup to settle it would be a third call to answer a
|
|
250
|
+
* question whose only use is deciding not to act. Treating any team request as
|
|
251
|
+
* possibly-you costs one draft left in the inbox; the other way round files
|
|
252
|
+
* away a review someone is waiting on.
|
|
253
|
+
*/
|
|
254
|
+
export function stillRequested(requests, login) {
|
|
255
|
+
return requests.teams.length > 0 || requests.users.includes(login);
|
|
256
|
+
}
|
|
209
257
|
export function searchAwaitingArgs(repoFilter, limit = 50) {
|
|
210
258
|
const args = [
|
|
211
259
|
"search",
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The machine's own shoulder-tap, for the hours the cockpit isn't open.
|
|
2
|
+
//
|
|
3
|
+
// The cockpit has a bell of its own (`web/src/notify.ts`), and it is the better
|
|
4
|
+
// one when it can ring: it names the PR and opens that review when you click
|
|
5
|
+
// it. But it is a page notification, so it needs a tab that is open, alive and
|
|
6
|
+
// permitted — which is exactly what you don't have while you're in an editor
|
|
7
|
+
// all afternoon. This half rides the poll that discovers the PR instead, so the
|
|
8
|
+
// tap survives a closed cockpit, a denied permission and a browser restart.
|
|
9
|
+
//
|
|
10
|
+
// Everything here shells out to whatever the OS already has. No dependency, no
|
|
11
|
+
// daemon of its own, and a machine with no notifier is a machine cerber stays
|
|
12
|
+
// quiet on rather than one it fails on.
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
14
|
+
import { promisify } from "node:util";
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
/**
|
|
17
|
+
* How long a notifier gets before the poll stops waiting for it.
|
|
18
|
+
*
|
|
19
|
+
* A notification is instant or it is broken, and the thing on the other end of
|
|
20
|
+
* this is somebody else's daemon: a wedged Notification Centre or a DBus that
|
|
21
|
+
* never answers would otherwise hang `execFile` forever. The poll awaits this
|
|
22
|
+
* call, and the daemon refuses to start a poll while one is running — so an
|
|
23
|
+
* unbounded wait here doesn't cost one notification, it stops cerber
|
|
24
|
+
* discovering PRs at all. Timing out is caught like any other failure.
|
|
25
|
+
*/
|
|
26
|
+
export const NOTIFY_TIMEOUT_MS = 5_000;
|
|
27
|
+
const slug = (a) => `${a.repo}#${a.number}`;
|
|
28
|
+
/**
|
|
29
|
+
* One popup for one poll's arrivals — a batch is one interruption, not five.
|
|
30
|
+
* Deliberately the same shape and wording the cockpit's bell uses, so the two
|
|
31
|
+
* channels never read as two different pieces of news about one PR.
|
|
32
|
+
*/
|
|
33
|
+
export function notice(arrived) {
|
|
34
|
+
if (arrived.length === 0)
|
|
35
|
+
return null;
|
|
36
|
+
if (arrived.length === 1) {
|
|
37
|
+
const a = arrived[0];
|
|
38
|
+
return { title: `${slug(a)} awaits your review`, body: `${a.title} — ${a.author}` };
|
|
39
|
+
}
|
|
40
|
+
const named = arrived.slice(0, 3).map(slug);
|
|
41
|
+
const rest = arrived.length - named.length;
|
|
42
|
+
return {
|
|
43
|
+
title: `${arrived.length} PRs await your review`,
|
|
44
|
+
body: rest > 0 ? `${named.join(", ")} and ${rest} more` : named.join(", "),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A PR title, as an AppleScript string literal.
|
|
49
|
+
*
|
|
50
|
+
* osascript takes a script, not an argument list, so the title has to go into
|
|
51
|
+
* the source — and a PR title is text somebody else wrote. Escaping the two
|
|
52
|
+
* characters a literal cannot hold, a backslash and a quote, is what stops a
|
|
53
|
+
* title from closing the string and being read as script. Control characters
|
|
54
|
+
* can't appear in a literal at all, so they collapse to a space.
|
|
55
|
+
*/
|
|
56
|
+
export function appleScriptLiteral(value) {
|
|
57
|
+
const flat = value.replace(/[\u0000-\u001f\u007f]+/g, " ");
|
|
58
|
+
return `"${flat.replace(/[\\"]/g, (c) => `\\${c}`)}"`;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The command that taps this machine's notification centre, or null where there
|
|
62
|
+
* is nothing to tap. macOS and the freedesktop notifiers cover what cerber runs
|
|
63
|
+
* on; anywhere else it stays quiet rather than pretending.
|
|
64
|
+
*/
|
|
65
|
+
export function notifyCommand(n, platform = process.platform) {
|
|
66
|
+
if (platform === "darwin") {
|
|
67
|
+
return {
|
|
68
|
+
file: "osascript",
|
|
69
|
+
args: [
|
|
70
|
+
"-e",
|
|
71
|
+
`display notification ${appleScriptLiteral(n.body)} with title ${appleScriptLiteral(n.title)}`,
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (platform === "linux") {
|
|
76
|
+
// `--` first, because notify-send parses options before positionals. The
|
|
77
|
+
// PR's own title reaches this as the notice *body* (`notice()` builds the
|
|
78
|
+
// summary itself), so the body is the argument carrying somebody else's
|
|
79
|
+
// text — a PR titled "--help me" would be read as a flag and cost the
|
|
80
|
+
// notification. The marker ends option parsing ahead of both arguments, so
|
|
81
|
+
// neither the summary nor the body can be read as anything but text.
|
|
82
|
+
return { file: "notify-send", args: ["--app-name=cerber", "--", n.title, n.body] };
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Show it, and say whether it was shown. False is an ordinary answer here —
|
|
88
|
+
* an unsupported platform, no `notify-send` installed, a headless box, a
|
|
89
|
+
* notifier that took too long — and the caller reports it once rather than
|
|
90
|
+
* every poll. Never throws and never hangs: a notification is the least
|
|
91
|
+
* important thing a poll does, so it is also the last thing allowed to stop
|
|
92
|
+
* one.
|
|
93
|
+
*/
|
|
94
|
+
export async function notify(n) {
|
|
95
|
+
const cmd = notifyCommand(n);
|
|
96
|
+
if (!cmd)
|
|
97
|
+
return false;
|
|
98
|
+
try {
|
|
99
|
+
await execFileAsync(cmd.file, cmd.args, { timeout: NOTIFY_TIMEOUT_MS });
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
package/dist/runner/review.js
CHANGED
|
@@ -16,8 +16,23 @@ const OFF_TOOLS = ["Bash", "Edit", "Write", "NotebookEdit", "Task", "WebFetch",
|
|
|
16
16
|
/** A trusted reviewer may run things. It still may not rewrite the PR. */
|
|
17
17
|
const TRUSTED_TOOLS = [...READ_TOOLS, "Bash", "WebFetch", "WebSearch"];
|
|
18
18
|
const TRUSTED_OFF_TOOLS = ["Edit", "Write", "NotebookEdit"];
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Statuses a re-review may overwrite once the PR has moved on. A draft nobody
|
|
21
|
+
* has acted on should track the current head, and a sent review has cleared
|
|
22
|
+
* GitHub's request — a fresh one only ever arrives because the author asked
|
|
23
|
+
* again, which is exactly when a new draft is the point.
|
|
24
|
+
*/
|
|
25
|
+
const HEAD_SENSITIVE = new Set(["ready", "sent"]);
|
|
26
|
+
/**
|
|
27
|
+
* Statuses that are your decision, not a fact about the code.
|
|
28
|
+
*
|
|
29
|
+
* Marking a review reviewed or skipped says you are done with this PR. New
|
|
30
|
+
* commits do not undo that: the author pushing again would otherwise drag the
|
|
31
|
+
* row back into the inbox with a fresh draft on top, which is the same thing
|
|
32
|
+
* as cerber overruling you — and on a busy PR it happens every few minutes.
|
|
33
|
+
* The way back in is the way you got out: press re-review, which forces.
|
|
34
|
+
*/
|
|
35
|
+
const SETTLED_BY_YOU = new Set(["reviewed", "skipped"]);
|
|
21
36
|
/**
|
|
22
37
|
* Fetch a PR, run the AI review, persist the artifact at each stage.
|
|
23
38
|
* Everything stays local — nothing is ever written to GitHub.
|
|
@@ -71,9 +86,12 @@ async function runReview(ref, opts) {
|
|
|
71
86
|
log(`Fetching ${ref.owner}/${ref.repo}#${ref.number}…`);
|
|
72
87
|
const pr = await fetchPrInfo(ref);
|
|
73
88
|
const existing = await loadArtifact(artifactId(pr));
|
|
74
|
-
if (!opts.force) {
|
|
75
|
-
if (existing
|
|
76
|
-
|
|
89
|
+
if (existing && !opts.force) {
|
|
90
|
+
if (SETTLED_BY_YOU.has(existing.status)) {
|
|
91
|
+
log(`You marked this ${existing.status} — leaving it alone. Use --force to re-review.`);
|
|
92
|
+
return { artifact: existing, skipped: true };
|
|
93
|
+
}
|
|
94
|
+
if (HEAD_SENSITIVE.has(existing.status) &&
|
|
77
95
|
existing.pr.headSha !== "" &&
|
|
78
96
|
existing.pr.headSha === pr.headSha) {
|
|
79
97
|
log(`Up to date (head ${pr.headSha.slice(0, 7)}, status ${existing.status}) — skipping. Use --force to re-review.`);
|
package/dist/server/daemon.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { SCHEMA_VERSION, artifactId, artifactKey } from "../core/artifact.js";
|
|
2
2
|
import { evaluateAutoSend } from "../core/autosend.js";
|
|
3
3
|
import { loadConfig } from "../core/config.js";
|
|
4
|
-
import { classifyReply, currentLogin, fetchConversation, fetchOwnReview, fetchPrInfo, searchAwaitingMe, submitReview, } from "../core/gh.js";
|
|
4
|
+
import { classifyReply, currentLogin, fetchConversation, fetchOwnReview, fetchPrInfo, fetchReviewRequests, lastWordOfYours, searchAwaitingMe, stillRequested, submitReview, } from "../core/gh.js";
|
|
5
|
+
import { notice, notify } from "../core/notify.js";
|
|
5
6
|
import { buildReviewPayload, computeCalibration } from "../core/send.js";
|
|
6
7
|
import { appendAutoSendLog, deleteArtifact, listArtifacts, loadArtifact, saveArtifact, updateArtifactByKey, } from "../core/state.js";
|
|
7
8
|
import { ReviewInProgressError } from "../runner/inflight.js";
|
|
@@ -67,21 +68,25 @@ export function isPureStub(artifact) {
|
|
|
67
68
|
return artifact.status === "awaiting" && artifact.comments.length === 0 && artifact.run === null;
|
|
68
69
|
}
|
|
69
70
|
/**
|
|
70
|
-
* Whether
|
|
71
|
+
* Whether something you did on GitHub at `at` settles this draft.
|
|
71
72
|
*
|
|
72
73
|
* Two facts have to hold. GitHub must have stopped asking you — the caller has
|
|
73
74
|
* already established that, since this only runs on artifacts absent from the
|
|
74
|
-
* awaiting search — and you must have
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
75
|
+
* awaiting search — and you must have had your say, which is the half cerber
|
|
76
|
+
* could not see before: it happens on github.com and leaves no trace here.
|
|
77
|
+
* Together they mean the PR is back with its author, while the inbox went on
|
|
78
|
+
* listing the draft as work still waiting on you.
|
|
79
|
+
*
|
|
80
|
+
* Having your say is a review of your own or a comment nobody has answered
|
|
81
|
+
* since. GitHub counts only the first as a review — a comment leaves the
|
|
82
|
+
* request standing — but the question here is not what GitHub counts, it is
|
|
83
|
+
* whether the row is still work. Somebody waiting on the author is not.
|
|
79
84
|
*
|
|
80
85
|
* The trigger test is what keeps this from undoing a deliberate act. A draft
|
|
81
86
|
* you asked for — pasted the URL in, pressed re-review — after you had already
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
87
|
+
* spoken is a second opinion you went and requested, so filing it away would
|
|
88
|
+
* answer a question you had just posed. A draft the poll wrote on its own
|
|
89
|
+
* carries no such intent: it is exactly the row this is here to close.
|
|
85
90
|
*
|
|
86
91
|
* A run recorded before the trigger was kept is treated as the poll's, which is
|
|
87
92
|
* the deliberate choice: the alternative leaves every draft written before this
|
|
@@ -89,15 +94,33 @@ export function isPureStub(artifact) {
|
|
|
89
94
|
* is one legacy draft filed once — and the re-review that un-files it records a
|
|
90
95
|
* trigger, so it cannot happen to the same row twice.
|
|
91
96
|
*/
|
|
92
|
-
export function
|
|
93
|
-
if (artifact.status !== "ready" || artifact.sent ||
|
|
97
|
+
export function filedByYourAct(artifact, at) {
|
|
98
|
+
if (artifact.status !== "ready" || artifact.sent || at === null)
|
|
94
99
|
return false;
|
|
95
100
|
const run = artifact.run;
|
|
96
101
|
// Parsed, not compared as text. `startedAt` carries milliseconds and GitHub's
|
|
97
102
|
// `submitted_at` does not, and "…:00.500Z" sorts *before* "…:00Z" — so a run
|
|
98
103
|
// that began half a second after your review would read as older than it, and
|
|
99
104
|
// the second opinion this guard exists to protect would be filed away.
|
|
100
|
-
return !(run?.trigger === "user" && Date.parse(run.startedAt) >= Date.parse(
|
|
105
|
+
return !(run?.trigger === "user" && Date.parse(run.startedAt) >= Date.parse(at));
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Whether a review request that simply went away settles this draft.
|
|
109
|
+
*
|
|
110
|
+
* The other two reasons rest on something you did; this one rests on something
|
|
111
|
+
* that stopped being true, which is a weaker kind of evidence and gets a
|
|
112
|
+
* stricter guard for it. Only a draft the poll wrote on its own can be filed
|
|
113
|
+
* this way: the poll only ever writes one because GitHub asked you, so the
|
|
114
|
+
* request going away is the whole of that draft's reason for existing going
|
|
115
|
+
* away. A draft you pasted in or asked for was never in that search to begin
|
|
116
|
+
* with, and `null` — a run older than the trigger field — cannot be told from
|
|
117
|
+
* one, so neither qualifies. Unlike the own-review case there is no second
|
|
118
|
+
* fact to fall back on, so an unknown here has to mean no.
|
|
119
|
+
*/
|
|
120
|
+
export function filedByWithdrawnRequest(artifact) {
|
|
121
|
+
if (artifact.status !== "ready" || artifact.sent)
|
|
122
|
+
return false;
|
|
123
|
+
return artifact.run?.trigger === "daemon";
|
|
101
124
|
}
|
|
102
125
|
/** How many open-PR artifacts to re-check against GitHub per poll, and how often each. */
|
|
103
126
|
const STATE_REFRESH_CAP = 10;
|
|
@@ -118,6 +141,9 @@ export function startDaemon(opts) {
|
|
|
118
141
|
repos: opts.repos,
|
|
119
142
|
intervalMs: opts.intervalMs,
|
|
120
143
|
pollEnabled: true,
|
|
144
|
+
// Claims nothing until a poll has read the config, so the cockpit's bell
|
|
145
|
+
// keeps its own counsel for the few seconds that takes.
|
|
146
|
+
notify: false,
|
|
121
147
|
autoReview: opts.autoReview,
|
|
122
148
|
trustedRuns: false,
|
|
123
149
|
polls: 0,
|
|
@@ -156,7 +182,7 @@ export function startDaemon(opts) {
|
|
|
156
182
|
* search results lag GitHub by minutes and never override them.
|
|
157
183
|
*/
|
|
158
184
|
async function syncQueue(refs) {
|
|
159
|
-
|
|
185
|
+
const discovered = [];
|
|
160
186
|
for (const ref of refs) {
|
|
161
187
|
const existing = await loadArtifact(artifactId(ref));
|
|
162
188
|
if (existing) {
|
|
@@ -173,22 +199,35 @@ export function startDaemon(opts) {
|
|
|
173
199
|
continue;
|
|
174
200
|
}
|
|
175
201
|
await saveArtifact(stubArtifact(ref));
|
|
176
|
-
discovered
|
|
202
|
+
discovered.push(ref);
|
|
177
203
|
}
|
|
178
204
|
// Asked once per pass, and only if a row actually reaches the check that
|
|
179
205
|
// needs it. Undefined is "not asked yet"; null is "gh could not say", which
|
|
180
206
|
// attributes nothing to you rather than guessing.
|
|
181
207
|
let login;
|
|
182
208
|
/**
|
|
183
|
-
* File a draft away once GitHub
|
|
209
|
+
* File a draft away once GitHub has moved past it without cerber's help.
|
|
210
|
+
*
|
|
211
|
+
* The queue lists what you have not dealt with locally, and everything that
|
|
212
|
+
* happens on github.com is invisible to it — so a PR you answered days ago
|
|
213
|
+
* sat in the inbox as if it still wanted you. Three ways that happens, in
|
|
214
|
+
* the order of how much they say:
|
|
184
215
|
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
216
|
+
* 1. You submitted a review there. The strongest: GitHub itself counts it.
|
|
217
|
+
* 2. You commented and nobody has answered since. Not a review as far as
|
|
218
|
+
* GitHub is concerned, but the ball is still in the author's court.
|
|
219
|
+
* 3. Nobody is asking any more. You never said anything; whoever
|
|
220
|
+
* requested you took the request back, or the PR got its reviews
|
|
221
|
+
* elsewhere. Nothing left here is work.
|
|
222
|
+
*
|
|
223
|
+
* Someone answering your comment (`them`) is the case that deliberately
|
|
224
|
+
* files nothing: that is a reply addressed to you, sitting on a PR whose
|
|
225
|
+
* draft is right here.
|
|
226
|
+
*
|
|
227
|
+
* All of it rides the same 30-minute-per-artifact leash as the state check
|
|
228
|
+
* it hangs off, so a queue of a hundred rows is still a handful of calls.
|
|
190
229
|
*/
|
|
191
|
-
async function
|
|
230
|
+
async function fileIfSettledElsewhere(artifact) {
|
|
192
231
|
// Only a finished, unsent draft can be filed — checked here too, so a row
|
|
193
232
|
// that could never qualify costs no GitHub call at all.
|
|
194
233
|
if (artifact.status !== "ready" || artifact.sent)
|
|
@@ -197,17 +236,45 @@ export function startDaemon(opts) {
|
|
|
197
236
|
login = await currentLogin().catch(() => null);
|
|
198
237
|
if (login === null)
|
|
199
238
|
return;
|
|
200
|
-
const
|
|
201
|
-
|
|
239
|
+
const me = login;
|
|
240
|
+
/** Re-reads from disk: a run or a send may have started in the seconds
|
|
241
|
+
* these reads took, and neither wants filing out from under it. */
|
|
242
|
+
async function file(filed, note, stillFilable) {
|
|
243
|
+
const saved = await updateArtifactByKey(artifactKey(artifact.id), (a) => stillFilable(a) ? { ...a, status: "reviewed", filed } : a);
|
|
244
|
+
if (saved?.filed)
|
|
245
|
+
log(`[${artifact.id}] ${note} — filed as reviewed`);
|
|
246
|
+
}
|
|
247
|
+
const review = await fetchOwnReview(artifact.pr, me);
|
|
248
|
+
if (review) {
|
|
249
|
+
// A review of yours is the answer either way: if the guard rejects it,
|
|
250
|
+
// this is a second opinion you asked for, not a row to keep looking for
|
|
251
|
+
// another reason to file.
|
|
252
|
+
if (filedByYourAct(artifact, review.at)) {
|
|
253
|
+
await file({ at: new Date().toISOString(), reason: "own-review", review, reply: null }, `you reviewed this on GitHub on ${review.at.slice(0, 10)}`, (a) => filedByYourAct(a, review.at));
|
|
254
|
+
}
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const conversation = await fetchConversation(artifact.pr);
|
|
258
|
+
const mine = lastWordOfYours(conversation, me);
|
|
259
|
+
if (mine) {
|
|
260
|
+
if (filedByYourAct(artifact, mine.at)) {
|
|
261
|
+
const reply = { at: mine.at, url: mine.url };
|
|
262
|
+
await file({ at: new Date().toISOString(), reason: "own-reply", review: null, reply }, `you replied on GitHub on ${mine.at.slice(0, 10)} and nobody has answered since`, (a) => filedByYourAct(a, mine.at));
|
|
263
|
+
}
|
|
202
264
|
return;
|
|
203
|
-
const filed = { at: new Date().toISOString(), review };
|
|
204
|
-
const saved = await updateArtifactByKey(artifactKey(artifact.id), (a) =>
|
|
205
|
-
// Re-read from disk: a run or a send may have started in the seconds
|
|
206
|
-
// this read took, and neither wants filing out from under it.
|
|
207
|
-
filedByOwnReview(a, review) ? { ...a, status: "reviewed", filed } : a);
|
|
208
|
-
if (saved?.filed) {
|
|
209
|
-
log(`[${artifact.id}] you reviewed this on GitHub on ${review.at.slice(0, 10)} — filed as reviewed`);
|
|
210
265
|
}
|
|
266
|
+
// Anything but silence from you leaves the row alone: `them` is somebody
|
|
267
|
+
// answering a comment of yours, which is the opposite of settled.
|
|
268
|
+
if (classifyReply(conversation, me) !== "none")
|
|
269
|
+
return;
|
|
270
|
+
if (!filedByWithdrawnRequest(artifact))
|
|
271
|
+
return;
|
|
272
|
+
// The awaiting search says nobody is asking, and here that is the entire
|
|
273
|
+
// case — so confirm it against the PR itself before acting on it. The
|
|
274
|
+
// other two reasons stand on a fact of their own and need no such check.
|
|
275
|
+
if (stillRequested(await fetchReviewRequests(artifact.pr), me))
|
|
276
|
+
return;
|
|
277
|
+
await file({ at: new Date().toISOString(), reason: "request-withdrawn", review: null, reply: null }, "nobody is asking for this review any more", filedByWithdrawnRequest);
|
|
211
278
|
}
|
|
212
279
|
const awaitingIds = new Set(refs.map((r) => artifactId(r)));
|
|
213
280
|
let refreshed = 0;
|
|
@@ -262,7 +329,7 @@ export function startDaemon(opts) {
|
|
|
262
329
|
if (pr.state !== "OPEN")
|
|
263
330
|
log(`[${artifact.id}] ${pr.state.toLowerCase()} — archived`);
|
|
264
331
|
else
|
|
265
|
-
await
|
|
332
|
+
await fileIfSettledElsewhere(artifact);
|
|
266
333
|
}
|
|
267
334
|
catch {
|
|
268
335
|
// Same: book-keeping can wait for the next poll.
|
|
@@ -270,6 +337,41 @@ export function startDaemon(opts) {
|
|
|
270
337
|
}
|
|
271
338
|
return { discovered };
|
|
272
339
|
}
|
|
340
|
+
/** Said once, not every poll: a machine with no notifier will never grow one. */
|
|
341
|
+
let notifierMissing = false;
|
|
342
|
+
/**
|
|
343
|
+
* What the last attempt to tap this machine actually did, or null before one
|
|
344
|
+
* has been made.
|
|
345
|
+
*
|
|
346
|
+
* `status.notify` is a promise to the cockpit — it stands its own bell down
|
|
347
|
+
* on the strength of it — so it has to mean "this machine gets told", not
|
|
348
|
+
* "this machine was asked to tell". A box with no `notify-send` would
|
|
349
|
+
* otherwise go on claiming the job while the browser politely declined it,
|
|
350
|
+
* and one PR would be announced by nobody at all. Untried counts as working:
|
|
351
|
+
* the alternative is both bells ringing everywhere until the first PR lands.
|
|
352
|
+
*/
|
|
353
|
+
let notifierWorks = null;
|
|
354
|
+
/**
|
|
355
|
+
* Tap this machine when a PR lands in the queue.
|
|
356
|
+
*
|
|
357
|
+
* An artifact is the ledger this rides on: a PR is announced on the poll that
|
|
358
|
+
* first writes a stub for it, so a restart re-announces nothing, and a PR
|
|
359
|
+
* that arrived while `serve` was down is still news the next time it runs.
|
|
360
|
+
* The cockpit's bell announces the same arrival, off the same stub — which is
|
|
361
|
+
* why it stands down when this is on and both are on one machine.
|
|
362
|
+
*/
|
|
363
|
+
async function announce(arrivals) {
|
|
364
|
+
const n = notice(arrivals);
|
|
365
|
+
if (!n)
|
|
366
|
+
return;
|
|
367
|
+
notifierWorks = await notify(n);
|
|
368
|
+
if (notifierWorks)
|
|
369
|
+
return;
|
|
370
|
+
if (notifierMissing)
|
|
371
|
+
return;
|
|
372
|
+
notifierMissing = true;
|
|
373
|
+
log("no desktop notifier here — arrivals will only show in the cockpit");
|
|
374
|
+
}
|
|
273
375
|
/**
|
|
274
376
|
* Whose move it is on each awaiting PR, one conversation read apiece.
|
|
275
377
|
*
|
|
@@ -347,6 +449,14 @@ export function startDaemon(opts) {
|
|
|
347
449
|
const knobs = config.daemon;
|
|
348
450
|
const autoReview = opts.autoReview && knobs.autoReview;
|
|
349
451
|
status.pollEnabled = knobs.poll;
|
|
452
|
+
// What the cockpit's bell defers to: a loop that isn't polling announces
|
|
453
|
+
// nothing, whatever the notify toggle says.
|
|
454
|
+
const notifyOn = opts.notify && knobs.notify;
|
|
455
|
+
// Only true while all three hold, and the third is only knowable by
|
|
456
|
+
// having tried: a notifier that isn't there is not a bell the cockpit
|
|
457
|
+
// should be standing down for.
|
|
458
|
+
const announcing = () => knobs.poll && notifyOn && notifierWorks !== false;
|
|
459
|
+
status.notify = announcing();
|
|
350
460
|
status.autoReview = autoReview;
|
|
351
461
|
// Recomputed too, so a trust rule added mid-run shows up in the strip.
|
|
352
462
|
status.trustedRuns = autoReview && opts.trust !== false && config.trust.length > 0;
|
|
@@ -365,6 +475,16 @@ export function startDaemon(opts) {
|
|
|
365
475
|
// A failed poll leaves the last good list up; lastPollError says so.
|
|
366
476
|
status.awaiting = await classifyAll(refs);
|
|
367
477
|
const { discovered } = await syncQueue(refs);
|
|
478
|
+
// Before the reviews, not after: the arrival is the news, and drafting it
|
|
479
|
+
// takes minutes. This is the same moment the cockpit's bell would ring.
|
|
480
|
+
if (notifyOn && discovered.length > 0) {
|
|
481
|
+
await announce(discovered);
|
|
482
|
+
// Recomputed on the spot rather than left to the next poll: that is
|
|
483
|
+
// minutes away, and the cockpit would spend them deferring to a tap
|
|
484
|
+
// that never came. Recomputed rather than only cleared, so a machine
|
|
485
|
+
// whose notifier comes back takes the job back with it.
|
|
486
|
+
status.notify = announcing();
|
|
487
|
+
}
|
|
368
488
|
if (autoReview) {
|
|
369
489
|
const { reviewed, skipped, failed } = await reviewAll(refs);
|
|
370
490
|
status.lastSummary =
|
|
@@ -372,7 +492,7 @@ export function startDaemon(opts) {
|
|
|
372
492
|
`${failed > 0 ? `, ${failed} failed` : ""}`;
|
|
373
493
|
}
|
|
374
494
|
else {
|
|
375
|
-
status.lastSummary = `${refs.length} awaiting${discovered > 0 ? `, ${discovered} new` : ""} (auto-review off)`;
|
|
495
|
+
status.lastSummary = `${refs.length} awaiting${discovered.length > 0 ? `, ${discovered.length} new` : ""} (auto-review off)`;
|
|
376
496
|
}
|
|
377
497
|
log(status.lastSummary);
|
|
378
498
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fullstackhouse/cerber",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "AI code-review cockpit: Claude reviews your PRs into local artifacts; nothing reaches GitHub until you say so.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Full Stack House (https://fullstack.house)",
|