@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.
- package/dist/commands/config-drift.test.js +4 -1
- package/dist/commands/config-drift.test.js.map +1 -1
- package/dist/commands/gate-install.d.ts +17 -0
- package/dist/commands/gate-install.d.ts.map +1 -1
- package/dist/commands/gate-install.js +23 -0
- package/dist/commands/gate-install.js.map +1 -1
- package/dist/commands/gate-install.test.js +467 -0
- package/dist/commands/gate-install.test.js.map +1 -1
- package/dist/commands/gate.d.ts +9 -0
- package/dist/commands/gate.d.ts.map +1 -1
- package/dist/commands/gate.js +24 -2
- package/dist/commands/gate.js.map +1 -1
- package/dist/commands/gate.test.js +42 -1
- package/dist/commands/gate.test.js.map +1 -1
- package/dist/commands/init-templates.d.ts +5 -5
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +758 -79
- package/dist/commands/init-templates.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +8 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/init.test.js +122 -0
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/mail-cli-wiring.test.js +58 -2
- package/dist/commands/mail-cli-wiring.test.js.map +1 -1
- package/dist/commands/mail-derive-seat.test.d.ts +28 -0
- package/dist/commands/mail-derive-seat.test.d.ts.map +1 -0
- package/dist/commands/mail-derive-seat.test.js +557 -0
- package/dist/commands/mail-derive-seat.test.js.map +1 -0
- package/dist/commands/mail.d.ts +112 -0
- package/dist/commands/mail.d.ts.map +1 -1
- package/dist/commands/mail.js +189 -0
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/resolve-threads-cli-wiring.test.d.ts +19 -0
- package/dist/commands/resolve-threads-cli-wiring.test.d.ts.map +1 -0
- package/dist/commands/resolve-threads-cli-wiring.test.js +103 -0
- package/dist/commands/resolve-threads-cli-wiring.test.js.map +1 -0
- package/dist/commands/resolve-threads.d.ts +472 -0
- package/dist/commands/resolve-threads.d.ts.map +1 -0
- package/dist/commands/resolve-threads.js +793 -0
- package/dist/commands/resolve-threads.js.map +1 -0
- package/dist/commands/resolve-threads.test.d.ts +2 -0
- package/dist/commands/resolve-threads.test.d.ts.map +1 -0
- package/dist/commands/resolve-threads.test.js +1123 -0
- package/dist/commands/resolve-threads.test.js.map +1 -0
- package/dist/commands/sync-labels-forms.test.d.ts +7 -4
- package/dist/commands/sync-labels-forms.test.d.ts.map +1 -1
- package/dist/commands/sync-labels-forms.test.js +266 -18
- package/dist/commands/sync-labels-forms.test.js.map +1 -1
- package/dist/index.js +74 -5
- package/dist/index.js.map +1 -1
- package/dist/parsers/bot-identity-parity.test.d.ts +2 -0
- package/dist/parsers/bot-identity-parity.test.d.ts.map +1 -0
- package/dist/parsers/bot-identity-parity.test.js +229 -0
- package/dist/parsers/bot-identity-parity.test.js.map +1 -0
- package/dist/parsers/bot-review-parser.d.ts +8 -5
- package/dist/parsers/bot-review-parser.d.ts.map +1 -1
- package/dist/parsers/bot-review-parser.js +12 -34
- package/dist/parsers/bot-review-parser.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,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
|