@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.
Files changed (64) hide show
  1. package/dist/commands/config-drift.test.js +4 -1
  2. package/dist/commands/config-drift.test.js.map +1 -1
  3. package/dist/commands/gate-install.d.ts +35 -0
  4. package/dist/commands/gate-install.d.ts.map +1 -1
  5. package/dist/commands/gate-install.js +48 -0
  6. package/dist/commands/gate-install.js.map +1 -1
  7. package/dist/commands/gate-install.test.js +638 -9
  8. package/dist/commands/gate-install.test.js.map +1 -1
  9. package/dist/commands/gate.d.ts +9 -0
  10. package/dist/commands/gate.d.ts.map +1 -1
  11. package/dist/commands/gate.js +37 -2
  12. package/dist/commands/gate.js.map +1 -1
  13. package/dist/commands/gate.test.js +42 -1
  14. package/dist/commands/gate.test.js.map +1 -1
  15. package/dist/commands/init-templates.d.ts +8 -8
  16. package/dist/commands/init-templates.d.ts.map +1 -1
  17. package/dist/commands/init-templates.js +866 -94
  18. package/dist/commands/init-templates.js.map +1 -1
  19. package/dist/commands/init.d.ts.map +1 -1
  20. package/dist/commands/init.js +22 -1
  21. package/dist/commands/init.js.map +1 -1
  22. package/dist/commands/init.test.js +132 -0
  23. package/dist/commands/init.test.js.map +1 -1
  24. package/dist/commands/mail-cli-wiring.test.js +58 -2
  25. package/dist/commands/mail-cli-wiring.test.js.map +1 -1
  26. package/dist/commands/mail-derive-seat.test.d.ts +28 -0
  27. package/dist/commands/mail-derive-seat.test.d.ts.map +1 -0
  28. package/dist/commands/mail-derive-seat.test.js +557 -0
  29. package/dist/commands/mail-derive-seat.test.js.map +1 -0
  30. package/dist/commands/mail.d.ts +112 -0
  31. package/dist/commands/mail.d.ts.map +1 -1
  32. package/dist/commands/mail.js +189 -0
  33. package/dist/commands/mail.js.map +1 -1
  34. package/dist/commands/resolve-threads-cli-wiring.test.d.ts +19 -0
  35. package/dist/commands/resolve-threads-cli-wiring.test.d.ts.map +1 -0
  36. package/dist/commands/resolve-threads-cli-wiring.test.js +103 -0
  37. package/dist/commands/resolve-threads-cli-wiring.test.js.map +1 -0
  38. package/dist/commands/resolve-threads.d.ts +468 -0
  39. package/dist/commands/resolve-threads.d.ts.map +1 -0
  40. package/dist/commands/resolve-threads.js +794 -0
  41. package/dist/commands/resolve-threads.js.map +1 -0
  42. package/dist/commands/resolve-threads.test.d.ts +2 -0
  43. package/dist/commands/resolve-threads.test.d.ts.map +1 -0
  44. package/dist/commands/resolve-threads.test.js +1121 -0
  45. package/dist/commands/resolve-threads.test.js.map +1 -0
  46. package/dist/commands/session-context-journal-select.test.d.ts +2 -0
  47. package/dist/commands/session-context-journal-select.test.d.ts.map +1 -0
  48. package/dist/commands/session-context-journal-select.test.js +95 -0
  49. package/dist/commands/session-context-journal-select.test.js.map +1 -0
  50. package/dist/commands/sync-labels-forms.test.d.ts +36 -0
  51. package/dist/commands/sync-labels-forms.test.d.ts.map +1 -0
  52. package/dist/commands/sync-labels-forms.test.js +573 -0
  53. package/dist/commands/sync-labels-forms.test.js.map +1 -0
  54. package/dist/index.js +74 -5
  55. package/dist/index.js.map +1 -1
  56. package/dist/parsers/bot-identity-parity.test.d.ts +2 -0
  57. package/dist/parsers/bot-identity-parity.test.d.ts.map +1 -0
  58. package/dist/parsers/bot-identity-parity.test.js +175 -0
  59. package/dist/parsers/bot-identity-parity.test.js.map +1 -0
  60. package/dist/parsers/bot-review-parser.d.ts +8 -5
  61. package/dist/parsers/bot-review-parser.d.ts.map +1 -1
  62. package/dist/parsers/bot-review-parser.js +12 -34
  63. package/dist/parsers/bot-review-parser.js.map +1 -1
  64. 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