@fullstackhouse/cerber 0.26.0 → 0.27.1
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 +6 -0
- package/dist/core/gh.js +115 -2
- package/dist/server/daemon.js +108 -29
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -415,6 +415,12 @@ above: the seven statuses and what sets each, which tab a PR lands in and why,
|
|
|
415
415
|
when the poll files or archives a row on its own, when a re-review happens and
|
|
416
416
|
what it will not overwrite, and what gets written to disk at each step.
|
|
417
417
|
|
|
418
|
+
[**SPEC.md**](SPEC.md) is the full service specification — the artifact
|
|
419
|
+
schema, every lifecycle and filing rule, the runner's tool policy and
|
|
420
|
+
credential hygiene, the send path — written precisely enough to reimplement
|
|
421
|
+
cerber from. lifecycle.md is the operator's view of the product; SPEC.md is
|
|
422
|
+
the engineer's.
|
|
423
|
+
|
|
418
424
|
## Development
|
|
419
425
|
|
|
420
426
|
```bash
|
package/dist/core/gh.js
CHANGED
|
@@ -90,8 +90,88 @@ async function membershipCheck(args) {
|
|
|
90
90
|
throw err;
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* GitHub refuses to render a diff past 300 changed files: `gh pr diff` comes
|
|
95
|
+
* back 406 and the review dies before it starts. The files API answers for ten
|
|
96
|
+
* times as many, so a too-large diff is re-assembled from it rather than
|
|
97
|
+
* reported as a broken review — see {@link assembleDiff}.
|
|
98
|
+
*/
|
|
93
99
|
export async function fetchPrDiff(ref) {
|
|
94
|
-
|
|
100
|
+
try {
|
|
101
|
+
return await gh(["pr", "diff", String(ref.number), "--repo", `${ref.owner}/${ref.repo}`]);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
// Matched on GitHub's own `too_large` signature rather than the bare 406:
|
|
105
|
+
// 406 is "Not Acceptable" generally, while `too_large` is emitted for this
|
|
106
|
+
// refusal and nothing else. Matching the meaning survives a status change;
|
|
107
|
+
// matching the status would fall back on some unrelated 406 one day.
|
|
108
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
109
|
+
if (!/too_large|exceeded the maximum number of files/i.test(message))
|
|
110
|
+
throw err;
|
|
111
|
+
const files = await fetchPrFiles(ref);
|
|
112
|
+
const diff = assembleDiff(files);
|
|
113
|
+
// The files API stops at 3000 too, and says nothing when it does. Hitting
|
|
114
|
+
// the cap exactly is not proof of truncation — a PR can change exactly
|
|
115
|
+
// 3000 files — and there is nothing here to tell the two apart, so the
|
|
116
|
+
// note reports the doubt rather than asserting a loss that may not exist.
|
|
117
|
+
return files.length < FILES_API_CAP
|
|
118
|
+
? diff
|
|
119
|
+
: `${diff}[GitHub's files API returns at most ${FILES_API_CAP} files and returned exactly that many — if this PR changes more, the rest is missing from this diff. Read the checkout.]\n`;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const FILES_API_CAP = 3000;
|
|
123
|
+
async function fetchPrFiles(ref) {
|
|
124
|
+
const out = await gh([
|
|
125
|
+
"api",
|
|
126
|
+
"--paginate",
|
|
127
|
+
`repos/${ref.owner}/${ref.repo}/pulls/${ref.number}/files?per_page=100`,
|
|
128
|
+
"--jq",
|
|
129
|
+
".[] | {filename, status, previous_filename, additions, deletions, patch}",
|
|
130
|
+
]);
|
|
131
|
+
// --jq streams one object per line rather than a JSON array.
|
|
132
|
+
return out
|
|
133
|
+
.split("\n")
|
|
134
|
+
.filter((line) => line.trim() !== "")
|
|
135
|
+
.map((line) => JSON.parse(line));
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Put the `diff --git` / `---` / `+++` headers back on the per-file hunks the
|
|
139
|
+
* files API returns, so the result parses exactly like `gh pr diff` output —
|
|
140
|
+
* `splitDiffByFile` and the line anchoring read both the same way.
|
|
141
|
+
*
|
|
142
|
+
* A file whose patch GitHub withheld still gets its headers and a line saying
|
|
143
|
+
* so: an unexplained gap would read as "nothing changed here".
|
|
144
|
+
*/
|
|
145
|
+
export function assembleDiff(files) {
|
|
146
|
+
const out = [];
|
|
147
|
+
for (const file of files) {
|
|
148
|
+
const renamed = file.status === "renamed" && file.previous_filename;
|
|
149
|
+
const oldPath = file.status === "added" ? null : renamed ? file.previous_filename : file.filename;
|
|
150
|
+
const newPath = file.status === "removed" ? null : file.filename;
|
|
151
|
+
// The side a file does not have is /dev/null, in the headers and in the
|
|
152
|
+
// binary line alike — that is how git names it.
|
|
153
|
+
const a = oldPath ? `a/${oldPath}` : "/dev/null";
|
|
154
|
+
const b = newPath ? `b/${newPath}` : "/dev/null";
|
|
155
|
+
out.push(`diff --git a/${oldPath ?? file.filename} b/${newPath ?? file.filename}`);
|
|
156
|
+
if (renamed)
|
|
157
|
+
out.push(`rename from ${oldPath}`, `rename to ${newPath}`);
|
|
158
|
+
if (file.patch) {
|
|
159
|
+
out.push(`--- ${a}`, `+++ ${b}`, file.patch.replace(/\n$/, ""));
|
|
160
|
+
}
|
|
161
|
+
else if (file.additions > 0 || file.deletions > 0) {
|
|
162
|
+
// GitHub counted the lines and then withheld the patch: a text file it
|
|
163
|
+
// decided was too big. Say so — an empty file block reads as "unchanged".
|
|
164
|
+
out.push(`--- ${a}`, `+++ ${b}`, `[GitHub withheld this file's patch — ${file.additions} addition(s), ${file.deletions} deletion(s). Read the file in the checkout.]`);
|
|
165
|
+
}
|
|
166
|
+
else if (!renamed) {
|
|
167
|
+
out.push(`Binary files ${a} and ${b} differ`);
|
|
168
|
+
}
|
|
169
|
+
// A pure rename ends here, with no hunks and no ---/+++ pair: nothing about
|
|
170
|
+
// the content changed, and `rename from`/`rename to` have already said
|
|
171
|
+
// everything. Calling it binary — which counting lines alone would — is
|
|
172
|
+
// both wrong and the more alarming of the two ways to be wrong.
|
|
173
|
+
}
|
|
174
|
+
return out.length === 0 ? "" : out.join("\n") + "\n";
|
|
95
175
|
}
|
|
96
176
|
/**
|
|
97
177
|
* Map `gh search prs` JSON output to PR refs.
|
|
@@ -132,7 +212,7 @@ export async function fetchConversation(ref) {
|
|
|
132
212
|
"--paginate",
|
|
133
213
|
`repos/${ref.owner}/${ref.repo}/issues/${ref.number}/comments`,
|
|
134
214
|
"--jq",
|
|
135
|
-
'.[] | {author: .user.login, at: .created_at, bot: (.user.type == "Bot"), url: .html_url}',
|
|
215
|
+
'.[] | {author: .user.login, at: .created_at, body: (.body // ""), bot: (.user.type == "Bot"), url: .html_url}',
|
|
136
216
|
]);
|
|
137
217
|
// --jq streams one object per line rather than a JSON array.
|
|
138
218
|
return out
|
|
@@ -173,6 +253,39 @@ export function lastWordOfYours(comments, login) {
|
|
|
173
253
|
const mine = comments.filter((c) => !isBot(c) && c.author === login);
|
|
174
254
|
return mine[mine.length - 1] ?? null;
|
|
175
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* Whether a comment speaks to you by name.
|
|
258
|
+
*
|
|
259
|
+
* GitHub's own rule, near enough: an `@login` that isn't part of a longer word.
|
|
260
|
+
* The boundary is "not a login character" rather than `\b`, because logins may
|
|
261
|
+
* contain hyphens — `@me-bot` is a different account from `@me`, and
|
|
262
|
+
* `you@me.dev` is an email address, not an ask.
|
|
263
|
+
*
|
|
264
|
+
* Deliberately not team mentions. `@org/team` is a room being addressed, and
|
|
265
|
+
* this is used to undo a decision of yours, which wants somebody naming *you*.
|
|
266
|
+
*/
|
|
267
|
+
export function mentionsYou(body, login) {
|
|
268
|
+
const name = login.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
269
|
+
return new RegExp(`(^|[^\\w-])@${name}(?![\\w-])`, "i").test(body);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* The last comment somebody addressed to you by name, or null if nobody has.
|
|
273
|
+
*
|
|
274
|
+
* The other half of what the conversation can say. `classifyReply` reads it for
|
|
275
|
+
* whose move it is; this reads the same comments for the ask GitHub's own API
|
|
276
|
+
* has no event for — "@you this is ready for another look", typed instead of
|
|
277
|
+
* clicking the re-request button. Your own comments never count, and bots never
|
|
278
|
+
* do: a bot that @-mentions the assignee on every push would otherwise be an
|
|
279
|
+
* ask on every push.
|
|
280
|
+
*/
|
|
281
|
+
export function lastMentionOfYou(comments, login) {
|
|
282
|
+
const theirs = comments.filter((c) => !isBot(c) && c.author !== login && mentionsYou(c.body, login));
|
|
283
|
+
if (theirs.length === 0)
|
|
284
|
+
return null;
|
|
285
|
+
// By date, not by position: this undoes a settle, so it does not lean on the
|
|
286
|
+
// API happening to return the conversation in order.
|
|
287
|
+
return theirs.reduce((a, b) => (b.at > a.at ? b : a));
|
|
288
|
+
}
|
|
176
289
|
/**
|
|
177
290
|
* The last review of your own that still stands, or null.
|
|
178
291
|
*
|
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, fetchLastReviewRequest, fetchOwnReview, fetchPrInfo, fetchReviewRequests, lastWordOfYours, searchAwaitingMe, stillRequested, submitReview, } from "../core/gh.js";
|
|
4
|
+
import { classifyReply, currentLogin, fetchConversation, fetchLastReviewRequest, fetchOwnReview, fetchPrInfo, fetchReviewRequests, lastMentionOfYou, lastWordOfYours, searchAwaitingMe, stillRequested, submitReview, } from "../core/gh.js";
|
|
5
5
|
import { withWriter } from "../core/history.js";
|
|
6
6
|
import { notice, notify } from "../core/notify.js";
|
|
7
7
|
import { buildReviewPayload, computeCalibration } from "../core/send.js";
|
|
@@ -145,25 +145,40 @@ export function settledAtOf(a) {
|
|
|
145
145
|
return a.settledAt ?? a.filed?.at ?? a.run?.finishedAt ?? null;
|
|
146
146
|
}
|
|
147
147
|
/**
|
|
148
|
-
* Whether
|
|
148
|
+
* Whether an ask is a *new* one rather than the one you settled.
|
|
149
149
|
*
|
|
150
150
|
* A skip says "I am done with this PR", and a push does not undo that — the
|
|
151
151
|
* whole point of `SETTLED_BY_YOU`. Somebody asking you again is a different
|
|
152
|
-
* event: your skip answered
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
152
|
+
* event: your skip answered whatever was open when you made it, and it cannot
|
|
153
|
+
* have answered something that came after. Withdrawn-then-re-added is one shape
|
|
154
|
+
* this catches, and it is not exotic — an author who pulls the request while
|
|
155
|
+
* you are mid-look and puts it back with the next push leaves the inbox silent
|
|
156
|
+
* about a review someone is now actively waiting on. The other shape is the
|
|
157
|
+
* same author typing it instead (`Ask`).
|
|
157
158
|
*
|
|
158
159
|
* Parsed, not compared as text, for the reason `filedByYourAct` gives: our
|
|
159
160
|
* timestamps carry milliseconds and GitHub's do not, and "…:00.500Z" sorts
|
|
160
161
|
* before "…:00Z".
|
|
161
162
|
*/
|
|
162
|
-
export function askedAgainAfterSettling(a,
|
|
163
|
-
if (
|
|
163
|
+
export function askedAgainAfterSettling(a, askedAt) {
|
|
164
|
+
if (askedAt === null)
|
|
164
165
|
return false;
|
|
165
166
|
const settled = settledAtOf(a);
|
|
166
|
-
return settled !== null && Date.parse(
|
|
167
|
+
return settled !== null && Date.parse(askedAt) > Date.parse(settled);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* What the daemon says about a reopen, in the log and in the row's history.
|
|
171
|
+
*
|
|
172
|
+
* One place, so the line the user reads in the terminal and the line they read
|
|
173
|
+
* on the review months later cannot tell different stories about why a decision
|
|
174
|
+
* of theirs was undone — which is the only thing that makes undoing it fair.
|
|
175
|
+
*/
|
|
176
|
+
export function askWording(ask) {
|
|
177
|
+
const day = ask.at.slice(0, 10);
|
|
178
|
+
const what = ask.how === "requested"
|
|
179
|
+
? `your review was requested again on ${day}`
|
|
180
|
+
: `${ask.by} asked for you by name on ${day}`;
|
|
181
|
+
return { log: `${what} — back in the inbox`, history: `back in the inbox: ${what}, after you settled it` };
|
|
167
182
|
}
|
|
168
183
|
/**
|
|
169
184
|
* What a reopened row goes back to being: the draft it has, or a row waiting
|
|
@@ -245,6 +260,11 @@ export function startDaemon(opts) {
|
|
|
245
260
|
// needs it. Undefined is "not asked yet"; null is "gh could not say", which
|
|
246
261
|
// attributes nothing to you rather than guessing.
|
|
247
262
|
let login;
|
|
263
|
+
async function who() {
|
|
264
|
+
if (login === undefined)
|
|
265
|
+
login = await currentLogin().catch(() => null);
|
|
266
|
+
return login;
|
|
267
|
+
}
|
|
248
268
|
/**
|
|
249
269
|
* Put a settled row back in the inbox when somebody has asked you again.
|
|
250
270
|
*
|
|
@@ -253,9 +273,17 @@ export function startDaemon(opts) {
|
|
|
253
273
|
* moved *to* it. Both exist because the queue's picture of who is waiting
|
|
254
274
|
* on what is local, and the thing it is a picture of lives on github.com.
|
|
255
275
|
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
276
|
+
* Two ways to be asked, because people use both. GitHub's re-request button
|
|
277
|
+
* leaves a dated event and is read first: it is the strongest evidence and
|
|
278
|
+
* the cheapest call. Failing that, the conversation is read for the last
|
|
279
|
+
* comment naming you — "@you this is ready for another look", which is the
|
|
280
|
+
* same ask with no event behind it. Reading only the button was the bug:
|
|
281
|
+
* a settled row sat invisible while its author waited, having said so in
|
|
282
|
+
* plain words on the PR.
|
|
283
|
+
*
|
|
284
|
+
* Only rows GitHub is still asking about get here — the caller is looping
|
|
285
|
+
* over the awaiting search. A row whose request is gone (you reviewed on
|
|
286
|
+
* github.com) is caught by `reopenIfAskedInWords` on the other loop instead.
|
|
259
287
|
*/
|
|
260
288
|
async function reopenIfAskedAgain(artifact) {
|
|
261
289
|
if (settledAtOf(artifact) === null)
|
|
@@ -265,26 +293,72 @@ export function startDaemon(opts) {
|
|
|
265
293
|
return;
|
|
266
294
|
reopenChecks++;
|
|
267
295
|
requestCheckedAt.set(artifact.id, Date.now());
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if (login === null)
|
|
296
|
+
const me = await who();
|
|
297
|
+
if (me === null)
|
|
271
298
|
return;
|
|
272
|
-
let
|
|
299
|
+
let ask;
|
|
273
300
|
try {
|
|
274
|
-
requestedAt = await fetchLastReviewRequest(artifact.pr,
|
|
301
|
+
const requestedAt = await fetchLastReviewRequest(artifact.pr, me);
|
|
302
|
+
const request = requestedAt === null ? null : { at: requestedAt, how: "requested" };
|
|
303
|
+
// The words are only worth a second call when the button did not
|
|
304
|
+
// already answer: a live re-request says everything a comment could.
|
|
305
|
+
ask = askedAgainAfterSettling(artifact, request?.at ?? null)
|
|
306
|
+
? request
|
|
307
|
+
: await askedInWords(artifact, me);
|
|
275
308
|
}
|
|
276
309
|
catch {
|
|
277
310
|
// A read that failed is not evidence of anything, so nothing happens to
|
|
278
311
|
// the row. It is not re-asked about until the leash above expires: the
|
|
279
312
|
// stamp is spent before the call on purpose, so gh being down cannot
|
|
280
|
-
// turn every settled row into a retry on every poll.
|
|
313
|
+
// turn every settled row into a retry on every poll. Only the reads are
|
|
314
|
+
// guarded: a write that fails is a real fault and belongs to the caller.
|
|
281
315
|
return;
|
|
282
316
|
}
|
|
283
|
-
|
|
317
|
+
await reopen(artifact, ask);
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* The same reopen for a settled row GitHub is *not* asking about.
|
|
321
|
+
*
|
|
322
|
+
* The case is ordinary: you reviewed on github.com, which clears the
|
|
323
|
+
* request and drops the PR out of the awaiting search, and the author then
|
|
324
|
+
* pushes and writes "@you ready for another look". No request exists to
|
|
325
|
+
* re-read, so the words are the whole of the evidence — and without this
|
|
326
|
+
* the mention check would only work for the rows that happen to still carry
|
|
327
|
+
* an open request, which is a fix that half-works.
|
|
328
|
+
*
|
|
329
|
+
* Rides the state-refresh leash rather than one of its own: the caller has
|
|
330
|
+
* already spent that artifact's slot for this half-hour, and a settled row
|
|
331
|
+
* never reaches `fileIfSettledElsewhere`, so this costs the poll one call
|
|
332
|
+
* on rows that were costing it one already.
|
|
333
|
+
*/
|
|
334
|
+
async function reopenIfAskedInWords(artifact) {
|
|
335
|
+
if (settledAtOf(artifact) === null)
|
|
336
|
+
return;
|
|
337
|
+
const me = await who();
|
|
338
|
+
if (me === null)
|
|
339
|
+
return;
|
|
340
|
+
let ask;
|
|
341
|
+
try {
|
|
342
|
+
ask = await askedInWords(artifact, me);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
// Same as above: a failed read changes nothing and waits for the leash.
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
await reopen(artifact, ask);
|
|
349
|
+
}
|
|
350
|
+
/** The conversation's answer to "has anybody asked for me again", or null. */
|
|
351
|
+
async function askedInWords(artifact, me) {
|
|
352
|
+
const mention = lastMentionOfYou(await fetchConversation(artifact.pr), me);
|
|
353
|
+
return mention === null ? null : { at: mention.at, how: "mentioned", by: mention.author };
|
|
354
|
+
}
|
|
355
|
+
/** Undo the settle, if the ask really is newer than it. */
|
|
356
|
+
async function reopen(artifact, ask) {
|
|
357
|
+
if (ask === null || !askedAgainAfterSettling(artifact, ask.at))
|
|
284
358
|
return;
|
|
285
|
-
const at =
|
|
359
|
+
const at = ask.at;
|
|
286
360
|
// Re-read from disk: a re-review or a send may have started in the time
|
|
287
|
-
//
|
|
361
|
+
// those calls took, and neither wants its status rewritten underneath it.
|
|
288
362
|
const saved = await updateArtifactByKey(artifactKey(artifact.id), (a) => askedAgainAfterSettling(a, at)
|
|
289
363
|
? // `filed` goes with the status it explained: cerber's account of why
|
|
290
364
|
// this row was settled is not the story of a row that is back.
|
|
@@ -293,10 +367,11 @@ export function startDaemon(opts) {
|
|
|
293
367
|
// The status is what says the write landed. `settledAt` cannot: a legacy
|
|
294
368
|
// row never had one, so a mutation that declined would read as a reopen.
|
|
295
369
|
if (saved && !SETTLED_BY_YOU.has(saved.status)) {
|
|
296
|
-
|
|
370
|
+
const said = askWording(ask);
|
|
371
|
+
log(`[${artifact.id}] ${said.log}`);
|
|
297
372
|
// The status change alone says a row moved; this says why it moved,
|
|
298
373
|
// which is the whole reason a settle is allowed to be undone at all.
|
|
299
|
-
await noteHistory(artifact.id,
|
|
374
|
+
await noteHistory(artifact.id, said.history);
|
|
300
375
|
}
|
|
301
376
|
}
|
|
302
377
|
for (const ref of refs) {
|
|
@@ -345,11 +420,9 @@ export function startDaemon(opts) {
|
|
|
345
420
|
// that could never qualify costs no GitHub call at all.
|
|
346
421
|
if (artifact.status !== "ready" || artifact.sent)
|
|
347
422
|
return;
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
if (login === null)
|
|
423
|
+
const me = await who();
|
|
424
|
+
if (me === null)
|
|
351
425
|
return;
|
|
352
|
-
const me = login;
|
|
353
426
|
/** Re-reads from disk: a run or a send may have started in the seconds
|
|
354
427
|
* these reads took, and neither wants filing out from under it. */
|
|
355
428
|
async function file(filed, note, stillFilable) {
|
|
@@ -447,8 +520,14 @@ export function startDaemon(opts) {
|
|
|
447
520
|
}
|
|
448
521
|
if (pr.state !== "OPEN")
|
|
449
522
|
log(`[${artifact.id}] ${pr.state.toLowerCase()} — archived`);
|
|
450
|
-
else
|
|
523
|
+
else {
|
|
524
|
+
// Exactly one of these two spends a call: the first only looks at
|
|
525
|
+
// settled rows, the second only at finished drafts. They are the same
|
|
526
|
+
// question from opposite sides — has GitHub moved past this, or back
|
|
527
|
+
// to it — and a row can only be on one side of it.
|
|
528
|
+
await reopenIfAskedInWords(artifact);
|
|
451
529
|
await fileIfSettledElsewhere(artifact);
|
|
530
|
+
}
|
|
452
531
|
}
|
|
453
532
|
catch {
|
|
454
533
|
// 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.
|
|
3
|
+
"version": "0.27.1",
|
|
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)",
|