@mmnto/totem 2.5.0 → 2.7.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/config-schema.d.ts +6 -1
- package/dist/config-schema.d.ts.map +1 -1
- package/dist/config-schema.js +6 -1
- package/dist/config-schema.js.map +1 -1
- 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/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/merge-ready.d.ts +42 -4
- package/dist/merge-ready.d.ts.map +1 -1
- package/dist/merge-ready.js +652 -49
- package/dist/merge-ready.js.map +1 -1
- package/dist/merge-ready.test.js +550 -21
- package/dist/merge-ready.test.js.map +1 -1
- package/dist/orchestration-resolver.d.ts +9 -0
- package/dist/orchestration-resolver.d.ts.map +1 -1
- package/dist/orchestration-resolver.js +44 -7
- package/dist/orchestration-resolver.js.map +1 -1
- package/dist/orchestration-resolver.test.js +30 -25
- package/dist/orchestration-resolver.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
|
|
@@ -78,7 +108,7 @@ const MERGE_READY_FRAGMENT = `fragment MergeReadyPr on PullRequest {
|
|
|
78
108
|
pageInfo { hasNextPage endCursor }
|
|
79
109
|
nodes {
|
|
80
110
|
__typename
|
|
81
|
-
... on CheckRun { name status conclusion }
|
|
111
|
+
... on CheckRun { name status conclusion databaseId checkSuite { databaseId app { slug } workflowRun { workflow { databaseId name } } } }
|
|
82
112
|
... on StatusContext { context state }
|
|
83
113
|
}
|
|
84
114
|
}
|
|
@@ -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,71 @@ function asArray(value) {
|
|
|
207
248
|
function asString(value) {
|
|
208
249
|
return typeof value === 'string' ? value : null;
|
|
209
250
|
}
|
|
251
|
+
/**
|
|
252
|
+
* A non-negative SAFE integer as the API typed it; anything else is null,
|
|
253
|
+
* never coerced. Safe, not merely integral: an id beyond 2^53 would have been
|
|
254
|
+
* rounded by JSON.parse, and two distinct ids rounded to one number would be
|
|
255
|
+
* ordered wrongly (bot round 1 on mmnto-ai/totem#2879, Greptile P2) — the
|
|
256
|
+
* review-comment id reader already refuses the same way.
|
|
257
|
+
*/
|
|
258
|
+
function asNonNegativeInteger(value) {
|
|
259
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* The per-thread disposition line (mmnto-ai/totem#2861, the operator's ruling
|
|
263
|
+
* of 2026-09-16 on the linkage fork): the review-reply skill's step 2 ends a
|
|
264
|
+
* round disposition with ONE machine line per bot-rooted thread the round
|
|
265
|
+
* answered —
|
|
266
|
+
*
|
|
267
|
+
* disposition: <root comment id> <verb>
|
|
268
|
+
*
|
|
269
|
+
* — the id being the thread root's REST comment id (`databaseId`; the `id=`
|
|
270
|
+
* on a `totem resolve-threads` dry-run row, the one command that prints it —
|
|
271
|
+
* `totem triage-pr` carries it internally and prints none) and the verb the
|
|
272
|
+
* round's word for it (fixed, declined, deferred,
|
|
273
|
+
* nit, extracted, held). Predicate 4 reads the ID: a PR-level comment names
|
|
274
|
+
* a thread when a line carries that thread's root id — and a round
|
|
275
|
+
* disposition that answered OTHER threads does not name this one. That last
|
|
276
|
+
* case is why the line exists: two earlier reads keyed the arm to the ROUND
|
|
277
|
+
* (post-dating alone, then post-dating plus the round's `local-lane:` line),
|
|
278
|
+
* and both let a bare resolve of a HIGH the round never addressed discharge —
|
|
279
|
+
* the two falsification legs' blocking findings, executed on the built core.
|
|
280
|
+
* Naming the thread is what the ruling asked for, and an id is exact where a
|
|
281
|
+
* marker was a shape. By construction no other surface writes a line of this
|
|
282
|
+
* shape: a trigger comment, a gate-read note or a merge note carries no id at
|
|
283
|
+
* line start, and the gate's own deny reason quotes the line inside double
|
|
284
|
+
* quotes mid-sentence (the third leg tried the reason verbatim, fenced,
|
|
285
|
+
* unquoted, and a pasted resolve-threads row, and none read as a line) — an
|
|
286
|
+
* inference about bodies, made safe by the id rather than proven.
|
|
287
|
+
*
|
|
288
|
+
* Read against the raw body with HTML comments removed (a terminated
|
|
289
|
+
* `<!-- … -->`, and an unterminated `<!--` to the end of the body, the way a
|
|
290
|
+
* renderer hides it), at line start with leading blanks allowed, anywhere in
|
|
291
|
+
* the body — fenced included, because the skills render machine lines in text
|
|
292
|
+
* fences. A blockquoted, listed or tabled line, or one inside an inline span,
|
|
293
|
+
* is not at line start and does not count. The id is the decimal the seat
|
|
294
|
+
* copied: no leading zero, no sign, no fraction, nothing glued to it — the
|
|
295
|
+
* third leg's r3-f5 showed `Number()` equating `01001`, `1001.5` and `1001x`
|
|
296
|
+
* to 1001, so the token is matched exactly and never coerced. The verb is not
|
|
297
|
+
* validated here: the gate answers "was this thread dispositioned", not
|
|
298
|
+
* "how".
|
|
299
|
+
*/
|
|
300
|
+
const DISPOSITION_LINE = /^[ \t]*disposition:[ \t]+([1-9]\d*)(?=[ \t]|\r?$)/gm;
|
|
301
|
+
/**
|
|
302
|
+
* The root comment ids a PR-level comment body names on disposition lines,
|
|
303
|
+
* in first-seen order, without duplicates. Exported for the tests, so they
|
|
304
|
+
* assert the shipped predicate and not a copy of it.
|
|
305
|
+
*/
|
|
306
|
+
export function dispositionedRootIds(body) {
|
|
307
|
+
const ids = [];
|
|
308
|
+
const stripped = body.replace(/<!--[\s\S]*?-->/g, '').replace(/<!--[\s\S]*$/, '');
|
|
309
|
+
for (const match of stripped.matchAll(DISPOSITION_LINE)) {
|
|
310
|
+
const id = Number(match[1]);
|
|
311
|
+
if (Number.isSafeInteger(id) && !ids.includes(id))
|
|
312
|
+
ids.push(id);
|
|
313
|
+
}
|
|
314
|
+
return ids;
|
|
315
|
+
}
|
|
210
316
|
/**
|
|
211
317
|
* A CheckRun's conclusion vocabulary. `NEUTRAL` and `SKIPPED` are successes (a
|
|
212
318
|
* skipped required check is branch protection's business, not this gate's);
|
|
@@ -215,6 +321,15 @@ function asString(value) {
|
|
|
215
321
|
*/
|
|
216
322
|
const SUCCESS_CONCLUSIONS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
|
|
217
323
|
const SUCCESS_CONTEXT_STATES = new Set(['SUCCESS']);
|
|
324
|
+
/**
|
|
325
|
+
* The name a CheckRun gets when GitHub's answer carried none. `CheckRun.name`
|
|
326
|
+
* is NON_NULL in the schema, so this is reachable only from a malformed or
|
|
327
|
+
* mocked payload — and two such nodes must never be read as one check ran
|
|
328
|
+
* twice (leg F10 on mmnto-ai/totem#2879): the latest-run collapse skips it.
|
|
329
|
+
*/
|
|
330
|
+
const UNNAMED_CHECK = '(unnamed check)';
|
|
331
|
+
/** The app slug GitHub Actions posts check runs under; its runs carry a workflow, and one without a readable workflow id is a producer that did not read. */
|
|
332
|
+
const ACTIONS_APP = 'github-actions';
|
|
218
333
|
const PENDING_CONTEXT_STATES = new Set(['PENDING', 'EXPECTED']);
|
|
219
334
|
/**
|
|
220
335
|
* A connection's `pageInfo`, read STRICTLY: the query asks for it on every
|
|
@@ -255,30 +370,69 @@ function readChecks(rollup) {
|
|
|
255
370
|
return { entries, hasNext: false, cursor: null, detail: 'a check node was not an object' };
|
|
256
371
|
const typename = asString(n.__typename);
|
|
257
372
|
if (typename === 'CheckRun') {
|
|
258
|
-
const
|
|
373
|
+
const readName = asString(n.name);
|
|
374
|
+
const name = readName ?? UNNAMED_CHECK;
|
|
375
|
+
const named = readName !== null;
|
|
259
376
|
const status = asString(n.status) ?? '';
|
|
260
377
|
const conclusion = asString(n.conclusion);
|
|
378
|
+
const runId = asNonNegativeInteger(n.databaseId);
|
|
379
|
+
const suite = asObject(n.checkSuite);
|
|
380
|
+
const suiteId = asNonNegativeInteger(suite?.databaseId);
|
|
381
|
+
const appSlug = asString(asObject(suite?.app)?.slug);
|
|
382
|
+
const workflow = asObject(asObject(suite?.workflowRun)?.workflow);
|
|
383
|
+
const workflowId = asNonNegativeInteger(workflow?.databaseId);
|
|
384
|
+
const workflowName = asString(workflow?.name);
|
|
385
|
+
// Actions without a readable workflow id is NOT "one producer for every
|
|
386
|
+
// workflow" — it is a producer that did not read (fail closed).
|
|
387
|
+
const producer = appSlug === null
|
|
388
|
+
? null
|
|
389
|
+
: workflowId !== null
|
|
390
|
+
? `${appSlug}/${String(workflowId)}`
|
|
391
|
+
: appSlug === ACTIONS_APP
|
|
392
|
+
? null
|
|
393
|
+
: appSlug;
|
|
394
|
+
const producerLabel = appSlug === null ? null : workflowName === null ? appSlug : `${appSlug}/${workflowName}`;
|
|
395
|
+
const shared = {
|
|
396
|
+
name,
|
|
397
|
+
typename: 'CheckRun',
|
|
398
|
+
named,
|
|
399
|
+
producer,
|
|
400
|
+
producerLabel,
|
|
401
|
+
suiteId,
|
|
402
|
+
runId,
|
|
403
|
+
};
|
|
261
404
|
if (status !== 'COMPLETED') {
|
|
262
|
-
entries.push({
|
|
405
|
+
entries.push({ ...shared, kind: 'pending' });
|
|
263
406
|
}
|
|
264
407
|
else if (conclusion !== null && SUCCESS_CONCLUSIONS.has(conclusion)) {
|
|
265
|
-
entries.push({
|
|
408
|
+
entries.push({ ...shared, kind: 'success' });
|
|
266
409
|
}
|
|
267
410
|
else {
|
|
268
|
-
entries.push({
|
|
411
|
+
entries.push({ ...shared, kind: 'failing' });
|
|
269
412
|
}
|
|
270
413
|
}
|
|
271
414
|
else if (typename === 'StatusContext') {
|
|
272
|
-
const
|
|
415
|
+
const readName = asString(n.context);
|
|
416
|
+
const name = readName ?? '(unnamed context)';
|
|
417
|
+
const named = readName !== null;
|
|
273
418
|
const state = asString(n.state) ?? '';
|
|
419
|
+
const shared = {
|
|
420
|
+
name,
|
|
421
|
+
typename: 'StatusContext',
|
|
422
|
+
named,
|
|
423
|
+
producer: null,
|
|
424
|
+
producerLabel: null,
|
|
425
|
+
suiteId: null,
|
|
426
|
+
runId: null,
|
|
427
|
+
};
|
|
274
428
|
if (SUCCESS_CONTEXT_STATES.has(state)) {
|
|
275
|
-
entries.push({
|
|
429
|
+
entries.push({ ...shared, kind: 'success' });
|
|
276
430
|
}
|
|
277
431
|
else if (PENDING_CONTEXT_STATES.has(state)) {
|
|
278
|
-
entries.push({
|
|
432
|
+
entries.push({ ...shared, kind: 'pending' });
|
|
279
433
|
}
|
|
280
434
|
else {
|
|
281
|
-
entries.push({
|
|
435
|
+
entries.push({ ...shared, kind: 'failing' });
|
|
282
436
|
}
|
|
283
437
|
}
|
|
284
438
|
else {
|
|
@@ -342,6 +496,43 @@ function readReviews(connection) {
|
|
|
342
496
|
return unreadable(info.detail);
|
|
343
497
|
return { entries, hasNext: info.hasNext, cursor: info.cursor };
|
|
344
498
|
}
|
|
499
|
+
/**
|
|
500
|
+
* A comment's author, read STRICTLY for the evidence rule (mmnto-ai/totem#2861).
|
|
501
|
+
* `null` is a deleted account — NOT a bot: the reply was human when it was
|
|
502
|
+
* written, and deleting the account does not retract it (the resolve-threads
|
|
503
|
+
* rule, item 6 of `.totem/specs/2841.md`). Otherwise `__typename` and `login`
|
|
504
|
+
* must both be strings. `__typename` is REQUIRED, not optional, because it is
|
|
505
|
+
* the only signal that names EVERY GitHub App a bot (`github-actions`, a
|
|
506
|
+
* Copilot reviewer, a scanner): a document that quietly stopped selecting it
|
|
507
|
+
* would fail OPEN, every App reply reading as the human answer — the same
|
|
508
|
+
* guard resolve-threads holds at its zod boundary. The three bot arms are the
|
|
509
|
+
* verb's: GitHub's own `Bot` typename, the `[bot]` App suffix, or core's exact
|
|
510
|
+
* review-bot list.
|
|
511
|
+
*/
|
|
512
|
+
function readAuthor(value) {
|
|
513
|
+
if (value === null)
|
|
514
|
+
return { ok: true, login: null, isBot: false };
|
|
515
|
+
const author = asObject(value);
|
|
516
|
+
if (author === null)
|
|
517
|
+
return { ok: false, detail: 'author is neither null nor an object' };
|
|
518
|
+
const typename = asString(author.__typename);
|
|
519
|
+
const login = asString(author.login);
|
|
520
|
+
if (typename === null || login === null) {
|
|
521
|
+
return { ok: false, detail: 'author has no __typename or no login' };
|
|
522
|
+
}
|
|
523
|
+
// On every capture the `Bot` typename is the arm that decides for a review
|
|
524
|
+
// bot (their GraphQL logins carry no suffix); the two login arms are the
|
|
525
|
+
// verb's belt-and-braces, kept so the two consumers of the rule agree. The
|
|
526
|
+
// null-author rule applies on BOTH surfaces: a deleted account's PR-level
|
|
527
|
+
// comment carrying a disposition line discharges the thread it names — not
|
|
528
|
+
// guarded, judged unreachable (a removed App's summary would have to carry
|
|
529
|
+
// this thread's root id at line start; the third leg's r3-f11).
|
|
530
|
+
return {
|
|
531
|
+
ok: true,
|
|
532
|
+
login,
|
|
533
|
+
isBot: typename === 'Bot' || hasBotAppLoginSuffix(login) || isBotReviewerLoginExact(login),
|
|
534
|
+
};
|
|
535
|
+
}
|
|
345
536
|
/**
|
|
346
537
|
* The `reviewThreads` connection, read with the same strictness as
|
|
347
538
|
* {@link readReviews}: a missing connection, a missing `nodes` array or
|
|
@@ -352,6 +543,16 @@ function readReviews(connection) {
|
|
|
352
543
|
* (mmnto-ai/totem#2844 round 1). A root comment whose author is a deleted
|
|
353
544
|
* account keeps `rootLogin: null` — it is not a known bot, which is a fact
|
|
354
545
|
* about the thread, not an unreadable answer.
|
|
546
|
+
*
|
|
547
|
+
* The evidence fields (mmnto-ai/totem#2861) are held to the same bar: the
|
|
548
|
+
* comments window's own `pageInfo`, the root's `createdAt`, and every
|
|
549
|
+
* author's `__typename` — a thread missing any of them is unreadable, never
|
|
550
|
+
* "complete with no reply", because that is the shape a discharge would fail
|
|
551
|
+
* open on. The root's `databaseId` is the one field read the other way: the
|
|
552
|
+
* schema types it nullable, and a root without one cannot be named by any
|
|
553
|
+
* line, so that THREAD stays applying (fail-closed) while the page stays
|
|
554
|
+
* readable — refusing the page would send a PR into the unevaluable class and
|
|
555
|
+
* pilot's `warn` (r3-f4).
|
|
355
556
|
*/
|
|
356
557
|
function readThreads(connection) {
|
|
357
558
|
const entries = [];
|
|
@@ -370,17 +571,59 @@ function readThreads(connection) {
|
|
|
370
571
|
const n = asObject(node);
|
|
371
572
|
if (n === null)
|
|
372
573
|
return unreadable('carried a node that is not an object');
|
|
373
|
-
const
|
|
574
|
+
const commentsConnection = asObject(n.comments);
|
|
575
|
+
const comments = asArray(commentsConnection?.nodes);
|
|
374
576
|
if (comments === null)
|
|
375
577
|
return unreadable('carried a thread with no comments array');
|
|
376
578
|
const root = asObject(comments[0]);
|
|
377
579
|
if (root === null)
|
|
378
580
|
return unreadable('carried a thread whose root comment is unreadable');
|
|
581
|
+
const window = readPageInfo(commentsConnection);
|
|
582
|
+
if (!window.ok) {
|
|
583
|
+
return unreadable(`carried a thread whose comments connection ${window.detail}`);
|
|
584
|
+
}
|
|
585
|
+
const rootAuthor = readAuthor(root.author);
|
|
586
|
+
if (!rootAuthor.ok)
|
|
587
|
+
return unreadable(`carried a thread whose root ${rootAuthor.detail}`);
|
|
588
|
+
const rootCreatedAt = asString(root.createdAt);
|
|
589
|
+
if (rootCreatedAt === null) {
|
|
590
|
+
return unreadable('carried a thread whose root comment has no createdAt');
|
|
591
|
+
}
|
|
592
|
+
// The root's REST id is what a disposition line names; a root without a
|
|
593
|
+
// safe-integer one cannot be matched to any line and its thread stays
|
|
594
|
+
// applying — thread-scoped, never a page refusal (mmnto-ai/totem#2861).
|
|
595
|
+
const rootId = typeof root.databaseId === 'number' && Number.isSafeInteger(root.databaseId)
|
|
596
|
+
? root.databaseId
|
|
597
|
+
: null;
|
|
598
|
+
const rootAt = Date.parse(rootCreatedAt);
|
|
599
|
+
let humanReplies = 0;
|
|
600
|
+
for (const reply of comments.slice(1)) {
|
|
601
|
+
const r = asObject(reply);
|
|
602
|
+
if (r === null)
|
|
603
|
+
return unreadable('carried a thread with a reply that is not an object');
|
|
604
|
+
const author = readAuthor(r.author);
|
|
605
|
+
if (!author.ok)
|
|
606
|
+
return unreadable(`carried a thread with a reply whose ${author.detail}`);
|
|
607
|
+
const replyCreatedAt = asString(r.createdAt);
|
|
608
|
+
if (replyCreatedAt === null)
|
|
609
|
+
return unreadable('carried a thread with a reply that has no createdAt');
|
|
610
|
+
// Positional AND temporal: a non-bot comment after the root in the list
|
|
611
|
+
// that also post-dates it. An unparseable instant on either side is not
|
|
612
|
+
// a reply that counts — the conservative direction.
|
|
613
|
+
const replyAt = Date.parse(replyCreatedAt);
|
|
614
|
+
if (!author.isBot && !Number.isNaN(rootAt) && !Number.isNaN(replyAt) && replyAt > rootAt) {
|
|
615
|
+
humanReplies++;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
379
618
|
entries.push({
|
|
380
619
|
isResolved: n.isResolved === true,
|
|
381
620
|
isOutdated: n.isOutdated === true,
|
|
382
|
-
rootLogin:
|
|
621
|
+
rootLogin: rootAuthor.login,
|
|
383
622
|
rootBody: asString(root.body) ?? '',
|
|
623
|
+
rootId,
|
|
624
|
+
rootCreatedAt,
|
|
625
|
+
humanReplies,
|
|
626
|
+
commentsComplete: !window.hasNext,
|
|
384
627
|
rootCommit: asString(asObject(root.commit)?.oid),
|
|
385
628
|
rootOriginalCommit: asString(asObject(root.originalCommit)?.oid),
|
|
386
629
|
});
|
|
@@ -390,6 +633,47 @@ function readThreads(connection) {
|
|
|
390
633
|
return unreadable(info.detail);
|
|
391
634
|
return { entries, hasNext: info.hasNext, cursor: info.cursor };
|
|
392
635
|
}
|
|
636
|
+
/**
|
|
637
|
+
* The PR-level `comments` connection — the second evidence surface
|
|
638
|
+
* (mmnto-ai/totem#2861), read as strictly as the others: a missing connection,
|
|
639
|
+
* a missing `nodes` array or `pageInfo`, a node that is not an object, an
|
|
640
|
+
* author without `__typename` / `login`, or a comment without a string `body`
|
|
641
|
+
* or `createdAt` is an UNREADABLE page, named — never "no evidence", which
|
|
642
|
+
* would deny a dispositioned HIGH for a reason that misnames its cause.
|
|
643
|
+
*/
|
|
644
|
+
function readPrComments(connection) {
|
|
645
|
+
const entries = [];
|
|
646
|
+
const unreadable = (why) => ({
|
|
647
|
+
entries,
|
|
648
|
+
hasNext: false,
|
|
649
|
+
cursor: null,
|
|
650
|
+
detail: `the PR comments connection ${why}`,
|
|
651
|
+
});
|
|
652
|
+
if (connection === null)
|
|
653
|
+
return unreadable('was missing from the answer');
|
|
654
|
+
const nodes = asArray(connection.nodes);
|
|
655
|
+
if (nodes === null)
|
|
656
|
+
return unreadable('carried no nodes array');
|
|
657
|
+
for (const node of nodes) {
|
|
658
|
+
const n = asObject(node);
|
|
659
|
+
if (n === null)
|
|
660
|
+
return unreadable('carried a node that is not an object');
|
|
661
|
+
const author = readAuthor(n.author);
|
|
662
|
+
if (!author.ok)
|
|
663
|
+
return unreadable(`carried a comment whose ${author.detail}`);
|
|
664
|
+
const body = asString(n.body);
|
|
665
|
+
if (body === null)
|
|
666
|
+
return unreadable('carried a comment with no body');
|
|
667
|
+
const createdAt = asString(n.createdAt);
|
|
668
|
+
if (createdAt === null)
|
|
669
|
+
return unreadable('carried a comment with no createdAt');
|
|
670
|
+
entries.push({ isBot: author.isBot, createdAt, dispositions: dispositionedRootIds(body) });
|
|
671
|
+
}
|
|
672
|
+
const info = readPageInfo(connection);
|
|
673
|
+
if (!info.ok)
|
|
674
|
+
return unreadable(info.detail);
|
|
675
|
+
return { entries, hasNext: info.hasNext, cursor: info.cursor };
|
|
676
|
+
}
|
|
393
677
|
/** Read one `gh api graphql` response body into a classified page. */
|
|
394
678
|
function parsePage(raw, byBranch) {
|
|
395
679
|
let body;
|
|
@@ -488,6 +772,10 @@ function parsePage(raw, byBranch) {
|
|
|
488
772
|
const threads = readThreads(asObject(pr.reviewThreads));
|
|
489
773
|
if (threads.detail !== undefined)
|
|
490
774
|
return { ok: false, detail: threads.detail };
|
|
775
|
+
// The evidence surface (mmnto-ai/totem#2861) is held to the same bar.
|
|
776
|
+
const prComments = readPrComments(asObject(pr.comments));
|
|
777
|
+
if (prComments.detail !== undefined)
|
|
778
|
+
return { ok: false, detail: prComments.detail };
|
|
491
779
|
return {
|
|
492
780
|
ok: true,
|
|
493
781
|
page: {
|
|
@@ -507,6 +795,9 @@ function parsePage(raw, byBranch) {
|
|
|
507
795
|
threads: threads.entries,
|
|
508
796
|
threadsHasNext: threads.hasNext,
|
|
509
797
|
threadsCursor: threads.cursor,
|
|
798
|
+
prComments: prComments.entries,
|
|
799
|
+
commentsHasNext: prComments.hasNext,
|
|
800
|
+
commentsCursor: prComments.cursor,
|
|
510
801
|
},
|
|
511
802
|
};
|
|
512
803
|
}
|
|
@@ -674,14 +965,22 @@ export function hasHighSeverityMarker(body) {
|
|
|
674
965
|
return HIGH_SEVERITY_MARKERS.some((re) => re.test(prose));
|
|
675
966
|
}
|
|
676
967
|
// ─── Evidence helpers ───────────────────────────────────────────────────────
|
|
677
|
-
/**
|
|
678
|
-
|
|
968
|
+
/**
|
|
969
|
+
* One line: C0 control characters and DEL to spaces, runs of whitespace to
|
|
970
|
+
* one, trimmed — never sliced. (C1 controls are left as they are, as they
|
|
971
|
+
* always were in `bounded`.)
|
|
972
|
+
*/
|
|
973
|
+
function oneLine(text) {
|
|
679
974
|
let out = '';
|
|
680
975
|
for (const ch of text) {
|
|
681
976
|
const code = ch.charCodeAt(0);
|
|
682
977
|
out += code < 0x20 || code === 0x7f ? ' ' : ch;
|
|
683
978
|
}
|
|
684
|
-
|
|
979
|
+
return out.replace(/\s+/g, ' ').trim();
|
|
980
|
+
}
|
|
981
|
+
/** Bound and sanitize a fragment for a reason / provenance: no control characters, bounded length. */
|
|
982
|
+
function bounded(text) {
|
|
983
|
+
const out = oneLine(text);
|
|
685
984
|
return out.length > MERGE_READY_EVIDENCE_MAX
|
|
686
985
|
? out.slice(0, MERGE_READY_EVIDENCE_MAX - 1) + '…'
|
|
687
986
|
: out;
|
|
@@ -715,17 +1014,22 @@ function readPullRequest(payload, runner) {
|
|
|
715
1014
|
rollupState: null,
|
|
716
1015
|
rollupTotalCount: null,
|
|
717
1016
|
checks: [],
|
|
1017
|
+
judgedChecks: [],
|
|
1018
|
+
supersededRuns: [],
|
|
718
1019
|
reviews: [],
|
|
719
1020
|
threads: [],
|
|
1021
|
+
prComments: [],
|
|
720
1022
|
pagesRead: 0,
|
|
721
1023
|
complete: false,
|
|
722
1024
|
};
|
|
723
1025
|
let checksDone = false;
|
|
724
1026
|
let reviewsDone = false;
|
|
725
1027
|
let threadsDone = false;
|
|
1028
|
+
let commentsDone = false;
|
|
726
1029
|
let checksCursor = null;
|
|
727
1030
|
let reviewsCursor = null;
|
|
728
1031
|
let threadsCursor = null;
|
|
1032
|
+
let commentsCursor = null;
|
|
729
1033
|
for (let page = 0; page < MAX_PAGES; page++) {
|
|
730
1034
|
// The branch-keyed document is used ONLY for the first page of a payload
|
|
731
1035
|
// with no number; once the number is known every later page targets it, so
|
|
@@ -748,6 +1052,8 @@ function readPullRequest(payload, runner) {
|
|
|
748
1052
|
variables.push(['reviewsAfter', reviewsCursor]);
|
|
749
1053
|
if (threadsCursor !== null)
|
|
750
1054
|
variables.push(['threadsAfter', threadsCursor]);
|
|
1055
|
+
if (commentsCursor !== null)
|
|
1056
|
+
variables.push(['commentsAfter', commentsCursor]);
|
|
751
1057
|
const run = runner(graphqlArgs(query, variables));
|
|
752
1058
|
state.pagesRead = page + 1;
|
|
753
1059
|
if (run.exitCode !== 0) {
|
|
@@ -794,7 +1100,12 @@ function readPullRequest(payload, runner) {
|
|
|
794
1100
|
threadsDone = !p.threadsHasNext;
|
|
795
1101
|
threadsCursor = p.threadsCursor;
|
|
796
1102
|
}
|
|
797
|
-
if (
|
|
1103
|
+
if (!commentsDone) {
|
|
1104
|
+
state.prComments.push(...p.prComments);
|
|
1105
|
+
commentsDone = !p.commentsHasNext;
|
|
1106
|
+
commentsCursor = p.commentsCursor;
|
|
1107
|
+
}
|
|
1108
|
+
if (checksDone && reviewsDone && threadsDone && commentsDone) {
|
|
798
1109
|
// The rollup must ACCOUNT for itself before predicate 1 reads it
|
|
799
1110
|
// (mmnto-ai/totem#2800 round 4, F3). `totalCount` is judged as the API
|
|
800
1111
|
// typed it — a string "3", a boolean, a negative or a fractional number
|
|
@@ -825,6 +1136,20 @@ function readPullRequest(payload, runner) {
|
|
|
825
1136
|
};
|
|
826
1137
|
}
|
|
827
1138
|
}
|
|
1139
|
+
// Same-named CheckRuns collapse to their LATEST run before predicate 1
|
|
1140
|
+
// reads them (mmnto-ai/totem#2879): a workflow's concurrency group
|
|
1141
|
+
// cancels the run a later push or body edit superseded, and the rollup
|
|
1142
|
+
// lists BOTH — the cancelled one is not a failing check, it is a
|
|
1143
|
+
// replaced one. The judgment needs every duplicate's id; a group with
|
|
1144
|
+
// an unreadable id is an unreadable check state, never "the first one".
|
|
1145
|
+
// Runs AFTER the count check on purpose: the rollup accounts for the
|
|
1146
|
+
// nodes it listed, and the collapse is a read of those nodes.
|
|
1147
|
+
const judged = judgeLatestRuns(state.checks);
|
|
1148
|
+
if (!judged.ok) {
|
|
1149
|
+
return { ok: false, detail: judged.detail, pagesRead: state.pagesRead };
|
|
1150
|
+
}
|
|
1151
|
+
state.judgedChecks = judged.judged;
|
|
1152
|
+
state.supersededRuns = judged.collapsed;
|
|
828
1153
|
// R5's zero-checks FACT applies ONLY where the rollup is consistent about
|
|
829
1154
|
// it, and that is exactly two shapes: no rollup at all, or a rollup that
|
|
830
1155
|
// reports SUCCESS over an empty context list AND counts zero (the count
|
|
@@ -862,11 +1187,155 @@ const MERGE_STATE_DENY = new Map([
|
|
|
862
1187
|
['BLOCKED', 'branch protection blocks the merge (a required review or check is missing)'],
|
|
863
1188
|
['DRAFT', 'the pull request is still a draft'],
|
|
864
1189
|
]);
|
|
865
|
-
|
|
1190
|
+
/**
|
|
1191
|
+
* Collapse every group of `CheckRun`s that share a NAME and a PRODUCER to its
|
|
1192
|
+
* LATEST run (mmnto-ai/totem#2879). GitHub's rollup `contexts` lists EVERY
|
|
1193
|
+
* check run on the head — a concurrency group's cancelled duplicate beside the
|
|
1194
|
+
* run that superseded it — while `gh pr checks` shows one row per name,
|
|
1195
|
+
* judged by the latest run. The rollup's own order is NOT chronological (on
|
|
1196
|
+
* mmnto-ai/totem#2877's head the later D1 run was listed before the earlier
|
|
1197
|
+
* one), so "last listed wins" is not a rule; the latest run is the one with
|
|
1198
|
+
* the greatest `databaseId`, GitHub's check-run id, a single increasing
|
|
1199
|
+
* sequence.
|
|
1200
|
+
*
|
|
1201
|
+
* The producer is part of the key on purpose: two apps, or two workflows
|
|
1202
|
+
* under one app, may name a job alike, and those are INDEPENDENT checks — a
|
|
1203
|
+
* later success from one must never hide a failure from the other (bot
|
|
1204
|
+
* round 1, Greptile P1). The producer is the app plus the WORKFLOW's id for
|
|
1205
|
+
* Actions, so two workflow files that share a display name stay apart. Same
|
|
1206
|
+
* name, different producer: both judged. Same name, same producer, different
|
|
1207
|
+
* check suites: reruns across workflow runs, the latest judged — and, ruled,
|
|
1208
|
+
* two independent runs of one workflow on one head (a push run beside a
|
|
1209
|
+
* scheduled run) collapse the same way, the way `gh pr checks` and the merge
|
|
1210
|
+
* box take the latest run of a workflow. Same name, same producer, ONE check
|
|
1211
|
+
* suite: two checks of one suite share a name (two jobs of one workflow run,
|
|
1212
|
+
* or two runs of one non-Actions app, which has one suite per head) — the key
|
|
1213
|
+
* cannot tell them apart, an unreadable check state (R2), never a collapse.
|
|
1214
|
+
* Same name, and a member whose producer or suite id did not read: the runs
|
|
1215
|
+
* cannot be placed — unreadable the same way. A group whose members share a
|
|
1216
|
+
* producer and a suite pattern but one lacks a readable id is unreadable too:
|
|
1217
|
+
* the latest cannot be derived, and picking one would be a guess dressed as a
|
|
1218
|
+
* read. A single run needs none of it. Legacy `StatusContext` nodes carry one
|
|
1219
|
+
* state per context already and pass through untouched. Output order is
|
|
1220
|
+
* first-seen order, so the deny reason's name list reads the way the rollup
|
|
1221
|
+
* listed it.
|
|
1222
|
+
*/
|
|
1223
|
+
function judgeLatestRuns(checks) {
|
|
1224
|
+
const byName = new Map();
|
|
1225
|
+
const order = [];
|
|
1226
|
+
for (const c of checks) {
|
|
1227
|
+
// A legacy status passes through; so does a run whose name did not read —
|
|
1228
|
+
// grouping the placeholder would fabricate an identity two malformed nodes
|
|
1229
|
+
// never shared (leg F10). Keyed on `named`, not on the placeholder text.
|
|
1230
|
+
if (c.typename !== 'CheckRun' || !c.named) {
|
|
1231
|
+
order.push({ entry: c });
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
1234
|
+
const group = byName.get(c.name);
|
|
1235
|
+
if (group === undefined) {
|
|
1236
|
+
byName.set(c.name, [c]);
|
|
1237
|
+
order.push({ key: c.name });
|
|
1238
|
+
}
|
|
1239
|
+
else {
|
|
1240
|
+
group.push(c);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
const judged = [];
|
|
1244
|
+
const collapsed = [];
|
|
1245
|
+
for (const slot of order) {
|
|
1246
|
+
if ('entry' in slot) {
|
|
1247
|
+
judged.push(slot.entry);
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
const sameName = byName.get(slot.key);
|
|
1251
|
+
if (sameName.length === 1) {
|
|
1252
|
+
judged.push(sameName[0]);
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1255
|
+
// Two or more runs of one name: split them by producer, first-seen order.
|
|
1256
|
+
const byProducer = new Map();
|
|
1257
|
+
for (const run of sameName) {
|
|
1258
|
+
if (run.producer === null) {
|
|
1259
|
+
return {
|
|
1260
|
+
ok: false,
|
|
1261
|
+
detail: 'check ' +
|
|
1262
|
+
bounded(JSON.stringify(slot.key)) +
|
|
1263
|
+
' ran ' +
|
|
1264
|
+
String(sameName.length) +
|
|
1265
|
+
' times on the head and one of its runs carries no readable producer (the check suite app, or the workflow id of an Actions run) - reruns of one check cannot be told from independent checks that share the name, the check state is unreadable',
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
const sub = byProducer.get(run.producer);
|
|
1269
|
+
if (sub === undefined)
|
|
1270
|
+
byProducer.set(run.producer, [run]);
|
|
1271
|
+
else
|
|
1272
|
+
sub.push(run);
|
|
1273
|
+
}
|
|
1274
|
+
for (const group of byProducer.values()) {
|
|
1275
|
+
if (group.length === 1) {
|
|
1276
|
+
judged.push(group[0]);
|
|
1277
|
+
continue;
|
|
1278
|
+
}
|
|
1279
|
+
const label = group[0].producerLabel ?? group[0].producer ?? '(unreadable producer)';
|
|
1280
|
+
const who = 'check ' + bounded(JSON.stringify(slot.key)) + ' from ' + bounded(JSON.stringify(label));
|
|
1281
|
+
// Same name, same producer: reruns sit in DIFFERENT check suites (one
|
|
1282
|
+
// per workflow run). Two members in ONE suite are two distinct jobs of
|
|
1283
|
+
// one run that share a display name — independent checks the key cannot
|
|
1284
|
+
// tell apart — and a member with no readable suite cannot be placed at
|
|
1285
|
+
// all: unreadable either way, never a collapse (the re-armed leg's F1).
|
|
1286
|
+
const suites = new Set();
|
|
1287
|
+
for (const run of group) {
|
|
1288
|
+
if (run.suiteId === null) {
|
|
1289
|
+
return {
|
|
1290
|
+
ok: false,
|
|
1291
|
+
detail: who +
|
|
1292
|
+
' ran ' +
|
|
1293
|
+
String(group.length) +
|
|
1294
|
+
' times on the head and one of its runs carries no readable check-suite id - reruns cannot be told from independent jobs, the check state is unreadable',
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
if (suites.has(run.suiteId)) {
|
|
1298
|
+
return {
|
|
1299
|
+
ok: false,
|
|
1300
|
+
detail: who +
|
|
1301
|
+
' ran ' +
|
|
1302
|
+
String(group.length) +
|
|
1303
|
+
' times on the head and two of its runs sit in one check suite - two checks of one suite share the name (two jobs of one workflow run, or two runs of one app), independent checks the key cannot tell apart, the check state is unreadable',
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
suites.add(run.suiteId);
|
|
1307
|
+
}
|
|
1308
|
+
let latest = null;
|
|
1309
|
+
for (const run of group) {
|
|
1310
|
+
if (run.runId === null) {
|
|
1311
|
+
return {
|
|
1312
|
+
ok: false,
|
|
1313
|
+
detail: who +
|
|
1314
|
+
' ran ' +
|
|
1315
|
+
String(group.length) +
|
|
1316
|
+
' times on the head and one of its runs carries no readable databaseId - the latest run cannot be derived, the check state is unreadable',
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
if (latest === null || run.runId > latest.runId)
|
|
1320
|
+
latest = run;
|
|
1321
|
+
}
|
|
1322
|
+
judged.push(latest);
|
|
1323
|
+
collapsed.push({
|
|
1324
|
+
name: slot.key,
|
|
1325
|
+
producer: label,
|
|
1326
|
+
runs: group.length,
|
|
1327
|
+
judgedId: latest.runId,
|
|
1328
|
+
kind: latest.kind,
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
return { ok: true, judged, collapsed };
|
|
1333
|
+
}
|
|
1334
|
+
function summarizeChecks(judged, materialised) {
|
|
866
1335
|
let success = 0;
|
|
867
1336
|
let pending = 0;
|
|
868
1337
|
let failing = 0;
|
|
869
|
-
for (const c of
|
|
1338
|
+
for (const c of judged) {
|
|
870
1339
|
if (c.kind === 'success')
|
|
871
1340
|
success++;
|
|
872
1341
|
else if (c.kind === 'pending')
|
|
@@ -874,7 +1343,13 @@ function summarizeChecks(checks) {
|
|
|
874
1343
|
else
|
|
875
1344
|
failing++;
|
|
876
1345
|
}
|
|
877
|
-
return {
|
|
1346
|
+
return {
|
|
1347
|
+
total: judged.length,
|
|
1348
|
+
success,
|
|
1349
|
+
pending,
|
|
1350
|
+
failing,
|
|
1351
|
+
superseded: materialised - judged.length,
|
|
1352
|
+
};
|
|
878
1353
|
}
|
|
879
1354
|
/**
|
|
880
1355
|
* The authors whose LATEST decision review is CHANGES_REQUESTED. `COMMENTED`
|
|
@@ -909,29 +1384,112 @@ function unresolvedBotThreads(threads) {
|
|
|
909
1384
|
isBotReviewerLoginExact(t.rootLogin));
|
|
910
1385
|
}
|
|
911
1386
|
/**
|
|
912
|
-
* HIGH/Major bot inlines that CURRENTLY apply to the head commit
|
|
913
|
-
*
|
|
1387
|
+
* HIGH/Major bot inlines that CURRENTLY apply to the head commit, before the
|
|
1388
|
+
* discharge read below.
|
|
914
1389
|
*
|
|
915
1390
|
* The comparison is `comment.commit.oid`, the commit the finding applies to
|
|
916
1391
|
* NOW (GitHub re-points it as the diff moves; it is the REST `commit_id` the
|
|
917
1392
|
* charter's predicate (c) names), never `originalCommit.oid`, the commit it was
|
|
918
1393
|
* 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.
|
|
1394
|
+
* moment the branch advances, which is exactly the inert state fold F2 of
|
|
1395
|
+
* mmnto-ai/totem#2800 removed.
|
|
921
1396
|
*
|
|
922
1397
|
* Predicate 4's distinct territory, the one predicate 2 cannot reach: a HIGH
|
|
923
|
-
* finding
|
|
924
|
-
*
|
|
925
|
-
*
|
|
926
|
-
*
|
|
1398
|
+
* finding whose thread is RESOLVED while the comment still applies to the head
|
|
1399
|
+
* commit. Since mmnto-ai/totem#2861 that territory splits by HOW the thread
|
|
1400
|
+
* was resolved — see {@link dischargeOf}: through the disposition path the
|
|
1401
|
+
* finding is DISCHARGED and no longer counts; by a bare resolve it still
|
|
1402
|
+
* applies and the floor still refuses the merge. Predicate 2 keeps its own
|
|
1403
|
+
* rule (unresolved AND non-outdated, any severity) and still fires first when
|
|
1404
|
+
* both match — which is what makes the resolve a REQUIRED step of the
|
|
1405
|
+
* disposition path rather than an optional one.
|
|
927
1406
|
*/
|
|
928
|
-
function
|
|
1407
|
+
function botHighInlinesOnHead(threads, headSha) {
|
|
929
1408
|
return threads.filter((t) => t.rootLogin !== null &&
|
|
930
1409
|
isBotReviewerLoginExact(t.rootLogin) &&
|
|
931
1410
|
t.rootCommit !== null &&
|
|
932
1411
|
t.rootCommit.toLowerCase() === headSha &&
|
|
933
1412
|
hasHighSeverityMarker(t.rootBody));
|
|
934
1413
|
}
|
|
1414
|
+
/**
|
|
1415
|
+
* The disposition path, read off the same page as the predicate
|
|
1416
|
+
* (mmnto-ai/totem#2861): a thread is DISCHARGED when it is RESOLVED and a
|
|
1417
|
+
* disposition NAMES it — a non-bot reply after the root inside the thread (it
|
|
1418
|
+
* names the thread by being in it), or a non-bot PR-level comment created
|
|
1419
|
+
* STRICTLY after the root that carries a `disposition:` line for this
|
|
1420
|
+
* thread's root comment id ({@link DISPOSITION_LINE}). `none` is the bare
|
|
1421
|
+
* resolve — a click with no disposition naming it, the fail-closed arm the
|
|
1422
|
+
* ruling names — and an unresolved thread alike; predicate 2 catches the
|
|
1423
|
+
* unresolved one first, so the resolve is a REQUIRED step of the path, never
|
|
1424
|
+
* an optional one. A round disposition that answered OTHER threads is `none`
|
|
1425
|
+
* for this one: the id is what closes the class two round-keyed reads left
|
|
1426
|
+
* open (the falsification legs' r-f1 and r2-f1, both executed on the built
|
|
1427
|
+
* core), and the operator ruled for the thread-level line on 2026-09-16.
|
|
1428
|
+
*
|
|
1429
|
+
* Two deliberate differences from the `totem resolve-threads` evidence rule
|
|
1430
|
+
* (mmnto-ai/totem#2841 R2), which this otherwise mirrors. The verb decides
|
|
1431
|
+
* whether a thread MAY be resolved and accepts any non-bot PR-level comment
|
|
1432
|
+
* after the root, disclosing that an operator's trigger comment counts; this
|
|
1433
|
+
* predicate decides whether a resolved HIGH is DISPOSITIONED, and a trigger,
|
|
1434
|
+
* a gate-read note, merge chatter or another thread's disposition must not
|
|
1435
|
+
* discharge it — so the PR-level arm reads the line that names this thread.
|
|
1436
|
+
* A thread the verb resolved on the strength of a trigger therefore stays
|
|
1437
|
+
* applying here until a disposition naming it is posted: the stricter side of
|
|
1438
|
+
* the asymmetry, by design. `createdAt` is a comment's CREATION instant and an
|
|
1439
|
+
* edit does not move it, so a line edited into a comment created after the
|
|
1440
|
+
* root reads as posted then, and a line edited into one created before the
|
|
1441
|
+
* root never counts (r3-f12) — a late line is posted as a new comment, which
|
|
1442
|
+
* is what the calibration replay did. And an unparseable
|
|
1443
|
+
* instant on either side is `none` here as it is there — the conservative
|
|
1444
|
+
* direction — never an unreadable page (the strict reader has already refused
|
|
1445
|
+
* a root without a string `createdAt`).
|
|
1446
|
+
*
|
|
1447
|
+
* A thread with more comments than the ten-comment window is judged on what
|
|
1448
|
+
* was read: evidence found inside the window or at PR level discharges it, and
|
|
1449
|
+
* a window with none is a bare resolve whose reason NAMES the window — a
|
|
1450
|
+
* fact-side deny at every tier, never the unevaluable class, which pilot maps
|
|
1451
|
+
* to `warn` and which would have let the shape pre-cure denied at both tiers
|
|
1452
|
+
* stop blocking (leg f2).
|
|
1453
|
+
*/
|
|
1454
|
+
function dischargeOf(thread, prComments) {
|
|
1455
|
+
if (!thread.isResolved)
|
|
1456
|
+
return 'none';
|
|
1457
|
+
if (thread.humanReplies > 0)
|
|
1458
|
+
return 'in-thread-reply';
|
|
1459
|
+
const rootAt = Date.parse(thread.rootCreatedAt);
|
|
1460
|
+
if (Number.isNaN(rootAt))
|
|
1461
|
+
return 'none';
|
|
1462
|
+
if (thread.rootId === null)
|
|
1463
|
+
return 'none';
|
|
1464
|
+
for (const c of prComments) {
|
|
1465
|
+
if (c.isBot || !c.dispositions.includes(thread.rootId))
|
|
1466
|
+
continue;
|
|
1467
|
+
const at = Date.parse(c.createdAt);
|
|
1468
|
+
if (!Number.isNaN(at) && at > rootAt)
|
|
1469
|
+
return 'pr-level-disposition';
|
|
1470
|
+
}
|
|
1471
|
+
return 'none';
|
|
1472
|
+
}
|
|
1473
|
+
/** Predicate 4's read: the bot HIGH inlines on the head, split by {@link dischargeOf}, with the arm counts. */
|
|
1474
|
+
function triageHighInlines(threads, headSha, prComments) {
|
|
1475
|
+
const applying = [];
|
|
1476
|
+
const discharged = [];
|
|
1477
|
+
const by = { inThreadReply: 0, prLevelDisposition: 0 };
|
|
1478
|
+
for (const t of botHighInlinesOnHead(threads, headSha)) {
|
|
1479
|
+
const discharge = dischargeOf(t, prComments);
|
|
1480
|
+
if (discharge === 'none') {
|
|
1481
|
+
applying.push(t);
|
|
1482
|
+
}
|
|
1483
|
+
else {
|
|
1484
|
+
discharged.push(t);
|
|
1485
|
+
if (discharge === 'in-thread-reply')
|
|
1486
|
+
by.inThreadReply++;
|
|
1487
|
+
else
|
|
1488
|
+
by.prLevelDisposition++;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
return { applying, discharged, by };
|
|
1492
|
+
}
|
|
935
1493
|
/**
|
|
936
1494
|
* Bot HIGH inlines whose `comment.commit` came back NULL — the predicate's
|
|
937
1495
|
* input is missing, so whether they apply to the head is UNKNOWN
|
|
@@ -982,10 +1540,12 @@ export function evaluateMergeReady(payload, options) {
|
|
|
982
1540
|
repo: parsed.repo,
|
|
983
1541
|
pr: parsed.pr,
|
|
984
1542
|
headSha: null,
|
|
985
|
-
checks: { total: 0, success: 0, pending: 0, failing: 0 },
|
|
1543
|
+
checks: { total: 0, success: 0, pending: 0, failing: 0, superseded: 0 },
|
|
986
1544
|
threads: { unresolvedBot: 0, pagesRead: 0, complete: false },
|
|
987
1545
|
changesRequestedBy: [],
|
|
988
1546
|
highInline: 0,
|
|
1547
|
+
dischargedHigh: 0,
|
|
1548
|
+
dischargedBy: { inThreadReply: 0, prLevelDisposition: 0 },
|
|
989
1549
|
mergeStateStatus: null,
|
|
990
1550
|
evaluatedAt: checkedAt,
|
|
991
1551
|
evaluatedBy: 'gh (not read)',
|
|
@@ -1051,20 +1611,39 @@ export function evaluateMergeReady(payload, options) {
|
|
|
1051
1611
|
detail.pr = state.number;
|
|
1052
1612
|
detail.headSha = state.headSha;
|
|
1053
1613
|
detail.mergeStateStatus = state.mergeStateStatus;
|
|
1054
|
-
detail.checks = summarizeChecks(state.checks);
|
|
1614
|
+
detail.checks = summarizeChecks(state.judgedChecks, state.checks.length);
|
|
1615
|
+
// A superseded run is a check the read SAW and set aside; the record must
|
|
1616
|
+
// say so, name by name, and name the run that stood in for it
|
|
1617
|
+
// (mmnto-ai/totem#2879). One line per collapsed name, on purpose: a single
|
|
1618
|
+
// line under the evidence bound lost its tail at three names (leg F1; a
|
|
1619
|
+
// head on main carried five), and a disclosure that trails off is not one.
|
|
1620
|
+
// Each line is sanitised, never sliced — it is a notice, not `matched`.
|
|
1621
|
+
for (const run of state.supersededRuns) {
|
|
1622
|
+
notices.push(`${MERGE_READY_NOTICE_PREFIX} ${parsed.repo}#${state.number} at ${shortSha(state.headSha)}: check ${oneLine(JSON.stringify(run.name))} from ${oneLine(JSON.stringify(run.producer))} ran ${run.runs} times on the head commit — judged by its latest run ${run.judgedId} (${run.kind}), the greatest check-run id; ${run.runs - 1} superseded run(s) not counted (mmnto-ai/totem#2879).`);
|
|
1623
|
+
}
|
|
1055
1624
|
detail.threads = {
|
|
1056
1625
|
unresolvedBot: unresolvedBotThreads(state.threads).length,
|
|
1057
1626
|
pagesRead: state.pagesRead,
|
|
1058
1627
|
complete: state.complete,
|
|
1059
1628
|
};
|
|
1060
1629
|
detail.changesRequestedBy = changesRequestedBy(state.reviews);
|
|
1061
|
-
|
|
1630
|
+
const high = triageHighInlines(state.threads, state.headSha, state.prComments);
|
|
1631
|
+
detail.highInline = high.applying.length;
|
|
1632
|
+
detail.dischargedHigh = high.discharged.length;
|
|
1633
|
+
detail.dischargedBy = { ...high.by };
|
|
1634
|
+
// The audit breadcrumb for what the predicate RELEASED (mmnto-ai/totem#2861):
|
|
1635
|
+
// a discharged HIGH is a bot finding the round answered, and the record
|
|
1636
|
+
// must show it was read and discharged — and by which arm — never that it
|
|
1637
|
+
// was not seen.
|
|
1638
|
+
if (high.discharged.length > 0) {
|
|
1639
|
+
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).`);
|
|
1640
|
+
}
|
|
1062
1641
|
// The predicates are read BEFORE the unreadable-commit arm below: a failure
|
|
1063
1642
|
// that stands in charter order ahead of predicate 4 is a fact, and a tier
|
|
1064
1643
|
// never softens a fact — the arm that returned first here turned a failing
|
|
1065
1644
|
// check plus one unplaceable HIGH inline into the UNEVALUABLE class, which
|
|
1066
1645
|
// pilot maps to `warn` (mmnto-ai/totem#2844 round 1, CodeRabbit).
|
|
1067
|
-
const blocked = firstFailure(state, detail);
|
|
1646
|
+
const blocked = firstFailure(state, detail, high.applying);
|
|
1068
1647
|
// A bot HIGH inline whose `comment.commit` came back null cannot be placed
|
|
1069
1648
|
// against the head, so predicate 4's input is missing for it: unevaluable and
|
|
1070
1649
|
// NAMED, never a silent pass (round 2, F8). It still preempts predicate 5 —
|
|
@@ -1134,11 +1713,16 @@ export function evaluateMergeReady(payload, options) {
|
|
|
1134
1713
|
notices,
|
|
1135
1714
|
};
|
|
1136
1715
|
}
|
|
1137
|
-
/**
|
|
1138
|
-
|
|
1716
|
+
/**
|
|
1717
|
+
* The FIRST predicate that fails, in charter order, or null when the floor is
|
|
1718
|
+
* met. `applyingHigh` is predicate 4's set after the discharge read — the
|
|
1719
|
+
* caller triages once so the provenance counts and the verdict read the same
|
|
1720
|
+
* list.
|
|
1721
|
+
*/
|
|
1722
|
+
function firstFailure(state, detail, applyingHigh) {
|
|
1139
1723
|
// 1. checks
|
|
1140
1724
|
if (detail.checks.failing > 0) {
|
|
1141
|
-
const names = state.
|
|
1725
|
+
const names = state.judgedChecks
|
|
1142
1726
|
.filter((c) => c.kind === 'failing')
|
|
1143
1727
|
.map((c) => c.name)
|
|
1144
1728
|
.join(', ');
|
|
@@ -1148,7 +1732,7 @@ function firstFailure(state, detail) {
|
|
|
1148
1732
|
};
|
|
1149
1733
|
}
|
|
1150
1734
|
if (detail.checks.pending > 0) {
|
|
1151
|
-
const names = state.
|
|
1735
|
+
const names = state.judgedChecks
|
|
1152
1736
|
.filter((c) => c.kind === 'pending')
|
|
1153
1737
|
.map((c) => c.name)
|
|
1154
1738
|
.join(', ');
|
|
@@ -1173,13 +1757,32 @@ function firstFailure(state, detail) {
|
|
|
1173
1757
|
evidence: `CHANGES_REQUESTED stands from ${detail.changesRequestedBy.join(', ')}`,
|
|
1174
1758
|
};
|
|
1175
1759
|
}
|
|
1176
|
-
// 4. HIGH/Major bot inline on the head commit
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1760
|
+
// 4. HIGH/Major bot inline on the head commit, not discharged
|
|
1761
|
+
if (applyingHigh.length > 0) {
|
|
1762
|
+
const first = applyingHigh[0];
|
|
1763
|
+
// Name the bare-resolve shape when that is what the first one is — the
|
|
1764
|
+
// operator's cure differs (post the disposition line) from the unanswered
|
|
1765
|
+
// shape's (answer the finding) — and name the window when the thread had
|
|
1766
|
+
// more comments than the read fetched. The LINE comes first in the clause
|
|
1767
|
+
// and the clause before the quoted body, so the id survives the
|
|
1768
|
+
// 160-character bound on `provenance.matched` (the third leg's r3-f3: a
|
|
1769
|
+
// clause that led with prose cut the id off at character 161); the tests
|
|
1770
|
+
// pin the id on `matched` for both clauses. With more than one applying,
|
|
1771
|
+
// the reason names the first and says where the rest are listed.
|
|
1772
|
+
const line = first.rootId === null
|
|
1773
|
+
? 'no line can name it (its root comment id was not readable)'
|
|
1774
|
+
: `no "disposition: ${first.rootId} <verb>" line after its root`;
|
|
1775
|
+
const bareResolve = !first.isResolved
|
|
1776
|
+
? ''
|
|
1777
|
+
: first.commentsComplete
|
|
1778
|
+
? ` — 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)`
|
|
1779
|
+
: ` — resolved, ${line} and no non-bot reply in the ten comments read (the thread has more; mmnto-ai/totem#2861)`;
|
|
1780
|
+
const rest = applyingHigh.length > 1
|
|
1781
|
+
? ` (the first of ${applyingHigh.length}; a \`totem resolve-threads\` dry run lists every root id)`
|
|
1782
|
+
: '';
|
|
1180
1783
|
return {
|
|
1181
1784
|
predicate: 'high-severity-inline',
|
|
1182
|
-
evidence: `${
|
|
1785
|
+
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
1786
|
};
|
|
1184
1787
|
}
|
|
1185
1788
|
// 5. GitHub's own mergeability
|