@mmnto/totem 2.2.0 → 2.3.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/bot-identity.d.ts +94 -0
- package/dist/bot-identity.d.ts.map +1 -0
- package/dist/bot-identity.js +118 -0
- package/dist/bot-identity.js.map +1 -0
- package/dist/capability/review-catch.d.ts.map +1 -1
- package/dist/capability/review-catch.js +10 -6
- package/dist/capability/review-catch.js.map +1 -1
- package/dist/gate-engine.d.ts +8 -2
- package/dist/gate-engine.d.ts.map +1 -1
- package/dist/gate-engine.js +10 -2
- package/dist/gate-engine.js.map +1 -1
- package/dist/gate-engine.test.js +112 -1
- package/dist/gate-engine.test.js.map +1 -1
- package/dist/gate-types.d.ts +41 -1
- package/dist/gate-types.d.ts.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/merge-ready.d.ts +157 -0
- package/dist/merge-ready.d.ts.map +1 -0
- package/dist/merge-ready.js +1256 -0
- package/dist/merge-ready.js.map +1 -0
- package/dist/merge-ready.test.d.ts +2 -0
- package/dist/merge-ready.test.d.ts.map +1 -0
- package/dist/merge-ready.test.js +814 -0
- package/dist/merge-ready.test.js.map +1 -0
- package/dist/orchestration-resolver.d.ts +13 -7
- package/dist/orchestration-resolver.d.ts.map +1 -1
- package/dist/orchestration-resolver.js +60 -14
- package/dist/orchestration-resolver.js.map +1 -1
- package/dist/orchestration-resolver.test.js +277 -1
- package/dist/orchestration-resolver.test.js.map +1 -1
- package/dist/parity-label-canon.d.ts +7 -5
- package/dist/parity-label-canon.d.ts.map +1 -1
- package/dist/parity-label-canon.js +7 -5
- package/dist/parity-label-canon.js.map +1 -1
- package/dist/parity-label-canon.test.js +36 -4
- package/dist/parity-label-canon.test.js.map +1 -1
- package/dist/retrospect.d.ts +6 -6
- package/dist/selection-manifest.d.ts +2 -2
- package/dist/spine/corpus-dispositions.d.ts +30 -30
- package/dist/sys/git.d.ts +50 -0
- package/dist/sys/git.d.ts.map +1 -1
- package/dist/sys/git.js +101 -0
- package/dist/sys/git.js.map +1 -1
- package/dist/transport-shield.test.js +6 -2
- package/dist/transport-shield.test.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1256 @@
|
|
|
1
|
+
import { isBotReviewerLoginExact } from './bot-identity.js';
|
|
2
|
+
import { TotemError } from './errors.js';
|
|
3
|
+
import { safeExec } from './sys/exec.js';
|
|
4
|
+
/**
|
|
5
|
+
* merge-ready (mmnto-ai/totem#2800): a PreToolUse gate over the Bash and
|
|
6
|
+
* PowerShell tools that reads the five-point deterministic pre-merge floor off
|
|
7
|
+
* GitHub before `gh pr merge` runs.
|
|
8
|
+
*
|
|
9
|
+
* ZERO LLM calls, zero filesystem writes. The only outside state is what `gh`
|
|
10
|
+
* answers, and `gh` reaches this module through the injected {@link GhRunner}
|
|
11
|
+
* seam so a test replaces the network with a checked-in capture (R3, R4).
|
|
12
|
+
*
|
|
13
|
+
* The five predicates, evaluated IN ORDER (the first failure is the reason):
|
|
14
|
+
* 1. `checks` — the head commit's status-check rollup is green
|
|
15
|
+
* 2. `unresolved-bot-threads`— no unresolved, non-outdated review thread whose
|
|
16
|
+
* ROOT comment is one of the known review bots
|
|
17
|
+
* 3. `changes-requested` — no un-superseded CHANGES_REQUESTED review
|
|
18
|
+
* 4. `high-severity-inline` — no HIGH/Major bot inline that CURRENTLY applies
|
|
19
|
+
* to the head commit (`comment.commit.oid`,
|
|
20
|
+
* thread resolution ignored)
|
|
21
|
+
* 5. `merge-state` — GitHub's own `mergeStateStatus` is mergeable
|
|
22
|
+
*
|
|
23
|
+
* THE TIER SPLIT (R1): a predicate that FAILS is `deny` at every tier (the
|
|
24
|
+
* wrapper's tier map decides whether that blocks). A predicate whose INPUT
|
|
25
|
+
* could not be derived — gh missing, an API error, an incomplete page, a head
|
|
26
|
+
* that moved mid-read, `mergeStateStatus: UNKNOWN` — is the UNEVALUABLE class:
|
|
27
|
+
* `deny` under strict, `warn` under pilot, and NEVER `allow`. Every unevaluable
|
|
28
|
+
* path is named on stderr under BOTH tiers; only the exit code differs.
|
|
29
|
+
*
|
|
30
|
+
* THE OVERRIDE: `TOTEM_MERGE_GATE_OVERRIDE=1` yields `allow` plus one stderr
|
|
31
|
+
* audit line naming the repo, the PR, the head sha and the predicates that
|
|
32
|
+
* would have denied. It is read per evaluation (no module-level state).
|
|
33
|
+
*/
|
|
34
|
+
/** The gate's registry event name. */
|
|
35
|
+
export const MERGE_READY_EVENT = 'merge-ready';
|
|
36
|
+
/** The module label every verdict cites as its `provenance.source` (never a path). */
|
|
37
|
+
export const MERGE_READY_SOURCE = 'merge-ready gh graphql read';
|
|
38
|
+
/** The audited bypass. Set to exactly `1`; any other value is not an override. */
|
|
39
|
+
export const MERGE_READY_OVERRIDE_ENV = 'TOTEM_MERGE_GATE_OVERRIDE';
|
|
40
|
+
/** Prefix every stderr line this gate emits carries, so a reader can grep one gate out of a hook log. */
|
|
41
|
+
export const MERGE_READY_NOTICE_PREFIX = '[totem merge-ready]';
|
|
42
|
+
/** Page size for every paginated connection in the query. */
|
|
43
|
+
const PAGE_SIZE = 100;
|
|
44
|
+
/**
|
|
45
|
+
* Hard cap on GraphQL round trips per evaluation. A PR that needs more pages
|
|
46
|
+
* than this is UNEVALUABLE (named), never "clean by exhaustion" — the capped-read
|
|
47
|
+
* bar from the charter's errata item 5.
|
|
48
|
+
*/
|
|
49
|
+
const MAX_PAGES = 20;
|
|
50
|
+
/** `provenance.matched` (and every quoted evidence fragment) is bounded to this many characters. */
|
|
51
|
+
export const MERGE_READY_EVIDENCE_MAX = 160;
|
|
52
|
+
// ─── The read surface (R2: one GraphQL query, explicit cursors, no REST) ────
|
|
53
|
+
/**
|
|
54
|
+
* The PR fields every predicate reads, as a fragment so the number-keyed and
|
|
55
|
+
* branch-keyed documents cannot drift apart. The three paginated connections
|
|
56
|
+
* each take their own cursor variable, declared by the operation.
|
|
57
|
+
*
|
|
58
|
+
* `comments(first: 10)` is deliberate: only the ROOT comment of a thread is
|
|
59
|
+
* judged (it is the finding; the rest are the discussion), matching how triage
|
|
60
|
+
* reads a thread. The later comments are fetched for evidence, not for a
|
|
61
|
+
* predicate, so a thread with more than ten comments is not an incomplete read.
|
|
62
|
+
*/
|
|
63
|
+
const MERGE_READY_FRAGMENT = `fragment MergeReadyPr on PullRequest {
|
|
64
|
+
number
|
|
65
|
+
isDraft
|
|
66
|
+
mergeStateStatus
|
|
67
|
+
headRefOid
|
|
68
|
+
headRefName
|
|
69
|
+
baseRefName
|
|
70
|
+
commits(last: 1) {
|
|
71
|
+
nodes {
|
|
72
|
+
commit {
|
|
73
|
+
oid
|
|
74
|
+
statusCheckRollup {
|
|
75
|
+
state
|
|
76
|
+
contexts(first: ${PAGE_SIZE}, after: $checksAfter) {
|
|
77
|
+
totalCount
|
|
78
|
+
pageInfo { hasNextPage endCursor }
|
|
79
|
+
nodes {
|
|
80
|
+
__typename
|
|
81
|
+
... on CheckRun { name status conclusion }
|
|
82
|
+
... on StatusContext { context state }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
reviews(first: ${PAGE_SIZE}, after: $reviewsAfter) {
|
|
90
|
+
pageInfo { hasNextPage endCursor }
|
|
91
|
+
nodes {
|
|
92
|
+
author { login }
|
|
93
|
+
state
|
|
94
|
+
submittedAt
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
reviewThreads(first: ${PAGE_SIZE}, after: $threadsAfter) {
|
|
98
|
+
pageInfo { hasNextPage endCursor }
|
|
99
|
+
nodes {
|
|
100
|
+
isResolved
|
|
101
|
+
isOutdated
|
|
102
|
+
comments(first: 10) {
|
|
103
|
+
nodes {
|
|
104
|
+
author { login }
|
|
105
|
+
body
|
|
106
|
+
commit { oid }
|
|
107
|
+
originalCommit { oid }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}`;
|
|
113
|
+
const SHARED_VARS = '$owner: String!, $name: String!, $reviewsAfter: String, $threadsAfter: String, $checksAfter: String';
|
|
114
|
+
/** The read the evaluator sends when the payload names a PR number. Fixtures are captured with THIS string. */
|
|
115
|
+
export const MERGE_READY_QUERY = `query TotemMergeReady($number: Int!, ${SHARED_VARS}) {
|
|
116
|
+
repository(owner: $owner, name: $name) {
|
|
117
|
+
pullRequest(number: $number) { ...MergeReadyPr }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
${MERGE_READY_FRAGMENT}`;
|
|
122
|
+
/**
|
|
123
|
+
* The read the evaluator sends when the payload carries a branch and no number
|
|
124
|
+
* (`gh pr merge` with no argument). Same fragment, so the predicates read the
|
|
125
|
+
* same fields; the first page resolves the number, and every later page uses
|
|
126
|
+
* {@link MERGE_READY_QUERY} against that fixed number.
|
|
127
|
+
*/
|
|
128
|
+
export const MERGE_READY_BRANCH_QUERY = `query TotemMergeReadyByBranch($branch: String!, ${SHARED_VARS}) {
|
|
129
|
+
repository(owner: $owner, name: $name) {
|
|
130
|
+
pullRequests(headRefName: $branch, first: 1, states: OPEN, orderBy: { field: CREATED_AT, direction: DESC }) {
|
|
131
|
+
nodes { ...MergeReadyPr }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
${MERGE_READY_FRAGMENT}`;
|
|
137
|
+
// ─── Payload parsing (fail loud — the engine never default-allows) ──────────
|
|
138
|
+
const REPO_SLUG = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
139
|
+
const SHA_40 = /^[0-9a-f]{40}$/i;
|
|
140
|
+
/** Parse + validate the payload. Throws `GATE_INVALID` (the wrapper's fail-closed arm) on anything else. */
|
|
141
|
+
export function parseMergeReadyPayload(payload) {
|
|
142
|
+
const invalid = (why) => {
|
|
143
|
+
throw new TotemError('GATE_INVALID', `merge-ready payload is invalid: ${why}.`, 'Pass --payload \'{"repo":"owner/name","pr":123}\' (or "pr":null with "branch":"<name>").');
|
|
144
|
+
};
|
|
145
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
146
|
+
return invalid('expected a JSON object');
|
|
147
|
+
}
|
|
148
|
+
const raw = payload;
|
|
149
|
+
const repo = typeof raw.repo === 'string' ? raw.repo.trim() : '';
|
|
150
|
+
if (repo === '')
|
|
151
|
+
return invalid('"repo" must be a non-empty "owner/name" string');
|
|
152
|
+
if (!REPO_SLUG.test(repo))
|
|
153
|
+
return invalid(`"repo" must look like owner/name (got "${repo}")`);
|
|
154
|
+
let pr = null;
|
|
155
|
+
if (typeof raw.pr === 'number') {
|
|
156
|
+
if (!Number.isInteger(raw.pr) || raw.pr <= 0)
|
|
157
|
+
return invalid('"pr" must be a positive integer');
|
|
158
|
+
pr = raw.pr;
|
|
159
|
+
}
|
|
160
|
+
else if (raw.pr !== null && raw.pr !== undefined) {
|
|
161
|
+
return invalid('"pr" must be a positive integer or null');
|
|
162
|
+
}
|
|
163
|
+
let branch;
|
|
164
|
+
if (typeof raw.branch === 'string' && raw.branch.trim() !== '') {
|
|
165
|
+
branch = raw.branch.trim();
|
|
166
|
+
}
|
|
167
|
+
else if (raw.branch !== undefined && typeof raw.branch !== 'string') {
|
|
168
|
+
return invalid('"branch" must be a string when present');
|
|
169
|
+
}
|
|
170
|
+
let unresolvedTarget;
|
|
171
|
+
if (typeof raw.unresolvedTarget === 'string' && raw.unresolvedTarget.trim() !== '') {
|
|
172
|
+
unresolvedTarget = raw.unresolvedTarget.trim();
|
|
173
|
+
}
|
|
174
|
+
else if (raw.unresolvedTarget !== undefined && typeof raw.unresolvedTarget !== 'string') {
|
|
175
|
+
return invalid('"unresolvedTarget" must be a string when present');
|
|
176
|
+
}
|
|
177
|
+
if (pr === null && branch === undefined && unresolvedTarget === undefined) {
|
|
178
|
+
return invalid('either "pr" or "branch" must identify the pull request');
|
|
179
|
+
}
|
|
180
|
+
let headSha;
|
|
181
|
+
if (typeof raw.headSha === 'string' && raw.headSha.trim() !== '') {
|
|
182
|
+
const trimmed = raw.headSha.trim();
|
|
183
|
+
if (!SHA_40.test(trimmed))
|
|
184
|
+
return invalid('"headSha" must be a 40-character hex sha');
|
|
185
|
+
headSha = trimmed.toLowerCase();
|
|
186
|
+
}
|
|
187
|
+
else if (raw.headSha !== undefined && typeof raw.headSha !== 'string') {
|
|
188
|
+
return invalid('"headSha" must be a string when present');
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
repo,
|
|
192
|
+
pr,
|
|
193
|
+
...(branch === undefined ? {} : { branch }),
|
|
194
|
+
...(headSha === undefined ? {} : { headSha }),
|
|
195
|
+
...(unresolvedTarget === undefined ? {} : { unresolvedTarget }),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
// ─── Narrow readers over the GraphQL body (no `any`, no silent coercion) ────
|
|
199
|
+
function asObject(value) {
|
|
200
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
201
|
+
? value
|
|
202
|
+
: null;
|
|
203
|
+
}
|
|
204
|
+
function asArray(value) {
|
|
205
|
+
return Array.isArray(value) ? value : null;
|
|
206
|
+
}
|
|
207
|
+
function asString(value) {
|
|
208
|
+
return typeof value === 'string' ? value : null;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* A CheckRun's conclusion vocabulary. `NEUTRAL` and `SKIPPED` are successes (a
|
|
212
|
+
* skipped required check is branch protection's business, not this gate's);
|
|
213
|
+
* every other terminal conclusion — including an absent one on a COMPLETED run
|
|
214
|
+
* — is a failure, because an unreadable outcome must never read as green.
|
|
215
|
+
*/
|
|
216
|
+
const SUCCESS_CONCLUSIONS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
|
|
217
|
+
const SUCCESS_CONTEXT_STATES = new Set(['SUCCESS']);
|
|
218
|
+
const PENDING_CONTEXT_STATES = new Set(['PENDING', 'EXPECTED']);
|
|
219
|
+
/**
|
|
220
|
+
* A connection's `pageInfo`, read STRICTLY: the query asks for it on every
|
|
221
|
+
* connection, so an answer without one — or with a `hasNextPage` that is not
|
|
222
|
+
* a boolean — is an unreadable page, never "no more pages". Reading a missing
|
|
223
|
+
* `pageInfo` as complete was the fail-open Greptile named on the reviews and
|
|
224
|
+
* threads connections (mmnto-ai/totem#2844 round 1): a truncated or malformed
|
|
225
|
+
* answer read as a clean, finished list and predicates 2–4 passed on it.
|
|
226
|
+
*/
|
|
227
|
+
function readPageInfo(connection) {
|
|
228
|
+
const info = asObject(connection?.pageInfo);
|
|
229
|
+
if (info === null)
|
|
230
|
+
return { ok: false, detail: 'carried no pageInfo' };
|
|
231
|
+
if (typeof info.hasNextPage !== 'boolean') {
|
|
232
|
+
return { ok: false, detail: 'carried a pageInfo whose hasNextPage is not a boolean' };
|
|
233
|
+
}
|
|
234
|
+
return { ok: true, hasNext: info.hasNextPage, cursor: asString(info.endCursor) };
|
|
235
|
+
}
|
|
236
|
+
function readChecks(rollup) {
|
|
237
|
+
const contexts = asObject(rollup?.contexts);
|
|
238
|
+
const entries = [];
|
|
239
|
+
// No rollup at all is the R5 no-checks shape (the caller has already
|
|
240
|
+
// refused a rollup WITHOUT a contexts connection): an empty, complete list.
|
|
241
|
+
if (contexts === null)
|
|
242
|
+
return { entries, hasNext: false, cursor: null };
|
|
243
|
+
const nodes = asArray(contexts.nodes);
|
|
244
|
+
if (nodes === null) {
|
|
245
|
+
return {
|
|
246
|
+
entries,
|
|
247
|
+
hasNext: false,
|
|
248
|
+
cursor: null,
|
|
249
|
+
detail: 'the status-check rollup contexts carried no nodes array',
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
for (const node of nodes) {
|
|
253
|
+
const n = asObject(node);
|
|
254
|
+
if (n === null)
|
|
255
|
+
return { entries, hasNext: false, cursor: null, detail: 'a check node was not an object' };
|
|
256
|
+
const typename = asString(n.__typename);
|
|
257
|
+
if (typename === 'CheckRun') {
|
|
258
|
+
const name = asString(n.name) ?? '(unnamed check)';
|
|
259
|
+
const status = asString(n.status) ?? '';
|
|
260
|
+
const conclusion = asString(n.conclusion);
|
|
261
|
+
if (status !== 'COMPLETED') {
|
|
262
|
+
entries.push({ name, kind: 'pending' });
|
|
263
|
+
}
|
|
264
|
+
else if (conclusion !== null && SUCCESS_CONCLUSIONS.has(conclusion)) {
|
|
265
|
+
entries.push({ name, kind: 'success' });
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
entries.push({ name, kind: 'failing' });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
else if (typename === 'StatusContext') {
|
|
272
|
+
const name = asString(n.context) ?? '(unnamed context)';
|
|
273
|
+
const state = asString(n.state) ?? '';
|
|
274
|
+
if (SUCCESS_CONTEXT_STATES.has(state)) {
|
|
275
|
+
entries.push({ name, kind: 'success' });
|
|
276
|
+
}
|
|
277
|
+
else if (PENDING_CONTEXT_STATES.has(state)) {
|
|
278
|
+
entries.push({ name, kind: 'pending' });
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
entries.push({ name, kind: 'failing' });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
return {
|
|
286
|
+
entries,
|
|
287
|
+
hasNext: false,
|
|
288
|
+
cursor: null,
|
|
289
|
+
detail: `a check node carried an unknown __typename "${typename ?? 'null'}"`,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const info = readPageInfo(contexts);
|
|
294
|
+
if (!info.ok) {
|
|
295
|
+
return {
|
|
296
|
+
entries,
|
|
297
|
+
hasNext: false,
|
|
298
|
+
cursor: null,
|
|
299
|
+
detail: `the status-check rollup contexts ${info.detail}`,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
return { entries, hasNext: info.hasNext, cursor: info.cursor };
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* The `reviews` connection, read as STRICTLY as the checks connection above
|
|
306
|
+
* (mmnto-ai/totem#2844 round 1, Greptile): a connection that is missing, has no
|
|
307
|
+
* `nodes` array, has no readable `pageInfo`, or carries a node that is not an
|
|
308
|
+
* object or has no `state` is an UNREADABLE answer, returned with a named
|
|
309
|
+
* `detail` — never an empty, complete list that lets predicate 3 pass on an
|
|
310
|
+
* answer the read did not actually receive. A review whose author is a
|
|
311
|
+
* deleted account (`author: null`) is the one shape SKIPPED rather than
|
|
312
|
+
* refused: it carries no identity to supersede or attribute, so it is never
|
|
313
|
+
* counted as a CHANGES_REQUESTED nobody can clear.
|
|
314
|
+
*/
|
|
315
|
+
function readReviews(connection) {
|
|
316
|
+
const entries = [];
|
|
317
|
+
const unreadable = (why) => ({
|
|
318
|
+
entries,
|
|
319
|
+
hasNext: false,
|
|
320
|
+
cursor: null,
|
|
321
|
+
detail: `the reviews connection ${why}`,
|
|
322
|
+
});
|
|
323
|
+
if (connection === null)
|
|
324
|
+
return unreadable('was missing from the answer');
|
|
325
|
+
const nodes = asArray(connection.nodes);
|
|
326
|
+
if (nodes === null)
|
|
327
|
+
return unreadable('carried no nodes array');
|
|
328
|
+
for (const node of nodes) {
|
|
329
|
+
const n = asObject(node);
|
|
330
|
+
if (n === null)
|
|
331
|
+
return unreadable('carried a node that is not an object');
|
|
332
|
+
const state = asString(n.state);
|
|
333
|
+
if (state === null)
|
|
334
|
+
return unreadable('carried a review with no state');
|
|
335
|
+
const login = asString(asObject(n.author)?.login);
|
|
336
|
+
if (login === null)
|
|
337
|
+
continue;
|
|
338
|
+
entries.push({ login, state, submittedAt: asString(n.submittedAt) });
|
|
339
|
+
}
|
|
340
|
+
const info = readPageInfo(connection);
|
|
341
|
+
if (!info.ok)
|
|
342
|
+
return unreadable(info.detail);
|
|
343
|
+
return { entries, hasNext: info.hasNext, cursor: info.cursor };
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* The `reviewThreads` connection, read with the same strictness as
|
|
347
|
+
* {@link readReviews}: a missing connection, a missing `nodes` array or
|
|
348
|
+
* `pageInfo`, a thread node that is not an object, or a thread whose first
|
|
349
|
+
* comment cannot be read (no `comments.nodes` array, or an empty one — a
|
|
350
|
+
* review thread always has its root comment) is UNREADABLE and named, so
|
|
351
|
+
* predicates 2 and 4 never pass on a list the read did not deliver
|
|
352
|
+
* (mmnto-ai/totem#2844 round 1). A root comment whose author is a deleted
|
|
353
|
+
* account keeps `rootLogin: null` — it is not a known bot, which is a fact
|
|
354
|
+
* about the thread, not an unreadable answer.
|
|
355
|
+
*/
|
|
356
|
+
function readThreads(connection) {
|
|
357
|
+
const entries = [];
|
|
358
|
+
const unreadable = (why) => ({
|
|
359
|
+
entries,
|
|
360
|
+
hasNext: false,
|
|
361
|
+
cursor: null,
|
|
362
|
+
detail: `the review threads connection ${why}`,
|
|
363
|
+
});
|
|
364
|
+
if (connection === null)
|
|
365
|
+
return unreadable('was missing from the answer');
|
|
366
|
+
const nodes = asArray(connection.nodes);
|
|
367
|
+
if (nodes === null)
|
|
368
|
+
return unreadable('carried no nodes array');
|
|
369
|
+
for (const node of nodes) {
|
|
370
|
+
const n = asObject(node);
|
|
371
|
+
if (n === null)
|
|
372
|
+
return unreadable('carried a node that is not an object');
|
|
373
|
+
const comments = asArray(asObject(n.comments)?.nodes);
|
|
374
|
+
if (comments === null)
|
|
375
|
+
return unreadable('carried a thread with no comments array');
|
|
376
|
+
const root = asObject(comments[0]);
|
|
377
|
+
if (root === null)
|
|
378
|
+
return unreadable('carried a thread whose root comment is unreadable');
|
|
379
|
+
entries.push({
|
|
380
|
+
isResolved: n.isResolved === true,
|
|
381
|
+
isOutdated: n.isOutdated === true,
|
|
382
|
+
rootLogin: asString(asObject(root.author)?.login),
|
|
383
|
+
rootBody: asString(root.body) ?? '',
|
|
384
|
+
rootCommit: asString(asObject(root.commit)?.oid),
|
|
385
|
+
rootOriginalCommit: asString(asObject(root.originalCommit)?.oid),
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
const info = readPageInfo(connection);
|
|
389
|
+
if (!info.ok)
|
|
390
|
+
return unreadable(info.detail);
|
|
391
|
+
return { entries, hasNext: info.hasNext, cursor: info.cursor };
|
|
392
|
+
}
|
|
393
|
+
/** Read one `gh api graphql` response body into a classified page. */
|
|
394
|
+
function parsePage(raw, byBranch) {
|
|
395
|
+
let body;
|
|
396
|
+
// totem-context: NOT a swallowed error — an unreadable response body is the
|
|
397
|
+
// gate's UNEVALUABLE class, and this returns it NAMED, which the caller turns
|
|
398
|
+
// into a `deny` (strict) or a `warn` (pilot) plus a stderr line. Throwing here
|
|
399
|
+
// would surface as the wrapper's generic fail-closed arm and lose the reason;
|
|
400
|
+
// the fail-loud property is kept by never returning a clean read (Tenet 4).
|
|
401
|
+
try {
|
|
402
|
+
body = JSON.parse(raw);
|
|
403
|
+
// totem-context: intentional degradation — see the directive above the try; dual placement so the rule reads either the catch-keyword line or the catch body.
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
// totem-context: intentional degradation — an unreadable body is the NAMED unevaluable class, never a clean read.
|
|
407
|
+
return { ok: false, detail: 'the gh response was not JSON' };
|
|
408
|
+
}
|
|
409
|
+
const top = asObject(body);
|
|
410
|
+
if (top === null)
|
|
411
|
+
return { ok: false, detail: 'the gh response was not a JSON object' };
|
|
412
|
+
// GraphQL reports authorization, not-found and rate-limit failures in the
|
|
413
|
+
// BODY, at HTTP 200 — an errors array is a failed read, never an empty one.
|
|
414
|
+
const errors = asArray(top.errors);
|
|
415
|
+
if (errors !== null && errors.length > 0) {
|
|
416
|
+
const first = asObject(errors[0]);
|
|
417
|
+
const message = asString(first?.message) ?? 'unnamed GraphQL error';
|
|
418
|
+
return { ok: false, detail: `the GraphQL read returned an error: ${message}` };
|
|
419
|
+
}
|
|
420
|
+
const repository = asObject(asObject(top.data)?.repository);
|
|
421
|
+
if (repository === null)
|
|
422
|
+
return { ok: false, detail: 'the response carried no repository' };
|
|
423
|
+
let pr;
|
|
424
|
+
if (byBranch) {
|
|
425
|
+
const nodes = asArray(asObject(repository.pullRequests)?.nodes) ?? [];
|
|
426
|
+
pr = asObject(nodes[0]);
|
|
427
|
+
if (pr === null)
|
|
428
|
+
return { ok: false, detail: 'no open pull request has that head branch' };
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
pr = asObject(repository.pullRequest);
|
|
432
|
+
if (pr === null)
|
|
433
|
+
return { ok: false, detail: 'the repository has no pull request with that number' };
|
|
434
|
+
}
|
|
435
|
+
const number = typeof pr.number === 'number' ? pr.number : null;
|
|
436
|
+
const headRefOid = asString(pr.headRefOid);
|
|
437
|
+
const mergeStateStatus = asString(pr.mergeStateStatus);
|
|
438
|
+
if (number === null || headRefOid === null || mergeStateStatus === null) {
|
|
439
|
+
return {
|
|
440
|
+
ok: false,
|
|
441
|
+
detail: 'the pull request answer was missing number, headRefOid or mergeStateStatus',
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
// Predicate 1 reads the rollup off `commits(last: 1)`. That is only the head
|
|
445
|
+
// commit's rollup if the node IS the head commit, so the identity is checked
|
|
446
|
+
// rather than assumed (mmnto-ai/totem#2800 fold F7): a rollup read off some
|
|
447
|
+
// other commit would be a green light for code that is not what merges.
|
|
448
|
+
const commitNodes = asArray(asObject(pr.commits)?.nodes) ?? [];
|
|
449
|
+
if (commitNodes.length === 0) {
|
|
450
|
+
return {
|
|
451
|
+
ok: false,
|
|
452
|
+
detail: 'the pull request answered no commits — the head commit rollup could not be read',
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
const commit = asObject(asObject(commitNodes[0])?.commit);
|
|
456
|
+
const commitOid = asString(commit?.oid);
|
|
457
|
+
if (commitOid === null) {
|
|
458
|
+
return {
|
|
459
|
+
ok: false,
|
|
460
|
+
detail: 'the head commit answered no oid — the rollup cannot be attributed',
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
if (commitOid.toLowerCase() !== headRefOid.toLowerCase()) {
|
|
464
|
+
return {
|
|
465
|
+
ok: false,
|
|
466
|
+
detail: `the status-check rollup belongs to ${shortSha(commitOid)}, not the head commit ${shortSha(headRefOid)}`,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
const rollup = asObject(commit?.statusCheckRollup);
|
|
470
|
+
// A rollup that exists but carries no `contexts` connection is an unreadable
|
|
471
|
+
// answer, NOT the zero-checks fact (R5): the fact needs the rollup itself to
|
|
472
|
+
// report an empty context list.
|
|
473
|
+
if (rollup !== null && asObject(rollup.contexts) === null) {
|
|
474
|
+
return {
|
|
475
|
+
ok: false,
|
|
476
|
+
detail: 'the status-check rollup carried no contexts connection — the check list is unreadable',
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
const checks = readChecks(rollup);
|
|
480
|
+
if (checks.detail !== undefined)
|
|
481
|
+
return { ok: false, detail: checks.detail };
|
|
482
|
+
// The two review connections are held to the SAME bar as the checks
|
|
483
|
+
// connection: an answer that is missing or malformed is a failed read, named,
|
|
484
|
+
// never an empty list (mmnto-ai/totem#2844 round 1).
|
|
485
|
+
const reviews = readReviews(asObject(pr.reviews));
|
|
486
|
+
if (reviews.detail !== undefined)
|
|
487
|
+
return { ok: false, detail: reviews.detail };
|
|
488
|
+
const threads = readThreads(asObject(pr.reviewThreads));
|
|
489
|
+
if (threads.detail !== undefined)
|
|
490
|
+
return { ok: false, detail: threads.detail };
|
|
491
|
+
return {
|
|
492
|
+
ok: true,
|
|
493
|
+
page: {
|
|
494
|
+
number,
|
|
495
|
+
headRefOid: headRefOid.toLowerCase(),
|
|
496
|
+
mergeStateStatus,
|
|
497
|
+
isDraft: pr.isDraft === true,
|
|
498
|
+
rollupPresent: rollup !== null,
|
|
499
|
+
rollupState: asString(rollup?.state),
|
|
500
|
+
rollupTotalCount: asObject(rollup?.contexts)?.totalCount ?? null,
|
|
501
|
+
checks: checks.entries,
|
|
502
|
+
checksHasNext: checks.hasNext,
|
|
503
|
+
checksCursor: checks.cursor,
|
|
504
|
+
reviews: reviews.entries,
|
|
505
|
+
reviewsHasNext: reviews.hasNext,
|
|
506
|
+
reviewsCursor: reviews.cursor,
|
|
507
|
+
threads: threads.entries,
|
|
508
|
+
threadsHasNext: threads.hasNext,
|
|
509
|
+
threadsCursor: threads.cursor,
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
// ─── Severity read (predicate 4) ────────────────────────────────────────────
|
|
514
|
+
/**
|
|
515
|
+
* The severity read is EXACT-BY-MARKER: it matches each bot's own STRUCTURED
|
|
516
|
+
* severity label and nothing else. Prose is never read
|
|
517
|
+
* (mmnto-ai/totem#2800 fold round 2, F4; round 3 F4/F5/F7/F11).
|
|
518
|
+
*
|
|
519
|
+
* Which forms are OBSERVED and which are DOCUMENTED-BUT-UNOBSERVED is stated
|
|
520
|
+
* per arm, because the two are not the same evidence:
|
|
521
|
+
* - **greptile** — a badge image whose alt text is the priority:
|
|
522
|
+
* `<a href="#"><img alt="P1" src="…/badges/p1.svg…" align="top"></a>`.
|
|
523
|
+
* OBSERVED in the corpus: `P1` (high) and `P2` (not high). `P0` is
|
|
524
|
+
* DOCUMENTED-BUT-UNOBSERVED — greptile publishes the `p0.svg` badge, so the
|
|
525
|
+
* arm accepts it; no thread in the window carries one. Single quotes are
|
|
526
|
+
* accepted beside double (`alt='P1'`) — HTML permits either and the read
|
|
527
|
+
* must not turn on the quote style.
|
|
528
|
+
* - **gemini-code-assist** — a priority image whose alt text is the level:
|
|
529
|
+
* ``.
|
|
530
|
+
* OBSERVED: `high`. There is NO `critical` arm: the corresponding asset
|
|
531
|
+
* 404s and GCA's published rubric emits `high` as its top level, so an arm
|
|
532
|
+
* for it would be inference, not transcription (round 3, F5).
|
|
533
|
+
* - **CodeRabbit** — an emphasis-wrapped label that OPENS a table cell or a
|
|
534
|
+
* line. CodeRabbit's severity scale is Critical / Major / Minor / Trivial;
|
|
535
|
+
* `Potential issue` is its issue-CLASS label, carried here because the
|
|
536
|
+
* user-level prototype matched it and it marks a blocking finding.
|
|
537
|
+
* OBSERVED: `_🟠 Major_` (high), `_🟡 Minor_` and `_🔵 Trivial_` (not
|
|
538
|
+
* high). DOCUMENTED-BUT-UNOBSERVED: `_🔴 Critical_` and
|
|
539
|
+
* `_⚠️ Potential issue_` — in CodeRabbit's own label vocabulary, absent
|
|
540
|
+
* from this window. The label must FILL its cell: the emphasis plus the
|
|
541
|
+
* cell/line boundary are what separate a LABEL from a word in a sentence.
|
|
542
|
+
*
|
|
543
|
+
* CODE IS NOT A LABEL (round 3, F4). Fenced blocks (``` … ```, ~~~ … ~~~) and
|
|
544
|
+
* inline code spans are stripped before the scan. A bot that QUOTES a marker —
|
|
545
|
+
* a CodeRabbit Minor whose suggestion block quotes this very file, which
|
|
546
|
+
* carries every marker in its docstring — would otherwise read as HIGH and
|
|
547
|
+
* false-deny the gate's own maintenance PR.
|
|
548
|
+
*
|
|
549
|
+
* FALSE-POSITIVE BUDGET (ADR-109: a non-exact-match gate ships a stated budget
|
|
550
|
+
* and the fixture that measures it — the `transport-shield` precedent):
|
|
551
|
+
* **ZERO** high-severity reads over the benign corpus in
|
|
552
|
+
* `gate-fixtures/merge-ready/benign-corpus-bot-inlines.json` — every bot inline
|
|
553
|
+
* thread on mmnto-ai/totem#2820 through mmnto-ai/totem#2839, each carrying the
|
|
554
|
+
* severity its own bot declared — and over the quoted-marker row in
|
|
555
|
+
* `gate-fixtures/merge-ready/synthetic-benign-fenced-marker-quote.json`.
|
|
556
|
+
* `merge-ready.test.ts` asserts the read agrees with every one of those
|
|
557
|
+
* declarations, so a marker that widens into prose or into quoted code fails
|
|
558
|
+
* there. The prose arms this replaced did not hold that budget: the greptile
|
|
559
|
+
* **P2** thread on mmnto-ai/totem#2831 read as HIGH through the word "critical"
|
|
560
|
+
* in its explanation — a false deny on a finding its own author ranked below
|
|
561
|
+
* the bar. A miss in the field is a corpus row plus a marker fix, never a
|
|
562
|
+
* hand-carved exemption; the `--pilot` tier exists for a measurement week, and
|
|
563
|
+
* `TOTEM_MERGE_GATE_OVERRIDE=1` is the audited way past one.
|
|
564
|
+
*
|
|
565
|
+
* Out of scope by design, disclosed: a bot that stops emitting a structured
|
|
566
|
+
* label (or a fourth bot) reads as NOT high — the miss direction, a corpus gap
|
|
567
|
+
* to be closed by observation, never a silent deny; a label a bot places
|
|
568
|
+
* somewhere this read does not look (a summary table, a list item, a
|
|
569
|
+
* blockquote) is the same class; a human quoting a bot's label verbatim would
|
|
570
|
+
* read as high, but predicates 2 and 4 both require the thread's ROOT comment
|
|
571
|
+
* to be a known bot login.
|
|
572
|
+
*
|
|
573
|
+
* THE STRIPPER'S EDGES, measured rather than assumed (round 5, F1/F4/F5/F6).
|
|
574
|
+
* It recognises fenced blocks of any backtick or tilde run, `<pre>` blocks,
|
|
575
|
+
* CommonMark indented blocks, and inline spans of any backtick run. At the
|
|
576
|
+
* edges:
|
|
577
|
+
* - a fence indented under a list item IS stripped, at two spaces or four —
|
|
578
|
+
* the opener and closer both allow leading blanks;
|
|
579
|
+
* - a four-backtick fence closed by three has its CONTENTS stripped (the
|
|
580
|
+
* opener matches three of the four backticks and the fourth reads as the
|
|
581
|
+
* info string), so only the text AFTER the short closer reaches the scan —
|
|
582
|
+
* a marker there reads HIGH, the false-deny direction;
|
|
583
|
+
* - an HTML `<code>` element is NOT stripped at all. A label inside one reads
|
|
584
|
+
* HIGH wherever it carries its own anchor — a `|` cell delimiter, or a line
|
|
585
|
+
* start inside a multi-line element — and reads not-high only when it has
|
|
586
|
+
* neither. False-deny direction.
|
|
587
|
+
* Three edges run the other way, toward ALLOW, by swallowing prose that is not
|
|
588
|
+
* code: a backtick run whose match crosses a paragraph break takes a label
|
|
589
|
+
* sitting between two lone backticks with it; an indented line that follows a
|
|
590
|
+
* prose line is stripped though CommonMark would not open a code block there;
|
|
591
|
+
* and `<pre>` tags quoted inside code spans still act as block delimiters,
|
|
592
|
+
* because the `<pre>` arm runs before the span arm. Measured reach: NONE of
|
|
593
|
+
* the 34 bodies in the checked-in fixtures is affected — every one of them,
|
|
594
|
+
* corpus included, gets the same verdict from its first line alone, and all
|
|
595
|
+
* 16 corpus labels sit on line 0, where no stripper edge can reach them. Each
|
|
596
|
+
* edge is a corpus row plus a stripper fix when one is observed in the field;
|
|
597
|
+
* `TOTEM_MERGE_GATE_OVERRIDE=1` is the audited way past a false deny in the
|
|
598
|
+
* meantime.
|
|
599
|
+
*/
|
|
600
|
+
/**
|
|
601
|
+
* The glyphs CodeRabbit puts before an un-emphasised severity label — its four
|
|
602
|
+
* severity dots and the warning sign, with and without the variation selector.
|
|
603
|
+
* Built from code points rather than typed, so this source carries no `\u`
|
|
604
|
+
* escape and no pasted emoji (round 3, F7 narrowed this from "any non-ASCII
|
|
605
|
+
* glyph", which let an em-dash-led line read as a label).
|
|
606
|
+
*/
|
|
607
|
+
const CR_LABEL_GLYPHS = [0x1f534, 0x1f7e0, 0x1f7e1, 0x1f535, 0x26a0]
|
|
608
|
+
.map((cp) => String.fromCodePoint(cp))
|
|
609
|
+
.join('');
|
|
610
|
+
/**
|
|
611
|
+
* The variation selector rides AFTER a glyph (`⚠️` is the warning sign plus
|
|
612
|
+
* this), so it is a modifier here rather than a class member: a BARE selector
|
|
613
|
+
* before "major" is not a severity label (round 4, F9).
|
|
614
|
+
*/
|
|
615
|
+
const VARIATION_SELECTOR = String.fromCodePoint(0xfe0f);
|
|
616
|
+
const LABEL_GLYPH = `(?:[${CR_LABEL_GLYPHS}][${VARIATION_SELECTOR}]?)`;
|
|
617
|
+
/**
|
|
618
|
+
* The un-emphasised arm is built with the `u` flag so `LABEL_GLYPH` is a
|
|
619
|
+
* CODE-POINT class: without it the astral dots decompose into surrogate halves
|
|
620
|
+
* and a lone surrogate — or a bare variation selector — before "major" matched
|
|
621
|
+
* as if it were a severity glyph (round 4, F9).
|
|
622
|
+
*/
|
|
623
|
+
const UNEMPHASISED_LABEL = new RegExp(`(?:^|\\n)[ \\t]*${LABEL_GLYPH}+[ \\t]*(?:critical|major|potential issue)(?![A-Za-z0-9])`, 'iu');
|
|
624
|
+
const HIGH_SEVERITY_MARKERS = [
|
|
625
|
+
// greptile: the badge's alt attribute, P0/P1 only, quoted either way or
|
|
626
|
+
// unquoted (the lookahead is what ends an unquoted attribute value).
|
|
627
|
+
/<img[^>]*\balt=["']?P[01]["']?(?=[\s/>])/i,
|
|
628
|
+
// gemini-code-assist: the priority image's alt text. `high` only.
|
|
629
|
+
/!\[high\]\(/i,
|
|
630
|
+
// CodeRabbit: an emphasis-wrapped severity label that OPENS a table cell or a
|
|
631
|
+
// line. The `[^A-Za-z0-9\n|]*` arms absorb the glyph and the spaces inside the
|
|
632
|
+
// emphasis; requiring the opening emphasis to sit at a cell/line boundary is
|
|
633
|
+
// what separates a LABEL from an emphasised word inside a sentence.
|
|
634
|
+
/(?:^|\n|\|)[ \t]*[_*]{1,2}[^A-Za-z0-9\n|]*(?:critical|major|potential issue)[^A-Za-z0-9\n|]*[_*]{1,2}/i,
|
|
635
|
+
// CodeRabbit's un-emphasised heading form: the severity glyph, then the
|
|
636
|
+
// label, at the start of a line.
|
|
637
|
+
UNEMPHASISED_LABEL,
|
|
638
|
+
];
|
|
639
|
+
/**
|
|
640
|
+
* The token every stripped code form leaves behind. It MUST be non-whitespace:
|
|
641
|
+
* replacing a span with a space opened a fresh line/cell-start position, and
|
|
642
|
+
* `` `x`_🟠 Major_ `` then read as a label — a regression this stripper itself
|
|
643
|
+
* introduced (round 4, F2). A word of ASCII letters is neither a boundary the
|
|
644
|
+
* anchors accept nor a glyph the un-emphasised arm reads.
|
|
645
|
+
*/
|
|
646
|
+
const CODE_PLACEHOLDER = 'CODE';
|
|
647
|
+
/**
|
|
648
|
+
* A body with its CODE replaced by {@link CODE_PLACEHOLDER}: fenced blocks
|
|
649
|
+
* first (they can contain backticks), then `<pre>` blocks, then CommonMark's
|
|
650
|
+
* indented blocks, then inline spans of any backtick run. What is left is the
|
|
651
|
+
* bot's prose and its labels — the only text a severity label can legitimately
|
|
652
|
+
* live in (round 3 F4; round 4 F2/F4). An unterminated fence swallows the rest
|
|
653
|
+
* of the body, which is how a markdown renderer reads it too.
|
|
654
|
+
*/
|
|
655
|
+
function withoutCode(body) {
|
|
656
|
+
return body
|
|
657
|
+
.replace(/^[ \t]*(```+|~~~+)[^\n]*\n[\s\S]*?^[ \t]*\1[^\n]*$/gm, CODE_PLACEHOLDER)
|
|
658
|
+
.replace(/^[ \t]*(```|~~~)[\s\S]*$/m, CODE_PLACEHOLDER)
|
|
659
|
+
.replace(/<pre[\s>][\s\S]*?<\/pre>/gi, CODE_PLACEHOLDER)
|
|
660
|
+
.replace(/^(?: {4}|\t)[^\n]*$/gm, CODE_PLACEHOLDER)
|
|
661
|
+
.replace(/(`+)[^`]*?\1/g, CODE_PLACEHOLDER);
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Whether a comment body carries a bot's OWN structured high-severity label —
|
|
665
|
+
* one of {@link HIGH_SEVERITY_MARKERS}, read against the body with every code
|
|
666
|
+
* form replaced first ({@link withoutCode}), so a quoted marker never reads
|
|
667
|
+
* as a label and prose is never matched. `true` is "this finding's author
|
|
668
|
+
* ranked it HIGH/Major/P0/P1"; `false` is "no such label in the prose" — which
|
|
669
|
+
* includes a bot that emits no structured label at all (the disclosed miss
|
|
670
|
+
* direction above).
|
|
671
|
+
*/
|
|
672
|
+
export function hasHighSeverityMarker(body) {
|
|
673
|
+
const prose = withoutCode(body);
|
|
674
|
+
return HIGH_SEVERITY_MARKERS.some((re) => re.test(prose));
|
|
675
|
+
}
|
|
676
|
+
// ─── Evidence helpers ───────────────────────────────────────────────────────
|
|
677
|
+
/** Bound and sanitize a fragment for a reason / provenance: no control characters, bounded length. */
|
|
678
|
+
function bounded(text) {
|
|
679
|
+
let out = '';
|
|
680
|
+
for (const ch of text) {
|
|
681
|
+
const code = ch.charCodeAt(0);
|
|
682
|
+
out += code < 0x20 || code === 0x7f ? ' ' : ch;
|
|
683
|
+
}
|
|
684
|
+
out = out.replace(/\s+/g, ' ').trim();
|
|
685
|
+
return out.length > MERGE_READY_EVIDENCE_MAX
|
|
686
|
+
? out.slice(0, MERGE_READY_EVIDENCE_MAX - 1) + '…'
|
|
687
|
+
: out;
|
|
688
|
+
}
|
|
689
|
+
function shortSha(sha) {
|
|
690
|
+
return sha === null ? '(unknown)' : sha.slice(0, 12);
|
|
691
|
+
}
|
|
692
|
+
function graphqlArgs(query, variables) {
|
|
693
|
+
const args = ['api', 'graphql', '-f', `query=${query}`];
|
|
694
|
+
for (const [key, value] of variables) {
|
|
695
|
+
// `-F` sends a typed value (the `$number: Int!` argument requires it); `-f`
|
|
696
|
+
// sends a string. Never a shell string — the runner spawns argv.
|
|
697
|
+
args.push(typeof value === 'number' ? '-F' : '-f', `${key}=${value}`);
|
|
698
|
+
}
|
|
699
|
+
return args;
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Run the paginated read. Every failure — a non-zero gh exit, an unparseable
|
|
703
|
+
* body, a GraphQL error, a head that moved between pages, a page budget blown —
|
|
704
|
+
* returns `ok: false` with a NAMED detail. There is no arm that returns a
|
|
705
|
+
* partial read as if it were complete.
|
|
706
|
+
*/
|
|
707
|
+
function readPullRequest(payload, runner) {
|
|
708
|
+
const [owner, name] = payload.repo.split('/');
|
|
709
|
+
const state = {
|
|
710
|
+
number: payload.pr ?? 0,
|
|
711
|
+
headSha: '',
|
|
712
|
+
mergeStateStatus: '',
|
|
713
|
+
isDraft: false,
|
|
714
|
+
rollupPresent: false,
|
|
715
|
+
rollupState: null,
|
|
716
|
+
rollupTotalCount: null,
|
|
717
|
+
checks: [],
|
|
718
|
+
reviews: [],
|
|
719
|
+
threads: [],
|
|
720
|
+
pagesRead: 0,
|
|
721
|
+
complete: false,
|
|
722
|
+
};
|
|
723
|
+
let checksDone = false;
|
|
724
|
+
let reviewsDone = false;
|
|
725
|
+
let threadsDone = false;
|
|
726
|
+
let checksCursor = null;
|
|
727
|
+
let reviewsCursor = null;
|
|
728
|
+
let threadsCursor = null;
|
|
729
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
730
|
+
// The branch-keyed document is used ONLY for the first page of a payload
|
|
731
|
+
// with no number; once the number is known every later page targets it, so
|
|
732
|
+
// a PR opened mid-read cannot swap the target under us.
|
|
733
|
+
const byBranch = payload.pr === null && page === 0;
|
|
734
|
+
const query = byBranch ? MERGE_READY_BRANCH_QUERY : MERGE_READY_QUERY;
|
|
735
|
+
const variables = [
|
|
736
|
+
['owner', owner ?? ''],
|
|
737
|
+
['name', name ?? ''],
|
|
738
|
+
];
|
|
739
|
+
if (byBranch) {
|
|
740
|
+
variables.push(['branch', payload.branch ?? '']);
|
|
741
|
+
}
|
|
742
|
+
else {
|
|
743
|
+
variables.push(['number', state.number]);
|
|
744
|
+
}
|
|
745
|
+
if (checksCursor !== null)
|
|
746
|
+
variables.push(['checksAfter', checksCursor]);
|
|
747
|
+
if (reviewsCursor !== null)
|
|
748
|
+
variables.push(['reviewsAfter', reviewsCursor]);
|
|
749
|
+
if (threadsCursor !== null)
|
|
750
|
+
variables.push(['threadsAfter', threadsCursor]);
|
|
751
|
+
const run = runner(graphqlArgs(query, variables));
|
|
752
|
+
state.pagesRead = page + 1;
|
|
753
|
+
if (run.exitCode !== 0) {
|
|
754
|
+
return {
|
|
755
|
+
ok: false,
|
|
756
|
+
detail: `gh api graphql exited ${run.exitCode}: ${bounded(run.stdout)}`,
|
|
757
|
+
pagesRead: state.pagesRead,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
const parsed = parsePage(run.stdout, byBranch);
|
|
761
|
+
if (!parsed.ok)
|
|
762
|
+
return { ok: false, detail: parsed.detail, pagesRead: state.pagesRead };
|
|
763
|
+
const p = parsed.page;
|
|
764
|
+
if (page === 0) {
|
|
765
|
+
state.number = p.number;
|
|
766
|
+
state.headSha = p.headRefOid;
|
|
767
|
+
state.mergeStateStatus = p.mergeStateStatus;
|
|
768
|
+
state.isDraft = p.isDraft;
|
|
769
|
+
state.rollupPresent = p.rollupPresent;
|
|
770
|
+
state.rollupState = p.rollupState;
|
|
771
|
+
state.rollupTotalCount = p.rollupTotalCount;
|
|
772
|
+
}
|
|
773
|
+
else if (p.headRefOid !== state.headSha) {
|
|
774
|
+
// The head moved between reads: everything already accumulated describes
|
|
775
|
+
// a commit that is no longer what would merge.
|
|
776
|
+
return {
|
|
777
|
+
ok: false,
|
|
778
|
+
detail: `the head sha moved during evaluation (${shortSha(state.headSha)} → ${shortSha(p.headRefOid)})`,
|
|
779
|
+
pagesRead: state.pagesRead,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
if (!checksDone) {
|
|
783
|
+
state.checks.push(...p.checks);
|
|
784
|
+
checksDone = !p.checksHasNext;
|
|
785
|
+
checksCursor = p.checksCursor;
|
|
786
|
+
}
|
|
787
|
+
if (!reviewsDone) {
|
|
788
|
+
state.reviews.push(...p.reviews);
|
|
789
|
+
reviewsDone = !p.reviewsHasNext;
|
|
790
|
+
reviewsCursor = p.reviewsCursor;
|
|
791
|
+
}
|
|
792
|
+
if (!threadsDone) {
|
|
793
|
+
state.threads.push(...p.threads);
|
|
794
|
+
threadsDone = !p.threadsHasNext;
|
|
795
|
+
threadsCursor = p.threadsCursor;
|
|
796
|
+
}
|
|
797
|
+
if (checksDone && reviewsDone && threadsDone) {
|
|
798
|
+
// The rollup must ACCOUNT for itself before predicate 1 reads it
|
|
799
|
+
// (mmnto-ai/totem#2800 round 4, F3). `totalCount` is judged as the API
|
|
800
|
+
// typed it — a string "3", a boolean, a negative or a fractional number
|
|
801
|
+
// is an unreadable answer, not a count — and once every page is in, the
|
|
802
|
+
// number of contexts the read MATERIALISED must equal the number the
|
|
803
|
+
// rollup CLAIMED. A claim the read did not deliver is an incomplete read
|
|
804
|
+
// (R2), never a smaller green list.
|
|
805
|
+
if (state.rollupPresent) {
|
|
806
|
+
const claimed = state.rollupTotalCount;
|
|
807
|
+
if (typeof claimed !== 'number' || !Number.isInteger(claimed) || claimed < 0) {
|
|
808
|
+
return {
|
|
809
|
+
ok: false,
|
|
810
|
+
detail: 'the status-check rollup reported a totalCount that is not a non-negative integer (' +
|
|
811
|
+
bounded(typeof claimed === 'string' ? JSON.stringify(claimed) : String(claimed)) +
|
|
812
|
+
') - the check state is unreadable',
|
|
813
|
+
pagesRead: state.pagesRead,
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
if (claimed !== state.checks.length) {
|
|
817
|
+
return {
|
|
818
|
+
ok: false,
|
|
819
|
+
detail: 'the status-check rollup claims ' +
|
|
820
|
+
String(claimed) +
|
|
821
|
+
' checks but the read materialised ' +
|
|
822
|
+
String(state.checks.length) +
|
|
823
|
+
' - the check state is unreadable',
|
|
824
|
+
pagesRead: state.pagesRead,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
// R5's zero-checks FACT applies ONLY where the rollup is consistent about
|
|
829
|
+
// it, and that is exactly two shapes: no rollup at all, or a rollup that
|
|
830
|
+
// reports SUCCESS over an empty context list AND counts zero (the count
|
|
831
|
+
// agreement above already holds). Every other shape — a PENDING or
|
|
832
|
+
// FAILURE state with nothing listed, a `state` that is null or not a
|
|
833
|
+
// string — is an unreadable answer, never a green light (fold F7; round 2
|
|
834
|
+
// F3 added the null/non-string state).
|
|
835
|
+
if (state.checks.length === 0 && state.rollupPresent && state.rollupState !== 'SUCCESS') {
|
|
836
|
+
return {
|
|
837
|
+
ok: false,
|
|
838
|
+
detail: state.rollupState === null
|
|
839
|
+
? 'the status-check rollup listed no checks and reported no readable state - the check state is unreadable'
|
|
840
|
+
: 'the status-check rollup reports ' +
|
|
841
|
+
bounded(state.rollupState) +
|
|
842
|
+
' but listed no checks - the check state is unreadable',
|
|
843
|
+
pagesRead: state.pagesRead,
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
state.complete = true;
|
|
847
|
+
return { ok: true, state };
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return {
|
|
851
|
+
ok: false,
|
|
852
|
+
detail: `the read needed more than ${MAX_PAGES} pages; a capped read is never reported as clean`,
|
|
853
|
+
pagesRead: state.pagesRead,
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
/** `mergeStateStatus` values that pass predicate 5. `UNSTABLE` (a non-required check red) is predicate 1's business. */
|
|
857
|
+
const MERGE_STATE_PASS = new Set(['CLEAN', 'HAS_HOOKS', 'UNSTABLE']);
|
|
858
|
+
/** Values that fail predicate 5 outright. `DRAFT` is GitHub's own "not ready to merge". */
|
|
859
|
+
const MERGE_STATE_DENY = new Map([
|
|
860
|
+
['BEHIND', 'the branch is behind its base — update it before merging'],
|
|
861
|
+
['DIRTY', 'the merge is conflicted — resolve the conflict before merging'],
|
|
862
|
+
['BLOCKED', 'branch protection blocks the merge (a required review or check is missing)'],
|
|
863
|
+
['DRAFT', 'the pull request is still a draft'],
|
|
864
|
+
]);
|
|
865
|
+
function summarizeChecks(checks) {
|
|
866
|
+
let success = 0;
|
|
867
|
+
let pending = 0;
|
|
868
|
+
let failing = 0;
|
|
869
|
+
for (const c of checks) {
|
|
870
|
+
if (c.kind === 'success')
|
|
871
|
+
success++;
|
|
872
|
+
else if (c.kind === 'pending')
|
|
873
|
+
pending++;
|
|
874
|
+
else
|
|
875
|
+
failing++;
|
|
876
|
+
}
|
|
877
|
+
return { total: checks.length, success, pending, failing };
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* The authors whose LATEST decision review is CHANGES_REQUESTED. `COMMENTED`
|
|
881
|
+
* reviews are not decisions and never supersede (GitHub's own semantics); a
|
|
882
|
+
* later APPROVED or DISMISSED from the same reviewer does.
|
|
883
|
+
*/
|
|
884
|
+
function changesRequestedBy(reviews) {
|
|
885
|
+
const DECISIONS = new Set(['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED']);
|
|
886
|
+
const latest = new Map();
|
|
887
|
+
for (const r of reviews) {
|
|
888
|
+
if (!DECISIONS.has(r.state))
|
|
889
|
+
continue;
|
|
890
|
+
const at = r.submittedAt ?? '';
|
|
891
|
+
const seen = latest.get(r.login);
|
|
892
|
+
// Ties (or a missing timestamp) fall back to array order, which the API
|
|
893
|
+
// returns chronologically — the later row wins.
|
|
894
|
+
if (seen === undefined || at >= seen.at)
|
|
895
|
+
latest.set(r.login, { state: r.state, at });
|
|
896
|
+
}
|
|
897
|
+
const out = [];
|
|
898
|
+
for (const [login, decision] of latest) {
|
|
899
|
+
if (decision.state === 'CHANGES_REQUESTED')
|
|
900
|
+
out.push(login);
|
|
901
|
+
}
|
|
902
|
+
return out.sort();
|
|
903
|
+
}
|
|
904
|
+
/** Unresolved, non-outdated threads whose ROOT comment is a known review bot. */
|
|
905
|
+
function unresolvedBotThreads(threads) {
|
|
906
|
+
return threads.filter((t) => !t.isResolved &&
|
|
907
|
+
!t.isOutdated &&
|
|
908
|
+
t.rootLogin !== null &&
|
|
909
|
+
isBotReviewerLoginExact(t.rootLogin));
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* HIGH/Major bot inlines that CURRENTLY apply to the head commit — thread
|
|
913
|
+
* resolution deliberately IGNORED (ruled on mmnto-ai/totem#2800, fold F2).
|
|
914
|
+
*
|
|
915
|
+
* The comparison is `comment.commit.oid`, the commit the finding applies to
|
|
916
|
+
* NOW (GitHub re-points it as the diff moves; it is the REST `commit_id` the
|
|
917
|
+
* charter's predicate (c) names), never `originalCommit.oid`, the commit it was
|
|
918
|
+
* written against — a predicate keyed on the write-time commit goes inert the
|
|
919
|
+
* moment the branch advances, which is exactly the inert state this fold
|
|
920
|
+
* removed.
|
|
921
|
+
*
|
|
922
|
+
* Predicate 4's distinct territory, the one predicate 2 cannot reach: a HIGH
|
|
923
|
+
* finding a human RESOLVED by hand without changing the code. The comment still
|
|
924
|
+
* applies to the head commit, so the floor still refuses the merge. Predicate 2
|
|
925
|
+
* keeps its own rule (unresolved AND non-outdated, any severity) and still
|
|
926
|
+
* fires first when both match.
|
|
927
|
+
*/
|
|
928
|
+
function highSeverityInlines(threads, headSha) {
|
|
929
|
+
return threads.filter((t) => t.rootLogin !== null &&
|
|
930
|
+
isBotReviewerLoginExact(t.rootLogin) &&
|
|
931
|
+
t.rootCommit !== null &&
|
|
932
|
+
t.rootCommit.toLowerCase() === headSha &&
|
|
933
|
+
hasHighSeverityMarker(t.rootBody));
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Bot HIGH inlines whose `comment.commit` came back NULL — the predicate's
|
|
937
|
+
* input is missing, so whether they apply to the head is UNKNOWN
|
|
938
|
+
* (mmnto-ai/totem#2800 round 2, F8). R2: an unreadable input is never a pass,
|
|
939
|
+
* so these never fall out of the filter above and read as "not on head".
|
|
940
|
+
* They make the evaluation UNEVALUABLE, named on stderr, only when no
|
|
941
|
+
* predicate ahead of them in charter order has already failed: a failing
|
|
942
|
+
* check, an unresolved bot thread, a standing CHANGES_REQUESTED or a
|
|
943
|
+
* placeable HIGH inline is a fact and denies first, and that verdict names
|
|
944
|
+
* its own cause, not these (the PR's review round; see the call site).
|
|
945
|
+
*/
|
|
946
|
+
function unreadableCommitHighInlines(threads) {
|
|
947
|
+
return threads.filter((t) => t.rootLogin !== null &&
|
|
948
|
+
isBotReviewerLoginExact(t.rootLogin) &&
|
|
949
|
+
t.rootCommit === null &&
|
|
950
|
+
hasHighSeverityMarker(t.rootBody));
|
|
951
|
+
}
|
|
952
|
+
// ─── The evaluation ─────────────────────────────────────────────────────────
|
|
953
|
+
/** Read `gh --version` through the seam. A failure here IS the "gh unavailable" failure mode. */
|
|
954
|
+
function readGhVersion(runner) {
|
|
955
|
+
const run = runner(['--version']);
|
|
956
|
+
if (run.exitCode !== 0) {
|
|
957
|
+
return {
|
|
958
|
+
ok: false,
|
|
959
|
+
detail: `gh is unavailable or unauthenticated (gh --version exited ${run.exitCode}: ${bounded(run.stdout)})`,
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
const match = /gh version (\S+)/.exec(run.stdout);
|
|
963
|
+
return {
|
|
964
|
+
ok: true,
|
|
965
|
+
version: match?.[1] !== undefined ? `gh ${match[1]}` : 'gh (version unknown)',
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Evaluate the merge-ready floor. Pure with respect to state: it reads GitHub
|
|
970
|
+
* through the injected runner and returns a verdict, its evidence, and the
|
|
971
|
+
* stderr lines the host must print. It writes nothing.
|
|
972
|
+
*/
|
|
973
|
+
export function evaluateMergeReady(payload, options) {
|
|
974
|
+
const parsed = parseMergeReadyPayload(payload);
|
|
975
|
+
const tier = options.tier ?? 'strict';
|
|
976
|
+
const env = options.env ?? process.env;
|
|
977
|
+
const now = options.now ?? (() => new Date());
|
|
978
|
+
const checkedAt = now().toISOString();
|
|
979
|
+
const notices = [];
|
|
980
|
+
const override = env[MERGE_READY_OVERRIDE_ENV] === '1';
|
|
981
|
+
const detail = {
|
|
982
|
+
repo: parsed.repo,
|
|
983
|
+
pr: parsed.pr,
|
|
984
|
+
headSha: null,
|
|
985
|
+
checks: { total: 0, success: 0, pending: 0, failing: 0 },
|
|
986
|
+
threads: { unresolvedBot: 0, pagesRead: 0, complete: false },
|
|
987
|
+
changesRequestedBy: [],
|
|
988
|
+
highInline: 0,
|
|
989
|
+
mergeStateStatus: null,
|
|
990
|
+
evaluatedAt: checkedAt,
|
|
991
|
+
evaluatedBy: 'gh (not read)',
|
|
992
|
+
};
|
|
993
|
+
/** The UNEVALUABLE class: `deny` under strict, `warn` under pilot — never `allow`. */
|
|
994
|
+
const unevaluable = (why) => {
|
|
995
|
+
notices.push(`${MERGE_READY_NOTICE_PREFIX} could not derive the merge floor for ${parsed.repo}#${parsed.pr ?? parsed.branch ?? '?'}: ${why} (tier=${tier} → ${tier === 'pilot' ? 'warn' : 'deny'}).`);
|
|
996
|
+
if (override)
|
|
997
|
+
return allowByOverride(`the read failed: ${why}`);
|
|
998
|
+
return {
|
|
999
|
+
verdict: {
|
|
1000
|
+
disposition: tier === 'pilot' ? 'warn' : 'deny',
|
|
1001
|
+
reason: `merge-ready could not derive: ${why}.`,
|
|
1002
|
+
provenance: {
|
|
1003
|
+
source: MERGE_READY_SOURCE,
|
|
1004
|
+
ref: 'unevaluable',
|
|
1005
|
+
matched: bounded(why),
|
|
1006
|
+
checkedAt,
|
|
1007
|
+
detail: { ...detail },
|
|
1008
|
+
},
|
|
1009
|
+
},
|
|
1010
|
+
detail,
|
|
1011
|
+
notices,
|
|
1012
|
+
};
|
|
1013
|
+
};
|
|
1014
|
+
/** The audited bypass: `allow` plus one line naming what would have denied. */
|
|
1015
|
+
const allowByOverride = (wouldHaveDenied) => {
|
|
1016
|
+
detail.override = true;
|
|
1017
|
+
notices.push(`${MERGE_READY_NOTICE_PREFIX} OVERRIDE (${MERGE_READY_OVERRIDE_ENV}=1): allowing ${parsed.repo}#${parsed.pr ?? detail.pr ?? '?'} at head ${shortSha(detail.headSha)} — would have denied: ${wouldHaveDenied}.`);
|
|
1018
|
+
return {
|
|
1019
|
+
verdict: {
|
|
1020
|
+
disposition: 'allow',
|
|
1021
|
+
reason: `Allowed by ${MERGE_READY_OVERRIDE_ENV}=1 (audited). Would have denied: ${wouldHaveDenied}.`,
|
|
1022
|
+
provenance: {
|
|
1023
|
+
source: MERGE_READY_SOURCE,
|
|
1024
|
+
ref: 'override',
|
|
1025
|
+
matched: bounded(wouldHaveDenied),
|
|
1026
|
+
checkedAt,
|
|
1027
|
+
detail: { ...detail },
|
|
1028
|
+
},
|
|
1029
|
+
},
|
|
1030
|
+
detail,
|
|
1031
|
+
notices,
|
|
1032
|
+
};
|
|
1033
|
+
};
|
|
1034
|
+
// An argument the wrapper could not project into a PR (an unexpanded shell
|
|
1035
|
+
// variable) is UNEVALUABLE before any read: there is nothing to query, and
|
|
1036
|
+
// guessing would judge the wrong PR (mmnto-ai/totem#2800 fold F13). No `gh`
|
|
1037
|
+
// runs on this path.
|
|
1038
|
+
if (parsed.unresolvedTarget !== undefined) {
|
|
1039
|
+
return unevaluable(`shell variable not expanded — pass a literal PR number or URL (the command named "${bounded(parsed.unresolvedTarget)}")`);
|
|
1040
|
+
}
|
|
1041
|
+
const version = readGhVersion(options.runner);
|
|
1042
|
+
if (!version.ok)
|
|
1043
|
+
return unevaluable(version.detail);
|
|
1044
|
+
detail.evaluatedBy = version.version;
|
|
1045
|
+
const read = readPullRequest(parsed, options.runner);
|
|
1046
|
+
if (!read.ok) {
|
|
1047
|
+
detail.threads.pagesRead = read.pagesRead;
|
|
1048
|
+
return unevaluable(read.detail);
|
|
1049
|
+
}
|
|
1050
|
+
const state = read.state;
|
|
1051
|
+
detail.pr = state.number;
|
|
1052
|
+
detail.headSha = state.headSha;
|
|
1053
|
+
detail.mergeStateStatus = state.mergeStateStatus;
|
|
1054
|
+
detail.checks = summarizeChecks(state.checks);
|
|
1055
|
+
detail.threads = {
|
|
1056
|
+
unresolvedBot: unresolvedBotThreads(state.threads).length,
|
|
1057
|
+
pagesRead: state.pagesRead,
|
|
1058
|
+
complete: state.complete,
|
|
1059
|
+
};
|
|
1060
|
+
detail.changesRequestedBy = changesRequestedBy(state.reviews);
|
|
1061
|
+
detail.highInline = highSeverityInlines(state.threads, state.headSha).length;
|
|
1062
|
+
// The predicates are read BEFORE the unreadable-commit arm below: a failure
|
|
1063
|
+
// that stands in charter order ahead of predicate 4 is a fact, and a tier
|
|
1064
|
+
// never softens a fact — the arm that returned first here turned a failing
|
|
1065
|
+
// check plus one unplaceable HIGH inline into the UNEVALUABLE class, which
|
|
1066
|
+
// pilot maps to `warn` (mmnto-ai/totem#2844 round 1, CodeRabbit).
|
|
1067
|
+
const blocked = firstFailure(state, detail);
|
|
1068
|
+
// A bot HIGH inline whose `comment.commit` came back null cannot be placed
|
|
1069
|
+
// against the head, so predicate 4's input is missing for it: unevaluable and
|
|
1070
|
+
// NAMED, never a silent pass (round 2, F8). It still preempts predicate 5 —
|
|
1071
|
+
// predicate 4's missing input comes before predicate 5 in charter order —
|
|
1072
|
+
// but never a failure of predicates 1–3 or a READABLE predicate-4 failure.
|
|
1073
|
+
const unreadable = unreadableCommitHighInlines(state.threads);
|
|
1074
|
+
if ((blocked === null || blocked.predicate === 'merge-state') && unreadable.length > 0) {
|
|
1075
|
+
const first = unreadable[0];
|
|
1076
|
+
return unevaluable(`${unreadable.length} HIGH bot inline(s) carry no commit, so predicate 4 cannot place them against the head — the first is ${first.rootLogin ?? 'a bot'}: "${bounded(first.rootBody)}"`);
|
|
1077
|
+
}
|
|
1078
|
+
// The caller's local head is evidence, not a predicate: a local branch can
|
|
1079
|
+
// legitimately sit ahead of or behind the PR head, and the charter's floor
|
|
1080
|
+
// does not include "your checkout matches". A mismatch is NAMED, never silent.
|
|
1081
|
+
if (parsed.headSha !== undefined && parsed.headSha !== state.headSha) {
|
|
1082
|
+
notices.push(`${MERGE_READY_NOTICE_PREFIX} the payload head sha (${shortSha(parsed.headSha)}) is not the PR head (${shortSha(state.headSha)}); the floor was read against the PR head.`);
|
|
1083
|
+
}
|
|
1084
|
+
// R5: zero checks passes predicate 1 as a FACT, with the count in provenance
|
|
1085
|
+
// and one stderr line. Branch protection owns "must have checks".
|
|
1086
|
+
if (detail.checks.total === 0) {
|
|
1087
|
+
notices.push(`${MERGE_READY_NOTICE_PREFIX} ${parsed.repo}#${state.number} at ${shortSha(state.headSha)} has ZERO status checks${state.rollupPresent ? '' : ' (no rollup on the head commit)'} — predicate 1 passes as a fact; branch protection, not this gate, decides whether zero checks may merge.`);
|
|
1088
|
+
}
|
|
1089
|
+
if (override) {
|
|
1090
|
+
return allowByOverride(blocked === null
|
|
1091
|
+
? 'nothing (every predicate passed)'
|
|
1092
|
+
: `${blocked.predicate} — ${blocked.evidence}`);
|
|
1093
|
+
}
|
|
1094
|
+
// `mergeStateStatus: UNKNOWN` means GitHub has not computed mergeability yet:
|
|
1095
|
+
// an input that failed to derive, not a fact — the UNEVALUABLE class.
|
|
1096
|
+
if (blocked === null && state.mergeStateStatus === 'UNKNOWN') {
|
|
1097
|
+
return unevaluable('GitHub has not computed mergeability yet (mergeStateStatus: UNKNOWN) — retry');
|
|
1098
|
+
}
|
|
1099
|
+
if (blocked === null && !MERGE_STATE_PASS.has(state.mergeStateStatus)) {
|
|
1100
|
+
return unevaluable(`mergeStateStatus "${bounded(state.mergeStateStatus)}" is not a value this gate reads`);
|
|
1101
|
+
}
|
|
1102
|
+
if (blocked !== null) {
|
|
1103
|
+
return {
|
|
1104
|
+
verdict: {
|
|
1105
|
+
disposition: 'deny',
|
|
1106
|
+
reason: `${parsed.repo}#${state.number} is not merge-ready: ${blocked.evidence}.`,
|
|
1107
|
+
provenance: {
|
|
1108
|
+
source: MERGE_READY_SOURCE,
|
|
1109
|
+
ref: blocked.predicate,
|
|
1110
|
+
matched: bounded(blocked.evidence),
|
|
1111
|
+
checkedAt,
|
|
1112
|
+
detail: { ...detail },
|
|
1113
|
+
},
|
|
1114
|
+
},
|
|
1115
|
+
detail,
|
|
1116
|
+
notices,
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
const botReviewSeen = state.threads.some((t) => t.rootLogin !== null && isBotReviewerLoginExact(t.rootLogin));
|
|
1120
|
+
const factClause = botReviewSeen ? '' : ' No bot review present — predicates 2–4 pass as a fact.';
|
|
1121
|
+
return {
|
|
1122
|
+
verdict: {
|
|
1123
|
+
disposition: 'allow',
|
|
1124
|
+
reason: `${parsed.repo}#${state.number} at ${shortSha(state.headSha)} meets the merge-ready floor (${detail.checks.success}/${detail.checks.total} checks green, mergeStateStatus ${state.mergeStateStatus}).${factClause}`,
|
|
1125
|
+
provenance: {
|
|
1126
|
+
source: MERGE_READY_SOURCE,
|
|
1127
|
+
ref: 'merge-ready',
|
|
1128
|
+
matched: state.headSha,
|
|
1129
|
+
checkedAt,
|
|
1130
|
+
detail: { ...detail },
|
|
1131
|
+
},
|
|
1132
|
+
},
|
|
1133
|
+
detail,
|
|
1134
|
+
notices,
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
/** The FIRST predicate that fails, in charter order, or null when the floor is met. */
|
|
1138
|
+
function firstFailure(state, detail) {
|
|
1139
|
+
// 1. checks
|
|
1140
|
+
if (detail.checks.failing > 0) {
|
|
1141
|
+
const names = state.checks
|
|
1142
|
+
.filter((c) => c.kind === 'failing')
|
|
1143
|
+
.map((c) => c.name)
|
|
1144
|
+
.join(', ');
|
|
1145
|
+
return {
|
|
1146
|
+
predicate: 'checks',
|
|
1147
|
+
evidence: `${detail.checks.failing} of ${detail.checks.total} status checks are failing (${bounded(names)})`,
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
if (detail.checks.pending > 0) {
|
|
1151
|
+
const names = state.checks
|
|
1152
|
+
.filter((c) => c.kind === 'pending')
|
|
1153
|
+
.map((c) => c.name)
|
|
1154
|
+
.join(', ');
|
|
1155
|
+
return {
|
|
1156
|
+
predicate: 'checks',
|
|
1157
|
+
evidence: `${detail.checks.pending} of ${detail.checks.total} status checks are still running (${bounded(names)})`,
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
// 2. unresolved, non-outdated bot threads
|
|
1161
|
+
const unresolved = unresolvedBotThreads(state.threads);
|
|
1162
|
+
if (unresolved.length > 0) {
|
|
1163
|
+
const first = unresolved[0];
|
|
1164
|
+
return {
|
|
1165
|
+
predicate: 'unresolved-bot-threads',
|
|
1166
|
+
evidence: `${unresolved.length} unresolved bot review thread(s) — the first is ${first.rootLogin ?? 'a bot'}: "${bounded(first.rootBody)}"`,
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
// 3. un-superseded CHANGES_REQUESTED
|
|
1170
|
+
if (detail.changesRequestedBy.length > 0) {
|
|
1171
|
+
return {
|
|
1172
|
+
predicate: 'changes-requested',
|
|
1173
|
+
evidence: `CHANGES_REQUESTED stands from ${detail.changesRequestedBy.join(', ')}`,
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
// 4. HIGH/Major bot inline on the head commit
|
|
1177
|
+
const high = highSeverityInlines(state.threads, state.headSha);
|
|
1178
|
+
if (high.length > 0) {
|
|
1179
|
+
const first = high[0];
|
|
1180
|
+
return {
|
|
1181
|
+
predicate: 'high-severity-inline',
|
|
1182
|
+
evidence: `${high.length} HIGH/Major bot inline(s) still applying to the head commit — the first is ${first.rootLogin ?? 'a bot'}: "${bounded(first.rootBody)}"`,
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
// 5. GitHub's own mergeability
|
|
1186
|
+
const denyReason = MERGE_STATE_DENY.get(state.mergeStateStatus);
|
|
1187
|
+
if (denyReason !== undefined) {
|
|
1188
|
+
return {
|
|
1189
|
+
predicate: 'merge-state',
|
|
1190
|
+
evidence: `mergeStateStatus is ${state.mergeStateStatus} — ${denyReason}`,
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
return null;
|
|
1194
|
+
}
|
|
1195
|
+
// ─── The registry entry ─────────────────────────────────────────────────────
|
|
1196
|
+
/**
|
|
1197
|
+
* The production gh seam: spawn `gh` with an argv (never a shell string) and
|
|
1198
|
+
* report `{ stdout, exitCode }`. A spawn failure (gh absent) and a non-zero
|
|
1199
|
+
* exit are the SAME shape here — both are "the read did not answer", which the
|
|
1200
|
+
* evaluator turns into the named UNEVALUABLE class.
|
|
1201
|
+
*
|
|
1202
|
+
* When gh writes its diagnosis to stderr and nothing to stdout (the missing
|
|
1203
|
+
* binary, an auth failure), the stderr text is returned as `stdout` so the
|
|
1204
|
+
* reason can name what gh said; the exit code is what decides.
|
|
1205
|
+
*/
|
|
1206
|
+
export function makeGhRunner(timeoutMs = 30_000, execute = safeExec) {
|
|
1207
|
+
return (args) => {
|
|
1208
|
+
// totem-context: NOT a swallowed error — the seam's contract is
|
|
1209
|
+
// `{ stdout, exitCode }`, so a spawn failure (gh absent) and a non-zero exit
|
|
1210
|
+
// must arrive at the evaluator the SAME way: as a read that did not answer,
|
|
1211
|
+
// which becomes a NAMED unevaluable verdict carrying what gh said. Throwing
|
|
1212
|
+
// instead would erase that text and land in the wrapper's generic
|
|
1213
|
+
// fail-closed arm; nothing here can return a clean read (Tenet 4).
|
|
1214
|
+
try {
|
|
1215
|
+
return {
|
|
1216
|
+
stdout: execute('gh', args, { timeout: timeoutMs, trim: false }),
|
|
1217
|
+
exitCode: 0,
|
|
1218
|
+
};
|
|
1219
|
+
// totem-context: intentional degradation — see the directive above the try; dual placement so the rule reads either the catch-keyword line or the catch body.
|
|
1220
|
+
}
|
|
1221
|
+
catch (err) {
|
|
1222
|
+
// totem-context: intentional degradation — a gh that did not answer is the NAMED unevaluable class, carrying what gh said; never a clean read.
|
|
1223
|
+
const fields = err;
|
|
1224
|
+
const stdout = fields.stdout?.trim();
|
|
1225
|
+
const stderr = fields.stderr?.trim();
|
|
1226
|
+
const text = stdout !== undefined && stdout !== ''
|
|
1227
|
+
? stdout
|
|
1228
|
+
: stderr !== undefined && stderr !== ''
|
|
1229
|
+
? stderr
|
|
1230
|
+
: err instanceof Error
|
|
1231
|
+
? err.message
|
|
1232
|
+
: String(err);
|
|
1233
|
+
const status = typeof fields.status === 'number' ? fields.status : 1;
|
|
1234
|
+
return { stdout: text, exitCode: status === 0 ? 1 : status };
|
|
1235
|
+
}
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* The registry evaluator. Builds the production runner lazily (so no gh is
|
|
1240
|
+
* spawned unless this gate actually runs), evaluates, and prints the gate's
|
|
1241
|
+
* stderr lines through the injected sink.
|
|
1242
|
+
*/
|
|
1243
|
+
export const mergeReadyEvaluator = (payload, _totemDir, context) => {
|
|
1244
|
+
const runner = context?.ghRunner ?? makeGhRunner();
|
|
1245
|
+
const evaluation = evaluateMergeReady(payload, {
|
|
1246
|
+
runner,
|
|
1247
|
+
tier: context?.tier,
|
|
1248
|
+
env: context?.env,
|
|
1249
|
+
});
|
|
1250
|
+
const write = context?.writeStderr ?? ((line) => void process.stderr.write(line));
|
|
1251
|
+
for (const notice of evaluation.notices) {
|
|
1252
|
+
write(`${notice}\n`);
|
|
1253
|
+
}
|
|
1254
|
+
return evaluation.verdict;
|
|
1255
|
+
};
|
|
1256
|
+
//# sourceMappingURL=merge-ready.js.map
|