@mmnto/cli 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/commands/config-drift.test.js +4 -1
- package/dist/commands/config-drift.test.js.map +1 -1
- package/dist/commands/gate-install.d.ts +35 -0
- package/dist/commands/gate-install.d.ts.map +1 -1
- package/dist/commands/gate-install.js +48 -0
- package/dist/commands/gate-install.js.map +1 -1
- package/dist/commands/gate-install.test.js +638 -9
- package/dist/commands/gate-install.test.js.map +1 -1
- package/dist/commands/gate.d.ts +9 -0
- package/dist/commands/gate.d.ts.map +1 -1
- package/dist/commands/gate.js +37 -2
- package/dist/commands/gate.js.map +1 -1
- package/dist/commands/gate.test.js +42 -1
- package/dist/commands/gate.test.js.map +1 -1
- package/dist/commands/init-templates.d.ts +8 -8
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +866 -94
- package/dist/commands/init-templates.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +22 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/init.test.js +132 -0
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/mail-cli-wiring.test.js +58 -2
- package/dist/commands/mail-cli-wiring.test.js.map +1 -1
- package/dist/commands/mail-derive-seat.test.d.ts +28 -0
- package/dist/commands/mail-derive-seat.test.d.ts.map +1 -0
- package/dist/commands/mail-derive-seat.test.js +557 -0
- package/dist/commands/mail-derive-seat.test.js.map +1 -0
- package/dist/commands/mail.d.ts +112 -0
- package/dist/commands/mail.d.ts.map +1 -1
- package/dist/commands/mail.js +189 -0
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/resolve-threads-cli-wiring.test.d.ts +19 -0
- package/dist/commands/resolve-threads-cli-wiring.test.d.ts.map +1 -0
- package/dist/commands/resolve-threads-cli-wiring.test.js +103 -0
- package/dist/commands/resolve-threads-cli-wiring.test.js.map +1 -0
- package/dist/commands/resolve-threads.d.ts +468 -0
- package/dist/commands/resolve-threads.d.ts.map +1 -0
- package/dist/commands/resolve-threads.js +794 -0
- package/dist/commands/resolve-threads.js.map +1 -0
- package/dist/commands/resolve-threads.test.d.ts +2 -0
- package/dist/commands/resolve-threads.test.d.ts.map +1 -0
- package/dist/commands/resolve-threads.test.js +1121 -0
- package/dist/commands/resolve-threads.test.js.map +1 -0
- package/dist/commands/session-context-journal-select.test.d.ts +2 -0
- package/dist/commands/session-context-journal-select.test.d.ts.map +1 -0
- package/dist/commands/session-context-journal-select.test.js +95 -0
- package/dist/commands/session-context-journal-select.test.js.map +1 -0
- package/dist/commands/sync-labels-forms.test.d.ts +36 -0
- package/dist/commands/sync-labels-forms.test.d.ts.map +1 -0
- package/dist/commands/sync-labels-forms.test.js +573 -0
- package/dist/commands/sync-labels-forms.test.js.map +1 -0
- package/dist/index.js +74 -5
- package/dist/index.js.map +1 -1
- package/dist/parsers/bot-identity-parity.test.d.ts +2 -0
- package/dist/parsers/bot-identity-parity.test.d.ts.map +1 -0
- package/dist/parsers/bot-identity-parity.test.js +175 -0
- package/dist/parsers/bot-identity-parity.test.js.map +1 -0
- package/dist/parsers/bot-review-parser.d.ts +8 -5
- package/dist/parsers/bot-review-parser.d.ts.map +1 -1
- package/dist/parsers/bot-review-parser.js +12 -34
- package/dist/parsers/bot-review-parser.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,1121 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { BOT_REVIEWER_IDENTITIES, isBotReviewerLogin, isBotReviewerLoginExact, } from '@mmnto/totem';
|
|
3
|
+
import { buildResolveThreadsPlan, classifyThread, confirmResolved, deriveEvidence, isBotRootedThread, parseIdSelection, RESOLVE_THREAD_COMMENTS_QUERY, RESOLVE_THREAD_MUTATION, RESOLVE_THREADS_QUERY, resolveThreadsCommand, toPrCommentRecords, toThreadRecords, } from './resolve-threads.js';
|
|
4
|
+
// The SHIPPED identity definition (mmnto-ai/totem#2800), not a copy: the exact
|
|
5
|
+
// closed-list test on the GraphQL surface, the loose pattern on REST.
|
|
6
|
+
const identity = {
|
|
7
|
+
isBotLoginExact: isBotReviewerLoginExact,
|
|
8
|
+
isBotLoginLoose: isBotReviewerLogin,
|
|
9
|
+
};
|
|
10
|
+
// ─── Fixture builders ────────────────────────────────────────────────────────
|
|
11
|
+
/**
|
|
12
|
+
* The review-bot logins a fixture models as `__typename: "Bot"` by default,
|
|
13
|
+
* DERIVED from core's `BOT_REVIEWER_IDENTITIES` (both spellings, with and
|
|
14
|
+
* without the `[bot]` suffix) rather than hand-listed — a hand-listed copy
|
|
15
|
+
* drifts the moment core gains or renames an identity, and it already had:
|
|
16
|
+
* the first version omitted `github-code-quality` while carrying an entry that
|
|
17
|
+
* is not a review bot at all.
|
|
18
|
+
*
|
|
19
|
+
* OBSERVED vs DECLARED, kept honest: only `greptile-apps` and `coderabbitai`
|
|
20
|
+
* were read from the real API (mmnto-ai/totem#2839, 2026-09-08T06:03:03Z, both
|
|
21
|
+
* answering `__typename: "Bot"` with no `[bot]` suffix on the login). The other
|
|
22
|
+
* identities are taken from core's declaration, not from an observation made
|
|
23
|
+
* here. A fixture may override `typename` explicitly; this only keeps the
|
|
24
|
+
* DEFAULT honest so a test cannot accidentally model a review bot as a User.
|
|
25
|
+
*/
|
|
26
|
+
const CORE_BOT_LOGINS = BOT_REVIEWER_IDENTITIES.flatMap((id) => [...id.exactLogins]);
|
|
27
|
+
/**
|
|
28
|
+
* A GitHub App that is NOT one of core's review bots — the whole point of the
|
|
29
|
+
* three-arm bot test (fold F2): core's list is closed, but any App can reply in
|
|
30
|
+
* a thread, and an App's comment is not a human answer. Deliberately a SEPARATE
|
|
31
|
+
* constant from {@link CORE_BOT_LOGINS} so the two ideas cannot be conflated.
|
|
32
|
+
*/
|
|
33
|
+
const APP_NOT_A_REVIEW_BOT = 'github-actions';
|
|
34
|
+
function defaultTypename(login) {
|
|
35
|
+
const lower = login.toLowerCase();
|
|
36
|
+
const isCoreReviewBot = CORE_BOT_LOGINS.includes(lower);
|
|
37
|
+
const isOtherApp = lower === APP_NOT_A_REVIEW_BOT || /\[bot\]$/i.test(login);
|
|
38
|
+
return isCoreReviewBot || isOtherApp ? 'Bot' : 'User';
|
|
39
|
+
}
|
|
40
|
+
function commentNode(c) {
|
|
41
|
+
return {
|
|
42
|
+
databaseId: c.databaseId === undefined ? 1 : c.databaseId,
|
|
43
|
+
author: c.login === null
|
|
44
|
+
? null
|
|
45
|
+
: { __typename: c.typename ?? defaultTypename(c.login), login: c.login },
|
|
46
|
+
createdAt: c.createdAt,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function threadNode(t) {
|
|
50
|
+
return {
|
|
51
|
+
id: t.id,
|
|
52
|
+
isResolved: t.isResolved ?? false,
|
|
53
|
+
isOutdated: t.isOutdated ?? false,
|
|
54
|
+
path: t.path ?? 'packages/cli/src/x.ts',
|
|
55
|
+
comments: {
|
|
56
|
+
pageInfo: {
|
|
57
|
+
hasNextPage: t.commentsHasNext ?? false,
|
|
58
|
+
endCursor: t.commentsCursor === undefined ? 'c0' : t.commentsCursor,
|
|
59
|
+
},
|
|
60
|
+
nodes: t.comments.map(commentNode),
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function makeRunner(spec) {
|
|
65
|
+
const calls = [];
|
|
66
|
+
const queries = [];
|
|
67
|
+
const runner = (args) => {
|
|
68
|
+
calls.push([...args]);
|
|
69
|
+
// The gh precondition probe: a non-zero answer models gh absent or broken.
|
|
70
|
+
if (args[0] === '--version') {
|
|
71
|
+
return spec.ghVersionExitCode !== undefined && spec.ghVersionExitCode !== 0
|
|
72
|
+
? { stdout: 'spawn gh ENOENT', exitCode: spec.ghVersionExitCode }
|
|
73
|
+
: { stdout: 'gh version 2.99.0 (2026-09-01)\n', exitCode: 0 };
|
|
74
|
+
}
|
|
75
|
+
if (args[0] === 'repo')
|
|
76
|
+
return { stdout: 'mmnto-ai/totem\n', exitCode: 0 };
|
|
77
|
+
if (args[0] === 'api' && args[1] === 'graphql') {
|
|
78
|
+
const query = args.find((a) => a.startsWith('query='))?.slice('query='.length) ?? '';
|
|
79
|
+
queries.push(query);
|
|
80
|
+
if (query.includes('TotemResolveReviewThread')) {
|
|
81
|
+
const threadId = args.find((a) => a.startsWith('threadId='))?.slice('threadId='.length) ?? '';
|
|
82
|
+
if ((spec.mutationFailures ?? []).includes(threadId)) {
|
|
83
|
+
return {
|
|
84
|
+
stdout: JSON.stringify({ errors: [{ message: 'Resource not accessible' }] }),
|
|
85
|
+
exitCode: 0,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const raw = spec.mutationRawBody?.[threadId];
|
|
89
|
+
if (raw !== undefined)
|
|
90
|
+
return { stdout: raw, exitCode: 0 };
|
|
91
|
+
return {
|
|
92
|
+
stdout: JSON.stringify({
|
|
93
|
+
data: { resolveReviewThread: { thread: { id: threadId, isResolved: true } } },
|
|
94
|
+
}),
|
|
95
|
+
exitCode: 0,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (query.includes('TotemResolveThreadComments')) {
|
|
99
|
+
const threadId = args.find((a) => a.startsWith('threadId='))?.slice('threadId='.length) ?? '';
|
|
100
|
+
const thread = spec.threads.find((t) => t.id === threadId);
|
|
101
|
+
return {
|
|
102
|
+
stdout: JSON.stringify({
|
|
103
|
+
data: {
|
|
104
|
+
node: {
|
|
105
|
+
comments: {
|
|
106
|
+
pageInfo: spec.commentsAlwaysHasNext === true
|
|
107
|
+
? { hasNextPage: true, endCursor: 'c-more' }
|
|
108
|
+
: { hasNextPage: false, endCursor: null },
|
|
109
|
+
nodes: (thread?.commentsNextPage ?? []).map(commentNode),
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
exitCode: 0,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
// The thread read. A non-zero exit carries the raw body when one is given
|
|
118
|
+
// (gh prints the GraphQL body on stdout AND exits 1 for a NOT_FOUND —
|
|
119
|
+
// observed 2026-09-08), else a canned failure string.
|
|
120
|
+
if (spec.threadsExitCode !== undefined && spec.threadsExitCode !== 0) {
|
|
121
|
+
return {
|
|
122
|
+
stdout: spec.threadsRawBody ?? 'gh: API rate limit exceeded',
|
|
123
|
+
exitCode: spec.threadsExitCode,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (spec.threadsRawBody !== undefined) {
|
|
127
|
+
return { stdout: spec.threadsRawBody, exitCode: 0 };
|
|
128
|
+
}
|
|
129
|
+
const after = args.find((a) => a.startsWith('threadsAfter='));
|
|
130
|
+
const first = after === undefined;
|
|
131
|
+
const nodes = first ? spec.threads : (spec.threadsSecondPage ?? []);
|
|
132
|
+
return {
|
|
133
|
+
stdout: JSON.stringify({
|
|
134
|
+
data: {
|
|
135
|
+
repository: {
|
|
136
|
+
pullRequest: {
|
|
137
|
+
reviewThreads: {
|
|
138
|
+
pageInfo: spec.threadsAlwaysHasNext === true
|
|
139
|
+
? { hasNextPage: true, endCursor: `t-${calls.length}` }
|
|
140
|
+
: {
|
|
141
|
+
hasNextPage: first ? (spec.threadsHasNext ?? false) : false,
|
|
142
|
+
endCursor: first
|
|
143
|
+
? spec.threadsCursor === undefined
|
|
144
|
+
? 't0'
|
|
145
|
+
: spec.threadsCursor
|
|
146
|
+
: null,
|
|
147
|
+
},
|
|
148
|
+
nodes: nodes.map(threadNode),
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
}),
|
|
154
|
+
exitCode: 0,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
// The REST issue-comment read.
|
|
158
|
+
if (spec.prCommentsExitCode !== undefined && spec.prCommentsExitCode !== 0) {
|
|
159
|
+
return { stdout: 'gh: Not Found', exitCode: spec.prCommentsExitCode };
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
stdout: JSON.stringify((spec.prComments ?? []).map((c, i) => ({
|
|
163
|
+
id: 100 + i,
|
|
164
|
+
user: c.login === null ? null : { login: c.login, type: c.type ?? 'User' },
|
|
165
|
+
created_at: c.createdAt,
|
|
166
|
+
}))),
|
|
167
|
+
exitCode: 0,
|
|
168
|
+
};
|
|
169
|
+
};
|
|
170
|
+
return { runner, calls, queries };
|
|
171
|
+
}
|
|
172
|
+
async function run(spec, opts = {}) {
|
|
173
|
+
const harness = makeRunner(spec);
|
|
174
|
+
let stdout = '';
|
|
175
|
+
let stderr = '';
|
|
176
|
+
const result = await resolveThreadsCommand(opts.pr ?? '2839', {
|
|
177
|
+
...(opts.apply === undefined ? {} : { apply: opts.apply }),
|
|
178
|
+
...(opts.ids === undefined ? {} : { ids: opts.ids }),
|
|
179
|
+
...(opts.json === undefined ? {} : { json: opts.json }),
|
|
180
|
+
runner: harness.runner,
|
|
181
|
+
out: (t) => {
|
|
182
|
+
stdout += t;
|
|
183
|
+
},
|
|
184
|
+
err: (t) => {
|
|
185
|
+
stderr += t;
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
return { ...result, stdout, stderr, calls: harness.calls, queries: harness.queries };
|
|
189
|
+
}
|
|
190
|
+
/** Every mutation call the run made. */
|
|
191
|
+
function mutationCalls(calls) {
|
|
192
|
+
return calls.filter((c) => c.some((a) => a.includes('TotemResolveReviewThread')));
|
|
193
|
+
}
|
|
194
|
+
const ISSUE_COMMENTS_PATH = /^repos\/[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+\/issues\/\d+\/comments$/;
|
|
195
|
+
/** Variables each declared document is allowed to carry. */
|
|
196
|
+
const ALLOWED_VARIABLES = {
|
|
197
|
+
'threads-read': ['owner', 'name', 'number', 'threadsAfter'],
|
|
198
|
+
'comments-read': ['threadId', 'commentsAfter'],
|
|
199
|
+
mutation: ['threadId'],
|
|
200
|
+
};
|
|
201
|
+
function classifyExecShape(args) {
|
|
202
|
+
const reject = (why) => {
|
|
203
|
+
throw new Error(`argv is not a declared exec shape (${why}): ${JSON.stringify(args)}`);
|
|
204
|
+
};
|
|
205
|
+
// The precondition probe: exactly `gh --version`, nothing riding on it.
|
|
206
|
+
if (args[0] === '--version') {
|
|
207
|
+
if (args.length !== 1)
|
|
208
|
+
return reject('the version probe takes no other argument');
|
|
209
|
+
return 'gh-version';
|
|
210
|
+
}
|
|
211
|
+
if (args[0] === 'repo') {
|
|
212
|
+
if (args.join(' ') !== 'repo view --json nameWithOwner --jq .nameWithOwner') {
|
|
213
|
+
return reject('unexpected `gh repo` argv');
|
|
214
|
+
}
|
|
215
|
+
return 'repo-view';
|
|
216
|
+
}
|
|
217
|
+
if (args[0] !== 'api')
|
|
218
|
+
return reject('only `gh --version`, `gh repo view` and `gh api` are declared');
|
|
219
|
+
if (args[1] === 'graphql') {
|
|
220
|
+
if (args[2] !== '-f' || args[3]?.startsWith('query=') !== true) {
|
|
221
|
+
return reject('a graphql call must pass the document as `-f query=…`');
|
|
222
|
+
}
|
|
223
|
+
const document = args[3].slice('query='.length);
|
|
224
|
+
const shape = document === RESOLVE_THREADS_QUERY
|
|
225
|
+
? 'threads-read'
|
|
226
|
+
: document === RESOLVE_THREAD_COMMENTS_QUERY
|
|
227
|
+
? 'comments-read'
|
|
228
|
+
: document === RESOLVE_THREAD_MUTATION
|
|
229
|
+
? 'mutation'
|
|
230
|
+
: null;
|
|
231
|
+
if (shape === null)
|
|
232
|
+
return reject('the document is not one of the three declared constants');
|
|
233
|
+
const allowed = ALLOWED_VARIABLES[shape];
|
|
234
|
+
for (let i = 4; i < args.length; i += 2) {
|
|
235
|
+
const flag = args[i];
|
|
236
|
+
const pair = args[i + 1];
|
|
237
|
+
if (flag !== '-f' && flag !== '-F')
|
|
238
|
+
return reject(`unexpected flag ${String(flag)}`);
|
|
239
|
+
const key = pair?.split('=')[0] ?? '';
|
|
240
|
+
if (!allowed.includes(key))
|
|
241
|
+
return reject(`unexpected variable ${key}`);
|
|
242
|
+
}
|
|
243
|
+
return shape;
|
|
244
|
+
}
|
|
245
|
+
// A REST call: exactly the paginated issue-comment READ, nothing else. Any
|
|
246
|
+
// method flag at all is a write attempt and is refused by falling through.
|
|
247
|
+
if (args.length !== 3 || !ISSUE_COMMENTS_PATH.test(args[1] ?? '') || args[2] !== '--paginate') {
|
|
248
|
+
return reject('the only declared REST call is the paginated issue-comment read');
|
|
249
|
+
}
|
|
250
|
+
return 'issue-comments';
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Assert every recorded argv is a declared shape, and that a mutation appears
|
|
254
|
+
* only when `--apply` was passed. Throws on the first offender.
|
|
255
|
+
*/
|
|
256
|
+
function assertOnlyDeclaredExecShapes(calls, opts) {
|
|
257
|
+
return calls.map((args) => {
|
|
258
|
+
const shape = classifyExecShape(args);
|
|
259
|
+
if (shape === 'mutation' && !opts.apply) {
|
|
260
|
+
throw new Error(`a mutation was sent without --apply: ${JSON.stringify(args)}`);
|
|
261
|
+
}
|
|
262
|
+
return shape;
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
const BOT_ROOT = { login: 'coderabbitai', createdAt: '2026-09-08T03:32:00Z' };
|
|
266
|
+
// ─── Real capture (disclosed) ────────────────────────────────────────────────
|
|
267
|
+
//
|
|
268
|
+
// Captured READ-ONLY from mmnto-ai/totem#2839: the review threads at
|
|
269
|
+
// 2026-09-08T06:03:03Z (`gh api graphql`, the same selection set
|
|
270
|
+
// RESOLVE_THREADS_QUERY sends, `author { __typename login }` included) and the
|
|
271
|
+
// PR-level comments at 2026-09-08T06:03:12Z
|
|
272
|
+
// (`gh api repos/mmnto-ai/totem/issues/2839/comments --paginate`). Both clock
|
|
273
|
+
// reads are from the same shell as the capture.
|
|
274
|
+
//
|
|
275
|
+
// COMPLETE, not trimmed by row: all three review threads and ALL SEVEN PR-level
|
|
276
|
+
// comments the PR carried at that instant are here, including the four bot ones
|
|
277
|
+
// that change no verdict — a fixture that quietly dropped the inert rows would
|
|
278
|
+
// stop being a witness to what the verb actually reads. Only the FIELDS are
|
|
279
|
+
// reduced (to the ones the verb reads: ids, logins, __typename/type, instants,
|
|
280
|
+
// paths); every value is verbatim.
|
|
281
|
+
//
|
|
282
|
+
// What it witnesses: three bot-rooted threads (two greptile, one coderabbit),
|
|
283
|
+
// none with an in-thread reply; a human comment at 03:24:01Z BEFORE every
|
|
284
|
+
// thread root (the round-trigger comment — not evidence for any of them) and a
|
|
285
|
+
// human comment at 03:42:27Z AFTER all three (the round disposition). Note the
|
|
286
|
+
// GraphQL logins carry no `[bot]` suffix while `__typename` is `Bot` — the trap
|
|
287
|
+
// the three-arm bot test exists for.
|
|
288
|
+
const CAPTURE_2839 = {
|
|
289
|
+
threads: [
|
|
290
|
+
{
|
|
291
|
+
id: 'PRRT_kwDORatBZ86gFX3q',
|
|
292
|
+
path: 'scripts/sync-labels.ps1',
|
|
293
|
+
comments: [
|
|
294
|
+
{
|
|
295
|
+
databaseId: 3954062164,
|
|
296
|
+
login: 'greptile-apps',
|
|
297
|
+
typename: 'Bot',
|
|
298
|
+
createdAt: '2026-09-08T03:30:29Z',
|
|
299
|
+
},
|
|
300
|
+
],
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
id: 'PRRT_kwDORatBZ86gFX3s',
|
|
304
|
+
path: 'packages/cli/src/commands/sync-labels-forms.test.ts',
|
|
305
|
+
comments: [
|
|
306
|
+
{
|
|
307
|
+
databaseId: 3954062167,
|
|
308
|
+
login: 'greptile-apps',
|
|
309
|
+
typename: 'Bot',
|
|
310
|
+
createdAt: '2026-09-08T03:30:29Z',
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
id: 'PRRT_kwDORatBZ86gFYvi',
|
|
316
|
+
path: 'scripts/sync-labels.ps1',
|
|
317
|
+
comments: [
|
|
318
|
+
{
|
|
319
|
+
databaseId: 3954067481,
|
|
320
|
+
login: 'coderabbitai',
|
|
321
|
+
typename: 'Bot',
|
|
322
|
+
createdAt: '2026-09-08T03:32:00Z',
|
|
323
|
+
},
|
|
324
|
+
],
|
|
325
|
+
},
|
|
326
|
+
],
|
|
327
|
+
prComments: [
|
|
328
|
+
{ login: 'satur8d', createdAt: '2026-09-08T03:24:01Z' },
|
|
329
|
+
{ login: 'coderabbitai[bot]', type: 'Bot', createdAt: '2026-09-08T03:24:15Z' },
|
|
330
|
+
{ login: 'greptile-apps[bot]', type: 'Bot', createdAt: '2026-09-08T03:30:23Z' },
|
|
331
|
+
{ login: 'coderabbitai[bot]', type: 'Bot', createdAt: '2026-09-08T03:31:57Z' },
|
|
332
|
+
{ login: 'satur8d', createdAt: '2026-09-08T03:42:27Z' },
|
|
333
|
+
{ login: 'coderabbitai[bot]', type: 'Bot', createdAt: '2026-09-08T03:42:48Z' },
|
|
334
|
+
{ login: 'greptile-apps[bot]', type: 'Bot', createdAt: '2026-09-08T03:42:53Z' },
|
|
335
|
+
],
|
|
336
|
+
};
|
|
337
|
+
// ─── Pure classification ─────────────────────────────────────────────────────
|
|
338
|
+
describe('resolve-threads classification', () => {
|
|
339
|
+
it('correctlyClassifiesVaryingThreadStatus', () => {
|
|
340
|
+
const records = toThreadRecords([
|
|
341
|
+
threadNode({
|
|
342
|
+
id: 'T-human-reply',
|
|
343
|
+
comments: [BOT_ROOT, { login: 'satur8d', createdAt: '2026-09-08T04:00:00Z' }],
|
|
344
|
+
}),
|
|
345
|
+
threadNode({
|
|
346
|
+
id: 'T-bot-replies-only',
|
|
347
|
+
comments: [BOT_ROOT, { login: 'coderabbitai', createdAt: '2026-09-08T04:00:00Z' }],
|
|
348
|
+
}),
|
|
349
|
+
threadNode({ id: 'T-no-reply', comments: [BOT_ROOT] }),
|
|
350
|
+
threadNode({
|
|
351
|
+
id: 'T-human-rooted',
|
|
352
|
+
comments: [{ login: 'satur8d', createdAt: '2026-09-08T03:00:00Z' }],
|
|
353
|
+
}),
|
|
354
|
+
threadNode({ id: 'T-resolved', isResolved: true, comments: [BOT_ROOT] }),
|
|
355
|
+
threadNode({ id: 'T-outdated', isOutdated: true, comments: [BOT_ROOT] }),
|
|
356
|
+
], identity);
|
|
357
|
+
const byId = new Map(records.map((r) => [r.id, r]));
|
|
358
|
+
const verdicts = records.map((r) => ({
|
|
359
|
+
id: r.id,
|
|
360
|
+
candidate: isBotRootedThread(r, identity),
|
|
361
|
+
verdict: classifyThread(r, deriveEvidence(r, []), true),
|
|
362
|
+
}));
|
|
363
|
+
expect(byId.get('T-human-reply')?.humanReplyCount).toBe(1);
|
|
364
|
+
expect(byId.get('T-bot-replies-only')?.humanReplyCount).toBe(0);
|
|
365
|
+
expect(verdicts).toEqual([
|
|
366
|
+
{ id: 'T-human-reply', candidate: true, verdict: 'resolve' },
|
|
367
|
+
{ id: 'T-bot-replies-only', candidate: true, verdict: 'skip:no-evidence' },
|
|
368
|
+
{ id: 'T-no-reply', candidate: true, verdict: 'skip:no-evidence' },
|
|
369
|
+
{ id: 'T-human-rooted', candidate: false, verdict: 'skip:no-evidence' },
|
|
370
|
+
{ id: 'T-resolved', candidate: true, verdict: 'skip:already-resolved' },
|
|
371
|
+
{ id: 'T-outdated', candidate: true, verdict: 'skip:outdated' },
|
|
372
|
+
]);
|
|
373
|
+
});
|
|
374
|
+
it('treats a deleted account (null author) as non-bot on both surfaces', () => {
|
|
375
|
+
const [botRootWithGhostReply, ghostRooted] = toThreadRecords([
|
|
376
|
+
threadNode({
|
|
377
|
+
id: 'T-ghost-reply',
|
|
378
|
+
comments: [BOT_ROOT, { login: null, createdAt: '2026-09-08T04:00:00Z' }],
|
|
379
|
+
}),
|
|
380
|
+
threadNode({ id: 'T-ghost-root', comments: [{ login: null, createdAt: '2026-09-08Z' }] }),
|
|
381
|
+
], identity);
|
|
382
|
+
// A ghost reply is a HUMAN reply (never a bot identity) — evidence.
|
|
383
|
+
expect(botRootWithGhostReply?.humanReplyCount).toBe(1);
|
|
384
|
+
expect(deriveEvidence(botRootWithGhostReply, [])).toBe('in-thread-reply');
|
|
385
|
+
// A ghost ROOT is not a bot root, so the thread is not a candidate at all.
|
|
386
|
+
expect(isBotRootedThread(ghostRooted, identity)).toBe(false);
|
|
387
|
+
// And a ghost PR-level comment is non-bot too.
|
|
388
|
+
expect(toPrCommentRecords([{ id: 1, user: null, created_at: 'x' }], identity)[0]?.isBot).toBe(false);
|
|
389
|
+
});
|
|
390
|
+
it('reads the REST surface with the loose pattern and the [bot]/type test', () => {
|
|
391
|
+
const records = toPrCommentRecords([
|
|
392
|
+
{ id: 1, user: { login: 'satur8d', type: 'User' }, created_at: 'a' },
|
|
393
|
+
{ id: 2, user: { login: 'coderabbitai[bot]', type: 'Bot' }, created_at: 'b' },
|
|
394
|
+
// A GitHub App that is NOT a review bot is still not the human disposition.
|
|
395
|
+
{ id: 3, user: { login: 'github-actions[bot]', type: 'Bot' }, created_at: 'c' },
|
|
396
|
+
// NARROW claim: `alice-greptile` stays human because core's greptile
|
|
397
|
+
// pattern REQUIRES the `[bot]` suffix. That is true of the greptile and
|
|
398
|
+
// ghcq arms only — it is NOT a general property of the loose pattern
|
|
399
|
+
// (see the disclosed limit below).
|
|
400
|
+
{ id: 4, user: { login: 'alice-greptile', type: 'User' }, created_at: 'd' },
|
|
401
|
+
], identity);
|
|
402
|
+
expect(records.map((r) => r.isBot)).toEqual([false, true, true, false]);
|
|
403
|
+
});
|
|
404
|
+
it('DISCLOSED LIMIT: a human whose login contains `coderabbit` reads as a bot on REST', async () => {
|
|
405
|
+
// core's coderabbit arm is a bare substring (`/coderabbit/i`, no `[bot]`
|
|
406
|
+
// requirement), so a real human account named `coderabbit-fan` is classified
|
|
407
|
+
// as a bot on the REST surface and its PR-level comment is NOT evidence.
|
|
408
|
+
// The direction is safe (a thread stays open rather than being resolved on
|
|
409
|
+
// thin evidence) and the pattern is core's, unchanged here
|
|
410
|
+
// (packages/core/src/bot-identity.ts, mmnto-ai/totem#2800) — this test
|
|
411
|
+
// exists so the limit is recorded, not so the behaviour looks correct.
|
|
412
|
+
expect(toPrCommentRecords([{ id: 1, user: { login: 'coderabbit-fan', type: 'User' } }], identity)[0]
|
|
413
|
+
?.isBot).toBe(true);
|
|
414
|
+
const result = await run({
|
|
415
|
+
threads: [{ id: 'T', comments: [{ databaseId: 1, ...BOT_ROOT }] }],
|
|
416
|
+
prComments: [{ login: 'coderabbit-fan', createdAt: '2026-09-08T04:00:00Z' }],
|
|
417
|
+
});
|
|
418
|
+
expect(result.rows[0]?.verdict).toBe('skip:no-evidence');
|
|
419
|
+
// On the GRAPHQL surface the same human is NOT a bot (the exact list plus
|
|
420
|
+
// `__typename: User`), so an in-thread reply from them WOULD be evidence.
|
|
421
|
+
const [record] = toThreadRecords([
|
|
422
|
+
threadNode({
|
|
423
|
+
id: 'T',
|
|
424
|
+
comments: [BOT_ROOT, { login: 'coderabbit-fan', createdAt: '2026-09-08T04:00:00Z' }],
|
|
425
|
+
}),
|
|
426
|
+
], identity);
|
|
427
|
+
expect(record?.humanReplyCount).toBe(1);
|
|
428
|
+
});
|
|
429
|
+
it('parses --ids and refuses a malformed entry', () => {
|
|
430
|
+
expect(parseIdSelection(undefined)).toEqual({ ok: true, ids: null });
|
|
431
|
+
expect(parseIdSelection(' ')).toEqual({ ok: true, ids: null });
|
|
432
|
+
expect(parseIdSelection('12, #34')).toEqual({ ok: true, ids: [12, 34] });
|
|
433
|
+
expect(parseIdSelection('12,not-an-id')).toEqual({ ok: false, invalid: ['not-an-id'] });
|
|
434
|
+
});
|
|
435
|
+
it('emits exactly one plan row per bot-rooted thread and none for a human-rooted one', () => {
|
|
436
|
+
const records = toThreadRecords([
|
|
437
|
+
threadNode({ id: 'T-bot', comments: [BOT_ROOT] }),
|
|
438
|
+
threadNode({
|
|
439
|
+
id: 'T-human',
|
|
440
|
+
comments: [{ login: 'satur8d', createdAt: '2026-09-08T03:00:00Z' }],
|
|
441
|
+
}),
|
|
442
|
+
], identity);
|
|
443
|
+
const plan = buildResolveThreadsPlan(records, [], identity, null);
|
|
444
|
+
expect(plan.rows.map((r) => r.threadId)).toEqual(['T-bot']);
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
// ─── The evidence rule (R2), including the GCA case ──────────────────────────
|
|
448
|
+
describe('resolve-threads evidence rule (R2)', () => {
|
|
449
|
+
it('resolves a GCA thread with no in-thread reply when a human PR comment lands AFTER the root', async () => {
|
|
450
|
+
const result = await run({
|
|
451
|
+
threads: [
|
|
452
|
+
{
|
|
453
|
+
id: 'T-gca',
|
|
454
|
+
comments: [
|
|
455
|
+
{ databaseId: 555, login: 'gemini-code-assist', createdAt: '2026-09-08T03:30:00Z' },
|
|
456
|
+
],
|
|
457
|
+
},
|
|
458
|
+
],
|
|
459
|
+
prComments: [{ login: 'satur8d', createdAt: '2026-09-08T03:42:00Z' }],
|
|
460
|
+
});
|
|
461
|
+
expect(result.rows.map((r) => [r.verdict, r.evidence])).toEqual([
|
|
462
|
+
['resolve', 'pr-level-disposition'],
|
|
463
|
+
]);
|
|
464
|
+
expect(result.exitCode).toBe(0);
|
|
465
|
+
});
|
|
466
|
+
it('does NOT resolve the same thread when the human PR comment predates the root', async () => {
|
|
467
|
+
const result = await run({
|
|
468
|
+
threads: [
|
|
469
|
+
{
|
|
470
|
+
id: 'T-gca',
|
|
471
|
+
comments: [
|
|
472
|
+
{ databaseId: 555, login: 'gemini-code-assist', createdAt: '2026-09-08T03:30:00Z' },
|
|
473
|
+
],
|
|
474
|
+
},
|
|
475
|
+
],
|
|
476
|
+
prComments: [{ login: 'satur8d', createdAt: '2026-09-08T03:24:00Z' }],
|
|
477
|
+
});
|
|
478
|
+
expect(result.rows.map((r) => [r.verdict, r.evidence])).toEqual([['skip:no-evidence', 'none']]);
|
|
479
|
+
expect(result.stdout).toContain('give it evidence');
|
|
480
|
+
});
|
|
481
|
+
// ── S3 / S3b: an App reply is not a human reply ──
|
|
482
|
+
// The four review-bot logins are a CLOSED list, but any GitHub App can reply
|
|
483
|
+
// in a review thread. Before the three-arm bot test these two cases resolved
|
|
484
|
+
// the thread on the App's own comment — the fail-open direction.
|
|
485
|
+
it('S3: a `github-actions` in-thread reply is NOT evidence (skip:no-evidence)', async () => {
|
|
486
|
+
const result = await run({
|
|
487
|
+
threads: [
|
|
488
|
+
{
|
|
489
|
+
id: 'T-gha',
|
|
490
|
+
comments: [
|
|
491
|
+
{ databaseId: 1, ...BOT_ROOT },
|
|
492
|
+
// The GraphQL spelling of an App login carries no `[bot]` suffix —
|
|
493
|
+
// `__typename` is the arm that catches it.
|
|
494
|
+
{
|
|
495
|
+
databaseId: 2,
|
|
496
|
+
login: APP_NOT_A_REVIEW_BOT,
|
|
497
|
+
typename: 'Bot',
|
|
498
|
+
createdAt: '2026-09-08T04:00:00Z',
|
|
499
|
+
},
|
|
500
|
+
],
|
|
501
|
+
},
|
|
502
|
+
],
|
|
503
|
+
});
|
|
504
|
+
expect(result.rows[0]?.humanReplyCount).toBe(0);
|
|
505
|
+
expect(result.rows.map((r) => [r.verdict, r.evidence])).toEqual([['skip:no-evidence', 'none']]);
|
|
506
|
+
});
|
|
507
|
+
it('S3b: a Copilot-reviewer in-thread reply is NOT evidence, on either spelling', async () => {
|
|
508
|
+
const result = await run({
|
|
509
|
+
threads: [
|
|
510
|
+
{
|
|
511
|
+
id: 'T-copilot-typename',
|
|
512
|
+
comments: [
|
|
513
|
+
{ databaseId: 1, ...BOT_ROOT },
|
|
514
|
+
// An App outside core's list, caught by `__typename` alone.
|
|
515
|
+
{
|
|
516
|
+
databaseId: 2,
|
|
517
|
+
login: 'copilot-pull-request-reviewer',
|
|
518
|
+
typename: 'Bot',
|
|
519
|
+
createdAt: '2026-09-08T04:00:00Z',
|
|
520
|
+
},
|
|
521
|
+
],
|
|
522
|
+
},
|
|
523
|
+
{
|
|
524
|
+
id: 'T-copilot-suffix',
|
|
525
|
+
comments: [
|
|
526
|
+
{ databaseId: 3, ...BOT_ROOT },
|
|
527
|
+
// Belt and braces: the `[bot]` suffix arm catches it even if a
|
|
528
|
+
// future payload were to answer `User` for the typename.
|
|
529
|
+
{
|
|
530
|
+
databaseId: 4,
|
|
531
|
+
login: 'copilot-pull-request-reviewer[bot]',
|
|
532
|
+
typename: 'User',
|
|
533
|
+
createdAt: '2026-09-08T04:00:00Z',
|
|
534
|
+
},
|
|
535
|
+
],
|
|
536
|
+
},
|
|
537
|
+
],
|
|
538
|
+
});
|
|
539
|
+
expect(result.rows.map((r) => r.verdict)).toEqual(['skip:no-evidence', 'skip:no-evidence']);
|
|
540
|
+
});
|
|
541
|
+
it('a HUMAN in-thread reply still resolves (the fold did not close the door on people)', async () => {
|
|
542
|
+
const result = await run({
|
|
543
|
+
threads: [
|
|
544
|
+
{
|
|
545
|
+
id: 'T-human-reply',
|
|
546
|
+
comments: [
|
|
547
|
+
{ databaseId: 1, ...BOT_ROOT },
|
|
548
|
+
{ databaseId: 2, login: 'satur8d', createdAt: '2026-09-08T04:00:00Z' },
|
|
549
|
+
],
|
|
550
|
+
},
|
|
551
|
+
],
|
|
552
|
+
});
|
|
553
|
+
expect(result.rows[0]?.humanReplyCount).toBe(1);
|
|
554
|
+
expect(result.rows.map((r) => [r.verdict, r.evidence])).toEqual([
|
|
555
|
+
['resolve', 'in-thread-reply'],
|
|
556
|
+
]);
|
|
557
|
+
expect(result.exitCode).toBe(0);
|
|
558
|
+
});
|
|
559
|
+
it('does not accept a BOT PR-level comment as the disposition', async () => {
|
|
560
|
+
const result = await run({
|
|
561
|
+
threads: [{ id: 'T', comments: [{ databaseId: 1, ...BOT_ROOT }] }],
|
|
562
|
+
prComments: [
|
|
563
|
+
{ login: 'coderabbitai[bot]', type: 'Bot', createdAt: '2026-09-08T04:00:00Z' },
|
|
564
|
+
{ login: 'github-actions[bot]', type: 'Bot', createdAt: '2026-09-08T04:01:00Z' },
|
|
565
|
+
],
|
|
566
|
+
});
|
|
567
|
+
expect(result.rows[0]?.verdict).toBe('skip:no-evidence');
|
|
568
|
+
});
|
|
569
|
+
it('never resolves an evidence-free thread even under --apply, and exits 2', async () => {
|
|
570
|
+
const result = await run({
|
|
571
|
+
threads: [{ id: 'T-none', comments: [{ databaseId: 1, ...BOT_ROOT }] }],
|
|
572
|
+
}, { apply: true });
|
|
573
|
+
expect(result.rows[0]?.verdict).toBe('skip:no-evidence');
|
|
574
|
+
expect(mutationCalls(result.calls)).toHaveLength(0);
|
|
575
|
+
expect(result.exitCode).toBe(2);
|
|
576
|
+
expect(result.stderr).toContain('no disposition evidence');
|
|
577
|
+
});
|
|
578
|
+
it('applies the evidenced rows and STILL exits 2 when a selected row had no evidence', async () => {
|
|
579
|
+
const result = await run({
|
|
580
|
+
threads: [
|
|
581
|
+
{
|
|
582
|
+
id: 'T-ok',
|
|
583
|
+
comments: [
|
|
584
|
+
{ databaseId: 1, ...BOT_ROOT },
|
|
585
|
+
{ databaseId: 2, login: 'satur8d', createdAt: '2026-09-08T04:00:00Z' },
|
|
586
|
+
],
|
|
587
|
+
},
|
|
588
|
+
{ id: 'T-none', comments: [{ databaseId: 3, ...BOT_ROOT }] },
|
|
589
|
+
],
|
|
590
|
+
}, { apply: true });
|
|
591
|
+
expect(mutationCalls(result.calls)).toHaveLength(1);
|
|
592
|
+
expect(result.rows.find((r) => r.threadId === 'T-ok')?.applied).toBe(true);
|
|
593
|
+
expect(result.rows.find((r) => r.threadId === 'T-none')?.applied).toBeNull();
|
|
594
|
+
expect(result.exitCode).toBe(2);
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
// ─── Mutation discipline ─────────────────────────────────────────────────────
|
|
598
|
+
describe('resolve-threads mutation discipline', () => {
|
|
599
|
+
const evidenced = {
|
|
600
|
+
threads: [
|
|
601
|
+
{
|
|
602
|
+
id: 'T-a',
|
|
603
|
+
comments: [
|
|
604
|
+
{ databaseId: 11, ...BOT_ROOT },
|
|
605
|
+
{ databaseId: 12, login: 'satur8d', createdAt: '2026-09-08T04:00:00Z' },
|
|
606
|
+
],
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
id: 'T-b',
|
|
610
|
+
comments: [{ databaseId: 21, login: 'greptile-apps', createdAt: '2026-09-08T03:30:00Z' }],
|
|
611
|
+
},
|
|
612
|
+
{ id: 'T-resolved', isResolved: true, comments: [{ databaseId: 31, ...BOT_ROOT }] },
|
|
613
|
+
{ id: 'T-outdated', isOutdated: true, comments: [{ databaseId: 41, ...BOT_ROOT }] },
|
|
614
|
+
],
|
|
615
|
+
prComments: [{ login: 'satur8d', createdAt: '2026-09-08T04:05:00Z' }],
|
|
616
|
+
};
|
|
617
|
+
it('dry-run (the default) performs zero mutations and sends only declared read shapes', async () => {
|
|
618
|
+
const result = await run(evidenced);
|
|
619
|
+
expect(mutationCalls(result.calls)).toHaveLength(0);
|
|
620
|
+
// The allowlist, run over the dry-run too: every argv is a declared shape
|
|
621
|
+
// AND none of them is the mutation.
|
|
622
|
+
const shapes = assertOnlyDeclaredExecShapes(result.calls, { apply: false });
|
|
623
|
+
expect(shapes).not.toContain('mutation');
|
|
624
|
+
expect(new Set(shapes)).toEqual(new Set(['gh-version', 'repo-view', 'threads-read', 'issue-comments']));
|
|
625
|
+
expect(result.rows.every((r) => r.applied === null)).toBe(true);
|
|
626
|
+
expect(result.stdout).toContain('dry-run');
|
|
627
|
+
expect(result.exitCode).toBe(0);
|
|
628
|
+
});
|
|
629
|
+
it('--apply issues exactly one resolveReviewThread per resolve row and none for a skip row', async () => {
|
|
630
|
+
const result = await run(evidenced, { apply: true });
|
|
631
|
+
const mutations = mutationCalls(result.calls);
|
|
632
|
+
expect(mutations).toHaveLength(2);
|
|
633
|
+
const ids = mutations.map((c) => c.find((a) => a.startsWith('threadId='))?.slice('threadId='.length));
|
|
634
|
+
expect(ids.sort()).toEqual(['T-a', 'T-b']);
|
|
635
|
+
// The resolved and outdated threads are reported and never mutated.
|
|
636
|
+
expect(result.rows.find((r) => r.threadId === 'T-resolved')?.verdict).toBe('skip:already-resolved');
|
|
637
|
+
expect(result.rows.find((r) => r.threadId === 'T-outdated')?.verdict).toBe('skip:outdated');
|
|
638
|
+
expect(result.exitCode).toBe(0);
|
|
639
|
+
});
|
|
640
|
+
it('every argv it sends is one of the six declared exec shapes (allowlist)', async () => {
|
|
641
|
+
const result = await run(evidenced, { apply: true });
|
|
642
|
+
const shapes = assertOnlyDeclaredExecShapes(result.calls, { apply: true });
|
|
643
|
+
// The whole run is: the version probe, one repo read, one thread page, one
|
|
644
|
+
// issue-comment read, two mutations. Nothing else was sent — not by
|
|
645
|
+
// omission from a denylist, but because nothing else is representable in
|
|
646
|
+
// the allowlist.
|
|
647
|
+
expect(shapes).toEqual([
|
|
648
|
+
'gh-version',
|
|
649
|
+
'repo-view',
|
|
650
|
+
'threads-read',
|
|
651
|
+
'issue-comments',
|
|
652
|
+
'mutation',
|
|
653
|
+
'mutation',
|
|
654
|
+
]);
|
|
655
|
+
expect(result.queries.filter((q) => q === RESOLVE_THREAD_MUTATION)).toHaveLength(2);
|
|
656
|
+
});
|
|
657
|
+
it('the allowlist REJECTS a write argv (the mutant check on the predicate itself)', () => {
|
|
658
|
+
// If any of these passed, the allowlist would be decorative.
|
|
659
|
+
const writes = [
|
|
660
|
+
['pr', 'review', '2839', '--approve'],
|
|
661
|
+
['pr', 'comment', '2839', '--body', 'hi'],
|
|
662
|
+
['api', '-X', 'POST', 'repos/mmnto-ai/totem/issues/2839/comments'],
|
|
663
|
+
['api', '--method', 'PUT', 'repos/mmnto-ai/totem/pulls/2839/merge'],
|
|
664
|
+
['api', 'repos/mmnto-ai/totem/pulls/comments/1/replies', '-f', 'body=hi'],
|
|
665
|
+
['api', 'repos/mmnto-ai/totem/pulls/2839/reviews', '-f', 'event=APPROVE'],
|
|
666
|
+
['api', 'graphql', '-f', 'query=mutation { addComment(input: {}) { clientMutationId } }'],
|
|
667
|
+
// The right document with a variable it never sends.
|
|
668
|
+
['api', 'graphql', '-f', `query=${RESOLVE_THREAD_MUTATION}`, '-f', 'body=hi'],
|
|
669
|
+
// Probe-shaped rows (round-1 leg, F2): the version probe is exactly one
|
|
670
|
+
// token; anything riding on it, or a near-spelling, is not the probe.
|
|
671
|
+
['--version', '--repo', 'x'],
|
|
672
|
+
['--versionx'],
|
|
673
|
+
];
|
|
674
|
+
for (const argv of writes) {
|
|
675
|
+
expect(() => assertOnlyDeclaredExecShapes([argv], { apply: true })).toThrow();
|
|
676
|
+
}
|
|
677
|
+
// And the declared mutation is still refused when --apply was not passed.
|
|
678
|
+
expect(() => assertOnlyDeclaredExecShapes([['api', 'graphql', '-f', `query=${RESOLVE_THREAD_MUTATION}`, '-f', 'threadId=T']], { apply: false })).toThrow(/without --apply/);
|
|
679
|
+
// A real read still passes, so the predicate is not simply always-throwing.
|
|
680
|
+
expect(assertOnlyDeclaredExecShapes([['api', 'repos/mmnto-ai/totem/issues/2839/comments', '--paginate']], { apply: false })).toEqual(['issue-comments']);
|
|
681
|
+
});
|
|
682
|
+
it('a mutation that exits 0 without confirming isResolved is a FAILURE, not an applied row', async () => {
|
|
683
|
+
const result = await run({
|
|
684
|
+
...evidenced,
|
|
685
|
+
// gh exits 0 and the body parses — but the payload carries no thread, so
|
|
686
|
+
// nothing confirms the thread was resolved.
|
|
687
|
+
mutationRawBody: { 'T-a': JSON.stringify({ data: { resolveReviewThread: null } }) },
|
|
688
|
+
}, { apply: true });
|
|
689
|
+
expect(result.rows.find((r) => r.threadId === 'T-a')?.applied).toBe(false);
|
|
690
|
+
expect(result.rows.find((r) => r.threadId === 'T-a')?.errorText).toContain('no thread');
|
|
691
|
+
expect(result.rows.find((r) => r.threadId === 'T-b')?.applied).toBe(true);
|
|
692
|
+
expect(result.exitCode).toBe(2);
|
|
693
|
+
expect(result.stderr).toContain('1 thread(s) failed to resolve');
|
|
694
|
+
});
|
|
695
|
+
it('a mutation answering isResolved: false is a FAILURE — the thread is still open', async () => {
|
|
696
|
+
const result = await run({
|
|
697
|
+
...evidenced,
|
|
698
|
+
mutationRawBody: {
|
|
699
|
+
'T-a': JSON.stringify({
|
|
700
|
+
data: { resolveReviewThread: { thread: { id: 'T-a', isResolved: false } } },
|
|
701
|
+
}),
|
|
702
|
+
},
|
|
703
|
+
}, { apply: true });
|
|
704
|
+
expect(result.rows.find((r) => r.threadId === 'T-a')?.applied).toBe(false);
|
|
705
|
+
expect(result.rows.find((r) => r.threadId === 'T-a')?.errorText).toContain('still open');
|
|
706
|
+
expect(result.exitCode).toBe(2);
|
|
707
|
+
});
|
|
708
|
+
it('confirmResolved refuses every unusable answer and accepts only a resolved thread', () => {
|
|
709
|
+
expect(confirmResolved({ data: { resolveReviewThread: { thread: { id: 'T', isResolved: true } } } })).toEqual({ ok: true });
|
|
710
|
+
expect(confirmResolved({ data: { resolveReviewThread: null } }).ok).toBe(false);
|
|
711
|
+
expect(confirmResolved({ data: { resolveReviewThread: { thread: null } } }).ok).toBe(false);
|
|
712
|
+
expect(confirmResolved({ data: {} }).ok).toBe(false);
|
|
713
|
+
expect(confirmResolved('not json at all').ok).toBe(false);
|
|
714
|
+
});
|
|
715
|
+
it('continues past a failed mutation and exits 2 naming the count', async () => {
|
|
716
|
+
const result = await run({ ...evidenced, mutationFailures: ['T-a'] }, { apply: true });
|
|
717
|
+
expect(mutationCalls(result.calls)).toHaveLength(2);
|
|
718
|
+
expect(result.rows.find((r) => r.threadId === 'T-a')?.applied).toBe(false);
|
|
719
|
+
expect(result.rows.find((r) => r.threadId === 'T-b')?.applied).toBe(true);
|
|
720
|
+
expect(result.exitCode).toBe(2);
|
|
721
|
+
expect(result.stderr).toContain('1 thread(s) failed to resolve');
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
// ─── Selection (R4) ──────────────────────────────────────────────────────────
|
|
725
|
+
describe('resolve-threads selection (R4)', () => {
|
|
726
|
+
const spec = {
|
|
727
|
+
threads: [
|
|
728
|
+
{ id: 'T-a', comments: [{ databaseId: 11, ...BOT_ROOT }] },
|
|
729
|
+
{ id: 'T-b', comments: [{ databaseId: 21, ...BOT_ROOT }] },
|
|
730
|
+
],
|
|
731
|
+
prComments: [{ login: 'satur8d', createdAt: '2026-09-08T04:05:00Z' }],
|
|
732
|
+
};
|
|
733
|
+
it('resolves every evidenced bot thread when no --ids is given', async () => {
|
|
734
|
+
const result = await run(spec, { apply: true });
|
|
735
|
+
expect(mutationCalls(result.calls)).toHaveLength(2);
|
|
736
|
+
});
|
|
737
|
+
it('--ids narrows the batch and the unselected row says so', async () => {
|
|
738
|
+
const result = await run(spec, { apply: true, ids: '11' });
|
|
739
|
+
expect(mutationCalls(result.calls)).toHaveLength(1);
|
|
740
|
+
expect(result.rows.find((r) => r.threadId === 'T-b')?.verdict).toBe('skip:not-selected');
|
|
741
|
+
expect(result.exitCode).toBe(0);
|
|
742
|
+
});
|
|
743
|
+
it('an unmatched id aborts before ANY mutation, exit 2', async () => {
|
|
744
|
+
const result = await run(spec, { apply: true, ids: '11,999999' });
|
|
745
|
+
expect(mutationCalls(result.calls)).toHaveLength(0);
|
|
746
|
+
expect(result.exitCode).toBe(2);
|
|
747
|
+
expect(result.stderr).toContain('999999');
|
|
748
|
+
expect(result.stderr).toContain('nothing was resolved');
|
|
749
|
+
});
|
|
750
|
+
it('a malformed id aborts before any read of the plan, exit 2', async () => {
|
|
751
|
+
const result = await run(spec, { apply: true, ids: 'oops' });
|
|
752
|
+
expect(mutationCalls(result.calls)).toHaveLength(0);
|
|
753
|
+
expect(result.exitCode).toBe(2);
|
|
754
|
+
});
|
|
755
|
+
it('prints the REST root comment id beside every row', async () => {
|
|
756
|
+
const result = await run(spec);
|
|
757
|
+
expect(result.stdout).toContain('id=11');
|
|
758
|
+
expect(result.stdout).toContain('id=21');
|
|
759
|
+
});
|
|
760
|
+
});
|
|
761
|
+
// ─── Reads that did not complete ─────────────────────────────────────────────
|
|
762
|
+
describe('resolve-threads read failures', () => {
|
|
763
|
+
it('fails hard when a next page of threads cannot be followed — never a partial plan', async () => {
|
|
764
|
+
const result = await run({
|
|
765
|
+
threads: [{ id: 'T-a', comments: [{ databaseId: 11, ...BOT_ROOT }] }],
|
|
766
|
+
threadsHasNext: true,
|
|
767
|
+
threadsCursor: null,
|
|
768
|
+
});
|
|
769
|
+
expect(result.exitCode).toBe(1);
|
|
770
|
+
expect(result.rows).toHaveLength(0);
|
|
771
|
+
expect(result.stderr).toContain('review threads NOT read');
|
|
772
|
+
expect(mutationCalls(result.calls)).toHaveLength(0);
|
|
773
|
+
});
|
|
774
|
+
it('follows a thread page that CAN be followed', async () => {
|
|
775
|
+
const result = await run({
|
|
776
|
+
threads: [{ id: 'T-a', comments: [{ databaseId: 11, ...BOT_ROOT }] }],
|
|
777
|
+
threadsHasNext: true,
|
|
778
|
+
threadsCursor: 't1',
|
|
779
|
+
threadsSecondPage: [{ id: 'T-b', comments: [{ databaseId: 21, ...BOT_ROOT }] }],
|
|
780
|
+
prComments: [{ login: 'satur8d', createdAt: '2026-09-08T04:05:00Z' }],
|
|
781
|
+
});
|
|
782
|
+
expect(result.rows.map((r) => r.threadId)).toEqual(['T-a', 'T-b']);
|
|
783
|
+
expect(result.exitCode).toBe(0);
|
|
784
|
+
});
|
|
785
|
+
it('follows a thread comment page by node id, and the extra page can carry the evidence', async () => {
|
|
786
|
+
const result = await run({
|
|
787
|
+
threads: [
|
|
788
|
+
{
|
|
789
|
+
id: 'T-a',
|
|
790
|
+
comments: [{ databaseId: 11, ...BOT_ROOT }],
|
|
791
|
+
commentsHasNext: true,
|
|
792
|
+
commentsCursor: 'c1',
|
|
793
|
+
commentsNextPage: [
|
|
794
|
+
{ databaseId: 12, login: 'satur8d', createdAt: '2026-09-08T04:00:00Z' },
|
|
795
|
+
],
|
|
796
|
+
},
|
|
797
|
+
],
|
|
798
|
+
});
|
|
799
|
+
expect(result.rows[0]?.evidence).toBe('in-thread-reply');
|
|
800
|
+
expect(result.queries.some((q) => q === RESOLVE_THREAD_COMMENTS_QUERY)).toBe(true);
|
|
801
|
+
});
|
|
802
|
+
it('fails hard when a thread comment page cannot be followed', async () => {
|
|
803
|
+
const result = await run({
|
|
804
|
+
threads: [
|
|
805
|
+
{
|
|
806
|
+
id: 'T-a',
|
|
807
|
+
comments: [{ databaseId: 11, ...BOT_ROOT }],
|
|
808
|
+
commentsHasNext: true,
|
|
809
|
+
commentsCursor: null,
|
|
810
|
+
},
|
|
811
|
+
],
|
|
812
|
+
});
|
|
813
|
+
expect(result.exitCode).toBe(1);
|
|
814
|
+
expect(result.stderr).toContain('no cursor to follow');
|
|
815
|
+
});
|
|
816
|
+
it('rejectsMalformattedGraphQLThreads', async () => {
|
|
817
|
+
const result = await run({
|
|
818
|
+
threads: [],
|
|
819
|
+
threadsRawBody: JSON.stringify({
|
|
820
|
+
data: {
|
|
821
|
+
repository: {
|
|
822
|
+
pullRequest: {
|
|
823
|
+
reviewThreads: {
|
|
824
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
825
|
+
// `author` must be an object-or-null; a bare string is malformed.
|
|
826
|
+
nodes: [
|
|
827
|
+
{
|
|
828
|
+
id: 'T',
|
|
829
|
+
isResolved: false,
|
|
830
|
+
isOutdated: false,
|
|
831
|
+
path: 'a.ts',
|
|
832
|
+
comments: {
|
|
833
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
834
|
+
nodes: [{ databaseId: 1, author: 'coderabbitai', createdAt: 'x' }],
|
|
835
|
+
},
|
|
836
|
+
},
|
|
837
|
+
],
|
|
838
|
+
},
|
|
839
|
+
},
|
|
840
|
+
},
|
|
841
|
+
},
|
|
842
|
+
}),
|
|
843
|
+
});
|
|
844
|
+
expect(result.exitCode).toBe(1);
|
|
845
|
+
expect(result.stderr).toContain('did not match the expected shape');
|
|
846
|
+
expect(mutationCalls(result.calls)).toHaveLength(0);
|
|
847
|
+
});
|
|
848
|
+
it('fails hard when gh does not answer the thread read', async () => {
|
|
849
|
+
const result = await run({ threads: [], threadsExitCode: 1 });
|
|
850
|
+
expect(result.exitCode).toBe(1);
|
|
851
|
+
expect(result.stderr).toContain('gh exited 1');
|
|
852
|
+
});
|
|
853
|
+
it('fails hard when the PR-level comment read fails — evidence cannot be derived', async () => {
|
|
854
|
+
const result = await run({
|
|
855
|
+
threads: [{ id: 'T-a', comments: [{ databaseId: 11, ...BOT_ROOT }] }],
|
|
856
|
+
prCommentsExitCode: 1,
|
|
857
|
+
});
|
|
858
|
+
expect(result.exitCode).toBe(1);
|
|
859
|
+
expect(result.stderr).toContain('evidence cannot be derived');
|
|
860
|
+
expect(mutationCalls(result.calls)).toHaveLength(0);
|
|
861
|
+
});
|
|
862
|
+
// The two NOT_FOUND fixtures below are the shape `gh` was OBSERVED to emit,
|
|
863
|
+
// not an invented one: read-only on 2026-09-08 against mmnto-ai/totem,
|
|
864
|
+
// `gh api graphql` for an absent PR number and for an absent repo each exited
|
|
865
|
+
// **1** while printing a body carrying BOTH the null data and a NOT_FOUND
|
|
866
|
+
// `errors` array. So the reachable arm is the non-zero-exit arm, and the line
|
|
867
|
+
// it prints quotes GitHub's own message.
|
|
868
|
+
it('fails hard when the PR is not found (gh exit 1 + a NOT_FOUND errors body)', async () => {
|
|
869
|
+
const result = await run({
|
|
870
|
+
threads: [],
|
|
871
|
+
threadsExitCode: 1,
|
|
872
|
+
threadsRawBody: JSON.stringify({
|
|
873
|
+
data: { repository: { pullRequest: null } },
|
|
874
|
+
errors: [
|
|
875
|
+
{
|
|
876
|
+
type: 'NOT_FOUND',
|
|
877
|
+
path: ['repository', 'pullRequest'],
|
|
878
|
+
message: 'Could not resolve to a PullRequest with the number of 2839.',
|
|
879
|
+
},
|
|
880
|
+
],
|
|
881
|
+
}),
|
|
882
|
+
});
|
|
883
|
+
expect(result.exitCode).toBe(1);
|
|
884
|
+
expect(result.stderr).toContain('review threads NOT read — nothing resolved');
|
|
885
|
+
expect(result.stderr).toContain('gh exited 1');
|
|
886
|
+
expect(result.stderr).toContain('Could not resolve to a PullRequest');
|
|
887
|
+
expect(mutationCalls(result.calls)).toHaveLength(0);
|
|
888
|
+
});
|
|
889
|
+
it('fails hard when the repository is inaccessible (gh exit 1 + a NOT_FOUND errors body)', async () => {
|
|
890
|
+
const result = await run({
|
|
891
|
+
threads: [],
|
|
892
|
+
threadsExitCode: 1,
|
|
893
|
+
threadsRawBody: JSON.stringify({
|
|
894
|
+
data: { repository: null },
|
|
895
|
+
errors: [
|
|
896
|
+
{
|
|
897
|
+
type: 'NOT_FOUND',
|
|
898
|
+
path: ['repository'],
|
|
899
|
+
message: "Could not resolve to a Repository with the name 'mmnto-ai/nope'.",
|
|
900
|
+
},
|
|
901
|
+
],
|
|
902
|
+
}),
|
|
903
|
+
});
|
|
904
|
+
expect(result.exitCode).toBe(1);
|
|
905
|
+
expect(result.stderr).toContain('Could not resolve to a Repository');
|
|
906
|
+
});
|
|
907
|
+
it('a 200 body with null data and NO errors is still a named hard failure (defence in depth)', async () => {
|
|
908
|
+
// GitHub has not been observed to emit this (the NOT_FOUND path exits 1,
|
|
909
|
+
// above) — a proxy or cache could. The arms exist so such a body is named
|
|
910
|
+
// rather than read as "a PR with no threads", and this is what they print.
|
|
911
|
+
const noPull = await run({
|
|
912
|
+
threads: [],
|
|
913
|
+
threadsRawBody: JSON.stringify({ data: { repository: { pullRequest: null } } }),
|
|
914
|
+
});
|
|
915
|
+
expect(noPull.exitCode).toBe(1);
|
|
916
|
+
expect(noPull.stderr).toContain('PR #2839 not found');
|
|
917
|
+
const noRepo = await run({
|
|
918
|
+
threads: [],
|
|
919
|
+
threadsRawBody: JSON.stringify({ data: { repository: null } }),
|
|
920
|
+
});
|
|
921
|
+
expect(noRepo.exitCode).toBe(1);
|
|
922
|
+
expect(noRepo.stderr).toContain('not found or inaccessible');
|
|
923
|
+
});
|
|
924
|
+
it('a GraphQL errors array in a 200 body is a hard failure, never a partial read', async () => {
|
|
925
|
+
const result = await run({
|
|
926
|
+
threads: [],
|
|
927
|
+
threadsRawBody: JSON.stringify({
|
|
928
|
+
data: { repository: null },
|
|
929
|
+
errors: [{ message: 'API rate limit exceeded' }],
|
|
930
|
+
}),
|
|
931
|
+
});
|
|
932
|
+
expect(result.exitCode).toBe(1);
|
|
933
|
+
expect(result.stderr).toContain('GraphQL errors');
|
|
934
|
+
expect(result.stderr).toContain('API rate limit exceeded');
|
|
935
|
+
});
|
|
936
|
+
it('fails hard and names the cure when gh cannot resolve the repository', async () => {
|
|
937
|
+
let stdout = '';
|
|
938
|
+
let stderr = '';
|
|
939
|
+
const calls = [];
|
|
940
|
+
const result = await resolveThreadsCommand('2839', {
|
|
941
|
+
runner: (args) => {
|
|
942
|
+
calls.push([...args]);
|
|
943
|
+
// gh IS present (the probe answers); it is the repository read that
|
|
944
|
+
// fails — an unauthenticated gh, the cure named is `gh auth status`.
|
|
945
|
+
if (args[0] === '--version')
|
|
946
|
+
return { stdout: 'gh version 2.99.0\n', exitCode: 0 };
|
|
947
|
+
return { stdout: 'gh: not authenticated', exitCode: 4 };
|
|
948
|
+
},
|
|
949
|
+
out: (t) => {
|
|
950
|
+
stdout += t;
|
|
951
|
+
},
|
|
952
|
+
err: (t) => {
|
|
953
|
+
stderr += t;
|
|
954
|
+
},
|
|
955
|
+
});
|
|
956
|
+
expect(result.exitCode).toBe(1);
|
|
957
|
+
expect(stderr).toContain('gh auth status');
|
|
958
|
+
expect(stdout).toBe('');
|
|
959
|
+
// It gave up on the FIRST call after the probe — no read, no mutation.
|
|
960
|
+
expect(calls).toEqual([
|
|
961
|
+
['--version'],
|
|
962
|
+
['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'],
|
|
963
|
+
]);
|
|
964
|
+
});
|
|
965
|
+
it('reports nothing to resolve when the PR has no bot-rooted thread', async () => {
|
|
966
|
+
const result = await run({
|
|
967
|
+
threads: [
|
|
968
|
+
{ id: 'T-human', comments: [{ databaseId: 11, login: 'satur8d', createdAt: 'x' }] },
|
|
969
|
+
],
|
|
970
|
+
});
|
|
971
|
+
expect(result.stdout).toContain('nothing to resolve');
|
|
972
|
+
expect(result.exitCode).toBe(0);
|
|
973
|
+
});
|
|
974
|
+
});
|
|
975
|
+
// ─── Output surfaces ─────────────────────────────────────────────────────────
|
|
976
|
+
describe('resolve-threads output', () => {
|
|
977
|
+
it('--json carries the same rows and nothing else on stdout', async () => {
|
|
978
|
+
const result = await run(CAPTURE_2839, { json: true });
|
|
979
|
+
const doc = JSON.parse(result.stdout);
|
|
980
|
+
// The SUCCESS shape is as much a contract as the failure shape: pin the key
|
|
981
|
+
// set exactly (so a field cannot be added or dropped unnoticed) and assert
|
|
982
|
+
// every summary field, not just the one this fixture happens to exercise.
|
|
983
|
+
expect(Object.keys(doc).sort()).toEqual(['apply', 'pr', 'repo', 'rows', 'summary']);
|
|
984
|
+
expect(doc.repo).toBe('mmnto-ai/totem');
|
|
985
|
+
expect(doc.pr).toBe(2839);
|
|
986
|
+
expect(doc.apply).toBe(false);
|
|
987
|
+
expect(doc.rows).toEqual(result.rows);
|
|
988
|
+
expect(Object.keys(doc.summary).sort()).toEqual([
|
|
989
|
+
'applied',
|
|
990
|
+
'botRooted',
|
|
991
|
+
'failed',
|
|
992
|
+
'resolve',
|
|
993
|
+
'skip:already-resolved',
|
|
994
|
+
'skip:no-evidence',
|
|
995
|
+
'skip:not-selected',
|
|
996
|
+
'skip:outdated',
|
|
997
|
+
]);
|
|
998
|
+
expect(doc.summary).toEqual({
|
|
999
|
+
botRooted: 3,
|
|
1000
|
+
resolve: 3,
|
|
1001
|
+
'skip:already-resolved': 0,
|
|
1002
|
+
'skip:outdated': 0,
|
|
1003
|
+
'skip:no-evidence': 0,
|
|
1004
|
+
'skip:not-selected': 0,
|
|
1005
|
+
// Dry-run: nothing was attempted, so nothing applied and nothing failed —
|
|
1006
|
+
// `applied` is 0 here even though `resolve` is 3.
|
|
1007
|
+
applied: 0,
|
|
1008
|
+
failed: 0,
|
|
1009
|
+
});
|
|
1010
|
+
});
|
|
1011
|
+
it('--json under --apply reports what was applied and what failed', async () => {
|
|
1012
|
+
// The other half of the summary contract: `applied` counts the rows the
|
|
1013
|
+
// mutation CONFIRMED, and a per-thread failure shows up in `failed` rather
|
|
1014
|
+
// than silently inflating `applied`.
|
|
1015
|
+
const result = await run({
|
|
1016
|
+
threads: [
|
|
1017
|
+
{ id: 'T-a', comments: [{ databaseId: 11, ...BOT_ROOT }] },
|
|
1018
|
+
{ id: 'T-b', comments: [{ databaseId: 21, ...BOT_ROOT }] },
|
|
1019
|
+
],
|
|
1020
|
+
prComments: [{ login: 'satur8d', createdAt: '2026-09-08T04:05:00Z' }],
|
|
1021
|
+
mutationFailures: ['T-b'],
|
|
1022
|
+
}, { apply: true, json: true });
|
|
1023
|
+
const doc = JSON.parse(result.stdout);
|
|
1024
|
+
expect(doc.apply).toBe(true);
|
|
1025
|
+
expect(doc.summary).toEqual({
|
|
1026
|
+
botRooted: 2,
|
|
1027
|
+
resolve: 2,
|
|
1028
|
+
'skip:already-resolved': 0,
|
|
1029
|
+
'skip:outdated': 0,
|
|
1030
|
+
'skip:no-evidence': 0,
|
|
1031
|
+
'skip:not-selected': 0,
|
|
1032
|
+
applied: 1,
|
|
1033
|
+
failed: 1,
|
|
1034
|
+
});
|
|
1035
|
+
expect(result.exitCode).toBe(2);
|
|
1036
|
+
});
|
|
1037
|
+
it('the mmnto-ai/totem#2839 capture: three bot threads, all carried by the later human disposition', async () => {
|
|
1038
|
+
const result = await run(CAPTURE_2839);
|
|
1039
|
+
expect(result.rows.map((r) => [r.rootCommentId, r.verdict, r.evidence])).toEqual([
|
|
1040
|
+
[3954062164, 'resolve', 'pr-level-disposition'],
|
|
1041
|
+
[3954062167, 'resolve', 'pr-level-disposition'],
|
|
1042
|
+
[3954067481, 'resolve', 'pr-level-disposition'],
|
|
1043
|
+
]);
|
|
1044
|
+
expect(result.exitCode).toBe(0);
|
|
1045
|
+
expect(mutationCalls(result.calls)).toHaveLength(0);
|
|
1046
|
+
});
|
|
1047
|
+
it('--json emits the {error, rows, exitCode} document on a failure, and nothing else', async () => {
|
|
1048
|
+
// The failure shape is a CONTRACT for a script that reads --json: it is not
|
|
1049
|
+
// the success document with an added key, and `rows` is present (empty when
|
|
1050
|
+
// the failure predates the plan) so a consumer can read it unconditionally.
|
|
1051
|
+
const readFailed = await run({ threads: [], threadsExitCode: 1 }, { json: true });
|
|
1052
|
+
const failDoc = JSON.parse(readFailed.stdout);
|
|
1053
|
+
expect(Object.keys(failDoc).sort()).toEqual(['error', 'exitCode', 'rows']);
|
|
1054
|
+
expect(typeof failDoc['error']).toBe('string');
|
|
1055
|
+
expect(failDoc['rows']).toEqual([]);
|
|
1056
|
+
expect(failDoc['exitCode']).toBe(1);
|
|
1057
|
+
// The unmatched-id abort carries the SAME shape, with the rows it had built.
|
|
1058
|
+
const unmatched = await run(CAPTURE_2839, { json: true, ids: '999999' });
|
|
1059
|
+
const idDoc = JSON.parse(unmatched.stdout);
|
|
1060
|
+
expect(Object.keys(idDoc).sort()).toEqual(['error', 'exitCode', 'rows']);
|
|
1061
|
+
expect(idDoc.exitCode).toBe(2);
|
|
1062
|
+
expect(idDoc.rows).toHaveLength(3);
|
|
1063
|
+
});
|
|
1064
|
+
it('gh absent fails IN CONTRACT — the text line in text mode, the {error, rows, exitCode} document under --json, and no further gh call (PR round 1, greptile)', async () => {
|
|
1065
|
+
// The precondition used to be an action-level `process.exit(1)` before the
|
|
1066
|
+
// command ran, which gave a `--json` script nothing to parse. The probe now
|
|
1067
|
+
// goes through the seam: it is the FIRST and only call when gh is missing.
|
|
1068
|
+
const text = await run({ threads: [], ghVersionExitCode: 127 });
|
|
1069
|
+
expect(text.exitCode).toBe(1);
|
|
1070
|
+
expect(text.stderr).toMatch(/requires the GitHub CLI \(gh\)/);
|
|
1071
|
+
expect(text.stdout).toBe('');
|
|
1072
|
+
expect(text.calls).toEqual([['--version']]);
|
|
1073
|
+
const json = await run({ threads: [], ghVersionExitCode: 127 }, { json: true });
|
|
1074
|
+
expect(json.exitCode).toBe(1);
|
|
1075
|
+
const doc = JSON.parse(json.stdout);
|
|
1076
|
+
expect(Object.keys(doc).sort()).toEqual(['error', 'exitCode', 'rows']);
|
|
1077
|
+
expect(doc['error']).toMatch(/requires the GitHub CLI/);
|
|
1078
|
+
expect(doc['rows']).toEqual([]);
|
|
1079
|
+
expect(doc['exitCode']).toBe(1);
|
|
1080
|
+
expect(json.calls).toEqual([['--version']]);
|
|
1081
|
+
});
|
|
1082
|
+
it('MAX_PAGES exhaustion on the thread read fails closed — exit 1, no rows, no mutation (PR round 1, CodeRabbit)', async () => {
|
|
1083
|
+
// Every page reports a followable next page, so the read can never
|
|
1084
|
+
// complete; the cap is the floor against an endless walk, and it is a
|
|
1085
|
+
// named hard failure, never a partial plan.
|
|
1086
|
+
const result = await run({
|
|
1087
|
+
threads: [{ id: 'T-1', comments: [{ databaseId: 1, ...BOT_ROOT }] }],
|
|
1088
|
+
threadsAlwaysHasNext: true,
|
|
1089
|
+
}, { apply: true });
|
|
1090
|
+
expect(result.exitCode).toBe(1);
|
|
1091
|
+
expect(result.rows).toEqual([]);
|
|
1092
|
+
expect(result.stderr).toMatch(/more review threads than 20 pages/);
|
|
1093
|
+
expect(mutationCalls(result.calls)).toHaveLength(0);
|
|
1094
|
+
// The probe, the repo read, then exactly MAX_PAGES thread pages.
|
|
1095
|
+
expect(result.calls.filter((c) => c.some((a) => a.includes('TotemResolveThreads'))).length).toBe(20);
|
|
1096
|
+
});
|
|
1097
|
+
it("MAX_PAGES exhaustion on a thread's comment continuation fails closed the same way (PR round 1, CodeRabbit)", async () => {
|
|
1098
|
+
const result = await run({
|
|
1099
|
+
threads: [
|
|
1100
|
+
{
|
|
1101
|
+
id: 'T-1',
|
|
1102
|
+
commentsHasNext: true,
|
|
1103
|
+
commentsCursor: 'c1',
|
|
1104
|
+
comments: [{ databaseId: 1, ...BOT_ROOT }],
|
|
1105
|
+
},
|
|
1106
|
+
],
|
|
1107
|
+
commentsAlwaysHasNext: true,
|
|
1108
|
+
}, { apply: true });
|
|
1109
|
+
expect(result.exitCode).toBe(1);
|
|
1110
|
+
expect(result.rows).toEqual([]);
|
|
1111
|
+
expect(result.stderr).toMatch(/more comments than 20 pages/);
|
|
1112
|
+
expect(mutationCalls(result.calls)).toHaveLength(0);
|
|
1113
|
+
expect(result.calls.filter((c) => c.some((a) => a.includes('TotemResolveThreadComments'))).length).toBe(20);
|
|
1114
|
+
});
|
|
1115
|
+
it('refuses a non-numeric PR argument before any gh call', async () => {
|
|
1116
|
+
const result = await run(CAPTURE_2839, { pr: 'main' });
|
|
1117
|
+
expect(result.exitCode).toBe(1);
|
|
1118
|
+
expect(result.calls).toHaveLength(0);
|
|
1119
|
+
});
|
|
1120
|
+
});
|
|
1121
|
+
//# sourceMappingURL=resolve-threads.test.js.map
|