@fullstackhouse/cerber 0.22.0 → 0.23.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.
@@ -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
- * 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.
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
- /** The review of yours GitHub had, which is what settled this one. */
119
- review: z.object({
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/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",
@@ -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
- /** Statuses that a fresh artifact can keep without a re-run. */
20
- const FRESH_STATUSES = new Set(["ready", "reviewed", "sent", "skipped"]);
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
- FRESH_STATUSES.has(existing.status) &&
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.`);
@@ -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, 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
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";
@@ -67,21 +67,25 @@ export function isPureStub(artifact) {
67
67
  return artifact.status === "awaiting" && artifact.comments.length === 0 && artifact.run === null;
68
68
  }
69
69
  /**
70
- * Whether a review of yours on GitHub settles this draft.
70
+ * Whether something you did on GitHub at `at` settles this draft.
71
71
  *
72
72
  * Two facts have to hold. GitHub must have stopped asking you — the caller has
73
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.
74
+ * awaiting search — and you must have had your say, which is the half cerber
75
+ * could not see before: it happens on github.com and leaves no trace here.
76
+ * Together they mean the PR is back with its author, while the inbox went on
77
+ * listing the draft as work still waiting on you.
78
+ *
79
+ * Having your say is a review of your own or a comment nobody has answered
80
+ * since. GitHub counts only the first as a review — a comment leaves the
81
+ * request standing — but the question here is not what GitHub counts, it is
82
+ * whether the row is still work. Somebody waiting on the author is not.
79
83
  *
80
84
  * The trigger test is what keeps this from undoing a deliberate act. A draft
81
85
  * 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.
86
+ * spoken is a second opinion you went and requested, so filing it away would
87
+ * answer a question you had just posed. A draft the poll wrote on its own
88
+ * carries no such intent: it is exactly the row this is here to close.
85
89
  *
86
90
  * A run recorded before the trigger was kept is treated as the poll's, which is
87
91
  * the deliberate choice: the alternative leaves every draft written before this
@@ -89,15 +93,33 @@ export function isPureStub(artifact) {
89
93
  * is one legacy draft filed once — and the re-review that un-files it records a
90
94
  * trigger, so it cannot happen to the same row twice.
91
95
  */
92
- export function filedByOwnReview(artifact, review) {
93
- if (artifact.status !== "ready" || artifact.sent || !review)
96
+ export function filedByYourAct(artifact, at) {
97
+ if (artifact.status !== "ready" || artifact.sent || at === null)
94
98
  return false;
95
99
  const run = artifact.run;
96
100
  // Parsed, not compared as text. `startedAt` carries milliseconds and GitHub's
97
101
  // `submitted_at` does not, and "…:00.500Z" sorts *before* "…:00Z" — so a run
98
102
  // that began half a second after your review would read as older than it, and
99
103
  // 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));
104
+ return !(run?.trigger === "user" && Date.parse(run.startedAt) >= Date.parse(at));
105
+ }
106
+ /**
107
+ * Whether a review request that simply went away settles this draft.
108
+ *
109
+ * The other two reasons rest on something you did; this one rests on something
110
+ * that stopped being true, which is a weaker kind of evidence and gets a
111
+ * stricter guard for it. Only a draft the poll wrote on its own can be filed
112
+ * this way: the poll only ever writes one because GitHub asked you, so the
113
+ * request going away is the whole of that draft's reason for existing going
114
+ * away. A draft you pasted in or asked for was never in that search to begin
115
+ * with, and `null` — a run older than the trigger field — cannot be told from
116
+ * one, so neither qualifies. Unlike the own-review case there is no second
117
+ * fact to fall back on, so an unknown here has to mean no.
118
+ */
119
+ export function filedByWithdrawnRequest(artifact) {
120
+ if (artifact.status !== "ready" || artifact.sent)
121
+ return false;
122
+ return artifact.run?.trigger === "daemon";
101
123
  }
102
124
  /** How many open-PR artifacts to re-check against GitHub per poll, and how often each. */
103
125
  const STATE_REFRESH_CAP = 10;
@@ -180,15 +202,28 @@ export function startDaemon(opts) {
180
202
  // attributes nothing to you rather than guessing.
181
203
  let login;
182
204
  /**
183
- * File a draft away once GitHub says you already reviewed the PR yourself.
205
+ * File a draft away once GitHub has moved past it without cerber's help.
206
+ *
207
+ * The queue lists what you have not dealt with locally, and everything that
208
+ * happens on github.com is invisible to it — so a PR you answered days ago
209
+ * sat in the inbox as if it still wanted you. Three ways that happens, in
210
+ * the order of how much they say:
211
+ *
212
+ * 1. You submitted a review there. The strongest: GitHub itself counts it.
213
+ * 2. You commented and nobody has answered since. Not a review as far as
214
+ * GitHub is concerned, but the ball is still in the author's court.
215
+ * 3. Nobody is asking any more. You never said anything; whoever
216
+ * requested you took the request back, or the PR got its reviews
217
+ * elsewhere. Nothing left here is work.
218
+ *
219
+ * Someone answering your comment (`them`) is the case that deliberately
220
+ * files nothing: that is a reply addressed to you, sitting on a PR whose
221
+ * draft is right here.
184
222
  *
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.
223
+ * All of it rides the same 30-minute-per-artifact leash as the state check
224
+ * it hangs off, so a queue of a hundred rows is still a handful of calls.
190
225
  */
191
- async function fileIfYouReviewedItYourself(artifact) {
226
+ async function fileIfSettledElsewhere(artifact) {
192
227
  // Only a finished, unsent draft can be filed — checked here too, so a row
193
228
  // that could never qualify costs no GitHub call at all.
194
229
  if (artifact.status !== "ready" || artifact.sent)
@@ -197,17 +232,45 @@ export function startDaemon(opts) {
197
232
  login = await currentLogin().catch(() => null);
198
233
  if (login === null)
199
234
  return;
200
- const review = await fetchOwnReview(artifact.pr, login);
201
- if (!review || !filedByOwnReview(artifact, review))
235
+ const me = login;
236
+ /** Re-reads from disk: a run or a send may have started in the seconds
237
+ * these reads took, and neither wants filing out from under it. */
238
+ async function file(filed, note, stillFilable) {
239
+ const saved = await updateArtifactByKey(artifactKey(artifact.id), (a) => stillFilable(a) ? { ...a, status: "reviewed", filed } : a);
240
+ if (saved?.filed)
241
+ log(`[${artifact.id}] ${note} — filed as reviewed`);
242
+ }
243
+ const review = await fetchOwnReview(artifact.pr, me);
244
+ if (review) {
245
+ // A review of yours is the answer either way: if the guard rejects it,
246
+ // this is a second opinion you asked for, not a row to keep looking for
247
+ // another reason to file.
248
+ if (filedByYourAct(artifact, review.at)) {
249
+ 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));
250
+ }
251
+ return;
252
+ }
253
+ const conversation = await fetchConversation(artifact.pr);
254
+ const mine = lastWordOfYours(conversation, me);
255
+ if (mine) {
256
+ if (filedByYourAct(artifact, mine.at)) {
257
+ const reply = { at: mine.at, url: mine.url };
258
+ 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));
259
+ }
202
260
  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
261
  }
262
+ // Anything but silence from you leaves the row alone: `them` is somebody
263
+ // answering a comment of yours, which is the opposite of settled.
264
+ if (classifyReply(conversation, me) !== "none")
265
+ return;
266
+ if (!filedByWithdrawnRequest(artifact))
267
+ return;
268
+ // The awaiting search says nobody is asking, and here that is the entire
269
+ // case — so confirm it against the PR itself before acting on it. The
270
+ // other two reasons stand on a fact of their own and need no such check.
271
+ if (stillRequested(await fetchReviewRequests(artifact.pr), me))
272
+ return;
273
+ await file({ at: new Date().toISOString(), reason: "request-withdrawn", review: null, reply: null }, "nobody is asking for this review any more", filedByWithdrawnRequest);
211
274
  }
212
275
  const awaitingIds = new Set(refs.map((r) => artifactId(r)));
213
276
  let refreshed = 0;
@@ -262,7 +325,7 @@ export function startDaemon(opts) {
262
325
  if (pr.state !== "OPEN")
263
326
  log(`[${artifact.id}] ${pr.state.toLowerCase()} — archived`);
264
327
  else
265
- await fileIfYouReviewedItYourself(artifact);
328
+ await fileIfSettledElsewhere(artifact);
266
329
  }
267
330
  catch {
268
331
  // Same: book-keeping can wait for the next poll.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackhouse/cerber",
3
- "version": "0.22.0",
3
+ "version": "0.23.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)",