@fullstackhouse/cerber 0.24.1 → 0.25.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/dist/core/artifact.js +12 -0
- package/dist/core/gh.js +52 -0
- package/dist/core/refresh.js +4 -0
- package/dist/runner/review.js +2 -1
- package/dist/server/daemon.js +117 -8
- package/dist/server/index.js +11 -0
- package/package.json +1 -1
package/dist/core/artifact.js
CHANGED
|
@@ -278,6 +278,18 @@ export const ArtifactSchema = z.object({
|
|
|
278
278
|
sent: SentInfoSchema.nullable().default(null),
|
|
279
279
|
/** Set when cerber filed this draft away itself, and why. Never on a sent one. */
|
|
280
280
|
filed: FiledInfoSchema.nullable().default(null),
|
|
281
|
+
/**
|
|
282
|
+
* When this review was settled — marked `reviewed` or `skipped`, by you or by
|
|
283
|
+
* cerber filing it. Null on anything not settled.
|
|
284
|
+
*
|
|
285
|
+
* A settle answers the request that was open at the time. It cannot answer
|
|
286
|
+
* one that came afterwards, so the poll needs to know which side of it a
|
|
287
|
+
* review request falls on before deciding a settled row stays settled
|
|
288
|
+
* (`askedAgainAfterSettling` in `src/server/daemon.ts`). `updatedAt` cannot
|
|
289
|
+
* stand in for this: opening a settled review refreshes it, which moves that
|
|
290
|
+
* field forward long after the decision it is meant to date.
|
|
291
|
+
*/
|
|
292
|
+
settledAt: z.string().nullable().default(null),
|
|
281
293
|
/** Last time this review was pulled forward onto a newer head commit. */
|
|
282
294
|
refresh: RefreshInfoSchema.nullable().default(null),
|
|
283
295
|
calibration: CalibrationSchema.nullable().default(null),
|
package/dist/core/gh.js
CHANGED
|
@@ -254,6 +254,58 @@ export async function fetchReviewRequests(ref) {
|
|
|
254
254
|
export function stillRequested(requests, login) {
|
|
255
255
|
return requests.teams.length > 0 || requests.users.includes(login);
|
|
256
256
|
}
|
|
257
|
+
export function lastRequestOf(events, login) {
|
|
258
|
+
const mine = events.filter((e) => e.requestedReviewer?.__typename === "User" && e.requestedReviewer.login === login);
|
|
259
|
+
if (mine.length === 0)
|
|
260
|
+
return null;
|
|
261
|
+
return mine.reduce((a, b) => (b.createdAt > a.createdAt ? b : a)).createdAt;
|
|
262
|
+
}
|
|
263
|
+
// 100 is the page maximum, and it costs exactly what a smaller window would:
|
|
264
|
+
// one call. It wants to be big because the recent events are not the useful
|
|
265
|
+
// ones — a PR that cycled through a dozen reviewers can push the request that
|
|
266
|
+
// named *you* past a short window, and the answer would come back "nobody
|
|
267
|
+
// asked", silently restoring the bug this exists to fix.
|
|
268
|
+
//
|
|
269
|
+
// It is still a window, not a guarantee: a PR carrying more than 100 review
|
|
270
|
+
// requests would lose the oldest, and that is accepted rather than paginated.
|
|
271
|
+
// Paginating would cost one call per extra page on every settled row on every
|
|
272
|
+
// poll, to cover a PR that does not realistically exist — and the failure mode
|
|
273
|
+
// is the conservative one, a row left settled rather than one wrongly reopened.
|
|
274
|
+
const LAST_REQUEST_QUERY = `query($owner:String!,$repo:String!,$number:Int!){
|
|
275
|
+
repository(owner:$owner,name:$repo){
|
|
276
|
+
pullRequest(number:$number){
|
|
277
|
+
timelineItems(last:100,itemTypes:[REVIEW_REQUESTED_EVENT]){
|
|
278
|
+
nodes{... on ReviewRequestedEvent{createdAt requestedReviewer{__typename ... on User{login}}}}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}`;
|
|
283
|
+
/**
|
|
284
|
+
* Ask GitHub when your review was last requested on a PR.
|
|
285
|
+
*
|
|
286
|
+
* GraphQL rather than the REST timeline on purpose: this runs on rows the queue
|
|
287
|
+
* already holds, poll after poll, and the REST timeline of a busy PR is hundreds
|
|
288
|
+
* of events across several pages — every one of them fetched to find the handful
|
|
289
|
+
* that are review requests. Filtering server-side to the one event type turns
|
|
290
|
+
* that into a single call; the window it reads is bounded, as the query above
|
|
291
|
+
* explains.
|
|
292
|
+
*/
|
|
293
|
+
export async function fetchLastReviewRequest(ref, login) {
|
|
294
|
+
const out = await gh([
|
|
295
|
+
"api",
|
|
296
|
+
"graphql",
|
|
297
|
+
"-f",
|
|
298
|
+
`query=${LAST_REQUEST_QUERY}`,
|
|
299
|
+
"-F",
|
|
300
|
+
`owner=${ref.owner}`,
|
|
301
|
+
"-F",
|
|
302
|
+
`repo=${ref.repo}`,
|
|
303
|
+
"-F",
|
|
304
|
+
`number=${ref.number}`,
|
|
305
|
+
]);
|
|
306
|
+
const raw = JSON.parse(out);
|
|
307
|
+
return lastRequestOf(raw.data?.repository?.pullRequest?.timelineItems?.nodes ?? [], login);
|
|
308
|
+
}
|
|
257
309
|
export function searchAwaitingArgs(repoFilter, limit = 50) {
|
|
258
310
|
const args = [
|
|
259
311
|
"search",
|
package/dist/core/refresh.js
CHANGED
|
@@ -71,6 +71,10 @@ export function mergeRunResult(fresh, current) {
|
|
|
71
71
|
return {
|
|
72
72
|
...fresh,
|
|
73
73
|
status: userOwnsStatus(current) ? current.status : fresh.status,
|
|
74
|
+
// Travels with the status it dates. A settle that landed while the run
|
|
75
|
+
// worked keeps both halves of itself, and a run that reopens the row takes
|
|
76
|
+
// the stamp away with the status.
|
|
77
|
+
settledAt: userOwnsStatus(current) ? current.settledAt : fresh.settledAt,
|
|
74
78
|
sent: current.sent,
|
|
75
79
|
calibration: current.calibration,
|
|
76
80
|
filed: current.filed,
|
package/dist/runner/review.js
CHANGED
|
@@ -32,7 +32,7 @@ const HEAD_SENSITIVE = new Set(["ready", "sent"]);
|
|
|
32
32
|
* as cerber overruling you — and on a busy PR it happens every few minutes.
|
|
33
33
|
* The way back in is the way you got out: press re-review, which forces.
|
|
34
34
|
*/
|
|
35
|
-
const SETTLED_BY_YOU = new Set(["reviewed", "skipped"]);
|
|
35
|
+
export const SETTLED_BY_YOU = new Set(["reviewed", "skipped"]);
|
|
36
36
|
/**
|
|
37
37
|
* Fetch a PR, run the AI review, persist the artifact at each stage.
|
|
38
38
|
* Everything stays local — nothing is ever written to GitHub.
|
|
@@ -160,6 +160,7 @@ async function runReview(ref, opts) {
|
|
|
160
160
|
sent: null,
|
|
161
161
|
refresh: null,
|
|
162
162
|
filed: null,
|
|
163
|
+
settledAt: null,
|
|
163
164
|
calibration: null,
|
|
164
165
|
// The conversation is the user's writing, so a re-review keeps it — the
|
|
165
166
|
// chat prompt replays the transcript, which is what makes it survive the
|
package/dist/server/daemon.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
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, fetchReviewRequests, lastWordOfYours, searchAwaitingMe, stillRequested, submitReview, } from "../core/gh.js";
|
|
4
|
+
import { classifyReply, currentLogin, fetchConversation, fetchLastReviewRequest, fetchOwnReview, fetchPrInfo, fetchReviewRequests, lastWordOfYours, searchAwaitingMe, stillRequested, submitReview, } from "../core/gh.js";
|
|
5
5
|
import { notice, notify } from "../core/notify.js";
|
|
6
6
|
import { buildReviewPayload, computeCalibration } from "../core/send.js";
|
|
7
7
|
import { appendAutoSendLog, deleteArtifact, listArtifacts, loadArtifact, saveArtifact, updateArtifactByKey, } from "../core/state.js";
|
|
8
8
|
import { ReviewInProgressError } from "../runner/inflight.js";
|
|
9
|
-
import { pool, reviewPr } from "../runner/review.js";
|
|
9
|
+
import { SETTLED_BY_YOU, pool, reviewPr } from "../runner/review.js";
|
|
10
10
|
/**
|
|
11
11
|
* A pure discovery stub: enough artifact for the queue to show an awaiting row.
|
|
12
12
|
* The AI run that follows (automatic or clicked) replaces it wholesale.
|
|
@@ -45,6 +45,7 @@ export function stubArtifact(ref) {
|
|
|
45
45
|
sent: null,
|
|
46
46
|
refresh: null,
|
|
47
47
|
filed: null,
|
|
48
|
+
settledAt: null,
|
|
48
49
|
calibration: null,
|
|
49
50
|
chat: [],
|
|
50
51
|
preChat: null,
|
|
@@ -122,7 +123,58 @@ export function filedByWithdrawnRequest(artifact) {
|
|
|
122
123
|
return false;
|
|
123
124
|
return artifact.run?.trigger === "daemon";
|
|
124
125
|
}
|
|
125
|
-
/**
|
|
126
|
+
/**
|
|
127
|
+
* When a settled row was settled, as well as cerber can date it — or null if it
|
|
128
|
+
* isn't settled at all, or a send already spoke for it.
|
|
129
|
+
*
|
|
130
|
+
* `settledAt` is the record and the only exact answer. Rows settled before that
|
|
131
|
+
* field existed fall back to the latest moment cerber can prove the decision
|
|
132
|
+
* came after: cerber filed it (`filed.at`), or you settled a draft, which you
|
|
133
|
+
* cannot have done before the draft existed (`run.finishedAt`). Both are lower
|
|
134
|
+
* bounds, so a stale one can only make this read a request as newer than it
|
|
135
|
+
* really was — one row coming back into the inbox once, against leaving every
|
|
136
|
+
* pre-existing settled row permanently unreachable, which is the bug.
|
|
137
|
+
*
|
|
138
|
+
* Not `updatedAt`: opening a settled review refreshes it onto the new head,
|
|
139
|
+
* which moves that field forward long after the decision it would be dating.
|
|
140
|
+
*/
|
|
141
|
+
export function settledAtOf(a) {
|
|
142
|
+
if (!SETTLED_BY_YOU.has(a.status) || a.sent)
|
|
143
|
+
return null;
|
|
144
|
+
return a.settledAt ?? a.filed?.at ?? a.run?.finishedAt ?? null;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Whether a review request is a *new* ask rather than the one you settled.
|
|
148
|
+
*
|
|
149
|
+
* A skip says "I am done with this PR", and a push does not undo that — the
|
|
150
|
+
* whole point of `SETTLED_BY_YOU`. Somebody asking you again is a different
|
|
151
|
+
* event: your skip answered the request that was open when you made it, and it
|
|
152
|
+
* cannot have answered one that came after. Withdrawn-then-re-added is the
|
|
153
|
+
* shape this catches, and it is not exotic — an author who pulls the request
|
|
154
|
+
* while you are mid-look and puts it back with the next push leaves the inbox
|
|
155
|
+
* silent about a review someone is now actively waiting on.
|
|
156
|
+
*
|
|
157
|
+
* Parsed, not compared as text, for the reason `filedByYourAct` gives: our
|
|
158
|
+
* timestamps carry milliseconds and GitHub's do not, and "…:00.500Z" sorts
|
|
159
|
+
* before "…:00Z".
|
|
160
|
+
*/
|
|
161
|
+
export function askedAgainAfterSettling(a, requestedAt) {
|
|
162
|
+
if (requestedAt === null)
|
|
163
|
+
return false;
|
|
164
|
+
const settled = settledAtOf(a);
|
|
165
|
+
return settled !== null && Date.parse(requestedAt) > Date.parse(settled);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* What a reopened row goes back to being: the draft it has, or a row waiting
|
|
169
|
+
* for one. A run that failed left no draft to show, so it reopens as `awaiting`
|
|
170
|
+
* and the poll drafts it again.
|
|
171
|
+
*/
|
|
172
|
+
export function reopenedStatus(a) {
|
|
173
|
+
return a.run?.finishedAt && !a.run.error ? "ready" : "awaiting";
|
|
174
|
+
}
|
|
175
|
+
/** How many artifacts each of the poll's GitHub re-checks — the open-PR state
|
|
176
|
+
* refresh, and the settled rows it asks about again — may spend per poll, and
|
|
177
|
+
* how often any one artifact is re-asked about. */
|
|
126
178
|
const STATE_REFRESH_CAP = 10;
|
|
127
179
|
const STATE_REFRESH_MIN_MS = 30 * 60_000;
|
|
128
180
|
/**
|
|
@@ -162,6 +214,9 @@ export function startDaemon(opts) {
|
|
|
162
214
|
/** When GitHub last confirmed an artifact's PR state, by artifact id. In-memory:
|
|
163
215
|
* a restart just re-checks sooner, which is harmless. */
|
|
164
216
|
const stateCheckedAt = new Map();
|
|
217
|
+
/** When GitHub was last asked whether a settled row has been re-requested, by
|
|
218
|
+
* artifact id. In-memory for the same reason: a restart just asks sooner. */
|
|
219
|
+
const requestCheckedAt = new Map();
|
|
165
220
|
async function discover() {
|
|
166
221
|
const filters = opts.repos.length > 0 ? opts.repos : [undefined];
|
|
167
222
|
const seen = new Set();
|
|
@@ -183,9 +238,67 @@ export function startDaemon(opts) {
|
|
|
183
238
|
*/
|
|
184
239
|
async function syncQueue(refs) {
|
|
185
240
|
const discovered = [];
|
|
241
|
+
/** Reopen checks spent this poll — the same cap the state refresh below obeys. */
|
|
242
|
+
let reopenChecks = 0;
|
|
243
|
+
// Asked once per pass, and only if a row actually reaches a check that
|
|
244
|
+
// needs it. Undefined is "not asked yet"; null is "gh could not say", which
|
|
245
|
+
// attributes nothing to you rather than guessing.
|
|
246
|
+
let login;
|
|
247
|
+
/**
|
|
248
|
+
* Put a settled row back in the inbox when somebody has asked you again.
|
|
249
|
+
*
|
|
250
|
+
* The mirror of `fileIfSettledElsewhere`: that one files rows away once
|
|
251
|
+
* GitHub has moved past them, this one brings one back when GitHub has
|
|
252
|
+
* moved *to* it. Both exist because the queue's picture of who is waiting
|
|
253
|
+
* on what is local, and the thing it is a picture of lives on github.com.
|
|
254
|
+
*
|
|
255
|
+
* Only rows GitHub is asking about right now get here at all — the caller
|
|
256
|
+
* is looping over the awaiting search — so the extra read is one call for
|
|
257
|
+
* one question: was that request made after you settled?
|
|
258
|
+
*/
|
|
259
|
+
async function reopenIfAskedAgain(artifact) {
|
|
260
|
+
if (settledAtOf(artifact) === null)
|
|
261
|
+
return;
|
|
262
|
+
const last = requestCheckedAt.get(artifact.id) ?? 0;
|
|
263
|
+
if (reopenChecks >= STATE_REFRESH_CAP || Date.now() - last < STATE_REFRESH_MIN_MS)
|
|
264
|
+
return;
|
|
265
|
+
reopenChecks++;
|
|
266
|
+
requestCheckedAt.set(artifact.id, Date.now());
|
|
267
|
+
if (login === undefined)
|
|
268
|
+
login = await currentLogin().catch(() => null);
|
|
269
|
+
if (login === null)
|
|
270
|
+
return;
|
|
271
|
+
let requestedAt;
|
|
272
|
+
try {
|
|
273
|
+
requestedAt = await fetchLastReviewRequest(artifact.pr, login);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
// A read that failed is not evidence of anything, so nothing happens to
|
|
277
|
+
// the row. It is not re-asked about until the leash above expires: the
|
|
278
|
+
// stamp is spent before the call on purpose, so gh being down cannot
|
|
279
|
+
// turn every settled row into a retry on every poll.
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (requestedAt === null || !askedAgainAfterSettling(artifact, requestedAt))
|
|
283
|
+
return;
|
|
284
|
+
const at = requestedAt;
|
|
285
|
+
// Re-read from disk: a re-review or a send may have started in the time
|
|
286
|
+
// that call took, and neither wants its status rewritten underneath it.
|
|
287
|
+
const saved = await updateArtifactByKey(artifactKey(artifact.id), (a) => askedAgainAfterSettling(a, at)
|
|
288
|
+
? // `filed` goes with the status it explained: cerber's account of why
|
|
289
|
+
// this row was settled is not the story of a row that is back.
|
|
290
|
+
{ ...a, status: reopenedStatus(a), settledAt: null, filed: null }
|
|
291
|
+
: a);
|
|
292
|
+
// The status is what says the write landed. `settledAt` cannot: a legacy
|
|
293
|
+
// row never had one, so a mutation that declined would read as a reopen.
|
|
294
|
+
if (saved && !SETTLED_BY_YOU.has(saved.status)) {
|
|
295
|
+
log(`[${artifact.id}] your review was requested again on ${at.slice(0, 10)} — back in the inbox`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
186
298
|
for (const ref of refs) {
|
|
187
299
|
const existing = await loadArtifact(artifactId(ref));
|
|
188
300
|
if (existing) {
|
|
301
|
+
await reopenIfAskedAgain(existing);
|
|
189
302
|
// The search result is what the PR is right now; the artifact can
|
|
190
303
|
// predate a draft→ready flip. Refresh from it — it costs nothing, it's
|
|
191
304
|
// already in hand — or the queue keeps calling a ready PR a draft until
|
|
@@ -201,10 +314,6 @@ export function startDaemon(opts) {
|
|
|
201
314
|
await saveArtifact(stubArtifact(ref));
|
|
202
315
|
discovered.push(ref);
|
|
203
316
|
}
|
|
204
|
-
// Asked once per pass, and only if a row actually reaches the check that
|
|
205
|
-
// needs it. Undefined is "not asked yet"; null is "gh could not say", which
|
|
206
|
-
// attributes nothing to you rather than guessing.
|
|
207
|
-
let login;
|
|
208
317
|
/**
|
|
209
318
|
* File a draft away once GitHub has moved past it without cerber's help.
|
|
210
319
|
*
|
|
@@ -240,7 +349,7 @@ export function startDaemon(opts) {
|
|
|
240
349
|
/** Re-reads from disk: a run or a send may have started in the seconds
|
|
241
350
|
* these reads took, and neither wants filing out from under it. */
|
|
242
351
|
async function file(filed, note, stillFilable) {
|
|
243
|
-
const saved = await updateArtifactByKey(artifactKey(artifact.id), (a) => stillFilable(a) ? { ...a, status: "reviewed", filed } : a);
|
|
352
|
+
const saved = await updateArtifactByKey(artifactKey(artifact.id), (a) => stillFilable(a) ? { ...a, status: "reviewed", filed, settledAt: filed.at } : a);
|
|
244
353
|
if (saved?.filed)
|
|
245
354
|
log(`[${artifact.id}] ${note} — filed as reviewed`);
|
|
246
355
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -270,6 +270,7 @@ export async function buildApp(opts) {
|
|
|
270
270
|
sent: null,
|
|
271
271
|
refresh: null,
|
|
272
272
|
filed: null,
|
|
273
|
+
settledAt: null,
|
|
273
274
|
calibration: null,
|
|
274
275
|
chat: [],
|
|
275
276
|
preChat: null,
|
|
@@ -311,6 +312,16 @@ export async function buildApp(opts) {
|
|
|
311
312
|
const next = { ...a };
|
|
312
313
|
if (body.status !== undefined) {
|
|
313
314
|
next.status = ArtifactStatusSchema.parse(body.status);
|
|
315
|
+
// Dated on the way in, because nothing else can date it later: the poll
|
|
316
|
+
// asks whether a review request came before or after this decision, and
|
|
317
|
+
// `updatedAt` moves every time the review is opened and refreshed.
|
|
318
|
+
next.settledAt = new Date().toISOString();
|
|
319
|
+
// And cerber's own account of why this row was settled goes with the
|
|
320
|
+
// status it explained. `filed` outranks the status wherever the queue
|
|
321
|
+
// tags a row (`rowTag`, `requestTag`), on the grounds that "reviewed"
|
|
322
|
+
// would otherwise read as a click nobody made — which stops being true
|
|
323
|
+
// the moment you click. Same rule the poll's reopen follows.
|
|
324
|
+
next.filed = null;
|
|
314
325
|
}
|
|
315
326
|
if (body.verdictRecommendation !== undefined && next.verdict) {
|
|
316
327
|
next.verdict = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fullstackhouse/cerber",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.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)",
|