@mmnto/cli 2.2.1 → 2.4.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 (60) 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 +17 -0
  4. package/dist/commands/gate-install.d.ts.map +1 -1
  5. package/dist/commands/gate-install.js +23 -0
  6. package/dist/commands/gate-install.js.map +1 -1
  7. package/dist/commands/gate-install.test.js +467 -0
  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 +24 -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 +5 -5
  16. package/dist/commands/init-templates.d.ts.map +1 -1
  17. package/dist/commands/init-templates.js +758 -79
  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 +8 -1
  21. package/dist/commands/init.js.map +1 -1
  22. package/dist/commands/init.test.js +122 -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 +472 -0
  39. package/dist/commands/resolve-threads.d.ts.map +1 -0
  40. package/dist/commands/resolve-threads.js +793 -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 +1123 -0
  45. package/dist/commands/resolve-threads.test.js.map +1 -0
  46. package/dist/commands/sync-labels-forms.test.d.ts +7 -4
  47. package/dist/commands/sync-labels-forms.test.d.ts.map +1 -1
  48. package/dist/commands/sync-labels-forms.test.js +266 -18
  49. package/dist/commands/sync-labels-forms.test.js.map +1 -1
  50. package/dist/index.js +74 -5
  51. package/dist/index.js.map +1 -1
  52. package/dist/parsers/bot-identity-parity.test.d.ts +2 -0
  53. package/dist/parsers/bot-identity-parity.test.d.ts.map +1 -0
  54. package/dist/parsers/bot-identity-parity.test.js +229 -0
  55. package/dist/parsers/bot-identity-parity.test.js.map +1 -0
  56. package/dist/parsers/bot-review-parser.d.ts +8 -5
  57. package/dist/parsers/bot-review-parser.d.ts.map +1 -1
  58. package/dist/parsers/bot-review-parser.js +12 -34
  59. package/dist/parsers/bot-review-parser.js.map +1 -1
  60. package/package.json +2 -2
@@ -0,0 +1,793 @@
1
+ /**
2
+ * `totem resolve-threads <pr>` — resolve the bot review threads a round has
3
+ * already dispositioned (mmnto-ai/totem#2841, rulings R1-R4 in
4
+ * `.totem/specs/2841.md`).
5
+ *
6
+ * WHY IT EXISTS: the merge-ready gate's predicate 2 (mmnto-ai/totem#2800)
7
+ * denies a merge while any unresolved, non-outdated review thread rooted by a
8
+ * known review bot is open. The lawful way past it is to RESOLVE the threads a
9
+ * round has answered — not to silence the predicate. This verb does exactly
10
+ * that, deterministically and with zero LLM calls.
11
+ *
12
+ * THE EVIDENCE RULE (R2 verbatim): "An in-thread human reply OR a human
13
+ * PR-level comment created after the thread's root — either suffices." So:
14
+ * - `in-thread-reply`: a comment after the root whose author is not a bot
15
+ * (the CodeRabbit/Greptile routine); or
16
+ * - `pr-level-disposition`: ANY non-bot PR-level (issue) comment created
17
+ * AFTER the thread's root comment. It is NOT keyed to the round-disposition
18
+ * comment specifically — the rule is any non-bot comment that postdates the
19
+ * root, because a PR-level comment is the only lawful answer to a GCA
20
+ * thread (bot-protocols forbids replying to GCA in-thread) and no
21
+ * deterministic reading distinguishes a disposition from other human prose.
22
+ * CONSEQUENCE, stated because it is load-bearing: on a re-invoked round the
23
+ * operator's trigger comment ("@coderabbitai review") is itself non-bot
24
+ * evidence for every thread it postdates — which is exactly why the round
25
+ * disposition must be posted BEFORE this verb runs (the review-reply
26
+ * skill's step 4 ordering), not after.
27
+ * A thread with neither is a `skip:no-evidence` row and is NEVER resolved,
28
+ * under any flag. There is no override.
29
+ *
30
+ * WHAT COUNTS AS A BOT, on each surface (the fail-open trap this holds shut):
31
+ * the four review-bot logins are core's closed list, but ANY GitHub App can
32
+ * comment — `github-actions[bot]`, a Copilot reviewer, a scanner — and an App's
33
+ * comment is not a human answer. So a comment is a bot's when GraphQL says
34
+ * `author.__typename === 'Bot'`, or the login ends in `[bot]`, or it is on
35
+ * core's exact list; on REST, when `user.type === 'Bot'`, or the login ends in
36
+ * `[bot]`, or core's loose review-bot pattern matches. Only what survives all
37
+ * three arms is evidence. A DELETED account (null author) is deliberately NOT a
38
+ * bot: the reply it left was a human reply when it was written, and deleting
39
+ * the account does not retract it (recorded as item (6) of the design's
40
+ * disagreement list in `.totem/specs/2841.md`).
41
+ *
42
+ * SAFETY PROPERTIES this module holds:
43
+ * - Dry-run by DEFAULT. `--apply` is the only path that mutates.
44
+ * - The verb NEVER posts a comment, a reply or a review. The only mutation it
45
+ * can issue is `resolveReviewThread`.
46
+ * - A read that could not be completed (a `hasNextPage` it cannot follow, a
47
+ * page budget blown, a `gh` failure, a GraphQL `errors` body) is a HARD
48
+ * failure — exit 1, nothing resolved. Never a shorter list silently
49
+ * treated as the whole PR (the no-silent-shrink rule the spine reader and
50
+ * merge-ready both hold).
51
+ * - A human-rooted thread is never a candidate, whatever its state.
52
+ * - Every bot-rooted thread prints exactly one row carrying its REST root
53
+ * comment id (R4), so nothing is skipped silently.
54
+ *
55
+ * Every `gh` call goes through the injected {@link GhRunner} seam (the
56
+ * mmnto-ai/totem#2800 shape: argv in, `{ stdout, exitCode }` out — never a
57
+ * shell string), so the tests run fully offline against checked-in captures.
58
+ */
59
+ import { z } from 'zod';
60
+ // ─── Named constants ─────────────────────────────────────────────────────────
61
+ /** Page size for every paginated connection in the read. */
62
+ const PAGE_SIZE = 100;
63
+ /**
64
+ * Hard cap on GraphQL round trips per connection. A PR needing more pages than
65
+ * this is a FAILED read (named, exit 1), never "clean by exhaustion" — the same
66
+ * capped-read bar merge-ready holds.
67
+ */
68
+ const MAX_PAGES = 20;
69
+ /** `gh` exec timeout (ms) for the default runner. */
70
+ const GH_TIMEOUT_MS = 60_000;
71
+ /** 10MB — a fully paginated issue-comment read on a busy PR stays well inside. */
72
+ const GH_MAX_BUFFER = 10 * 1024 * 1024;
73
+ // ─── The read surface (one query per connection, explicit cursors) ───────────
74
+ /**
75
+ * The thread read. `comments(first:)` carries `databaseId` — the REST comment
76
+ * id `totem triage-pr` prints as a finding's `rootCommentId`, which is what
77
+ * `--ids` selects on and what every row prints (R4).
78
+ */
79
+ export const RESOLVE_THREADS_QUERY = `query TotemResolveThreads($owner: String!, $name: String!, $number: Int!, $threadsAfter: String) {
80
+ repository(owner: $owner, name: $name) {
81
+ pullRequest(number: $number) {
82
+ reviewThreads(first: ${PAGE_SIZE}, after: $threadsAfter) {
83
+ pageInfo { hasNextPage endCursor }
84
+ nodes {
85
+ id
86
+ isResolved
87
+ isOutdated
88
+ path
89
+ comments(first: ${PAGE_SIZE}) {
90
+ pageInfo { hasNextPage endCursor }
91
+ nodes {
92
+ databaseId
93
+ author { __typename login }
94
+ createdAt
95
+ }
96
+ }
97
+ }
98
+ }
99
+ }
100
+ }
101
+ }`;
102
+ /**
103
+ * The per-thread comment continuation. A nested connection cannot be paged from
104
+ * the outer document (one cursor cannot address many threads), so a thread that
105
+ * reports `comments.pageInfo.hasNextPage` is followed by node id until it is
106
+ * complete. A page that cannot be followed is a failed read, not a short list.
107
+ */
108
+ export const RESOLVE_THREAD_COMMENTS_QUERY = `query TotemResolveThreadComments($threadId: ID!, $commentsAfter: String) {
109
+ node(id: $threadId) {
110
+ ... on PullRequestReviewThread {
111
+ comments(first: ${PAGE_SIZE}, after: $commentsAfter) {
112
+ pageInfo { hasNextPage endCursor }
113
+ nodes {
114
+ databaseId
115
+ author { __typename login }
116
+ createdAt
117
+ }
118
+ }
119
+ }
120
+ }
121
+ }`;
122
+ /**
123
+ * The ONLY mutation this verb can issue. It asks the thread back with
124
+ * `isResolved` so the run can CONFIRM the outcome rather than infer it from a
125
+ * clean exit (see {@link confirmResolved}).
126
+ */
127
+ export const RESOLVE_THREAD_MUTATION = `mutation TotemResolveReviewThread($threadId: ID!) {
128
+ resolveReviewThread(input: { threadId: $threadId }) {
129
+ thread { id isResolved }
130
+ }
131
+ }`;
132
+ // ─── Zod at the IO boundary ──────────────────────────────────────────────────
133
+ const PageInfoSchema = z.object({
134
+ hasNextPage: z.boolean(),
135
+ endCursor: z.string().nullable(),
136
+ });
137
+ /**
138
+ * `author` is null for a deleted/ghost account — never coerced to a bot.
139
+ * `__typename` is REQUIRED, not optional: both documents select it, and it is
140
+ * the only signal that catches a GitHub App outside core's four-name review-bot
141
+ * list (`github-actions`, a Copilot reviewer, a scanner). Making it optional
142
+ * would let a document that quietly stopped selecting it fail OPEN — every App
143
+ * reply reading as a human reply. Absent ⇒ the read fails loud instead.
144
+ */
145
+ const GqlCommentSchema = z.object({
146
+ databaseId: z.number().nullable(),
147
+ author: z.object({ __typename: z.string(), login: z.string() }).nullable(),
148
+ createdAt: z.string(),
149
+ });
150
+ const GqlThreadSchema = z.object({
151
+ id: z.string(),
152
+ isResolved: z.boolean(),
153
+ isOutdated: z.boolean(),
154
+ path: z.string(),
155
+ comments: z.object({ pageInfo: PageInfoSchema, nodes: z.array(GqlCommentSchema) }),
156
+ });
157
+ const ThreadsPageSchema = z.object({
158
+ data: z.object({
159
+ repository: z
160
+ .object({
161
+ pullRequest: z
162
+ .object({
163
+ reviewThreads: z.object({
164
+ pageInfo: PageInfoSchema,
165
+ nodes: z.array(GqlThreadSchema),
166
+ }),
167
+ })
168
+ .nullable(),
169
+ })
170
+ .nullable(),
171
+ }),
172
+ });
173
+ /** The mutation's answer. `thread.isResolved` is what makes a row `applied`. */
174
+ const ResolveMutationSchema = z.object({
175
+ data: z.object({
176
+ resolveReviewThread: z
177
+ .object({ thread: z.object({ id: z.string(), isResolved: z.boolean() }).nullable() })
178
+ .nullable(),
179
+ }),
180
+ });
181
+ const CommentsPageSchema = z.object({
182
+ data: z.object({
183
+ node: z
184
+ .object({
185
+ comments: z.object({ pageInfo: PageInfoSchema, nodes: z.array(GqlCommentSchema) }),
186
+ })
187
+ .nullable(),
188
+ }),
189
+ });
190
+ /** A GraphQL body can carry `errors` beside (or instead of) `data`. */
191
+ const GqlErrorsSchema = z.object({
192
+ errors: z.array(z.object({ message: z.string() }).passthrough()).optional(),
193
+ });
194
+ /**
195
+ * The REST issue-comment shape. `user.login` keeps its `[bot]` suffix on this
196
+ * surface and `user.type` is `Bot` for every GitHub App — both are read, so an
197
+ * app comment can never pass as the human disposition.
198
+ */
199
+ const RestIssueCommentSchema = z.object({
200
+ id: z.number(),
201
+ user: z.object({ login: z.string(), type: z.string() }).nullable(),
202
+ created_at: z.string().optional(),
203
+ });
204
+ // ─── Pure mapping + selection ────────────────────────────────────────────────
205
+ /**
206
+ * Is this GraphQL comment written by a bot? THREE arms, because core's
207
+ * review-bot list is a closed list of four and any GitHub App can reply in a
208
+ * thread: GitHub's own `author.__typename === 'Bot'` (the authoritative
209
+ * signal — verified 2026-09-08T06:03:03Z on mmnto-ai/totem#2839, where
210
+ * `greptile-apps` and `coderabbitai` both answer `Bot` with no `[bot]` suffix
211
+ * on the login), a `[bot]`-suffixed login, or core's exact review-bot list.
212
+ * Anything narrower fails OPEN: a `github-actions[bot]` or Copilot-reviewer
213
+ * comment would count as the human reply and resolve the thread.
214
+ *
215
+ * A null author (deleted/ghost account) is deliberately NOT a bot — the reply
216
+ * it left was a human reply when it was written, and deleting the account does
217
+ * not retract it. So such a comment still counts as evidence, while such a ROOT
218
+ * is not a bot root and its thread is not a candidate at all.
219
+ */
220
+ function isBotComment(comment, identity) {
221
+ const author = comment.author;
222
+ if (author === null)
223
+ return false;
224
+ if (author.__typename === 'Bot')
225
+ return true;
226
+ if (identity.hasAppSuffix(author.login))
227
+ return true;
228
+ return identity.isBotLoginExact(author.login);
229
+ }
230
+ /**
231
+ * Reduce validated GraphQL threads to {@link ReviewThreadRecord}s. Pure given
232
+ * `identity`. The ENTIRE comment list is inspected — human comments are never
233
+ * filtered out before the classification, because whether a human replied is
234
+ * exactly what the evidence rule reads.
235
+ */
236
+ export function toThreadRecords(threads, identity) {
237
+ return threads.map((t) => {
238
+ const [root, ...replies] = t.comments.nodes;
239
+ return {
240
+ id: t.id,
241
+ rootCommentId: root?.databaseId ?? null,
242
+ rootAuthor: root?.author?.login ?? null,
243
+ rootCreatedAt: root?.createdAt ?? '',
244
+ isResolved: t.isResolved,
245
+ isOutdated: t.isOutdated,
246
+ path: t.path,
247
+ humanReplyCount: replies.filter((c) => !isBotComment(c, identity)).length,
248
+ };
249
+ });
250
+ }
251
+ /**
252
+ * Is this thread rooted by a review bot? Only bot-rooted threads are candidates
253
+ * (and only they print a row) — a human-rooted thread is never resolved by this
254
+ * verb, whatever its state.
255
+ */
256
+ export function isBotRootedThread(record, identity) {
257
+ // The ROOT test stays core's EXACT four-name list, deliberately narrower than
258
+ // {@link isBotComment}'s three arms: predicate 2 of merge-ready denies on a
259
+ // thread rooted by a KNOWN REVIEW BOT, and this verb exists to clear exactly
260
+ // those. Widening it to every App would have the verb resolving threads the
261
+ // gate never denied on.
262
+ return record.rootAuthor !== null && identity.isBotLoginExact(record.rootAuthor);
263
+ }
264
+ /**
265
+ * Reduce validated REST issue comments to {@link PrIssueCommentRecord}s. Pure
266
+ * given `identity`. The bot test is the REST mirror of {@link isBotComment}'s
267
+ * three arms: GitHub's `user.type === 'Bot'` (every App), a `[bot]`-suffixed
268
+ * login, or core's LOOSE review-bot pattern (the REST surface's own test).
269
+ * A null user (deleted account) is not a bot, for the same reason a null
270
+ * GraphQL author is not.
271
+ */
272
+ export function toPrCommentRecords(comments, identity) {
273
+ return comments.map((c) => {
274
+ const login = c.user?.login ?? '';
275
+ const isBot = c.user !== null &&
276
+ (c.user.type === 'Bot' || identity.hasAppSuffix(login) || identity.isBotLoginLoose(login));
277
+ return { author: login, isBot, createdAt: c.created_at ?? null };
278
+ });
279
+ }
280
+ /**
281
+ * Derive the evidence for one thread (R2). An in-thread human reply wins; else
282
+ * a non-bot PR-level comment created strictly AFTER the thread's root; else
283
+ * `none`. An unparseable instant on either side is not evidence — the
284
+ * conservative direction, since `none` can only ever refuse to resolve.
285
+ */
286
+ export function deriveEvidence(record, prComments) {
287
+ if (record.humanReplyCount > 0)
288
+ return 'in-thread-reply';
289
+ const rootAt = Date.parse(record.rootCreatedAt);
290
+ if (Number.isNaN(rootAt))
291
+ return 'none';
292
+ for (const c of prComments) {
293
+ if (c.isBot || c.createdAt === null)
294
+ continue;
295
+ const at = Date.parse(c.createdAt);
296
+ if (!Number.isNaN(at) && at > rootAt)
297
+ return 'pr-level-disposition';
298
+ }
299
+ return 'none';
300
+ }
301
+ /**
302
+ * The verdict for one candidate thread. PRECEDENCE, most-informative first: an
303
+ * already-resolved or outdated thread reports THAT (it is the fact the operator
304
+ * needs, and neither is ever mutated), then non-selection, then the evidence
305
+ * rule. Only `resolve` ever reaches the mutation.
306
+ */
307
+ export function classifyThread(record, evidence, selected) {
308
+ if (record.isResolved)
309
+ return 'skip:already-resolved';
310
+ if (record.isOutdated)
311
+ return 'skip:outdated';
312
+ if (!selected)
313
+ return 'skip:not-selected';
314
+ return evidence === 'none' ? 'skip:no-evidence' : 'resolve';
315
+ }
316
+ /**
317
+ * Parse `--ids`. `null` (the option absent or empty) means "every evidenced bot
318
+ * thread" — the default (R4). A malformed entry aborts the run exactly like an
319
+ * unmatched one: a typo must never silently narrow the batch.
320
+ */
321
+ export function parseIdSelection(raw) {
322
+ if (raw === undefined)
323
+ return { ok: true, ids: null };
324
+ const entries = raw
325
+ .split(',')
326
+ .map((e) => e.trim())
327
+ .filter((e) => e !== '');
328
+ if (entries.length === 0)
329
+ return { ok: true, ids: null };
330
+ const ids = [];
331
+ const invalid = [];
332
+ for (const entry of entries) {
333
+ const digits = entry.startsWith('#') ? entry.slice(1) : entry;
334
+ if (!/^[0-9]+$/.test(digits)) {
335
+ invalid.push(entry);
336
+ continue;
337
+ }
338
+ ids.push(Number(digits));
339
+ }
340
+ return invalid.length > 0 ? { ok: false, invalid } : { ok: true, ids };
341
+ }
342
+ /**
343
+ * Build the plan. Pure. Every bot-rooted thread yields exactly one row; a
344
+ * human-rooted thread yields none.
345
+ */
346
+ export function buildResolveThreadsPlan(records, prComments, identity, selectedIds) {
347
+ const candidates = records.filter((r) => isBotRootedThread(r, identity));
348
+ const matched = new Set();
349
+ const rows = candidates.map((record) => {
350
+ const selected = selectedIds === null ||
351
+ (record.rootCommentId !== null && selectedIds.includes(record.rootCommentId));
352
+ if (selected && selectedIds !== null && record.rootCommentId !== null) {
353
+ matched.add(record.rootCommentId);
354
+ }
355
+ const evidence = deriveEvidence(record, prComments);
356
+ return {
357
+ threadId: record.id,
358
+ rootCommentId: record.rootCommentId,
359
+ rootAuthor: record.rootAuthor,
360
+ rootCreatedAt: record.rootCreatedAt,
361
+ path: record.path,
362
+ isResolved: record.isResolved,
363
+ isOutdated: record.isOutdated,
364
+ humanReplyCount: record.humanReplyCount,
365
+ evidence,
366
+ verdict: classifyThread(record, evidence, selected),
367
+ applied: null,
368
+ errorText: null,
369
+ };
370
+ });
371
+ const unmatchedIds = selectedIds === null ? [] : selectedIds.filter((id) => !matched.has(id));
372
+ return { rows, unmatchedIds };
373
+ }
374
+ // ─── The gh seam ─────────────────────────────────────────────────────────────
375
+ /**
376
+ * Build the default `gh`-backed runner. A spawn failure (gh absent) and a
377
+ * non-zero exit arrive the same way — as a read that did not answer, carrying
378
+ * what `gh` said — so the caller can NAME it instead of losing the text in a
379
+ * generic error boundary. Nothing here can return a clean read.
380
+ */
381
+ async function defaultRunner(cwd) {
382
+ const { safeExec } = await import('@mmnto/totem');
383
+ return (args) => {
384
+ // totem-context: NOT a swallowed error — the seam's contract is
385
+ // `{ stdout, exitCode }` (the mmnto-ai/totem#2800 GhRunner shape). A gh that
386
+ // did not answer becomes a NAMED hard failure at the call site (exit 1,
387
+ // nothing resolved); it is never converted into a clean or partial read.
388
+ try {
389
+ return {
390
+ stdout: safeExec('gh', args, {
391
+ cwd,
392
+ timeout: GH_TIMEOUT_MS,
393
+ maxBuffer: GH_MAX_BUFFER,
394
+ trim: false,
395
+ env: { ...process.env, GH_PROMPT_DISABLED: '1' },
396
+ }),
397
+ exitCode: 0,
398
+ };
399
+ // totem-context: intentional — see the directive above the try; the failure is reified into the seam's contract and named by the caller, never silently degraded.
400
+ }
401
+ catch (err) {
402
+ // totem-context: intentional — a gh that did not answer is reified into `{ stdout, exitCode }` and becomes a named hard failure (exit 1); never a clean read.
403
+ const fields = err;
404
+ const stdout = fields.stdout?.trim();
405
+ const stderr = fields.stderr?.trim();
406
+ const text = stdout !== undefined && stdout !== ''
407
+ ? stdout
408
+ : stderr !== undefined && stderr !== ''
409
+ ? stderr
410
+ : err instanceof Error
411
+ ? err.message
412
+ : String(err);
413
+ return { stdout: text, exitCode: typeof fields.status === 'number' ? fields.status : 1 };
414
+ }
415
+ };
416
+ }
417
+ /** Build the argv for a `gh api graphql` call. Never a shell string. */
418
+ export function graphqlArgs(query, variables) {
419
+ const args = ['api', 'graphql', '-f', `query=${query}`];
420
+ for (const [key, value] of variables) {
421
+ // `-F` sends a typed value (an `Int!` argument requires it); `-f` a string.
422
+ args.push(typeof value === 'number' ? '-F' : '-f', `${key}=${value}`);
423
+ }
424
+ return args;
425
+ }
426
+ /** Bound any quoted failure text so one bad body cannot flood the transcript. */
427
+ function bounded(text, max = 400) {
428
+ const flat = text.replace(/\s+/g, ' ').trim();
429
+ return flat.length <= max ? flat : `${flat.slice(0, max)}…`;
430
+ }
431
+ /** Run one `gh` call and JSON-parse it, checking the GraphQL `errors` array. */
432
+ function runJson(runner, args, what) {
433
+ const run = runner([...args]);
434
+ if (run.exitCode !== 0) {
435
+ return { ok: false, detail: `${what}: gh exited ${run.exitCode}: ${bounded(run.stdout)}` };
436
+ }
437
+ let body;
438
+ // totem-context: NOT a swallowed error — the parse failure is reified into the
439
+ // discriminated ReadFailure and becomes a named hard failure (exit 1, nothing
440
+ // resolved) at the call site. Throwing here would lose the body text.
441
+ try {
442
+ body = JSON.parse(run.stdout);
443
+ // totem-context: intentional — the unparseable body is reified into ReadFailure and named; never treated as an empty or partial read.
444
+ }
445
+ catch (err) {
446
+ // totem-context: intentional — the unparseable body is reified into ReadFailure and named; never treated as an empty or partial read.
447
+ return {
448
+ ok: false,
449
+ detail: `${what}: response was not JSON (${err instanceof Error ? err.message : String(err)}): ${bounded(run.stdout)}`,
450
+ };
451
+ }
452
+ const errors = GqlErrorsSchema.safeParse(body);
453
+ if (errors.success && errors.data.errors !== undefined && errors.data.errors.length > 0) {
454
+ return {
455
+ ok: false,
456
+ detail: `${what}: GraphQL errors: ${bounded(errors.data.errors.map((e) => e.message).join('; '))}`,
457
+ };
458
+ }
459
+ return { ok: true, body };
460
+ }
461
+ /**
462
+ * Read every review thread on the PR, following BOTH connections' cursors.
463
+ * Any page that cannot be followed — no cursor, the budget blown, a failed or
464
+ * unparseable response — fails the whole read. There is no arm that returns a
465
+ * partial thread set.
466
+ */
467
+ export function readReviewThreads(runner, owner, name, pr) {
468
+ const threads = [];
469
+ let cursor = null;
470
+ for (let page = 0; page < MAX_PAGES; page++) {
471
+ const variables = [
472
+ ['owner', owner],
473
+ ['name', name],
474
+ ['number', pr],
475
+ ];
476
+ if (cursor !== null)
477
+ variables.push(['threadsAfter', cursor]);
478
+ const run = runJson(runner, graphqlArgs(RESOLVE_THREADS_QUERY, variables), `review threads page ${page + 1}`);
479
+ if (!run.ok)
480
+ return run;
481
+ const parsed = ThreadsPageSchema.safeParse(run.body);
482
+ if (!parsed.success) {
483
+ return {
484
+ ok: false,
485
+ detail: `review threads page ${page + 1} did not match the expected shape: ${bounded(parsed.error.message)}`,
486
+ };
487
+ }
488
+ const repo = parsed.data.data.repository;
489
+ if (repo === null) {
490
+ return { ok: false, detail: `repository ${owner}/${name} not found or inaccessible` };
491
+ }
492
+ const pull = repo.pullRequest;
493
+ if (pull === null) {
494
+ return { ok: false, detail: `PR #${pr} not found in ${owner}/${name}` };
495
+ }
496
+ threads.push(...pull.reviewThreads.nodes);
497
+ const info = pull.reviewThreads.pageInfo;
498
+ if (!info.hasNextPage) {
499
+ const completed = completeThreadComments(runner, threads);
500
+ return completed.ok ? { ok: true, threads } : completed;
501
+ }
502
+ if (info.endCursor === null) {
503
+ return {
504
+ ok: false,
505
+ detail: `PR #${pr} reports more review threads but returned no cursor to follow (page ${page + 1})`,
506
+ };
507
+ }
508
+ cursor = info.endCursor;
509
+ }
510
+ return {
511
+ ok: false,
512
+ detail: `PR #${pr} has more review threads than ${MAX_PAGES} pages of ${PAGE_SIZE} — the read is incomplete, so nothing was resolved`,
513
+ };
514
+ }
515
+ /**
516
+ * Follow each thread's comment cursor until every comment is in hand. Mutates
517
+ * the passed nodes in place (they are this module's own parsed copies).
518
+ */
519
+ function completeThreadComments(runner, threads) {
520
+ for (const thread of threads) {
521
+ let info = thread.comments.pageInfo;
522
+ for (let page = 0; info.hasNextPage; page++) {
523
+ if (page >= MAX_PAGES) {
524
+ return {
525
+ ok: false,
526
+ detail: `thread ${thread.id} has more comments than ${MAX_PAGES} pages of ${PAGE_SIZE} — the read is incomplete, so nothing was resolved`,
527
+ };
528
+ }
529
+ if (info.endCursor === null) {
530
+ return {
531
+ ok: false,
532
+ detail: `thread ${thread.id} reports more comments but returned no cursor to follow`,
533
+ };
534
+ }
535
+ const run = runJson(runner, graphqlArgs(RESOLVE_THREAD_COMMENTS_QUERY, [
536
+ ['threadId', thread.id],
537
+ ['commentsAfter', info.endCursor],
538
+ ]), `comments of thread ${thread.id}`);
539
+ if (!run.ok)
540
+ return run;
541
+ const parsed = CommentsPageSchema.safeParse(run.body);
542
+ if (!parsed.success) {
543
+ return {
544
+ ok: false,
545
+ detail: `comments of thread ${thread.id} did not match the expected shape: ${bounded(parsed.error.message)}`,
546
+ };
547
+ }
548
+ const node = parsed.data.data.node;
549
+ if (node === null) {
550
+ return { ok: false, detail: `thread ${thread.id} could not be read back by node id` };
551
+ }
552
+ thread.comments.nodes.push(...node.comments.nodes);
553
+ info = node.comments.pageInfo;
554
+ }
555
+ }
556
+ return { ok: true };
557
+ }
558
+ /**
559
+ * Read the PR's issue comments (REST, `--paginate`). This is the surface where
560
+ * a login keeps its `[bot]` suffix and `user.type` is available, so a bot's own
561
+ * PR-level comment can never be read as the human disposition. A failure here
562
+ * is a HARD failure: evidence cannot be derived, so no thread may be resolved.
563
+ */
564
+ export function readPrIssueComments(runner, owner, name, pr) {
565
+ const run = runJson(runner, ['api', `repos/${owner}/${name}/issues/${pr}/comments`, '--paginate'], `PR-level comments on #${pr}`);
566
+ if (!run.ok)
567
+ return run;
568
+ const parsed = z.array(RestIssueCommentSchema).safeParse(run.body);
569
+ if (!parsed.success) {
570
+ return {
571
+ ok: false,
572
+ detail: `PR-level comments on #${pr} did not match the expected shape: ${bounded(parsed.error.message)}`,
573
+ };
574
+ }
575
+ return { ok: true, comments: parsed.data };
576
+ }
577
+ /**
578
+ * Did the mutation actually resolve the thread? `gh` exiting 0 says only that a
579
+ * request was answered; the ANSWER has to say `isResolved: true`. A null
580
+ * payload, a null thread, `isResolved: false`, or a body that does not parse
581
+ * are each a named per-thread failure — a row is never marked applied on a
582
+ * mutation whose result was not read back.
583
+ */
584
+ export function confirmResolved(body) {
585
+ const parsed = ResolveMutationSchema.safeParse(body);
586
+ if (!parsed.success) {
587
+ return {
588
+ ok: false,
589
+ detail: `the resolveReviewThread response did not match the expected shape: ${bounded(parsed.error.message)}`,
590
+ };
591
+ }
592
+ const thread = parsed.data.data.resolveReviewThread?.thread ?? null;
593
+ if (thread === null) {
594
+ return { ok: false, detail: 'the resolveReviewThread response carried no thread' };
595
+ }
596
+ if (!thread.isResolved) {
597
+ return {
598
+ ok: false,
599
+ detail: `GitHub answered isResolved: false for ${thread.id} — the thread is still open`,
600
+ };
601
+ }
602
+ return { ok: true };
603
+ }
604
+ // ─── Rendering ───────────────────────────────────────────────────────────────
605
+ const VERDICT_WIDTH = 'skip:already-resolved'.length;
606
+ /** One printed row. The REST root comment id is on EVERY row (R4). */
607
+ export function formatRow(row) {
608
+ const id = row.rootCommentId === null ? 'id=unknown' : `id=${row.rootCommentId}`;
609
+ const author = row.rootAuthor ?? '(deleted account)';
610
+ const parts = [
611
+ row.verdict.padEnd(VERDICT_WIDTH),
612
+ id,
613
+ author,
614
+ `evidence=${row.evidence}`,
615
+ row.path,
616
+ ];
617
+ return parts.join(' ');
618
+ }
619
+ /** The one-line cure a `skip:no-evidence` row carries — both lawful ways to give it evidence. */
620
+ const NO_EVIDENCE_HINT = 'give it evidence — reply in the thread, or post the round-disposition PR comment AFTER the thread root — then re-run';
621
+ // ─── The command ─────────────────────────────────────────────────────────────
622
+ /**
623
+ * Run the verb. Returns the exit code rather than exiting, so `index.ts` owns
624
+ * `process.exitCode` (the `pr merge` / `mail` convention).
625
+ *
626
+ * EXIT CODES:
627
+ * 0 — the plan printed, or `--apply` resolved everything it planned to
628
+ * 1 — a hard read failure (nothing resolved), or `gh` did not answer
629
+ * 2 — an unmatched `--ids` entry (nothing resolved); a mutation that failed;
630
+ * or, under `--apply`, a SELECTED thread skipped for want of evidence
631
+ * (the run did not do what was asked)
632
+ */
633
+ export async function resolveThreadsCommand(prArg, opts = {}) {
634
+ const out = opts.out ?? ((t) => process.stdout.write(t));
635
+ const err = opts.err ?? ((t) => process.stderr.write(t));
636
+ const json = opts.json === true;
637
+ const apply = opts.apply === true;
638
+ const emit = (line) => {
639
+ if (!json)
640
+ out(`${line}\n`);
641
+ };
642
+ const fail = (detail, exitCode, rows = []) => {
643
+ err(`[Totem Error] ${detail}\n`);
644
+ if (json)
645
+ out(`${JSON.stringify({ error: detail, rows, exitCode }, null, 2)}\n`);
646
+ };
647
+ const pr = Number(prArg.trim().replace(/^#/, ''));
648
+ if (!Number.isInteger(pr) || pr <= 0) {
649
+ fail(`"${prArg}" is not a pull-request number`, 1);
650
+ return { exitCode: 1, rows: [] };
651
+ }
652
+ const selection = parseIdSelection(opts.ids);
653
+ if (!selection.ok) {
654
+ fail(`--ids carries ${selection.invalid.length} entry/entries that are not REST comment ids: ${selection.invalid.join(', ')} — nothing was resolved`, 2);
655
+ return { exitCode: 2, rows: [] };
656
+ }
657
+ // The core barrel is loaded HERE, inside the command (mmnto-ai/totem#2339).
658
+ const { hasBotAppLoginSuffix, isBotReviewerLogin, isBotReviewerLoginExact } = await import('@mmnto/totem');
659
+ const identity = {
660
+ isBotLoginExact: isBotReviewerLoginExact,
661
+ isBotLoginLoose: isBotReviewerLogin,
662
+ hasAppSuffix: hasBotAppLoginSuffix,
663
+ };
664
+ const runner = opts.runner ?? (await defaultRunner(opts.cwd ?? process.cwd()));
665
+ // The gh precondition lives HERE, inside the command, not in the CLI action:
666
+ // `--json` promises one `{ error, rows, exitCode }` document on EVERY
667
+ // failure, and an action-level `process.exit(1)` before the command ran gave
668
+ // a script nothing to parse when gh was missing (the PR's review round,
669
+ // greptile). Probed through the seam so the allowlist names it.
670
+ const versionRun = runner(['--version']);
671
+ if (versionRun.exitCode !== 0) {
672
+ fail(`this command requires the GitHub CLI (gh), which did not answer (exit ${versionRun.exitCode}: ${bounded(versionRun.stdout)}) — nothing was resolved. Install: https://cli.github.com`, 1);
673
+ return { exitCode: 1, rows: [] };
674
+ }
675
+ const repoRun = runner(['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner']);
676
+ if (repoRun.exitCode !== 0) {
677
+ fail(`could not resolve the repository from gh (exit ${repoRun.exitCode}: ${bounded(repoRun.stdout)}) — nothing was resolved. Run \`gh auth status\` inside the repo.`, 1);
678
+ return { exitCode: 1, rows: [] };
679
+ }
680
+ const nwo = repoRun.stdout.trim();
681
+ const [owner, name] = nwo.split('/');
682
+ if (owner === undefined || name === undefined || owner === '' || name === '') {
683
+ fail(`gh answered "${bounded(nwo)}" for the repository, which is not owner/name`, 1);
684
+ return { exitCode: 1, rows: [] };
685
+ }
686
+ const threadsRead = readReviewThreads(runner, owner, name, pr);
687
+ if (!threadsRead.ok) {
688
+ fail(`review threads NOT read — nothing resolved. ${threadsRead.detail}`, 1);
689
+ return { exitCode: 1, rows: [] };
690
+ }
691
+ const commentsRead = readPrIssueComments(runner, owner, name, pr);
692
+ if (!commentsRead.ok) {
693
+ fail(`PR-level comments NOT read — evidence cannot be derived, so nothing resolved. ${commentsRead.detail}`, 1);
694
+ return { exitCode: 1, rows: [] };
695
+ }
696
+ const records = toThreadRecords(threadsRead.threads, identity);
697
+ const prComments = toPrCommentRecords(commentsRead.comments, identity);
698
+ const plan = buildResolveThreadsPlan(records, prComments, identity, selection.ids);
699
+ if (plan.unmatchedIds.length > 0) {
700
+ fail(`--ids named ${plan.unmatchedIds.length} id(s) matching no bot-rooted thread on PR #${pr}: ${plan.unmatchedIds.join(', ')} — nothing was resolved`, 2, plan.rows);
701
+ return { exitCode: 2, rows: plan.rows };
702
+ }
703
+ const mode = apply ? 'apply' : 'dry-run';
704
+ emit(`resolve-threads ${nwo}#${pr} (${mode})`);
705
+ emit(`${plan.rows.length} bot-rooted thread(s) of ${records.length} review thread(s); ${prComments.length} PR-level comment(s) read`);
706
+ if (plan.rows.length === 0) {
707
+ emit('nothing to resolve');
708
+ if (json)
709
+ out(`${JSON.stringify(jsonDocument(nwo, pr, apply, plan.rows, 0), null, 2)}\n`);
710
+ return { exitCode: 0, rows: plan.rows };
711
+ }
712
+ for (const row of plan.rows) {
713
+ emit(formatRow(row));
714
+ if (row.verdict === 'skip:no-evidence')
715
+ emit(` ${NO_EVIDENCE_HINT}`);
716
+ }
717
+ let failures = 0;
718
+ if (apply) {
719
+ const targets = plan.rows.filter((r) => r.verdict === 'resolve');
720
+ emit(`applying resolveReviewThread to ${targets.length} thread(s)`);
721
+ for (const row of targets) {
722
+ const run = runJson(runner, graphqlArgs(RESOLVE_THREAD_MUTATION, [['threadId', row.threadId]]), `resolveReviewThread ${row.threadId}`);
723
+ // A clean exit is not the same as a resolved thread: the mutation's own
724
+ // answer must say so. Anything else — a null payload, a thread that came
725
+ // back `isResolved: false`, a shape that does not parse — is a per-thread
726
+ // FAILURE, never a silent "applied".
727
+ const confirmed = run.ok ? confirmResolved(run.body) : { ok: false, detail: run.detail };
728
+ if (confirmed.ok) {
729
+ row.applied = true;
730
+ emit(`applied id=${row.rootCommentId ?? 'unknown'} ${row.threadId}`);
731
+ }
732
+ else {
733
+ // A per-thread failure never aborts the run — the remaining threads are
734
+ // still resolved and the count is named in the exit code.
735
+ row.applied = false;
736
+ row.errorText = confirmed.detail;
737
+ failures += 1;
738
+ err(`[Totem Error] failed to resolve ${row.threadId}: ${confirmed.detail}\n`);
739
+ emit(`failed id=${row.rootCommentId ?? 'unknown'} ${row.threadId}`);
740
+ }
741
+ }
742
+ }
743
+ const counts = countVerdicts(plan.rows);
744
+ emit(`plan: ${counts.resolve} resolve, ${counts['skip:already-resolved']} already-resolved, ${counts['skip:outdated']} outdated, ${counts['skip:no-evidence']} no-evidence, ${counts['skip:not-selected']} not-selected`);
745
+ if (apply) {
746
+ emit(`applied ${counts.resolve - failures} of ${counts.resolve}; ${failures} failed`);
747
+ }
748
+ else {
749
+ emit("dry-run — nothing was mutated; re-run with --apply on the operator's explicit go");
750
+ }
751
+ let exitCode = 0;
752
+ if (failures > 0) {
753
+ err(`[Totem Error] ${failures} thread(s) failed to resolve — re-run (the mutation is idempotent)\n`);
754
+ exitCode = 2;
755
+ }
756
+ if (apply && counts['skip:no-evidence'] > 0) {
757
+ err(`[Totem Error] ${counts['skip:no-evidence']} selected thread(s) had no disposition evidence and were NOT resolved — ${NO_EVIDENCE_HINT}\n`);
758
+ exitCode = 2;
759
+ }
760
+ if (json)
761
+ out(`${JSON.stringify(jsonDocument(nwo, pr, apply, plan.rows, failures), null, 2)}\n`);
762
+ return { exitCode, rows: plan.rows };
763
+ }
764
+ /** Verdict tally over the plan rows. */
765
+ function countVerdicts(rows) {
766
+ const counts = {
767
+ resolve: 0,
768
+ 'skip:already-resolved': 0,
769
+ 'skip:outdated': 0,
770
+ 'skip:no-evidence': 0,
771
+ 'skip:not-selected': 0,
772
+ };
773
+ for (const row of rows)
774
+ counts[row.verdict] += 1;
775
+ return counts;
776
+ }
777
+ /** The `--json` document: the SAME rows the human lines carry, plus the tally. */
778
+ function jsonDocument(repo, pr, apply, rows, failures) {
779
+ const counts = countVerdicts(rows);
780
+ return {
781
+ repo,
782
+ pr,
783
+ apply,
784
+ rows,
785
+ summary: {
786
+ botRooted: rows.length,
787
+ ...counts,
788
+ applied: apply ? counts.resolve - failures : 0,
789
+ failed: failures,
790
+ },
791
+ };
792
+ }
793
+ //# sourceMappingURL=resolve-threads.js.map