@fullstackhouse/cerber 0.34.0 → 0.35.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 +3 -0
- package/dist/cli/index.js +37 -2
- package/dist/core/artifact.js +85 -0
- package/dist/core/autosend.js +37 -1
- package/dist/core/export.js +8 -2
- package/dist/core/gh.js +151 -2
- package/dist/core/raised.js +175 -0
- package/dist/core/revise.js +11 -1
- package/dist/core/send.js +42 -2
- package/dist/core/state.js +7 -2
- package/dist/runner/claude.js +3 -0
- package/dist/runner/raised.js +218 -0
- package/dist/runner/review.js +9 -4
- package/dist/server/daemon.js +26 -14
- package/dist/server/index.js +145 -20
- package/package.json +4 -4
- package/web/dist/assets/index-BwVeoj7s.js +241 -0
- package/web/dist/assets/{index-Deoiz__g.css → index-DjRn6xUY.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-D-kgeiDO.js +0 -241
package/README.md
CHANGED
|
@@ -25,6 +25,9 @@ That gate is the whole point, and the reason this is not another review bot:
|
|
|
25
25
|
|
|
26
26
|
Named after Cerberus, the gatekeeper: cerber guards what gets merged.
|
|
27
27
|
|
|
28
|
+
Why it exists, and the four things I tried first:
|
|
29
|
+
[AI drafts the review. I decide what gets sent.](https://www.fullstack.house/blog/cerber?utm_source=github&utm_medium=readme)
|
|
30
|
+
|
|
28
31
|
**The whole loop in seventeen seconds** — five PRs already drafted when you sit
|
|
29
32
|
down, the one that isn't an approval, straight to its blocker, arguing with the
|
|
30
33
|
reviewer until it rewrites its own finding, dropping the comment you disagree
|
package/dist/cli/index.js
CHANGED
|
@@ -9,8 +9,8 @@ import { configPath, loadConfig, saveConfig } from "../core/config.js";
|
|
|
9
9
|
import { formatBlockers, isBlocking, preflight } from "../core/preflight.js";
|
|
10
10
|
import { withWriter } from "../core/history.js";
|
|
11
11
|
import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
|
|
12
|
-
import { parsePrRef, searchAwaitingMe, submitReview } from "../core/gh.js";
|
|
13
|
-
import { buildReviewPayload, computeCalibration, eventForRecommendation } from "../core/send.js";
|
|
12
|
+
import { parsePrRef, postReplies, searchAwaitingMe, submitReview } from "../core/gh.js";
|
|
13
|
+
import { buildReviewPayload, computeCalibration, describeReplyFailures, eventForRecommendation, recordReplies, } from "../core/send.js";
|
|
14
14
|
import { cerberHome, listArtifacts, loadArtifact, readAutoSendLog, reconcileRunning, updateArtifactByKey, } from "../core/state.js";
|
|
15
15
|
import { pool, reviewPr } from "../runner/review.js";
|
|
16
16
|
import { isReviewRunning } from "../runner/inflight.js";
|
|
@@ -21,6 +21,13 @@ const program = new Command();
|
|
|
21
21
|
* The published version, read from the manifest rather than typed here — a
|
|
22
22
|
* hand-maintained copy drifted to 0.5.0 while 0.30.0 shipped, and `--version`
|
|
23
23
|
* is the first thing anyone runs to decide whether a project is alive.
|
|
24
|
+
*
|
|
25
|
+
* In the published package that manifest is the one the release bumped, so this
|
|
26
|
+
* is the real version wherever anyone installed cerber. In a clone it reads
|
|
27
|
+
* `0.0.0-development`, because nothing commits the bump back to `main` and a
|
|
28
|
+
* number left behind there would quietly describe the release before last. A
|
|
29
|
+
* sentinel cannot drift: it says "built from source", which is true, instead of
|
|
30
|
+
* naming a version this build is not.
|
|
24
31
|
*/
|
|
25
32
|
const { version } = createRequire(import.meta.url)("../../package.json");
|
|
26
33
|
/**
|
|
@@ -208,6 +215,14 @@ program
|
|
|
208
215
|
`either of which clears the stale status.`);
|
|
209
216
|
process.exit(1);
|
|
210
217
|
}
|
|
218
|
+
// The same wait the cockpit makes. A check run by `serve` leaves the status
|
|
219
|
+
// `ready`, so the guard above cannot see it — and sending now would post
|
|
220
|
+
// the duplicates it is about to find.
|
|
221
|
+
if (artifact.raisedCheck?.checkingSince) {
|
|
222
|
+
console.error(`cerber is checking which findings of ${id} other reviewers already raised — send once it finishes. ` +
|
|
223
|
+
`If nothing is running, restart \`cerber serve\`, which clears a check that died mid-flight.`);
|
|
224
|
+
process.exit(1);
|
|
225
|
+
}
|
|
211
226
|
const event = (opts.event ??
|
|
212
227
|
(artifact.verdict ? eventForRecommendation(artifact.verdict.recommendation) : "COMMENT")).toUpperCase();
|
|
213
228
|
if (!["APPROVE", "COMMENT", "REQUEST_CHANGES"].includes(event)) {
|
|
@@ -221,6 +236,16 @@ program
|
|
|
221
236
|
if (payload.folded.length > 0) {
|
|
222
237
|
console.log(` comments folded into body (no diff anchor): ${payload.folded.length}`);
|
|
223
238
|
}
|
|
239
|
+
if (payload.replies.length > 0) {
|
|
240
|
+
console.log(` replies in another reviewer's thread: ${payload.replies.length}`);
|
|
241
|
+
}
|
|
242
|
+
if (payload.held.length > 0) {
|
|
243
|
+
console.log(` left out — already raised by another reviewer: ${payload.held.length}` +
|
|
244
|
+
` (decide in the cockpit to post or reply instead)`);
|
|
245
|
+
}
|
|
246
|
+
if (artifact.raisedCheck?.error) {
|
|
247
|
+
console.log(` ⚠ could not check what others already raised: ${artifact.raisedCheck.error}`);
|
|
248
|
+
}
|
|
224
249
|
console.log(` body: ${payload.body.length} chars\n`);
|
|
225
250
|
if (!opts.yes) {
|
|
226
251
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -244,6 +269,16 @@ program
|
|
|
244
269
|
calibration: computeCalibration(a, event),
|
|
245
270
|
}));
|
|
246
271
|
console.log(`✔ Review sent${url ? `: ${url}` : "."}`);
|
|
272
|
+
const outcomes = await postReplies(ref, payload.replies);
|
|
273
|
+
await updateArtifactByKey(artifactKey(id), (a) => recordReplies(a, outcomes));
|
|
274
|
+
const replyError = describeReplyFailures(outcomes);
|
|
275
|
+
if (replyError) {
|
|
276
|
+
console.error(`✖ ${replyError}`);
|
|
277
|
+
process.exitCode = 1;
|
|
278
|
+
}
|
|
279
|
+
else if (outcomes.length > 0) {
|
|
280
|
+
console.log(`✔ ${outcomes.length} ${outcomes.length === 1 ? "reply" : "replies"} posted in existing threads.`);
|
|
281
|
+
}
|
|
247
282
|
});
|
|
248
283
|
program
|
|
249
284
|
.command("trust")
|
package/dist/core/artifact.js
CHANGED
|
@@ -8,6 +8,63 @@ export const SCHEMA_VERSION = 1;
|
|
|
8
8
|
* is not a finding at all — a question, a note, praise — not "unknown".
|
|
9
9
|
*/
|
|
10
10
|
export const SeveritySchema = z.enum(["blocker", "minor", "nit"]);
|
|
11
|
+
/**
|
|
12
|
+
* Where on the PR somebody else already made a point: an inline thread, or the
|
|
13
|
+
* body of a review — some bots put their findings there and nowhere else.
|
|
14
|
+
*/
|
|
15
|
+
export const RaisedPlaceSchema = z.discriminatedUnion("kind", [
|
|
16
|
+
z.object({
|
|
17
|
+
kind: z.literal("thread"),
|
|
18
|
+
path: z.string(),
|
|
19
|
+
/** Null once GitHub can no longer place the thread on the current diff. */
|
|
20
|
+
line: z.number().int().positive().nullable(),
|
|
21
|
+
/** Outdated: the code it was on has changed since. Resolved wins over outdated. */
|
|
22
|
+
state: z.enum(["open", "resolved", "outdated"]),
|
|
23
|
+
/** REST id of the thread's first comment — what a reply is posted against. */
|
|
24
|
+
replyTo: z.string(),
|
|
25
|
+
}),
|
|
26
|
+
z.object({ kind: z.literal("review") }),
|
|
27
|
+
]);
|
|
28
|
+
/**
|
|
29
|
+
* Another reviewer — a person or a bot, never you — already raised the same
|
|
30
|
+
* defect on the PR.
|
|
31
|
+
*
|
|
32
|
+
* Found after the review, never shown to it: a review that has read earlier
|
|
33
|
+
* comments anchors on them, and they are third-party text the prompt would
|
|
34
|
+
* have to treat as hostile. So the draft is written blind, and this is the
|
|
35
|
+
* comparison afterwards (`src/runner/raised.ts`).
|
|
36
|
+
*
|
|
37
|
+
* It changes what Send does with the comment and nothing else — the comment
|
|
38
|
+
* stays in the draft, as written. What Send does is `decision`, and while it
|
|
39
|
+
* is null the default for the place applies (`sendTreatment` in
|
|
40
|
+
* `src/core/raised.ts`): left out, unless the thread was resolved, because a
|
|
41
|
+
* problem somebody marked settled that is still in the code is news.
|
|
42
|
+
*/
|
|
43
|
+
export const AlreadyRaisedSchema = z.object({
|
|
44
|
+
/** GitHub node id of the thread or review — how the next check finds it again. */
|
|
45
|
+
remarkId: z.string(),
|
|
46
|
+
by: z.string(),
|
|
47
|
+
at: z.string(),
|
|
48
|
+
url: z.string().nullable().default(null),
|
|
49
|
+
where: RaisedPlaceSchema,
|
|
50
|
+
/** Why the two are the same defect, in a sentence — and what this one adds, if anything. */
|
|
51
|
+
reason: z.string(),
|
|
52
|
+
/**
|
|
53
|
+
* What you chose: post it as a comment of its own anyway, or answer in their
|
|
54
|
+
* thread. Null is "not decided", which Send reads as the default above. Drop
|
|
55
|
+
* is not here: dropping is the comment's own `status`, as for any comment.
|
|
56
|
+
*/
|
|
57
|
+
decision: z.enum(["send", "reply"]).nullable().default(null),
|
|
58
|
+
/** The reply Send posted into their thread. */
|
|
59
|
+
replied: z.object({ at: z.string(), url: z.string().nullable().default(null) }).nullable().default(null),
|
|
60
|
+
/**
|
|
61
|
+
* The other remarks that raised the same thing, behind the one shown. Kept
|
|
62
|
+
* because each was compared once and will not be asked about again: if the
|
|
63
|
+
* one shown is deleted, the next best takes its place instead of the
|
|
64
|
+
* finding quietly posting as new.
|
|
65
|
+
*/
|
|
66
|
+
others: z.array(z.object({ remarkId: z.string(), reason: z.string() })).default([]),
|
|
67
|
+
});
|
|
11
68
|
/** A draft inline comment. Never sent to GitHub unless the user explicitly sends the review. */
|
|
12
69
|
export const CommentSchema = z.object({
|
|
13
70
|
id: z.string(),
|
|
@@ -26,6 +83,32 @@ export const CommentSchema = z.object({
|
|
|
26
83
|
originalLine: z.number().int().positive().nullable().default(null),
|
|
27
84
|
/** The commented line no longer exists in the diff — it can't post inline. */
|
|
28
85
|
drifted: z.boolean().default(false),
|
|
86
|
+
/** Somebody else already raised this on the PR. AI comments only. */
|
|
87
|
+
alreadyRaised: AlreadyRaisedSchema.nullable().default(null),
|
|
88
|
+
});
|
|
89
|
+
/**
|
|
90
|
+
* The last comparison of the draft against what others already said on the PR.
|
|
91
|
+
*
|
|
92
|
+
* It is incremental, and these lists are why: a check compares only what is
|
|
93
|
+
* new since the last one — a remark nobody has looked at yet, or a finding a
|
|
94
|
+
* chat turn added — so opening a review costs one read of GitHub and no model
|
|
95
|
+
* call unless something actually changed.
|
|
96
|
+
*/
|
|
97
|
+
export const RaisedCheckSchema = z.object({
|
|
98
|
+
/**
|
|
99
|
+
* When the last check that changed anything finished, or failed. A check
|
|
100
|
+
* that found nothing new writes nothing (`raisedChanged`). Null while the
|
|
101
|
+
* first is still running.
|
|
102
|
+
*/
|
|
103
|
+
at: z.string().nullable().default(null),
|
|
104
|
+
/** Set while a check runs detached, so the cockpit knows to wait for it. */
|
|
105
|
+
checkingSince: z.string().nullable().default(null),
|
|
106
|
+
/** GitHub node ids of every remark compared so far. */
|
|
107
|
+
remarks: z.array(z.string()).default([]),
|
|
108
|
+
/** Ids of every finding compared so far. */
|
|
109
|
+
findings: z.array(z.string()).default([]),
|
|
110
|
+
/** Why the last check could not finish. The matches from before it stand. */
|
|
111
|
+
error: z.string().nullable().default(null),
|
|
29
112
|
});
|
|
30
113
|
/** A logical group of changes — the unit of the review walkthrough. */
|
|
31
114
|
export const ChapterSchema = z.object({
|
|
@@ -364,6 +447,8 @@ export const ArtifactSchema = z.object({
|
|
|
364
447
|
.optional(),
|
|
365
448
|
/** Last time this review was pulled forward onto a newer head commit. */
|
|
366
449
|
refresh: RefreshInfoSchema.nullable().default(null),
|
|
450
|
+
/** The last check for findings somebody else already raised. Null: never checked. */
|
|
451
|
+
raisedCheck: RaisedCheckSchema.nullable().default(null),
|
|
367
452
|
calibration: CalibrationSchema.nullable().default(null),
|
|
368
453
|
/** The conversation about this review. Never sent to GitHub. */
|
|
369
454
|
chat: z.array(ChatTurnSchema).default([]),
|
package/dist/core/autosend.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
+
import { sendTreatment } from "./raised.js";
|
|
1
2
|
/**
|
|
2
3
|
* Auto-send policy — deliberately narrow:
|
|
3
4
|
* only APPROVE verdicts, at or above the confidence threshold, never a
|
|
4
5
|
* re-send. COMMENT and REQUEST_CHANGES always wait for a human.
|
|
6
|
+
*
|
|
7
|
+
* Findings somebody else already raised go the way Send's default sends them:
|
|
8
|
+
* an undecided one is left out, and the reason says how many were. Nobody is
|
|
9
|
+
* there to decide, and the default is the side that posts less. A reply into
|
|
10
|
+
* someone's thread is never auto-sent — that is a decision only a person makes,
|
|
11
|
+
* and auto-send posts one review and nothing else.
|
|
5
12
|
*/
|
|
6
13
|
export function evaluateAutoSend(artifact, threshold) {
|
|
7
14
|
if (artifact.sent)
|
|
@@ -31,5 +38,34 @@ export function evaluateAutoSend(artifact, threshold) {
|
|
|
31
38
|
reason: `confidence ${artifact.verdict.confidence}% < threshold ${threshold}%`,
|
|
32
39
|
};
|
|
33
40
|
}
|
|
34
|
-
|
|
41
|
+
// Nobody is watching an auto-send, so a check that never ran, is still
|
|
42
|
+
// running, or failed is not the same as one that found nothing: each would
|
|
43
|
+
// post every duplicate it missed. Only a finished, clean check will do.
|
|
44
|
+
const check = artifact.raisedCheck;
|
|
45
|
+
if (check?.error) {
|
|
46
|
+
return {
|
|
47
|
+
eligible: false,
|
|
48
|
+
reason: `could not check which findings other reviewers already raised (${check.error})`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (check == null || check.at == null || check.checkingSince != null) {
|
|
52
|
+
return {
|
|
53
|
+
eligible: false,
|
|
54
|
+
reason: "the check for findings other reviewers already raised has not finished",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const live = artifact.comments.filter((c) => c.status !== "dropped");
|
|
58
|
+
const replies = live.filter((c) => sendTreatment(c) === "reply").length;
|
|
59
|
+
if (replies > 0) {
|
|
60
|
+
return {
|
|
61
|
+
eligible: false,
|
|
62
|
+
reason: `${replies} finding(s) marked to reply in another reviewer's thread — only a human Send posts replies`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const held = live.filter((c) => sendTreatment(c) === "hold").length;
|
|
66
|
+
return {
|
|
67
|
+
eligible: true,
|
|
68
|
+
reason: `approve at ${artifact.verdict.confidence}% ≥ ${threshold}%` +
|
|
69
|
+
(held > 0 ? `; ${held} finding(s) already raised by another reviewer left out` : ""),
|
|
70
|
+
};
|
|
35
71
|
}
|
package/dist/core/export.js
CHANGED
|
@@ -38,7 +38,7 @@ export function toMarkdown(artifact) {
|
|
|
38
38
|
lines.push("");
|
|
39
39
|
const comments = artifact.comments.filter((c) => c.chapterId === ch.id && c.status !== "dropped");
|
|
40
40
|
for (const c of comments) {
|
|
41
|
-
lines.push(
|
|
41
|
+
lines.push(renderComment(c));
|
|
42
42
|
lines.push("");
|
|
43
43
|
}
|
|
44
44
|
}
|
|
@@ -49,9 +49,15 @@ export function toMarkdown(artifact) {
|
|
|
49
49
|
lines.push("## Other comments");
|
|
50
50
|
lines.push("");
|
|
51
51
|
for (const c of orphans) {
|
|
52
|
-
lines.push(
|
|
52
|
+
lines.push(renderComment(c));
|
|
53
53
|
lines.push("");
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
return lines.join("\n");
|
|
57
57
|
}
|
|
58
|
+
function renderComment(c) {
|
|
59
|
+
const text = `> **${c.path}${c.line != null ? `:${c.line}` : ""}** — ${withGrade(c.body, c.severity).replace(/\n/g, "\n> ")}`;
|
|
60
|
+
// Said here too, so a reader of the export does not take a repeat for news.
|
|
61
|
+
const raised = c.alreadyRaised;
|
|
62
|
+
return raised ? `${text}\n>\n> _Already raised by @${raised.by}: ${raised.reason}_` : text;
|
|
63
|
+
}
|
package/dist/core/gh.js
CHANGED
|
@@ -424,6 +424,105 @@ export async function fetchLastReviewRequest(ref, login) {
|
|
|
424
424
|
const raw = JSON.parse(out);
|
|
425
425
|
return lastRequestOf(raw.data?.repository?.pullRequest?.timelineItems?.nodes ?? [], login);
|
|
426
426
|
}
|
|
427
|
+
/**
|
|
428
|
+
* Everything others have said on the PR in review form: the inline threads, by
|
|
429
|
+
* their first comment, and the review bodies.
|
|
430
|
+
*
|
|
431
|
+
* Yours are left out — what you already said is not something a draft of yours
|
|
432
|
+
* repeats from somebody else — and so are the author's: a thread the author
|
|
433
|
+
* opens on their own PR is an explanation, not a finding. Bots stay in. A bot
|
|
434
|
+
* is very often the first to raise a thing, and some put their findings only in
|
|
435
|
+
* the review body, which is why bodies are read at all.
|
|
436
|
+
*
|
|
437
|
+
* Replies inside a thread are not read. The finding is what opened the thread;
|
|
438
|
+
* the rest is the conversation about it.
|
|
439
|
+
*/
|
|
440
|
+
export function priorRemarks(raw, people) {
|
|
441
|
+
const skip = new Set([people.you.toLowerCase(), people.author.toLowerCase()]);
|
|
442
|
+
const remarks = [];
|
|
443
|
+
for (const t of raw.threads) {
|
|
444
|
+
const first = t.comments.nodes[0];
|
|
445
|
+
// GitHub's own deleted-user placeholder, so a thread whose author left is
|
|
446
|
+
// still a thing somebody raised.
|
|
447
|
+
const by = first?.author?.login ?? "ghost";
|
|
448
|
+
if (!first || first.fullDatabaseId == null || skip.has(by.toLowerCase()))
|
|
449
|
+
continue;
|
|
450
|
+
remarks.push({
|
|
451
|
+
kind: "thread",
|
|
452
|
+
id: t.id,
|
|
453
|
+
by,
|
|
454
|
+
bot: first.author?.__typename === "Bot",
|
|
455
|
+
at: first.createdAt,
|
|
456
|
+
body: first.body ?? "",
|
|
457
|
+
url: first.url,
|
|
458
|
+
path: t.path,
|
|
459
|
+
line: t.line,
|
|
460
|
+
state: t.isResolved ? "resolved" : t.isOutdated ? "outdated" : "open",
|
|
461
|
+
replyTo: String(first.fullDatabaseId),
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
for (const r of raw.reviews) {
|
|
465
|
+
const by = r.author?.login ?? "ghost";
|
|
466
|
+
// An empty body is a review whose whole say is in its threads, read above.
|
|
467
|
+
// A pending one is a draft nobody else can see.
|
|
468
|
+
if (!r.body?.trim() || r.submittedAt == null || skip.has(by.toLowerCase()))
|
|
469
|
+
continue;
|
|
470
|
+
remarks.push({
|
|
471
|
+
kind: "review",
|
|
472
|
+
id: r.id,
|
|
473
|
+
by,
|
|
474
|
+
bot: r.author?.__typename === "Bot",
|
|
475
|
+
at: r.submittedAt,
|
|
476
|
+
body: r.body,
|
|
477
|
+
url: r.url,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
return remarks;
|
|
481
|
+
}
|
|
482
|
+
// The last 100 of each, in one call. A PR past 100 threads loses its oldest
|
|
483
|
+
// from the comparison — accepted rather than paginated, because the failure is
|
|
484
|
+
// the conservative one: a duplicate that posts, which is what happened before
|
|
485
|
+
// there was any comparison at all.
|
|
486
|
+
const PRIOR_REMARKS_QUERY = `query($owner:String!,$repo:String!,$number:Int!){
|
|
487
|
+
repository(owner:$owner,name:$repo){
|
|
488
|
+
pullRequest(number:$number){
|
|
489
|
+
reviewThreads(last:100){
|
|
490
|
+
nodes{ id isResolved isOutdated path line
|
|
491
|
+
comments(first:1){ nodes{ fullDatabaseId url createdAt body author{ login __typename } } }
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
reviews(last:100){
|
|
495
|
+
nodes{ id state submittedAt url body author{ login __typename } }
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}`;
|
|
500
|
+
/**
|
|
501
|
+
* Read what others have already said on a PR in review form.
|
|
502
|
+
*
|
|
503
|
+
* GraphQL because REST has no thread resolution: `pulls/N/comments` returns the
|
|
504
|
+
* comments but not whether anybody resolved the thread they are in, and that is
|
|
505
|
+
* the fact that decides what Send does with a match.
|
|
506
|
+
*/
|
|
507
|
+
export async function fetchPriorRemarks(ref, people) {
|
|
508
|
+
const out = await gh([
|
|
509
|
+
"api",
|
|
510
|
+
"graphql",
|
|
511
|
+
"-f",
|
|
512
|
+
`query=${PRIOR_REMARKS_QUERY}`,
|
|
513
|
+
"-F",
|
|
514
|
+
`owner=${ref.owner}`,
|
|
515
|
+
"-F",
|
|
516
|
+
`repo=${ref.repo}`,
|
|
517
|
+
"-F",
|
|
518
|
+
`number=${ref.number}`,
|
|
519
|
+
]);
|
|
520
|
+
const raw = JSON.parse(out);
|
|
521
|
+
const pr = raw.data?.repository?.pullRequest;
|
|
522
|
+
if (!pr)
|
|
523
|
+
throw new Error(`GitHub returned no pull request for ${ref.owner}/${ref.repo}#${ref.number}`);
|
|
524
|
+
return priorRemarks({ threads: pr.reviewThreads?.nodes ?? [], reviews: pr.reviews?.nodes ?? [] }, people);
|
|
525
|
+
}
|
|
427
526
|
export function searchAwaitingArgs(repoFilter, limit = 50) {
|
|
428
527
|
const args = [
|
|
429
528
|
"search",
|
|
@@ -541,14 +640,64 @@ export async function postIssueComment(ref, body) {
|
|
|
541
640
|
return { url: null };
|
|
542
641
|
}
|
|
543
642
|
}
|
|
643
|
+
/**
|
|
644
|
+
* Answer an existing review thread — a finding of yours that somebody else
|
|
645
|
+
* already raised, posted where they raised it instead of as a second thread.
|
|
646
|
+
*
|
|
647
|
+
* Part of Send and never anything on its own: it is called only by
|
|
648
|
+
* `postReplies`, after `submitReview` has posted the review it goes with, and
|
|
649
|
+
* only for comments the user marked "reply in their thread". GitHub files each
|
|
650
|
+
* reply under a small review of its own, which is GitHub's bookkeeping and not
|
|
651
|
+
* a second verdict: it carries no event, and the review that carries yours has
|
|
652
|
+
* already landed.
|
|
653
|
+
*/
|
|
654
|
+
export async function replyInThread(ref, replyTo, body) {
|
|
655
|
+
const out = await gh([
|
|
656
|
+
"api",
|
|
657
|
+
`repos/${ref.owner}/${ref.repo}/pulls/${ref.number}/comments/${replyTo}/replies`,
|
|
658
|
+
"--method",
|
|
659
|
+
"POST",
|
|
660
|
+
"-f",
|
|
661
|
+
`body=${body}`,
|
|
662
|
+
]);
|
|
663
|
+
try {
|
|
664
|
+
return { url: JSON.parse(out).html_url ?? null };
|
|
665
|
+
}
|
|
666
|
+
catch {
|
|
667
|
+
return { url: null };
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Post a Send's replies, one by one, each allowed to fail on its own.
|
|
672
|
+
*
|
|
673
|
+
* After the review, never before it: the review is the thing the user vouched
|
|
674
|
+
* for, and a review that failed must leave nothing behind for the retry to post
|
|
675
|
+
* twice. A reply that fails once the review is up is reported against its
|
|
676
|
+
* comment rather than rolled back into a claim that nothing was sent.
|
|
677
|
+
*/
|
|
678
|
+
export async function postReplies(ref, replies) {
|
|
679
|
+
const outcomes = [];
|
|
680
|
+
for (const r of replies) {
|
|
681
|
+
const at = new Date().toISOString();
|
|
682
|
+
try {
|
|
683
|
+
const { url } = await replyInThread(ref, r.replyTo, r.body);
|
|
684
|
+
outcomes.push({ commentId: r.commentId, at, url, error: null });
|
|
685
|
+
}
|
|
686
|
+
catch (err) {
|
|
687
|
+
outcomes.push({ commentId: r.commentId, at, url: null, error: err instanceof Error ? err.message : String(err) });
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
return outcomes;
|
|
691
|
+
}
|
|
544
692
|
/**
|
|
545
693
|
* THE ONLY GITHUB WRITE THAT SPEAKS AS A REVIEW.
|
|
546
694
|
* Submits a review in one shot. Must only ever be called from a human act:
|
|
547
695
|
* the cockpit's Send button, `cerber send` after confirmation, opt-in
|
|
548
696
|
* auto-send, or a handoff that was ticked to carry the draft — which composes
|
|
549
697
|
* through this same path and always as COMMENT.
|
|
550
|
-
* The
|
|
551
|
-
*
|
|
698
|
+
* The other writes are a handoff's request swap and its note, neither of them
|
|
699
|
+
* a review, and the thread replies a Send carries for findings somebody else
|
|
700
|
+
* already raised (`replyInThread`).
|
|
552
701
|
*/
|
|
553
702
|
export async function submitReview(ref, payload) {
|
|
554
703
|
const args = [
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule Send follows for a comment, given what is known about who else
|
|
3
|
+
* raised it. Dropped comments never reach this — they post nothing either way.
|
|
4
|
+
*
|
|
5
|
+
* Undecided is not "post": a duplicate the user never looked at is exactly what
|
|
6
|
+
* this exists to keep off the PR. The one place undecided still posts is a
|
|
7
|
+
* resolved thread — somebody marked that problem settled, and a review that
|
|
8
|
+
* found it again in the current code is saying it isn't.
|
|
9
|
+
*/
|
|
10
|
+
export function sendTreatment(comment) {
|
|
11
|
+
const raised = comment.alreadyRaised;
|
|
12
|
+
if (!raised)
|
|
13
|
+
return "post";
|
|
14
|
+
if (raised.decision === "send")
|
|
15
|
+
return "post";
|
|
16
|
+
// Only a thread can be answered; a reply decision anywhere else is not one
|
|
17
|
+
// the cockpit or the API will write, and holding is the side that posts less.
|
|
18
|
+
if (raised.decision === "reply")
|
|
19
|
+
return raised.where.kind === "thread" ? "reply" : "hold";
|
|
20
|
+
return raised.where.kind === "thread" && raised.where.state === "resolved" ? "post" : "hold";
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Which match a comment keeps when several remarks raised the same thing:
|
|
24
|
+
* the liveliest place first, because that is what decides whether posting it
|
|
25
|
+
* again would repeat a conversation still going on. An open thread, then an
|
|
26
|
+
* outdated one nobody resolved, then a review body, then a resolved thread.
|
|
27
|
+
* Ties go to whoever said it first.
|
|
28
|
+
*/
|
|
29
|
+
function rank(place) {
|
|
30
|
+
if (place.kind === "review")
|
|
31
|
+
return 2;
|
|
32
|
+
return { open: 0, outdated: 1, resolved: 3 }[place.state];
|
|
33
|
+
}
|
|
34
|
+
export function pickMatch(candidates) {
|
|
35
|
+
if (candidates.length === 0)
|
|
36
|
+
return null;
|
|
37
|
+
return candidates.reduce((best, c) => {
|
|
38
|
+
const d = rank(c.where) - rank(best.where);
|
|
39
|
+
return d < 0 || (d === 0 && c.at < best.at) ? c : best;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/** A remark as the match it becomes on a comment. */
|
|
43
|
+
export function raisedFrom(remark, reason) {
|
|
44
|
+
const where = remark.kind === "thread"
|
|
45
|
+
? { kind: "thread", path: remark.path, line: remark.line, state: remark.state, replyTo: remark.replyTo }
|
|
46
|
+
: { kind: "review" };
|
|
47
|
+
return {
|
|
48
|
+
remarkId: remark.id,
|
|
49
|
+
by: remark.by,
|
|
50
|
+
at: remark.at,
|
|
51
|
+
url: remark.url,
|
|
52
|
+
where,
|
|
53
|
+
reason,
|
|
54
|
+
decision: null,
|
|
55
|
+
replied: null,
|
|
56
|
+
others: [],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** The comments a check compares: the AI's, and only those still in the draft. */
|
|
60
|
+
export function comparable(comments) {
|
|
61
|
+
// Not the user's own. A comment you wrote is something you decided to say,
|
|
62
|
+
// and holding it back from Send by default would overrule you.
|
|
63
|
+
return comments.filter((c) => c.origin === "ai" && c.status !== "dropped");
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* What a check has to compare: everything new against everything, and nothing
|
|
67
|
+
* already compared against each other. Empty on both sides means no model call.
|
|
68
|
+
*/
|
|
69
|
+
export function whatToCompare(comments, remarks, previous) {
|
|
70
|
+
const findings = comparable(comments);
|
|
71
|
+
const seenRemarks = new Set(previous?.remarks ?? []);
|
|
72
|
+
const seenFindings = new Set(previous?.findings ?? []);
|
|
73
|
+
const newRemarks = remarks.filter((r) => !seenRemarks.has(r.id));
|
|
74
|
+
const newFindings = findings.filter((f) => !seenFindings.has(f.id));
|
|
75
|
+
if (newRemarks.length === 0 && newFindings.length === 0)
|
|
76
|
+
return { findings: [], remarks: [] };
|
|
77
|
+
// New remarks are compared against every finding, new findings against
|
|
78
|
+
// every remark. Doing both whenever either is new over-asks a little, and
|
|
79
|
+
// is one call either way.
|
|
80
|
+
return {
|
|
81
|
+
findings: newRemarks.length > 0 ? findings : newFindings,
|
|
82
|
+
remarks: newFindings.length > 0 ? remarks : newRemarks,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const NO_CHECK = { at: null, checkingSince: null, remarks: [], findings: [], error: null };
|
|
86
|
+
/** Mark a check as running, so the cockpit waits for it. */
|
|
87
|
+
export function startRaisedCheck(artifact, at) {
|
|
88
|
+
return { ...artifact, raisedCheck: { ...(artifact.raisedCheck ?? NO_CHECK), checkingSince: at } };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Fold a finished check onto the artifact as it is now.
|
|
92
|
+
*
|
|
93
|
+
* The check takes up to a minute and reads a snapshot, so by the time it lands the
|
|
94
|
+
* user may have dropped a comment, decided about a match, or a chat turn may
|
|
95
|
+
* have added one. It writes `alreadyRaised` and `raisedCheck` and nothing else,
|
|
96
|
+
* and a comment it has nothing new to say about keeps what it had — the user's
|
|
97
|
+
* decision included, as long as it is still about the same remark.
|
|
98
|
+
*
|
|
99
|
+
* A remark that is gone from the PR (deleted) takes its match with it. A
|
|
100
|
+
* remark still there refreshes its state, so a thread resolved since the last
|
|
101
|
+
* check reads as resolved — and posts by default — without a model call.
|
|
102
|
+
*/
|
|
103
|
+
export function foldRaised(current, result) {
|
|
104
|
+
// A sent review is a record of what was posted; nothing is re-decided on it.
|
|
105
|
+
if (current.sent) {
|
|
106
|
+
return current.raisedCheck ? { ...current, raisedCheck: { ...current.raisedCheck, checkingSince: null } } : current;
|
|
107
|
+
}
|
|
108
|
+
const remarks = new Map(result.remarks.map((r) => [r.id, r]));
|
|
109
|
+
const found = new Map();
|
|
110
|
+
for (const m of result.matches)
|
|
111
|
+
found.set(m.findingId, [...(found.get(m.findingId) ?? []), m]);
|
|
112
|
+
const comments = current.comments.map((c) => {
|
|
113
|
+
const before = c.alreadyRaised;
|
|
114
|
+
// Every remark this comment has ever matched that is still on the PR —
|
|
115
|
+
// the one shown, the ones behind it, and whatever this check found — each
|
|
116
|
+
// refreshed from the remark itself, so a thread resolved since reads as
|
|
117
|
+
// resolved without asking anyone.
|
|
118
|
+
const known = new Map();
|
|
119
|
+
for (const m of [
|
|
120
|
+
...(before ? [{ remarkId: before.remarkId, reason: before.reason }, ...before.others] : []),
|
|
121
|
+
...(found.get(c.id) ?? []),
|
|
122
|
+
]) {
|
|
123
|
+
if (remarks.has(m.remarkId) && !known.has(m.remarkId))
|
|
124
|
+
known.set(m.remarkId, m.reason);
|
|
125
|
+
}
|
|
126
|
+
const candidates = [...known].flatMap(([id, reason]) => {
|
|
127
|
+
const remark = remarks.get(id);
|
|
128
|
+
return remark ? [raisedFrom(remark, reason)] : [];
|
|
129
|
+
});
|
|
130
|
+
const best = pickMatch(candidates);
|
|
131
|
+
if (best === null)
|
|
132
|
+
return before === null ? c : { ...c, alreadyRaised: null };
|
|
133
|
+
// The user's decision belongs to the remark it was made about.
|
|
134
|
+
const same = before !== null && before.remarkId === best.remarkId;
|
|
135
|
+
return {
|
|
136
|
+
...c,
|
|
137
|
+
alreadyRaised: {
|
|
138
|
+
...best,
|
|
139
|
+
decision: same ? before.decision : null,
|
|
140
|
+
replied: same ? before.replied : null,
|
|
141
|
+
others: candidates.filter((x) => x !== best).map((x) => ({ remarkId: x.remarkId, reason: x.reason })),
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
});
|
|
145
|
+
return {
|
|
146
|
+
...current,
|
|
147
|
+
comments,
|
|
148
|
+
raisedCheck: {
|
|
149
|
+
at: result.at,
|
|
150
|
+
checkingSince: null,
|
|
151
|
+
remarks: result.remarks.map((r) => r.id),
|
|
152
|
+
findings: result.findings,
|
|
153
|
+
error: null,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/** A check that could not finish: say why, and leave every match as it was. */
|
|
158
|
+
export function foldRaisedError(current, at, error) {
|
|
159
|
+
return {
|
|
160
|
+
...current,
|
|
161
|
+
raisedCheck: { ...(current.raisedCheck ?? NO_CHECK), at, checkingSince: null, error },
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Whether a fold changed anything worth a write. Opening a review checks every
|
|
166
|
+
* time, and a write moves `updatedAt` — which the queue sorts and ages rows by
|
|
167
|
+
* — so a check that found nothing new must leave the file alone.
|
|
168
|
+
*/
|
|
169
|
+
export function raisedChanged(before, after) {
|
|
170
|
+
const material = (a) => JSON.stringify([
|
|
171
|
+
a.comments.map((c) => c.alreadyRaised),
|
|
172
|
+
a.raisedCheck && { ...a.raisedCheck, at: null },
|
|
173
|
+
]);
|
|
174
|
+
return material(before) !== material(after);
|
|
175
|
+
}
|
package/dist/core/revise.js
CHANGED
|
@@ -41,7 +41,13 @@ export function mergeConcurrentEdits(before, after, current) {
|
|
|
41
41
|
return true;
|
|
42
42
|
return then.body !== now.body || then.status !== now.status || then.severity !== now.severity;
|
|
43
43
|
};
|
|
44
|
-
const merged = current.comments.map((now) =>
|
|
44
|
+
const merged = current.comments.map((now) => {
|
|
45
|
+
const chosen = userTouched(now.id, now) ? now : fromTurn.get(now.id) ?? now;
|
|
46
|
+
// Who else raised a comment is the check's to say, not the turn's: a check
|
|
47
|
+
// that landed while the turn ran — and a decision made about it — is only
|
|
48
|
+
// on disk, and the turn's copy predates both.
|
|
49
|
+
return chosen === now ? now : { ...chosen, alreadyRaised: now.alreadyRaised };
|
|
50
|
+
});
|
|
45
51
|
// Comments the turn itself added are not in `current` yet. Only those: a
|
|
46
52
|
// comment present in both `before` and `after` but missing from `current` is
|
|
47
53
|
// one the user deleted while the turn ran, and pushing it back would undo
|
|
@@ -66,6 +72,9 @@ export function mergeConcurrentEdits(before, after, current) {
|
|
|
66
72
|
// Nor does a turn announce anything: the ledger is the poll's, and a turn
|
|
67
73
|
// takes minutes, which is long enough for a poll to have stamped it.
|
|
68
74
|
notified: current.notified,
|
|
75
|
+
// Nor does it compare the draft with what others said; that check folds
|
|
76
|
+
// onto the disk on its own.
|
|
77
|
+
raisedCheck: current.raisedCheck,
|
|
69
78
|
// Nor does it hand a review to anybody. A handoff that landed while the
|
|
70
79
|
// turn ran has already moved the request on GitHub, so restoring the
|
|
71
80
|
// pre-turn `null` would erase the only local record of where the PR went.
|
|
@@ -172,6 +181,7 @@ export function applyRevisions(artifact, revisions, opts = {}) {
|
|
|
172
181
|
editedByUser: false,
|
|
173
182
|
originalLine: null,
|
|
174
183
|
drifted: false,
|
|
184
|
+
alreadyRaised: null,
|
|
175
185
|
},
|
|
176
186
|
],
|
|
177
187
|
};
|