@mmnto/totem 2.5.0 → 2.6.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/dist/gate-engine.test.js +4 -0
- package/dist/gate-engine.test.js.map +1 -1
- package/dist/gate-types.d.ts +4 -3
- package/dist/gate-types.d.ts.map +1 -1
- package/dist/merge-ready.d.ts +32 -4
- package/dist/merge-ready.d.ts.map +1 -1
- package/dist/merge-ready.js +392 -30
- package/dist/merge-ready.js.map +1 -1
- package/dist/merge-ready.test.js +262 -18
- package/dist/merge-ready.test.js.map +1 -1
- package/package.json +1 -1
package/dist/merge-ready.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isBotReviewerLoginExact } from './bot-identity.js';
|
|
1
|
+
import { hasBotAppLoginSuffix, isBotReviewerLoginExact } from './bot-identity.js';
|
|
2
2
|
import { TotemError } from './errors.js';
|
|
3
3
|
import { safeExec } from './sys/exec.js';
|
|
4
4
|
/**
|
|
@@ -16,8 +16,17 @@ import { safeExec } from './sys/exec.js';
|
|
|
16
16
|
* ROOT comment is one of the known review bots
|
|
17
17
|
* 3. `changes-requested` — no un-superseded CHANGES_REQUESTED review
|
|
18
18
|
* 4. `high-severity-inline` — no HIGH/Major bot inline that CURRENTLY applies
|
|
19
|
-
* to the head commit (`comment.commit.oid
|
|
20
|
-
*
|
|
19
|
+
* to the head commit (`comment.commit.oid`) and
|
|
20
|
+
* is not DISCHARGED through the disposition path
|
|
21
|
+
* (mmnto-ai/totem#2861): its thread RESOLVED plus
|
|
22
|
+
* a disposition NAMING IT — a non-bot reply
|
|
23
|
+
* after the root inside the thread, or a
|
|
24
|
+
* non-bot PR-level comment created after the
|
|
25
|
+
* root carrying a `disposition: <root comment
|
|
26
|
+
* id> <verb>` line for this thread. A bare
|
|
27
|
+
* resolve, or a round disposition that did not
|
|
28
|
+
* name the thread, still applies at every tier
|
|
29
|
+
* (fail-closed)
|
|
21
30
|
* 5. `merge-state` — GitHub's own `mergeStateStatus` is mergeable
|
|
22
31
|
*
|
|
23
32
|
* THE TIER SPLIT (R1): a predicate that FAILS is `deny` at every tier (the
|
|
@@ -52,13 +61,34 @@ export const MERGE_READY_EVIDENCE_MAX = 160;
|
|
|
52
61
|
// ─── The read surface (R2: one GraphQL query, explicit cursors, no REST) ────
|
|
53
62
|
/**
|
|
54
63
|
* The PR fields every predicate reads, as a fragment so the number-keyed and
|
|
55
|
-
* branch-keyed documents cannot drift apart. The
|
|
64
|
+
* branch-keyed documents cannot drift apart. The four paginated connections
|
|
56
65
|
* each take their own cursor variable, declared by the operation.
|
|
57
66
|
*
|
|
58
|
-
* `comments(first: 10)` is deliberate: only the ROOT comment
|
|
67
|
+
* `comments(first: 10)` on a thread is deliberate: only the ROOT comment is
|
|
59
68
|
* judged (it is the finding; the rest are the discussion), matching how triage
|
|
60
|
-
* reads a thread. The later comments are
|
|
61
|
-
*
|
|
69
|
+
* reads a thread. The later comments are read for EVIDENCE only — a non-bot
|
|
70
|
+
* reply after the root discharges a resolved HIGH (mmnto-ai/totem#2861) — and
|
|
71
|
+
* the window's `pageInfo` is selected too, so a thread with more replies than
|
|
72
|
+
* the window is judged on what was read and SAYS so in the reason, never read
|
|
73
|
+
* as "no reply" silently. The root is `comments.nodes[0]`: the one capture in
|
|
74
|
+
* `gate-fixtures/merge-ready/` with a multi-comment thread (liquid-city-363)
|
|
75
|
+
* answers root first, then the reply, and `resolve-threads` reads the same
|
|
76
|
+
* position — a positional assumption shared by both consumers and resting on
|
|
77
|
+
* that one observation, not a transcribed schema guarantee, which is why the
|
|
78
|
+
* reply test below is ALSO temporal (a reply counts only when its `createdAt`
|
|
79
|
+
* follows the root's; across sixteen live PRs no reply preceded its root). The
|
|
80
|
+
* root's `databaseId` is its REST comment id — the `id=` a `totem
|
|
81
|
+
* resolve-threads` dry-run row prints (`totem triage-pr` carries it internally
|
|
82
|
+
* and prints none: the third leg's r3-f2), and the id a PR-level disposition
|
|
83
|
+
* line names.
|
|
84
|
+
* The PR-level `comments` connection is the other evidence surface: a non-bot
|
|
85
|
+
* comment created after a thread's root whose BODY carries a
|
|
86
|
+
* `disposition: <root comment id> <verb>` line for THAT thread (the review-reply
|
|
87
|
+
* skill's step 2 emits one per bot thread the round answered) — paginated in
|
|
88
|
+
* full like the rest, bodies included. `author { __typename }` is selected on
|
|
89
|
+
* both because it is the one signal that names EVERY GitHub App a bot (the
|
|
90
|
+
* resolve-threads rule): without it an App's reply would read as the human
|
|
91
|
+
* answer.
|
|
62
92
|
*/
|
|
63
93
|
const MERGE_READY_FRAGMENT = `fragment MergeReadyPr on PullRequest {
|
|
64
94
|
number
|
|
@@ -100,17 +130,28 @@ const MERGE_READY_FRAGMENT = `fragment MergeReadyPr on PullRequest {
|
|
|
100
130
|
isResolved
|
|
101
131
|
isOutdated
|
|
102
132
|
comments(first: 10) {
|
|
133
|
+
pageInfo { hasNextPage }
|
|
103
134
|
nodes {
|
|
104
|
-
|
|
135
|
+
databaseId
|
|
136
|
+
author { __typename login }
|
|
105
137
|
body
|
|
138
|
+
createdAt
|
|
106
139
|
commit { oid }
|
|
107
140
|
originalCommit { oid }
|
|
108
141
|
}
|
|
109
142
|
}
|
|
110
143
|
}
|
|
111
144
|
}
|
|
145
|
+
comments(first: ${PAGE_SIZE}, after: $commentsAfter) {
|
|
146
|
+
pageInfo { hasNextPage endCursor }
|
|
147
|
+
nodes {
|
|
148
|
+
author { __typename login }
|
|
149
|
+
body
|
|
150
|
+
createdAt
|
|
151
|
+
}
|
|
152
|
+
}
|
|
112
153
|
}`;
|
|
113
|
-
const SHARED_VARS = '$owner: String!, $name: String!, $reviewsAfter: String, $threadsAfter: String, $checksAfter: String';
|
|
154
|
+
const SHARED_VARS = '$owner: String!, $name: String!, $reviewsAfter: String, $threadsAfter: String, $checksAfter: String, $commentsAfter: String';
|
|
114
155
|
/** The read the evaluator sends when the payload names a PR number. Fixtures are captured with THIS string. */
|
|
115
156
|
export const MERGE_READY_QUERY = `query TotemMergeReady($number: Int!, ${SHARED_VARS}) {
|
|
116
157
|
repository(owner: $owner, name: $name) {
|
|
@@ -207,6 +248,61 @@ function asArray(value) {
|
|
|
207
248
|
function asString(value) {
|
|
208
249
|
return typeof value === 'string' ? value : null;
|
|
209
250
|
}
|
|
251
|
+
/**
|
|
252
|
+
* The per-thread disposition line (mmnto-ai/totem#2861, the operator's ruling
|
|
253
|
+
* of 2026-09-16 on the linkage fork): the review-reply skill's step 2 ends a
|
|
254
|
+
* round disposition with ONE machine line per bot-rooted thread the round
|
|
255
|
+
* answered —
|
|
256
|
+
*
|
|
257
|
+
* disposition: <root comment id> <verb>
|
|
258
|
+
*
|
|
259
|
+
* — the id being the thread root's REST comment id (`databaseId`; the `id=`
|
|
260
|
+
* on a `totem resolve-threads` dry-run row, the one command that prints it —
|
|
261
|
+
* `totem triage-pr` carries it internally and prints none) and the verb the
|
|
262
|
+
* round's word for it (fixed, declined, deferred,
|
|
263
|
+
* nit, extracted, held). Predicate 4 reads the ID: a PR-level comment names
|
|
264
|
+
* a thread when a line carries that thread's root id — and a round
|
|
265
|
+
* disposition that answered OTHER threads does not name this one. That last
|
|
266
|
+
* case is why the line exists: two earlier reads keyed the arm to the ROUND
|
|
267
|
+
* (post-dating alone, then post-dating plus the round's `local-lane:` line),
|
|
268
|
+
* and both let a bare resolve of a HIGH the round never addressed discharge —
|
|
269
|
+
* the two falsification legs' blocking findings, executed on the built core.
|
|
270
|
+
* Naming the thread is what the ruling asked for, and an id is exact where a
|
|
271
|
+
* marker was a shape. By construction no other surface writes a line of this
|
|
272
|
+
* shape: a trigger comment, a gate-read note or a merge note carries no id at
|
|
273
|
+
* line start, and the gate's own deny reason quotes the line inside double
|
|
274
|
+
* quotes mid-sentence (the third leg tried the reason verbatim, fenced,
|
|
275
|
+
* unquoted, and a pasted resolve-threads row, and none read as a line) — an
|
|
276
|
+
* inference about bodies, made safe by the id rather than proven.
|
|
277
|
+
*
|
|
278
|
+
* Read against the raw body with HTML comments removed (a terminated
|
|
279
|
+
* `<!-- … -->`, and an unterminated `<!--` to the end of the body, the way a
|
|
280
|
+
* renderer hides it), at line start with leading blanks allowed, anywhere in
|
|
281
|
+
* the body — fenced included, because the skills render machine lines in text
|
|
282
|
+
* fences. A blockquoted, listed or tabled line, or one inside an inline span,
|
|
283
|
+
* is not at line start and does not count. The id is the decimal the seat
|
|
284
|
+
* copied: no leading zero, no sign, no fraction, nothing glued to it — the
|
|
285
|
+
* third leg's r3-f5 showed `Number()` equating `01001`, `1001.5` and `1001x`
|
|
286
|
+
* to 1001, so the token is matched exactly and never coerced. The verb is not
|
|
287
|
+
* validated here: the gate answers "was this thread dispositioned", not
|
|
288
|
+
* "how".
|
|
289
|
+
*/
|
|
290
|
+
const DISPOSITION_LINE = /^[ \t]*disposition:[ \t]+([1-9]\d*)(?=[ \t]|\r?$)/gm;
|
|
291
|
+
/**
|
|
292
|
+
* The root comment ids a PR-level comment body names on disposition lines,
|
|
293
|
+
* in first-seen order, without duplicates. Exported for the tests, so they
|
|
294
|
+
* assert the shipped predicate and not a copy of it.
|
|
295
|
+
*/
|
|
296
|
+
export function dispositionedRootIds(body) {
|
|
297
|
+
const ids = [];
|
|
298
|
+
const stripped = body.replace(/<!--[\s\S]*?-->/g, '').replace(/<!--[\s\S]*$/, '');
|
|
299
|
+
for (const match of stripped.matchAll(DISPOSITION_LINE)) {
|
|
300
|
+
const id = Number(match[1]);
|
|
301
|
+
if (Number.isSafeInteger(id) && !ids.includes(id))
|
|
302
|
+
ids.push(id);
|
|
303
|
+
}
|
|
304
|
+
return ids;
|
|
305
|
+
}
|
|
210
306
|
/**
|
|
211
307
|
* A CheckRun's conclusion vocabulary. `NEUTRAL` and `SKIPPED` are successes (a
|
|
212
308
|
* skipped required check is branch protection's business, not this gate's);
|
|
@@ -342,6 +438,43 @@ function readReviews(connection) {
|
|
|
342
438
|
return unreadable(info.detail);
|
|
343
439
|
return { entries, hasNext: info.hasNext, cursor: info.cursor };
|
|
344
440
|
}
|
|
441
|
+
/**
|
|
442
|
+
* A comment's author, read STRICTLY for the evidence rule (mmnto-ai/totem#2861).
|
|
443
|
+
* `null` is a deleted account — NOT a bot: the reply was human when it was
|
|
444
|
+
* written, and deleting the account does not retract it (the resolve-threads
|
|
445
|
+
* rule, item 6 of `.totem/specs/2841.md`). Otherwise `__typename` and `login`
|
|
446
|
+
* must both be strings. `__typename` is REQUIRED, not optional, because it is
|
|
447
|
+
* the only signal that names EVERY GitHub App a bot (`github-actions`, a
|
|
448
|
+
* Copilot reviewer, a scanner): a document that quietly stopped selecting it
|
|
449
|
+
* would fail OPEN, every App reply reading as the human answer — the same
|
|
450
|
+
* guard resolve-threads holds at its zod boundary. The three bot arms are the
|
|
451
|
+
* verb's: GitHub's own `Bot` typename, the `[bot]` App suffix, or core's exact
|
|
452
|
+
* review-bot list.
|
|
453
|
+
*/
|
|
454
|
+
function readAuthor(value) {
|
|
455
|
+
if (value === null)
|
|
456
|
+
return { ok: true, login: null, isBot: false };
|
|
457
|
+
const author = asObject(value);
|
|
458
|
+
if (author === null)
|
|
459
|
+
return { ok: false, detail: 'author is neither null nor an object' };
|
|
460
|
+
const typename = asString(author.__typename);
|
|
461
|
+
const login = asString(author.login);
|
|
462
|
+
if (typename === null || login === null) {
|
|
463
|
+
return { ok: false, detail: 'author has no __typename or no login' };
|
|
464
|
+
}
|
|
465
|
+
// On every capture the `Bot` typename is the arm that decides for a review
|
|
466
|
+
// bot (their GraphQL logins carry no suffix); the two login arms are the
|
|
467
|
+
// verb's belt-and-braces, kept so the two consumers of the rule agree. The
|
|
468
|
+
// null-author rule applies on BOTH surfaces: a deleted account's PR-level
|
|
469
|
+
// comment carrying a disposition line discharges the thread it names — not
|
|
470
|
+
// guarded, judged unreachable (a removed App's summary would have to carry
|
|
471
|
+
// this thread's root id at line start; the third leg's r3-f11).
|
|
472
|
+
return {
|
|
473
|
+
ok: true,
|
|
474
|
+
login,
|
|
475
|
+
isBot: typename === 'Bot' || hasBotAppLoginSuffix(login) || isBotReviewerLoginExact(login),
|
|
476
|
+
};
|
|
477
|
+
}
|
|
345
478
|
/**
|
|
346
479
|
* The `reviewThreads` connection, read with the same strictness as
|
|
347
480
|
* {@link readReviews}: a missing connection, a missing `nodes` array or
|
|
@@ -352,6 +485,16 @@ function readReviews(connection) {
|
|
|
352
485
|
* (mmnto-ai/totem#2844 round 1). A root comment whose author is a deleted
|
|
353
486
|
* account keeps `rootLogin: null` — it is not a known bot, which is a fact
|
|
354
487
|
* about the thread, not an unreadable answer.
|
|
488
|
+
*
|
|
489
|
+
* The evidence fields (mmnto-ai/totem#2861) are held to the same bar: the
|
|
490
|
+
* comments window's own `pageInfo`, the root's `createdAt`, and every
|
|
491
|
+
* author's `__typename` — a thread missing any of them is unreadable, never
|
|
492
|
+
* "complete with no reply", because that is the shape a discharge would fail
|
|
493
|
+
* open on. The root's `databaseId` is the one field read the other way: the
|
|
494
|
+
* schema types it nullable, and a root without one cannot be named by any
|
|
495
|
+
* line, so that THREAD stays applying (fail-closed) while the page stays
|
|
496
|
+
* readable — refusing the page would send a PR into the unevaluable class and
|
|
497
|
+
* pilot's `warn` (r3-f4).
|
|
355
498
|
*/
|
|
356
499
|
function readThreads(connection) {
|
|
357
500
|
const entries = [];
|
|
@@ -370,17 +513,59 @@ function readThreads(connection) {
|
|
|
370
513
|
const n = asObject(node);
|
|
371
514
|
if (n === null)
|
|
372
515
|
return unreadable('carried a node that is not an object');
|
|
373
|
-
const
|
|
516
|
+
const commentsConnection = asObject(n.comments);
|
|
517
|
+
const comments = asArray(commentsConnection?.nodes);
|
|
374
518
|
if (comments === null)
|
|
375
519
|
return unreadable('carried a thread with no comments array');
|
|
376
520
|
const root = asObject(comments[0]);
|
|
377
521
|
if (root === null)
|
|
378
522
|
return unreadable('carried a thread whose root comment is unreadable');
|
|
523
|
+
const window = readPageInfo(commentsConnection);
|
|
524
|
+
if (!window.ok) {
|
|
525
|
+
return unreadable(`carried a thread whose comments connection ${window.detail}`);
|
|
526
|
+
}
|
|
527
|
+
const rootAuthor = readAuthor(root.author);
|
|
528
|
+
if (!rootAuthor.ok)
|
|
529
|
+
return unreadable(`carried a thread whose root ${rootAuthor.detail}`);
|
|
530
|
+
const rootCreatedAt = asString(root.createdAt);
|
|
531
|
+
if (rootCreatedAt === null) {
|
|
532
|
+
return unreadable('carried a thread whose root comment has no createdAt');
|
|
533
|
+
}
|
|
534
|
+
// The root's REST id is what a disposition line names; a root without a
|
|
535
|
+
// safe-integer one cannot be matched to any line and its thread stays
|
|
536
|
+
// applying — thread-scoped, never a page refusal (mmnto-ai/totem#2861).
|
|
537
|
+
const rootId = typeof root.databaseId === 'number' && Number.isSafeInteger(root.databaseId)
|
|
538
|
+
? root.databaseId
|
|
539
|
+
: null;
|
|
540
|
+
const rootAt = Date.parse(rootCreatedAt);
|
|
541
|
+
let humanReplies = 0;
|
|
542
|
+
for (const reply of comments.slice(1)) {
|
|
543
|
+
const r = asObject(reply);
|
|
544
|
+
if (r === null)
|
|
545
|
+
return unreadable('carried a thread with a reply that is not an object');
|
|
546
|
+
const author = readAuthor(r.author);
|
|
547
|
+
if (!author.ok)
|
|
548
|
+
return unreadable(`carried a thread with a reply whose ${author.detail}`);
|
|
549
|
+
const replyCreatedAt = asString(r.createdAt);
|
|
550
|
+
if (replyCreatedAt === null)
|
|
551
|
+
return unreadable('carried a thread with a reply that has no createdAt');
|
|
552
|
+
// Positional AND temporal: a non-bot comment after the root in the list
|
|
553
|
+
// that also post-dates it. An unparseable instant on either side is not
|
|
554
|
+
// a reply that counts — the conservative direction.
|
|
555
|
+
const replyAt = Date.parse(replyCreatedAt);
|
|
556
|
+
if (!author.isBot && !Number.isNaN(rootAt) && !Number.isNaN(replyAt) && replyAt > rootAt) {
|
|
557
|
+
humanReplies++;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
379
560
|
entries.push({
|
|
380
561
|
isResolved: n.isResolved === true,
|
|
381
562
|
isOutdated: n.isOutdated === true,
|
|
382
|
-
rootLogin:
|
|
563
|
+
rootLogin: rootAuthor.login,
|
|
383
564
|
rootBody: asString(root.body) ?? '',
|
|
565
|
+
rootId,
|
|
566
|
+
rootCreatedAt,
|
|
567
|
+
humanReplies,
|
|
568
|
+
commentsComplete: !window.hasNext,
|
|
384
569
|
rootCommit: asString(asObject(root.commit)?.oid),
|
|
385
570
|
rootOriginalCommit: asString(asObject(root.originalCommit)?.oid),
|
|
386
571
|
});
|
|
@@ -390,6 +575,47 @@ function readThreads(connection) {
|
|
|
390
575
|
return unreadable(info.detail);
|
|
391
576
|
return { entries, hasNext: info.hasNext, cursor: info.cursor };
|
|
392
577
|
}
|
|
578
|
+
/**
|
|
579
|
+
* The PR-level `comments` connection — the second evidence surface
|
|
580
|
+
* (mmnto-ai/totem#2861), read as strictly as the others: a missing connection,
|
|
581
|
+
* a missing `nodes` array or `pageInfo`, a node that is not an object, an
|
|
582
|
+
* author without `__typename` / `login`, or a comment without a string `body`
|
|
583
|
+
* or `createdAt` is an UNREADABLE page, named — never "no evidence", which
|
|
584
|
+
* would deny a dispositioned HIGH for a reason that misnames its cause.
|
|
585
|
+
*/
|
|
586
|
+
function readPrComments(connection) {
|
|
587
|
+
const entries = [];
|
|
588
|
+
const unreadable = (why) => ({
|
|
589
|
+
entries,
|
|
590
|
+
hasNext: false,
|
|
591
|
+
cursor: null,
|
|
592
|
+
detail: `the PR comments connection ${why}`,
|
|
593
|
+
});
|
|
594
|
+
if (connection === null)
|
|
595
|
+
return unreadable('was missing from the answer');
|
|
596
|
+
const nodes = asArray(connection.nodes);
|
|
597
|
+
if (nodes === null)
|
|
598
|
+
return unreadable('carried no nodes array');
|
|
599
|
+
for (const node of nodes) {
|
|
600
|
+
const n = asObject(node);
|
|
601
|
+
if (n === null)
|
|
602
|
+
return unreadable('carried a node that is not an object');
|
|
603
|
+
const author = readAuthor(n.author);
|
|
604
|
+
if (!author.ok)
|
|
605
|
+
return unreadable(`carried a comment whose ${author.detail}`);
|
|
606
|
+
const body = asString(n.body);
|
|
607
|
+
if (body === null)
|
|
608
|
+
return unreadable('carried a comment with no body');
|
|
609
|
+
const createdAt = asString(n.createdAt);
|
|
610
|
+
if (createdAt === null)
|
|
611
|
+
return unreadable('carried a comment with no createdAt');
|
|
612
|
+
entries.push({ isBot: author.isBot, createdAt, dispositions: dispositionedRootIds(body) });
|
|
613
|
+
}
|
|
614
|
+
const info = readPageInfo(connection);
|
|
615
|
+
if (!info.ok)
|
|
616
|
+
return unreadable(info.detail);
|
|
617
|
+
return { entries, hasNext: info.hasNext, cursor: info.cursor };
|
|
618
|
+
}
|
|
393
619
|
/** Read one `gh api graphql` response body into a classified page. */
|
|
394
620
|
function parsePage(raw, byBranch) {
|
|
395
621
|
let body;
|
|
@@ -488,6 +714,10 @@ function parsePage(raw, byBranch) {
|
|
|
488
714
|
const threads = readThreads(asObject(pr.reviewThreads));
|
|
489
715
|
if (threads.detail !== undefined)
|
|
490
716
|
return { ok: false, detail: threads.detail };
|
|
717
|
+
// The evidence surface (mmnto-ai/totem#2861) is held to the same bar.
|
|
718
|
+
const prComments = readPrComments(asObject(pr.comments));
|
|
719
|
+
if (prComments.detail !== undefined)
|
|
720
|
+
return { ok: false, detail: prComments.detail };
|
|
491
721
|
return {
|
|
492
722
|
ok: true,
|
|
493
723
|
page: {
|
|
@@ -507,6 +737,9 @@ function parsePage(raw, byBranch) {
|
|
|
507
737
|
threads: threads.entries,
|
|
508
738
|
threadsHasNext: threads.hasNext,
|
|
509
739
|
threadsCursor: threads.cursor,
|
|
740
|
+
prComments: prComments.entries,
|
|
741
|
+
commentsHasNext: prComments.hasNext,
|
|
742
|
+
commentsCursor: prComments.cursor,
|
|
510
743
|
},
|
|
511
744
|
};
|
|
512
745
|
}
|
|
@@ -717,15 +950,18 @@ function readPullRequest(payload, runner) {
|
|
|
717
950
|
checks: [],
|
|
718
951
|
reviews: [],
|
|
719
952
|
threads: [],
|
|
953
|
+
prComments: [],
|
|
720
954
|
pagesRead: 0,
|
|
721
955
|
complete: false,
|
|
722
956
|
};
|
|
723
957
|
let checksDone = false;
|
|
724
958
|
let reviewsDone = false;
|
|
725
959
|
let threadsDone = false;
|
|
960
|
+
let commentsDone = false;
|
|
726
961
|
let checksCursor = null;
|
|
727
962
|
let reviewsCursor = null;
|
|
728
963
|
let threadsCursor = null;
|
|
964
|
+
let commentsCursor = null;
|
|
729
965
|
for (let page = 0; page < MAX_PAGES; page++) {
|
|
730
966
|
// The branch-keyed document is used ONLY for the first page of a payload
|
|
731
967
|
// with no number; once the number is known every later page targets it, so
|
|
@@ -748,6 +984,8 @@ function readPullRequest(payload, runner) {
|
|
|
748
984
|
variables.push(['reviewsAfter', reviewsCursor]);
|
|
749
985
|
if (threadsCursor !== null)
|
|
750
986
|
variables.push(['threadsAfter', threadsCursor]);
|
|
987
|
+
if (commentsCursor !== null)
|
|
988
|
+
variables.push(['commentsAfter', commentsCursor]);
|
|
751
989
|
const run = runner(graphqlArgs(query, variables));
|
|
752
990
|
state.pagesRead = page + 1;
|
|
753
991
|
if (run.exitCode !== 0) {
|
|
@@ -794,7 +1032,12 @@ function readPullRequest(payload, runner) {
|
|
|
794
1032
|
threadsDone = !p.threadsHasNext;
|
|
795
1033
|
threadsCursor = p.threadsCursor;
|
|
796
1034
|
}
|
|
797
|
-
if (
|
|
1035
|
+
if (!commentsDone) {
|
|
1036
|
+
state.prComments.push(...p.prComments);
|
|
1037
|
+
commentsDone = !p.commentsHasNext;
|
|
1038
|
+
commentsCursor = p.commentsCursor;
|
|
1039
|
+
}
|
|
1040
|
+
if (checksDone && reviewsDone && threadsDone && commentsDone) {
|
|
798
1041
|
// The rollup must ACCOUNT for itself before predicate 1 reads it
|
|
799
1042
|
// (mmnto-ai/totem#2800 round 4, F3). `totalCount` is judged as the API
|
|
800
1043
|
// typed it — a string "3", a boolean, a negative or a fractional number
|
|
@@ -909,29 +1152,112 @@ function unresolvedBotThreads(threads) {
|
|
|
909
1152
|
isBotReviewerLoginExact(t.rootLogin));
|
|
910
1153
|
}
|
|
911
1154
|
/**
|
|
912
|
-
* HIGH/Major bot inlines that CURRENTLY apply to the head commit
|
|
913
|
-
*
|
|
1155
|
+
* HIGH/Major bot inlines that CURRENTLY apply to the head commit, before the
|
|
1156
|
+
* discharge read below.
|
|
914
1157
|
*
|
|
915
1158
|
* The comparison is `comment.commit.oid`, the commit the finding applies to
|
|
916
1159
|
* NOW (GitHub re-points it as the diff moves; it is the REST `commit_id` the
|
|
917
1160
|
* charter's predicate (c) names), never `originalCommit.oid`, the commit it was
|
|
918
1161
|
* written against — a predicate keyed on the write-time commit goes inert the
|
|
919
|
-
* moment the branch advances, which is exactly the inert state
|
|
920
|
-
* removed.
|
|
1162
|
+
* moment the branch advances, which is exactly the inert state fold F2 of
|
|
1163
|
+
* mmnto-ai/totem#2800 removed.
|
|
921
1164
|
*
|
|
922
1165
|
* Predicate 4's distinct territory, the one predicate 2 cannot reach: a HIGH
|
|
923
|
-
* finding
|
|
924
|
-
*
|
|
925
|
-
*
|
|
926
|
-
*
|
|
1166
|
+
* finding whose thread is RESOLVED while the comment still applies to the head
|
|
1167
|
+
* commit. Since mmnto-ai/totem#2861 that territory splits by HOW the thread
|
|
1168
|
+
* was resolved — see {@link dischargeOf}: through the disposition path the
|
|
1169
|
+
* finding is DISCHARGED and no longer counts; by a bare resolve it still
|
|
1170
|
+
* applies and the floor still refuses the merge. Predicate 2 keeps its own
|
|
1171
|
+
* rule (unresolved AND non-outdated, any severity) and still fires first when
|
|
1172
|
+
* both match — which is what makes the resolve a REQUIRED step of the
|
|
1173
|
+
* disposition path rather than an optional one.
|
|
927
1174
|
*/
|
|
928
|
-
function
|
|
1175
|
+
function botHighInlinesOnHead(threads, headSha) {
|
|
929
1176
|
return threads.filter((t) => t.rootLogin !== null &&
|
|
930
1177
|
isBotReviewerLoginExact(t.rootLogin) &&
|
|
931
1178
|
t.rootCommit !== null &&
|
|
932
1179
|
t.rootCommit.toLowerCase() === headSha &&
|
|
933
1180
|
hasHighSeverityMarker(t.rootBody));
|
|
934
1181
|
}
|
|
1182
|
+
/**
|
|
1183
|
+
* The disposition path, read off the same page as the predicate
|
|
1184
|
+
* (mmnto-ai/totem#2861): a thread is DISCHARGED when it is RESOLVED and a
|
|
1185
|
+
* disposition NAMES it — a non-bot reply after the root inside the thread (it
|
|
1186
|
+
* names the thread by being in it), or a non-bot PR-level comment created
|
|
1187
|
+
* STRICTLY after the root that carries a `disposition:` line for this
|
|
1188
|
+
* thread's root comment id ({@link DISPOSITION_LINE}). `none` is the bare
|
|
1189
|
+
* resolve — a click with no disposition naming it, the fail-closed arm the
|
|
1190
|
+
* ruling names — and an unresolved thread alike; predicate 2 catches the
|
|
1191
|
+
* unresolved one first, so the resolve is a REQUIRED step of the path, never
|
|
1192
|
+
* an optional one. A round disposition that answered OTHER threads is `none`
|
|
1193
|
+
* for this one: the id is what closes the class two round-keyed reads left
|
|
1194
|
+
* open (the falsification legs' r-f1 and r2-f1, both executed on the built
|
|
1195
|
+
* core), and the operator ruled for the thread-level line on 2026-09-16.
|
|
1196
|
+
*
|
|
1197
|
+
* Two deliberate differences from the `totem resolve-threads` evidence rule
|
|
1198
|
+
* (mmnto-ai/totem#2841 R2), which this otherwise mirrors. The verb decides
|
|
1199
|
+
* whether a thread MAY be resolved and accepts any non-bot PR-level comment
|
|
1200
|
+
* after the root, disclosing that an operator's trigger comment counts; this
|
|
1201
|
+
* predicate decides whether a resolved HIGH is DISPOSITIONED, and a trigger,
|
|
1202
|
+
* a gate-read note, merge chatter or another thread's disposition must not
|
|
1203
|
+
* discharge it — so the PR-level arm reads the line that names this thread.
|
|
1204
|
+
* A thread the verb resolved on the strength of a trigger therefore stays
|
|
1205
|
+
* applying here until a disposition naming it is posted: the stricter side of
|
|
1206
|
+
* the asymmetry, by design. `createdAt` is a comment's CREATION instant and an
|
|
1207
|
+
* edit does not move it, so a line edited into a comment created after the
|
|
1208
|
+
* root reads as posted then, and a line edited into one created before the
|
|
1209
|
+
* root never counts (r3-f12) — a late line is posted as a new comment, which
|
|
1210
|
+
* is what the calibration replay did. And an unparseable
|
|
1211
|
+
* instant on either side is `none` here as it is there — the conservative
|
|
1212
|
+
* direction — never an unreadable page (the strict reader has already refused
|
|
1213
|
+
* a root without a string `createdAt`).
|
|
1214
|
+
*
|
|
1215
|
+
* A thread with more comments than the ten-comment window is judged on what
|
|
1216
|
+
* was read: evidence found inside the window or at PR level discharges it, and
|
|
1217
|
+
* a window with none is a bare resolve whose reason NAMES the window — a
|
|
1218
|
+
* fact-side deny at every tier, never the unevaluable class, which pilot maps
|
|
1219
|
+
* to `warn` and which would have let the shape pre-cure denied at both tiers
|
|
1220
|
+
* stop blocking (leg f2).
|
|
1221
|
+
*/
|
|
1222
|
+
function dischargeOf(thread, prComments) {
|
|
1223
|
+
if (!thread.isResolved)
|
|
1224
|
+
return 'none';
|
|
1225
|
+
if (thread.humanReplies > 0)
|
|
1226
|
+
return 'in-thread-reply';
|
|
1227
|
+
const rootAt = Date.parse(thread.rootCreatedAt);
|
|
1228
|
+
if (Number.isNaN(rootAt))
|
|
1229
|
+
return 'none';
|
|
1230
|
+
if (thread.rootId === null)
|
|
1231
|
+
return 'none';
|
|
1232
|
+
for (const c of prComments) {
|
|
1233
|
+
if (c.isBot || !c.dispositions.includes(thread.rootId))
|
|
1234
|
+
continue;
|
|
1235
|
+
const at = Date.parse(c.createdAt);
|
|
1236
|
+
if (!Number.isNaN(at) && at > rootAt)
|
|
1237
|
+
return 'pr-level-disposition';
|
|
1238
|
+
}
|
|
1239
|
+
return 'none';
|
|
1240
|
+
}
|
|
1241
|
+
/** Predicate 4's read: the bot HIGH inlines on the head, split by {@link dischargeOf}, with the arm counts. */
|
|
1242
|
+
function triageHighInlines(threads, headSha, prComments) {
|
|
1243
|
+
const applying = [];
|
|
1244
|
+
const discharged = [];
|
|
1245
|
+
const by = { inThreadReply: 0, prLevelDisposition: 0 };
|
|
1246
|
+
for (const t of botHighInlinesOnHead(threads, headSha)) {
|
|
1247
|
+
const discharge = dischargeOf(t, prComments);
|
|
1248
|
+
if (discharge === 'none') {
|
|
1249
|
+
applying.push(t);
|
|
1250
|
+
}
|
|
1251
|
+
else {
|
|
1252
|
+
discharged.push(t);
|
|
1253
|
+
if (discharge === 'in-thread-reply')
|
|
1254
|
+
by.inThreadReply++;
|
|
1255
|
+
else
|
|
1256
|
+
by.prLevelDisposition++;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
return { applying, discharged, by };
|
|
1260
|
+
}
|
|
935
1261
|
/**
|
|
936
1262
|
* Bot HIGH inlines whose `comment.commit` came back NULL — the predicate's
|
|
937
1263
|
* input is missing, so whether they apply to the head is UNKNOWN
|
|
@@ -986,6 +1312,8 @@ export function evaluateMergeReady(payload, options) {
|
|
|
986
1312
|
threads: { unresolvedBot: 0, pagesRead: 0, complete: false },
|
|
987
1313
|
changesRequestedBy: [],
|
|
988
1314
|
highInline: 0,
|
|
1315
|
+
dischargedHigh: 0,
|
|
1316
|
+
dischargedBy: { inThreadReply: 0, prLevelDisposition: 0 },
|
|
989
1317
|
mergeStateStatus: null,
|
|
990
1318
|
evaluatedAt: checkedAt,
|
|
991
1319
|
evaluatedBy: 'gh (not read)',
|
|
@@ -1058,13 +1386,23 @@ export function evaluateMergeReady(payload, options) {
|
|
|
1058
1386
|
complete: state.complete,
|
|
1059
1387
|
};
|
|
1060
1388
|
detail.changesRequestedBy = changesRequestedBy(state.reviews);
|
|
1061
|
-
|
|
1389
|
+
const high = triageHighInlines(state.threads, state.headSha, state.prComments);
|
|
1390
|
+
detail.highInline = high.applying.length;
|
|
1391
|
+
detail.dischargedHigh = high.discharged.length;
|
|
1392
|
+
detail.dischargedBy = { ...high.by };
|
|
1393
|
+
// The audit breadcrumb for what the predicate RELEASED (mmnto-ai/totem#2861):
|
|
1394
|
+
// a discharged HIGH is a bot finding the round answered, and the record
|
|
1395
|
+
// must show it was read and discharged — and by which arm — never that it
|
|
1396
|
+
// was not seen.
|
|
1397
|
+
if (high.discharged.length > 0) {
|
|
1398
|
+
notices.push(`${MERGE_READY_NOTICE_PREFIX} ${parsed.repo}#${state.number} at ${shortSha(state.headSha)}: ${high.discharged.length} HIGH/Major bot inline(s) on the head commit discharged through the disposition path (thread resolved; ${high.by.inThreadReply} by a non-bot in-thread reply, ${high.by.prLevelDisposition} by a PR-level disposition line naming the thread after its root) — no longer applying (mmnto-ai/totem#2861).`);
|
|
1399
|
+
}
|
|
1062
1400
|
// The predicates are read BEFORE the unreadable-commit arm below: a failure
|
|
1063
1401
|
// that stands in charter order ahead of predicate 4 is a fact, and a tier
|
|
1064
1402
|
// never softens a fact — the arm that returned first here turned a failing
|
|
1065
1403
|
// check plus one unplaceable HIGH inline into the UNEVALUABLE class, which
|
|
1066
1404
|
// pilot maps to `warn` (mmnto-ai/totem#2844 round 1, CodeRabbit).
|
|
1067
|
-
const blocked = firstFailure(state, detail);
|
|
1405
|
+
const blocked = firstFailure(state, detail, high.applying);
|
|
1068
1406
|
// A bot HIGH inline whose `comment.commit` came back null cannot be placed
|
|
1069
1407
|
// against the head, so predicate 4's input is missing for it: unevaluable and
|
|
1070
1408
|
// NAMED, never a silent pass (round 2, F8). It still preempts predicate 5 —
|
|
@@ -1134,8 +1472,13 @@ export function evaluateMergeReady(payload, options) {
|
|
|
1134
1472
|
notices,
|
|
1135
1473
|
};
|
|
1136
1474
|
}
|
|
1137
|
-
/**
|
|
1138
|
-
|
|
1475
|
+
/**
|
|
1476
|
+
* The FIRST predicate that fails, in charter order, or null when the floor is
|
|
1477
|
+
* met. `applyingHigh` is predicate 4's set after the discharge read — the
|
|
1478
|
+
* caller triages once so the provenance counts and the verdict read the same
|
|
1479
|
+
* list.
|
|
1480
|
+
*/
|
|
1481
|
+
function firstFailure(state, detail, applyingHigh) {
|
|
1139
1482
|
// 1. checks
|
|
1140
1483
|
if (detail.checks.failing > 0) {
|
|
1141
1484
|
const names = state.checks
|
|
@@ -1173,13 +1516,32 @@ function firstFailure(state, detail) {
|
|
|
1173
1516
|
evidence: `CHANGES_REQUESTED stands from ${detail.changesRequestedBy.join(', ')}`,
|
|
1174
1517
|
};
|
|
1175
1518
|
}
|
|
1176
|
-
// 4. HIGH/Major bot inline on the head commit
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1519
|
+
// 4. HIGH/Major bot inline on the head commit, not discharged
|
|
1520
|
+
if (applyingHigh.length > 0) {
|
|
1521
|
+
const first = applyingHigh[0];
|
|
1522
|
+
// Name the bare-resolve shape when that is what the first one is — the
|
|
1523
|
+
// operator's cure differs (post the disposition line) from the unanswered
|
|
1524
|
+
// shape's (answer the finding) — and name the window when the thread had
|
|
1525
|
+
// more comments than the read fetched. The LINE comes first in the clause
|
|
1526
|
+
// and the clause before the quoted body, so the id survives the
|
|
1527
|
+
// 160-character bound on `provenance.matched` (the third leg's r3-f3: a
|
|
1528
|
+
// clause that led with prose cut the id off at character 161); the tests
|
|
1529
|
+
// pin the id on `matched` for both clauses. With more than one applying,
|
|
1530
|
+
// the reason names the first and says where the rest are listed.
|
|
1531
|
+
const line = first.rootId === null
|
|
1532
|
+
? 'no line can name it (its root comment id was not readable)'
|
|
1533
|
+
: `no "disposition: ${first.rootId} <verb>" line after its root`;
|
|
1534
|
+
const bareResolve = !first.isResolved
|
|
1535
|
+
? ''
|
|
1536
|
+
: first.commentsComplete
|
|
1537
|
+
? ` — resolved, ${line} and no non-bot reply in its thread (a bare resolve, or a round disposition that did not name this thread, does not discharge a HIGH; mmnto-ai/totem#2861)`
|
|
1538
|
+
: ` — resolved, ${line} and no non-bot reply in the ten comments read (the thread has more; mmnto-ai/totem#2861)`;
|
|
1539
|
+
const rest = applyingHigh.length > 1
|
|
1540
|
+
? ` (the first of ${applyingHigh.length}; a \`totem resolve-threads\` dry run lists every root id)`
|
|
1541
|
+
: '';
|
|
1180
1542
|
return {
|
|
1181
1543
|
predicate: 'high-severity-inline',
|
|
1182
|
-
evidence: `${
|
|
1544
|
+
evidence: `${applyingHigh.length} HIGH/Major bot inline(s) still applying to the head commit${bareResolve} — the first is ${first.rootLogin ?? 'a bot'}${rest}: "${bounded(first.rootBody)}"`,
|
|
1183
1545
|
};
|
|
1184
1546
|
}
|
|
1185
1547
|
// 5. GitHub's own mergeability
|