@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 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")
@@ -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([]),
@@ -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
- return { eligible: true, reason: `approve at ${artifact.verdict.confidence}% ≥ ${threshold}%` };
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
  }
@@ -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(`> **${c.path}${c.line != null ? `:${c.line}` : ""}** — ${withGrade(c.body, c.severity).replace(/\n/g, "\n> ")}`);
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(`> **${c.path}${c.line != null ? `:${c.line}` : ""}** — ${withGrade(c.body, c.severity).replace(/\n/g, "\n> ")}`);
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 two writes above are the other things that reach GitHub — a handoff's
551
- * request swap and its note — and neither of those is a review.
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
+ }
@@ -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) => userTouched(now.id, now) ? now : fromTurn.get(now.id) ?? 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
  };