@fullstackhouse/cerber 0.20.0 → 0.22.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 +19 -0
- package/dist/core/artifact.js +27 -0
- package/dist/core/gh.js +32 -0
- package/dist/runner/review.js +3 -0
- package/dist/server/daemon.js +72 -1
- package/dist/server/index.js +5 -0
- package/package.json +1 -1
- package/web/dist/assets/{index-Do1QOj6T.js → index-DvH4pObZ.js} +40 -40
- package/web/dist/favicon-dot.svg +34 -0
- package/web/dist/index.html +1 -1
package/README.md
CHANGED
|
@@ -60,6 +60,20 @@ inbox — cerber never tells you nothing awaits you while its own poll says
|
|
|
60
60
|
otherwise. It is a report, not a nag: what you decided here stands, and the
|
|
61
61
|
mark clears itself the moment GitHub hears from you.
|
|
62
62
|
|
|
63
|
+
### Except when you reviewed it on GitHub
|
|
64
|
+
|
|
65
|
+
There is one thing you never have to settle by hand. Review a PR the ordinary
|
|
66
|
+
way — GitHub's own review button, cerber not involved — and the review request
|
|
67
|
+
clears, but nothing here hears about it: the draft cerber wrote for that PR
|
|
68
|
+
goes on sitting in the inbox as work still waiting on you, days after the PR
|
|
69
|
+
went back to its author. So the poll asks the other question too. When GitHub
|
|
70
|
+
has stopped requesting your review *and* already holds one of yours, the draft
|
|
71
|
+
is filed under settled, tagged `reviewed on GitHub` with the date it was
|
|
72
|
+
submitted. Nothing else happens to it — it was never sent, and it is still
|
|
73
|
+
yours to open, re-review or send. The one draft this leaves alone is one you
|
|
74
|
+
asked for *after* that review: a second opinion you went and requested is not
|
|
75
|
+
a row the queue forgot to close.
|
|
76
|
+
|
|
63
77
|
### Whose move is it
|
|
64
78
|
|
|
65
79
|
An open request doesn't mean the ball is yours, though. You may have argued the
|
|
@@ -84,6 +98,11 @@ the record of what has been announced are per-browser, kept in that browser
|
|
|
84
98
|
rather than in `~/.cerber/config.json` — the permission they gate is the
|
|
85
99
|
browser's, so a second browser starts its own record.
|
|
86
100
|
|
|
101
|
+
The tab itself says it too, without asking anyone: while the inbox holds
|
|
102
|
+
anything, the favicon wears a red dot, and it goes away when the last review is
|
|
103
|
+
dealt with. No permission, no switch — just the paw with a mark on it, so a
|
|
104
|
+
glance at the tab strip answers "is there anything for me?".
|
|
105
|
+
|
|
87
106
|
## What a review looks like
|
|
88
107
|
|
|
89
108
|
Each review is a plain JSON artifact in `~/.cerber/reviews/` (override with
|
package/dist/core/artifact.js
CHANGED
|
@@ -89,6 +89,14 @@ export const RunInfoSchema = z.object({
|
|
|
89
89
|
* reviewer answering a question still holds everything it read.
|
|
90
90
|
*/
|
|
91
91
|
sessionId: z.string().nullable().default(null),
|
|
92
|
+
/**
|
|
93
|
+
* Who asked for this run: the inbox poll, or a person — the cockpit's review
|
|
94
|
+
* button, a pasted URL, `cerber review`. Null on runs recorded before this
|
|
95
|
+
* was kept. It is what tells a draft the queue produced on its own from one
|
|
96
|
+
* you deliberately asked for, which is the difference between a row that can
|
|
97
|
+
* be filed away automatically and one that must not be.
|
|
98
|
+
*/
|
|
99
|
+
trigger: z.enum(["daemon", "user"]).nullable().default(null),
|
|
92
100
|
});
|
|
93
101
|
export const SentInfoSchema = z.object({
|
|
94
102
|
at: z.string(),
|
|
@@ -97,6 +105,23 @@ export const SentInfoSchema = z.object({
|
|
|
97
105
|
/** True when the daemon auto-sent this review (opt-in --auto-send). */
|
|
98
106
|
auto: z.boolean().default(false),
|
|
99
107
|
});
|
|
108
|
+
/**
|
|
109
|
+
* Why a draft was filed away without ever being sent.
|
|
110
|
+
*
|
|
111
|
+
* The only cause today is the one the queue could not see before: you reviewed
|
|
112
|
+
* the PR through GitHub itself, so cerber's draft is a second opinion on a
|
|
113
|
+
* conversation you already had. It is a record, not a decision to defend — the
|
|
114
|
+
* review is untouched, still openable and still sendable.
|
|
115
|
+
*/
|
|
116
|
+
export const FiledInfoSchema = z.object({
|
|
117
|
+
at: z.string(),
|
|
118
|
+
/** The review of yours GitHub had, which is what settled this one. */
|
|
119
|
+
review: z.object({
|
|
120
|
+
at: z.string(),
|
|
121
|
+
state: z.enum(["APPROVED", "CHANGES_REQUESTED", "COMMENTED"]),
|
|
122
|
+
url: z.string().nullable().default(null),
|
|
123
|
+
}),
|
|
124
|
+
});
|
|
100
125
|
/** Result of pulling a review forward onto a newer head commit. */
|
|
101
126
|
export const RefreshInfoSchema = z.object({
|
|
102
127
|
at: z.string(),
|
|
@@ -225,6 +250,8 @@ export const ArtifactSchema = z.object({
|
|
|
225
250
|
run: RunInfoSchema.nullable().default(null),
|
|
226
251
|
/** Set once the review was sent to GitHub (explicitly, or via opt-in auto-send). */
|
|
227
252
|
sent: SentInfoSchema.nullable().default(null),
|
|
253
|
+
/** Set when cerber filed this draft away itself, and why. Never on a sent one. */
|
|
254
|
+
filed: FiledInfoSchema.nullable().default(null),
|
|
228
255
|
/** Last time this review was pulled forward onto a newer head commit. */
|
|
229
256
|
refresh: RefreshInfoSchema.nullable().default(null),
|
|
230
257
|
calibration: CalibrationSchema.nullable().default(null),
|
package/dist/core/gh.js
CHANGED
|
@@ -158,6 +158,38 @@ 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 last review of your own that still stands, or null.
|
|
163
|
+
*
|
|
164
|
+
* PENDING is a draft on GitHub's side and has no `submitted_at`, so it is not
|
|
165
|
+
* a review anyone has seen. DISMISSED is one a maintainer explicitly struck
|
|
166
|
+
* off; taking it as "you have had your say" would file work away on the
|
|
167
|
+
* strength of a review GitHub no longer counts.
|
|
168
|
+
*/
|
|
169
|
+
const STANDS = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED"]);
|
|
170
|
+
export function latestOwnReview(rows, login) {
|
|
171
|
+
const mine = rows.filter((r) => r.author === login && r.at != null && STANDS.has(r.state));
|
|
172
|
+
if (mine.length === 0)
|
|
173
|
+
return null;
|
|
174
|
+
const last = mine.reduce((a, b) => (b.at > a.at ? b : a));
|
|
175
|
+
return { at: last.at, state: last.state, url: last.url };
|
|
176
|
+
}
|
|
177
|
+
/** Ask GitHub whether you have reviewed this PR yourself. */
|
|
178
|
+
export async function fetchOwnReview(ref, login) {
|
|
179
|
+
const out = await gh([
|
|
180
|
+
"api",
|
|
181
|
+
"--paginate",
|
|
182
|
+
`repos/${ref.owner}/${ref.repo}/pulls/${ref.number}/reviews`,
|
|
183
|
+
"--jq",
|
|
184
|
+
".[] | {author: .user.login, at: .submitted_at, state: .state, url: .html_url}",
|
|
185
|
+
]);
|
|
186
|
+
// --jq streams one object per line rather than a JSON array.
|
|
187
|
+
const rows = out
|
|
188
|
+
.split("\n")
|
|
189
|
+
.filter((line) => line.trim() !== "")
|
|
190
|
+
.map((line) => JSON.parse(line));
|
|
191
|
+
return latestOwnReview(rows, login);
|
|
192
|
+
}
|
|
161
193
|
/** The login `@me` resolves to. Asked once — it cannot change mid-process. */
|
|
162
194
|
let cachedLogin = null;
|
|
163
195
|
export function currentLogin() {
|
package/dist/runner/review.js
CHANGED
|
@@ -123,9 +123,11 @@ async function runReview(ref, opts) {
|
|
|
123
123
|
withSource: source !== null,
|
|
124
124
|
trusted: trusted && source !== null,
|
|
125
125
|
sessionId: null,
|
|
126
|
+
trigger: opts.trigger ?? "user",
|
|
126
127
|
},
|
|
127
128
|
sent: null,
|
|
128
129
|
refresh: null,
|
|
130
|
+
filed: null,
|
|
129
131
|
calibration: null,
|
|
130
132
|
// The conversation is the user's writing, so a re-review keeps it — the
|
|
131
133
|
// chat prompt replays the transcript, which is what makes it survive the
|
|
@@ -183,6 +185,7 @@ async function runReview(ref, opts) {
|
|
|
183
185
|
// Only worth keeping when there is a checkout to resume into: a session
|
|
184
186
|
// whose working directory was empty has nothing a chat turn could read.
|
|
185
187
|
sessionId: source ? review.sessionId : null,
|
|
188
|
+
trigger: artifact.run.trigger,
|
|
186
189
|
},
|
|
187
190
|
};
|
|
188
191
|
// A re-review regenerates the AI's comments, but the human's own comments
|
package/dist/server/daemon.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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, fetchPrInfo, searchAwaitingMe, submitReview, } from "../core/gh.js";
|
|
4
|
+
import { classifyReply, currentLogin, fetchConversation, fetchOwnReview, fetchPrInfo, searchAwaitingMe, submitReview, } from "../core/gh.js";
|
|
5
5
|
import { buildReviewPayload, computeCalibration } from "../core/send.js";
|
|
6
6
|
import { appendAutoSendLog, deleteArtifact, listArtifacts, loadArtifact, saveArtifact, updateArtifactByKey, } from "../core/state.js";
|
|
7
7
|
import { ReviewInProgressError } from "../runner/inflight.js";
|
|
@@ -43,6 +43,7 @@ export function stubArtifact(ref) {
|
|
|
43
43
|
run: null,
|
|
44
44
|
sent: null,
|
|
45
45
|
refresh: null,
|
|
46
|
+
filed: null,
|
|
46
47
|
calibration: null,
|
|
47
48
|
chat: [],
|
|
48
49
|
preChat: null,
|
|
@@ -65,6 +66,39 @@ export function stubArtifact(ref) {
|
|
|
65
66
|
export function isPureStub(artifact) {
|
|
66
67
|
return artifact.status === "awaiting" && artifact.comments.length === 0 && artifact.run === null;
|
|
67
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Whether a review of yours on GitHub settles this draft.
|
|
71
|
+
*
|
|
72
|
+
* Two facts have to hold. GitHub must have stopped asking you — the caller has
|
|
73
|
+
* already established that, since this only runs on artifacts absent from the
|
|
74
|
+
* awaiting search — and you must have submitted a review of your own, which is
|
|
75
|
+
* the half cerber could not see before: a review clears the request whether it
|
|
76
|
+
* went through Send or through GitHub's own button, and only the first left a
|
|
77
|
+
* trace here. Together they mean the PR is back with its author, while the
|
|
78
|
+
* inbox went on listing the draft as work still waiting on you.
|
|
79
|
+
*
|
|
80
|
+
* The trigger test is what keeps this from undoing a deliberate act. A draft
|
|
81
|
+
* you asked for — pasted the URL in, pressed re-review — after you had already
|
|
82
|
+
* reviewed the PR is a second opinion you went and requested, so filing it
|
|
83
|
+
* away would answer a question you had just posed. A draft the poll wrote on
|
|
84
|
+
* its own carries no such intent: it is exactly the row this is here to close.
|
|
85
|
+
*
|
|
86
|
+
* A run recorded before the trigger was kept is treated as the poll's, which is
|
|
87
|
+
* the deliberate choice: the alternative leaves every draft written before this
|
|
88
|
+
* shipped stuck in the inbox forever, which is the bug. The cost of being wrong
|
|
89
|
+
* is one legacy draft filed once — and the re-review that un-files it records a
|
|
90
|
+
* trigger, so it cannot happen to the same row twice.
|
|
91
|
+
*/
|
|
92
|
+
export function filedByOwnReview(artifact, review) {
|
|
93
|
+
if (artifact.status !== "ready" || artifact.sent || !review)
|
|
94
|
+
return false;
|
|
95
|
+
const run = artifact.run;
|
|
96
|
+
// Parsed, not compared as text. `startedAt` carries milliseconds and GitHub's
|
|
97
|
+
// `submitted_at` does not, and "…:00.500Z" sorts *before* "…:00Z" — so a run
|
|
98
|
+
// that began half a second after your review would read as older than it, and
|
|
99
|
+
// the second opinion this guard exists to protect would be filed away.
|
|
100
|
+
return !(run?.trigger === "user" && Date.parse(run.startedAt) >= Date.parse(review.at));
|
|
101
|
+
}
|
|
68
102
|
/** How many open-PR artifacts to re-check against GitHub per poll, and how often each. */
|
|
69
103
|
const STATE_REFRESH_CAP = 10;
|
|
70
104
|
const STATE_REFRESH_MIN_MS = 30 * 60_000;
|
|
@@ -141,6 +175,40 @@ export function startDaemon(opts) {
|
|
|
141
175
|
await saveArtifact(stubArtifact(ref));
|
|
142
176
|
discovered++;
|
|
143
177
|
}
|
|
178
|
+
// Asked once per pass, and only if a row actually reaches the check that
|
|
179
|
+
// needs it. Undefined is "not asked yet"; null is "gh could not say", which
|
|
180
|
+
// attributes nothing to you rather than guessing.
|
|
181
|
+
let login;
|
|
182
|
+
/**
|
|
183
|
+
* File a draft away once GitHub says you already reviewed the PR yourself.
|
|
184
|
+
*
|
|
185
|
+
* The queue lists what you have not dealt with locally, and a review
|
|
186
|
+
* submitted on github.com never reached it — so a PR you answered days ago
|
|
187
|
+
* sat in the inbox as if it still wanted you. This is the one read that
|
|
188
|
+
* closes that gap, on the same 30-minute-per-artifact leash as the state
|
|
189
|
+
* check it rides along with.
|
|
190
|
+
*/
|
|
191
|
+
async function fileIfYouReviewedItYourself(artifact) {
|
|
192
|
+
// Only a finished, unsent draft can be filed — checked here too, so a row
|
|
193
|
+
// that could never qualify costs no GitHub call at all.
|
|
194
|
+
if (artifact.status !== "ready" || artifact.sent)
|
|
195
|
+
return;
|
|
196
|
+
if (login === undefined)
|
|
197
|
+
login = await currentLogin().catch(() => null);
|
|
198
|
+
if (login === null)
|
|
199
|
+
return;
|
|
200
|
+
const review = await fetchOwnReview(artifact.pr, login);
|
|
201
|
+
if (!review || !filedByOwnReview(artifact, review))
|
|
202
|
+
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
|
+
}
|
|
211
|
+
}
|
|
144
212
|
const awaitingIds = new Set(refs.map((r) => artifactId(r)));
|
|
145
213
|
let refreshed = 0;
|
|
146
214
|
for (const artifact of await listArtifacts()) {
|
|
@@ -193,6 +261,8 @@ export function startDaemon(opts) {
|
|
|
193
261
|
}
|
|
194
262
|
if (pr.state !== "OPEN")
|
|
195
263
|
log(`[${artifact.id}] ${pr.state.toLowerCase()} — archived`);
|
|
264
|
+
else
|
|
265
|
+
await fileIfYouReviewedItYourself(artifact);
|
|
196
266
|
}
|
|
197
267
|
catch {
|
|
198
268
|
// Same: book-keeping can wait for the next poll.
|
|
@@ -237,6 +307,7 @@ export function startDaemon(opts) {
|
|
|
237
307
|
try {
|
|
238
308
|
const result = await reviewPr(ref, {
|
|
239
309
|
model: opts.model,
|
|
310
|
+
trigger: "daemon",
|
|
240
311
|
withSource: opts.withSource,
|
|
241
312
|
trust: opts.trust,
|
|
242
313
|
onProgress: (m) => log(`[${label}] ${m}`),
|
package/dist/server/index.js
CHANGED
|
@@ -165,6 +165,8 @@ export async function buildApp(opts) {
|
|
|
165
165
|
trusted: a.run?.trusted ?? null,
|
|
166
166
|
runError: a.run?.error ?? null,
|
|
167
167
|
sent: a.sent ? { at: a.sent.at, event: a.sent.event, url: a.sent.url, auto: a.sent.auto ?? false } : null,
|
|
168
|
+
// Why a settled row is settled, when cerber settled it rather than you.
|
|
169
|
+
filed: a.filed,
|
|
168
170
|
})));
|
|
169
171
|
});
|
|
170
172
|
// ---- Pull a PR in: review anything, not just what the inbox found ----
|
|
@@ -248,9 +250,11 @@ export async function buildApp(opts) {
|
|
|
248
250
|
withSource: withSource !== false,
|
|
249
251
|
trusted: false,
|
|
250
252
|
sessionId: null,
|
|
253
|
+
trigger: "user",
|
|
251
254
|
},
|
|
252
255
|
sent: null,
|
|
253
256
|
refresh: null,
|
|
257
|
+
filed: null,
|
|
254
258
|
calibration: null,
|
|
255
259
|
chat: [],
|
|
256
260
|
preChat: null,
|
|
@@ -424,6 +428,7 @@ export async function buildApp(opts) {
|
|
|
424
428
|
withSource: withSource !== false,
|
|
425
429
|
trusted: false,
|
|
426
430
|
sessionId: null,
|
|
431
|
+
trigger: "user",
|
|
427
432
|
},
|
|
428
433
|
}));
|
|
429
434
|
// Minutes-long: run it detached and let the cockpit poll the artifact.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fullstackhouse/cerber",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.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)",
|