@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.
@@ -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",
@@ -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,
@@ -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
@@ -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
- /** How many open-PR artifacts to re-check against GitHub per poll, and how often each. */
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
  }
@@ -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.24.1",
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)",