@fullstackhouse/cerber 0.20.0 → 0.21.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 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
@@ -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() {
@@ -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
@@ -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}`),
@@ -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.20.0",
3
+ "version": "0.21.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)",