trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
//! `tm pr queue-check` — the merge-queue stop-condition table as one command
//! (#6653).
//!
//! Why: `tm-workflow.md`'s "Merge-Queue Ownership — the Procedure" section is
//! a fixed decision table driven by `gh` reads, and the recorded failure mode
//! is a session under time pressure skipping one of them — a critic BLOCK
//! arrived four minutes after a merge. The stop conditions are booleans over
//! JSON; nothing here is a judgment.
//!
//! What: [`run`] evaluates, per PR, the stop conditions IN THE DOCUMENTED
//! ORDER and reports the FIRST that fires:
//!
//!   1. `isDraft: true`
//!   2. a hold label (`do-not-merge*`, `hold`)
//!   3. `reviewDecision: CHANGES_REQUESTED`
//!   4. an unresolved `code-critic` BLOCK in the PR comments
//!   5. `mergeable: CONFLICTING` or `mergeStateStatus: DIRTY` — a real
//!      conflict GitHub already detected; `UNKNOWN` on either field, or
//!      either field missing from the payload, is pending (GitHub has not
//!      finished computing it, or the response shape changed) and NEVER
//!      reads as mergeable (#8670)
//!   6. a required status context missing, pending, or not `SUCCESS` on the
//!      head SHA — pending while any run has no result, else judged on its
//!      latest run (#8638)
//!
//! Order matters: the required contexts are the LAST gate, not the first, so a
//! draft PR reports "draft" rather than "checks pending". Required contexts
//! are read LIVE from branch protection (root `CLAUDE.md`, "What CI actually
//! gates" — a hand-copied list already cost PR #5836 a merge); the same read
//! is available standalone as `scripts/required-checks.sh`.
//!
//! Test: the sibling `tests.rs` — `queue_*`.

use serde::{Deserialize, Serialize};

// #8638: one rollup entry shape and one latest-run rule, shared with `tm wait`.
use super::rollup::{RollupEntry, deciding_runs};
use super::{EXIT_BLOCKED, EXIT_OK, GhRunner, argv, repo_slug};
use crate::cli::PrQueueCheckArgs;

/// Label names that hold a PR out of the queue, lowercase.
const HOLD_LABELS: [&str; 2] = ["hold", "do-not-merge"];

/// One PR's verdict.
///
/// Why: `--json` and the human line must never disagree, so both render the
/// same value.
/// What: the PR number, whether it is mergeable, and the first stop reason.
/// Test: `queue_verdict_json_matches_the_line`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct Verdict {
    /// PR number.
    pub(crate) number: u64,
    /// Whether every stop condition passed.
    pub(crate) mergeable: bool,
    /// The FIRST stop condition that fired, if any.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) reason: Option<String>,
}

impl Verdict {
    /// The one-line human rendering.
    pub(crate) fn line(&self) -> String {
        match &self.reason {
            None => format!("#{} MERGEABLE", self.number),
            Some(r) => format!("#{} BLOCKED: {r}", self.number),
        }
    }
}

/// A PR as `gh pr list --json …` reports it.
#[derive(Debug, Deserialize)]
struct PrListRow {
    number: u64,
}

/// A GitHub label, as every `--json labels` payload shapes it.
#[derive(Debug, Deserialize)]
struct Label {
    name: String,
}

/// One PR comment.
#[derive(Debug, Deserialize)]
struct Comment {
    #[serde(default)]
    body: String,
}

/// The single-PR view the stop conditions are evaluated against.
#[derive(Debug, Deserialize)]
struct PrView {
    #[serde(default)]
    #[serde(rename = "isDraft")]
    is_draft: bool,
    #[serde(default)]
    labels: Vec<Label>,
    #[serde(default)]
    #[serde(rename = "reviewDecision")]
    review_decision: Option<String>,
    /// `MergeableState`: `MERGEABLE`, `CONFLICTING`, `UNKNOWN`, or absent.
    ///
    /// Why (#8670): never requesting this field is how queue-check reported
    /// MERGEABLE for a PR GitHub itself already marked CONFLICTING.
    /// `CONFLICTING` lives HERE, never in `mergeStateStatus` — the two enums
    /// are disjoint, mirroring `tm pr merge`'s `conflict_field` (#6808).
    #[serde(default)]
    mergeable: Option<String>,
    /// `MergeStateStatus`: `DIRTY`, `UNKNOWN`, `BLOCKED`, `BEHIND`,
    /// `UNSTABLE`, `HAS_HOOKS`, `CLEAN`, or absent. `DIRTY` is the conflict.
    #[serde(default)]
    #[serde(rename = "mergeStateStatus")]
    merge_state_status: Option<String>,
    #[serde(default)]
    #[serde(rename = "statusCheckRollup")]
    rollup: Vec<RollupEntry>,
    #[serde(default)]
    comments: Vec<Comment>,
}

/// Evaluate the stop-condition table against one PR view.
///
/// Why: keeping this a pure function of (view, required contexts) is what
/// makes the documented ORDER testable — a PR that is simultaneously draft and
/// missing a check must report "draft", and only a test can pin that.
/// What: returns the first stop reason, or `None` when the PR is mergeable.
/// Test: `queue_stop_order_prefers_draft`, `queue_stop_order_prefers_hold`,
/// `queue_stop_order_prefers_changes_requested`,
/// `queue_stop_order_prefers_critic_block`,
/// `queue_required_context_missing`, `queue_required_context_not_success`,
/// `queue_duplicate_cancelled_then_success_is_mergeable`,
/// `queue_duplicate_success_then_failure_is_blocked`,
/// `queue_duplicate_success_then_running_is_pending`,
/// `queue_duplicate_success_then_queued_is_pending`,
/// `queue_check_and_status_same_name_both_required`,
/// `queue_mergeable_conflicting_is_blocked`, `queue_merge_state_dirty_is_blocked`,
/// `queue_mergeable_unknown_is_pending`, `queue_merge_state_unknown_is_pending`,
/// `queue_mergeable_field_missing_is_pending`,
/// `queue_merge_state_field_missing_is_pending`,
/// `queue_mergeable_clean_happy_path_is_admitted`.
fn stop_reason(view: &PrView, required: &[String]) -> Option<String> {
    if view.is_draft {
        return Some("draft".to_string());
    }
    if let Some(l) = view.labels.iter().find(|l| is_hold_label(&l.name)) {
        return Some(format!("hold label `{}`", l.name));
    }
    if view
        .review_decision
        .as_deref()
        .is_some_and(|d| d.eq_ignore_ascii_case("CHANGES_REQUESTED"))
    {
        return Some("review decision CHANGES_REQUESTED".to_string());
    }
    if latest_critic_verdict(&view.comments) == Some(CriticVerdict::Block) {
        return Some("unresolved code-critic BLOCK in the PR comments".to_string());
    }
    if let Some(reason) = mergeability_reason(view) {
        return Some(reason);
    }
    for context in required {
        // #8638: the latest run decides, never the first listed, and a
        // CheckRun and a StatusContext sharing the name must BOTH pass.
        let runs = deciding_runs(&view.rollup, context);
        if runs.is_empty() {
            return Some(format!(
                "required context `{context}` is missing on the head SHA"
            ));
        }
        if runs.iter().any(|e| e.settled() && !e.is_success()) {
            return Some(format!("required context `{context}` is not SUCCESS"));
        }
        if let Some(e) = runs.iter().find(|e| e.is_unfinished()) {
            return Some(format!(
                "required context `{context}` is pending: a run has no result yet {}",
                e.run_summary()
            ));
        }
    }
    None
}

/// Is `name` a hold label?
fn is_hold_label(name: &str) -> bool {
    let lower = name.trim().to_ascii_lowercase();
    HOLD_LABELS.iter().any(|h| {
        lower == *h || lower.starts_with(&format!("{h}/")) || lower.starts_with(&format!("{h}:"))
    })
}

/// Whether GitHub's own `mergeable`/`mergeStateStatus` fields stop this PR.
///
/// Why (#8670): queue-check never requested either field, so a PR GitHub
/// already marked CONFLICTING or DIRTY still reported MERGEABLE — a caller
/// trusting queue-check alone could attempt, and waste, a doomed merge. `tm
/// pr merge`'s own `conflict_field` (#6808) is the model for reading the
/// conflict: `CONFLICTING` lives in `mergeable`, `DIRTY` in
/// `mergeStateStatus`, and the two enums are disjoint.
/// What: a real conflict returns a reason naming the field. `UNKNOWN` on
/// either field, or either field absent from the payload (an unparseable
/// shape or a truncated response), also returns a reason — GitHub has not
/// finished computing mergeability, or the field never arrived — so both
/// fail CLOSED rather than defaulting to mergeable. Only `mergeable:
/// MERGEABLE` together with `mergeStateStatus` outside `{DIRTY, UNKNOWN}`
/// (e.g. `CLEAN`) returns `None`.
/// Test: `queue_mergeable_conflicting_is_blocked`,
/// `queue_merge_state_dirty_is_blocked`, `queue_mergeable_unknown_is_pending`,
/// `queue_merge_state_unknown_is_pending`,
/// `queue_mergeable_field_missing_is_pending`,
/// `queue_merge_state_field_missing_is_pending`,
/// `queue_mergeable_clean_happy_path_is_admitted`.
fn mergeability_reason(view: &PrView) -> Option<String> {
    let mergeable = view.mergeable.as_deref();
    let merge_state = view.merge_state_status.as_deref();

    if mergeable.is_some_and(|m| m.eq_ignore_ascii_case("CONFLICTING")) {
        return Some("mergeable CONFLICTING — resolve with `gh pr update-branch`".to_string());
    }
    if merge_state.is_some_and(|s| s.eq_ignore_ascii_case("DIRTY")) {
        return Some("mergeStateStatus DIRTY — resolve with `gh pr update-branch`".to_string());
    }

    match mergeable {
        None => {
            return Some("mergeable field is missing on the head SHA".to_string());
        }
        Some(m) if m.eq_ignore_ascii_case("UNKNOWN") => {
            return Some("mergeable is UNKNOWN — GitHub is still computing it; retry".to_string());
        }
        _ => {}
    }
    match merge_state {
        None => {
            return Some("mergeStateStatus field is missing on the head SHA".to_string());
        }
        Some(s) if s.eq_ignore_ascii_case("UNKNOWN") => {
            return Some(
                "mergeStateStatus is UNKNOWN — GitHub is still computing it; retry".to_string(),
            );
        }
        _ => {}
    }
    None
}

/// A `code-critic` verdict.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CriticVerdict {
    /// The critic blocked the change.
    Block,
    /// The critic approved, or warned without blocking.
    Cleared,
}

/// The most recent `code-critic` verdict across a PR's comments.
///
/// Why: a BLOCK is unresolved only when no LATER comment cleared it, so the
/// answer is the last verdict in comment order, not "does a BLOCK exist".
/// What: considers only comments that name `code-critic` (case-insensitive),
/// and reads the last standalone `BLOCK` / `APPROVE` / `WARN` token in each.
/// Comments are returned by `gh` oldest-first, so the final match wins.
/// Test: `queue_critic_block_then_approve_is_clear`,
/// `queue_critic_ignores_unrelated_comments`.
fn latest_critic_verdict(comments: &[Comment]) -> Option<CriticVerdict> {
    let mut latest = None;
    for c in comments {
        if !c.body.to_ascii_lowercase().contains("code-critic") {
            continue;
        }
        for token in c.body.split(|ch: char| !ch.is_ascii_alphabetic()) {
            match token {
                "BLOCK" => latest = Some(CriticVerdict::Block),
                "APPROVE" | "WARN" => latest = Some(CriticVerdict::Cleared),
                _ => {}
            }
        }
    }
    latest
}

/// Read the live required status-check contexts for `base`.
///
/// Why: root `CLAUDE.md` requires this list be read live, never hand-copied —
/// a stale copy cost PR #5836 a merge. `scripts/required-checks.sh` is the
/// same read, standalone, for callers outside `tm`.
/// What: `gh api repos/<slug>/branches/<base>/protection --jq
/// '.required_status_checks.contexts[]'`, one context per line. An empty list
/// is an error: it means either no protection or a changed payload shape, and
/// treating "no required contexts" as "everything passed" would silently
/// remove the last gate.
/// Test: `queue_required_contexts_parse`, `queue_empty_required_list_errors`.
pub(crate) fn required_contexts<R: GhRunner>(
    gh: &R,
    slug: &str,
    base: &str,
) -> anyhow::Result<Vec<String>> {
    let path = format!("repos/{slug}/branches/{base}/protection");
    let a = argv(&["api", &path, "--jq", ".required_status_checks.contexts[]"]);
    let stdout = gh.run(&a)?.stdout_ok(&a)?;
    let contexts: Vec<String> = stdout
        .lines()
        .map(|l| l.trim().trim_matches('"').to_string())
        .filter(|l| !l.is_empty())
        .collect();
    anyhow::ensure!(
        !contexts.is_empty(),
        "branch protection for `{base}` lists no required status checks; \
         refusing to report every PR mergeable off an empty gate list"
    );
    Ok(contexts)
}

/// Run `tm pr queue-check`.
///
/// Why: this replaces four hand-typed `gh` reads and the cross-check between
/// them with one exit code, so a batch merge cannot proceed on a queue nobody
/// fully read.
/// What: resolves `owner/repo`, reads the live required contexts, lists the
/// open PRs on `--base` (or takes the single `<pr>` argument), views each, and
/// prints one verdict line — or the whole set as a JSON array under `--json`.
/// Exits 0 when every listed PR is mergeable and 1 otherwise; an empty queue
/// is 0.
/// Test: `queue_exits_1_when_any_pr_blocked`, `queue_reports_mergeable`,
/// `queue_verdict_json_matches_the_line`, `queue_empty_queue_is_ok`.
pub(crate) fn run<R: GhRunner>(gh: &R, args: &PrQueueCheckArgs) -> anyhow::Result<i32> {
    let slug = repo_slug(gh, args.repo.as_deref())?;
    let required = required_contexts(gh, &slug, &args.base)?;

    let numbers = match args.pr {
        Some(n) => vec![n],
        None => list_open_prs(gh, &slug, &args.base)?,
    };

    let verdicts = verdicts(gh, &slug, &required, &numbers)?;

    if args.json {
        println!("{}", serde_json::to_string_pretty(&verdicts)?);
    } else {
        for v in &verdicts {
            println!("{}", v.line());
        }
    }

    Ok(if verdicts.iter().all(|v| v.mergeable) {
        EXIT_OK
    } else {
        EXIT_BLOCKED
    })
}

/// Evaluate every PR in `numbers` against the stop-condition table.
///
/// Why: separating the per-PR evaluation from the printing lets the tests
/// assert the verdict values themselves rather than scraping stdout.
/// What: one `gh pr view` per PR, then [`stop_reason`].
/// Test: every `queue_stop_order_*` and `queue_required_context_*` case.
pub(crate) fn verdicts<R: GhRunner>(
    gh: &R,
    slug: &str,
    required: &[String],
    numbers: &[u64],
) -> anyhow::Result<Vec<Verdict>> {
    let mut out = Vec::with_capacity(numbers.len());
    for n in numbers {
        let view = pr_view(gh, slug, *n)?;
        let reason = stop_reason(&view, required);
        out.push(Verdict {
            number: *n,
            mergeable: reason.is_none(),
            reason,
        });
    }
    Ok(out)
}

/// The open PR numbers on `base`, oldest first.
///
/// Why: the merge-queue procedure's own ownership read is
/// `gh pr list --json number,author,assignees,isDraft,labels,headRefName`;
/// the extra fields are requested so the one call answers both the ownership
/// question and this one without a second round trip.
/// What: parses the `number` field out of that payload.
/// Test: `queue_lists_open_prs`.
fn list_open_prs<R: GhRunner>(gh: &R, slug: &str, base: &str) -> anyhow::Result<Vec<u64>> {
    let a = argv(&[
        "pr",
        "list",
        "--repo",
        slug,
        "--base",
        base,
        "--state",
        "open",
        "--limit",
        "100",
        "--json",
        "number,author,assignees,isDraft,labels,headRefName",
    ]);
    let stdout = gh.run(&a)?.stdout_ok(&a)?;
    let rows: Vec<PrListRow> = serde_json::from_str(&stdout)
        .map_err(|e| anyhow::anyhow!("cannot parse `gh pr list` JSON: {e}"))?;
    let mut numbers: Vec<u64> = rows.into_iter().map(|r| r.number).collect();
    numbers.sort_unstable();
    Ok(numbers)
}

/// One PR's stop-condition inputs, in a single `gh pr view` call.
///
/// #8670: `mergeable,mergeStateStatus` join the requested fields so
/// [`mergeability_reason`] has something to read; a prior version of this
/// call omitted them entirely.
fn pr_view<R: GhRunner>(gh: &R, slug: &str, pr: u64) -> anyhow::Result<PrView> {
    let n = pr.to_string();
    let a = argv(&[
        "pr",
        "view",
        &n,
        "--repo",
        slug,
        "--json",
        "isDraft,labels,reviewDecision,mergeable,mergeStateStatus,statusCheckRollup,comments",
    ]);
    let stdout = gh.run(&a)?.stdout_ok(&a)?;
    serde_json::from_str(&stdout)
        .map_err(|e| anyhow::anyhow!("cannot parse `gh pr view {pr}` JSON: {e}"))
}