trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
//! `tm pr …` — deterministic pull-request open and merge-queue gates (#6653).
//!
//! Why: two procedures `version-control` runs on every delivery are written
//! down only as prose in `tm-workflow.md`, and both are mechanical. Opening a
//! PR means checking the nine-field body contract, the exact attribution
//! footer, the shipped `--assignee @me --label trusty-mpm --label
//! ws/<session>` defaults, and whether the diff owes a changelog fragment —
//! four judgments an agent re-derives each time and can silently skip.
//! Merging means walking a fixed stop-condition table over three `gh` reads,
//! where skipping one under time pressure is the recorded failure mode. A
//! command that returns an exit code removes both.
//!
//! What: [`run`] dispatches the three verbs to [`open`], [`merge`] and
//! [`queue_check`].
//! Every `gh` call in this module goes through [`GhRunner`], whose production
//! implementation is [`RealGhRunner`] over `trusty_common::gh::GhCommand` —
//! this workspace's single `gh` entry point (#5475), carrying the
//! project-scoped identity binding. A second `Command::new("gh")` here would
//! be a defect. The trait is also the test seam: every test in the sibling
//! `tests.rs` drives a scripted fake, so nothing in this module needs a
//! network or a live `gh`.
//!
//! Exit codes are the verb surface:
//!
//! | verb | 0 | 1 | 2 | 3 |
//! |---|---|---|---|---|
//! | `open` | PR created (or `--dry-run` printed the argv) | — | a pre-flight check failed; `gh` was never called | the PR EXISTS; some metadata could not be applied (#7869) |
//! | `merge` | squash-merged, or auto-merge armed | a hold signal, a failing check, a check `--auto` would not wait for (#8614), or a missing attribution footer refused it; `gh pr merge` was never called | usage or `gh` error | — |
//! | `queue-check` | every listed PR is mergeable | at least one is blocked | usage or `gh` error | — |
//!
//! Test: the sibling `tests.rs`; `cli_parses_pr_*` in `tests.rs`.

pub(crate) mod body;
// #8614: the failing/running checks gate `tm pr merge` applies.
pub(crate) mod check_gate;
pub(crate) mod cleanup;
pub(crate) mod merge;
pub(crate) mod metadata;
pub(crate) mod metadata_apply;
pub(crate) mod missing_label;
pub(crate) mod open;
// #9571: the `[EPIC_N PHASE_M]` tag on a phase PR's title.
pub(crate) mod phase_title;
pub(crate) mod queue_check;
// #8638: one latest-run rule for every rollup reader (queue-check, wait).
pub(crate) mod rollup;

#[cfg(test)]
#[path = "tests.rs"]
mod tests;

use anyhow::Context as _;

use crate::cli::PrCmd;

/// Exit code: the verb's contract holds.
pub(crate) const EXIT_OK: i32 = 0;
/// Exit code: `queue-check` found at least one blocked PR.
pub(crate) const EXIT_BLOCKED: i32 = 1;
/// Exit code: a pre-flight check failed, or the invocation was wrong.
pub(crate) const EXIT_CHECK_FAILED: i32 = 2;
/// Exit code: the PR EXISTS but some of its metadata could not be applied.
///
/// Why (#7869): 2 means "a check failed and `gh` was never called", which is
/// the opposite of what a partial apply is — `tm pr open` reported exit 2 for
/// PR #7918 after creating it, so the caller read it as "no PR" and went
/// hunting with `gh pr list --head`. A code of its own keeps "nothing
/// happened" and "the PR is open but incomplete" distinguishable.
/// Test: `pr_7646_a_failed_edit_names_the_field_and_retries_per_field`,
/// `pr_7869_a_create_retry_that_also_fails_exits_non_zero`.
pub(crate) const EXIT_PARTIAL: i32 = 3;

/// One completed `gh` invocation, as this module needs it.
///
/// Why: `gh pr view --json` on a missing PR and `gh pr create` on a rejected
/// push both exit non-zero, and the two want different handling, so the
/// runner hands back the triple rather than deciding.
/// What: exit success, stdout, stderr.
/// Test: `FakeGh` in `tests.rs` constructs these directly.
#[derive(Debug, Clone)]
pub(crate) struct GhRun {
    /// Whether `gh` exited zero.
    pub(crate) success: bool,
    /// Verbatim stdout.
    pub(crate) stdout: String,
    /// Verbatim stderr.
    pub(crate) stderr: String,
}

impl GhRun {
    /// stdout when the run succeeded, else an error naming the argv + stderr.
    pub(crate) fn stdout_ok(self, argv: &[String]) -> anyhow::Result<String> {
        if self.success {
            return Ok(self.stdout);
        }
        anyhow::bail!("`gh {}` failed: {}", argv.join(" "), self.stderr.trim())
    }
}

/// The seam every `gh` call in `tm pr` goes through.
///
/// Why: the verdicts these verbs compute are pure functions of `gh` output,
/// so hiding the spawn behind a trait makes the whole decision table testable
/// against canned JSON — no network, no live `gh`, no PR to create.
/// What: one method taking a fully-formed argv (without the `gh` itself).
/// Test: `FakeGh` in `tests.rs` scripts responses by argv prefix.
pub(crate) trait GhRunner {
    /// Run `gh <args>` to completion.
    fn run(&self, args: &[String]) -> anyhow::Result<GhRun>;
}

/// Production [`GhRunner`] over `trusty_common::gh::GhCommand`.
///
/// Why: `GhCommand` is this workspace's single `gh` entry point (#5475) — it
/// decides which binary, renders the argv for errors, classifies a missing
/// binary, and never goes through a shell. Spawning `gh` here directly would
/// be a second implementation of that capability, which the repo's "common
/// entry point" rule exists to prevent.
/// What: overlays the active project's GitHub identity (`tm`'s `gh_identity`,
/// #1265) onto the child, then runs blocking and maps the outcome to
/// [`GhRun`].
/// Test: exercised live (see the `tm pr queue-check` evidence on the PR); the
/// logic it feeds is covered against [`GhRunner`] fakes.
pub(crate) struct RealGhRunner {
    /// `GH_*` overrides resolved from the active project's config.
    env: Vec<(String, String)>,
    /// #6668: inherited identity vars removed before `env` is applied.
    unset: Vec<String>,
}

impl RealGhRunner {
    /// Build a runner bound to the active project's GitHub identity.
    pub(crate) fn new() -> anyhow::Result<Self> {
        let gh_env = crate::gh_identity::load_gh_env()?;
        Ok(Self {
            env: gh_env.vars().to_vec(),
            // #6668: an inherited GH_TOKEN outranks a resolved GH_CONFIG_DIR.
            unset: gh_env.unset_vars().to_vec(),
        })
    }
}

impl GhRunner for RealGhRunner {
    fn run(&self, args: &[String]) -> anyhow::Result<GhRun> {
        let mut cmd = trusty_common::gh::GhCommand::new(args);
        // #6668: removals first, then the overrides.
        for k in &self.unset {
            cmd = cmd.env_remove(k);
        }
        for (k, v) in &self.env {
            cmd = cmd.env(k, v);
        }
        let out = cmd
            .output_blocking()
            .with_context(|| format!("`gh {}` could not be run", args.join(" ")))?;
        Ok(GhRun {
            success: out.success,
            stdout: out.stdout,
            stderr: out.stderr,
        })
    }
}

/// Let the shared cleanup engine spawn `gh` through this module's runner
/// (#7275).
///
/// Why: `trusty_mpm::core::pr_cleanup` defines its own `gh` seam so the daemon
/// can drive it too, and `tm pr` already has a runner carrying the active
/// project's GitHub identity. Bridging the two is one impl; building a second
/// identity-bound runner for the CLI would be the duplication the
/// common-entry-point rule forbids.
/// Test: the engine's decisions are covered against fakes in
/// `core::pr_cleanup::tests`; this bridge is exercised live.
impl trusty_mpm::core::pr_cleanup::Gh for RealGhRunner {
    fn run(&self, args: &[String]) -> anyhow::Result<trusty_mpm::core::pr_cleanup::CmdOut> {
        let out = GhRunner::run(self, args)?;
        Ok(trusty_mpm::core::pr_cleanup::CmdOut {
            success: out.success,
            stdout: out.stdout,
            stderr: out.stderr,
        })
    }
}

/// Resolve `owner/repo` for the verbs that need the REST API.
///
/// Why: branch-protection lives at `repos/<owner>/<repo>/branches/<base>/
/// protection`, which needs the pair spelled out — unlike `gh pr view`, which
/// infers it from the cwd's remote. Asking `gh` for it keeps the answer
/// consistent with whichever remote every other call in the run resolved.
/// What: returns `--repo` verbatim when given, else `gh repo view --json
/// nameWithOwner --jq .nameWithOwner`.
/// Test: `repo_slug_prefers_the_flag`, `repo_slug_falls_back_to_gh`.
pub(crate) fn repo_slug<R: GhRunner>(gh: &R, explicit: Option<&str>) -> anyhow::Result<String> {
    if let Some(slug) = explicit.map(str::trim).filter(|s| !s.is_empty()) {
        return Ok(slug.to_string());
    }
    let argv = argv(&[
        "repo",
        "view",
        "--json",
        "nameWithOwner",
        "--jq",
        ".nameWithOwner",
    ]);
    let slug = gh.run(&argv)?.stdout_ok(&argv)?.trim().to_string();
    anyhow::ensure!(
        !slug.is_empty(),
        "`gh repo view` returned no `nameWithOwner`; pass --repo <owner/repo>"
    );
    Ok(slug)
}

/// Build an owned argv from string slices.
pub(crate) fn argv(parts: &[&str]) -> Vec<String> {
    parts.iter().map(|s| (*s).to_string()).collect()
}

/// Drive one `tm pr` invocation to its exit code.
///
/// Why: both verbs answer with an exit code a caller branches on, so — like
/// `tm wait` (#5843) — exiting here rather than returning a `Result` to
/// `main` is what makes that guarantee hold for the error paths too.
/// What: builds the production `gh` runner, runs the requested verb, and
/// exits. An error from either verb prints to stderr and exits
/// [`EXIT_CHECK_FAILED`].
/// Test: the verb bodies are unit-tested against [`GhRunner`] fakes; this is
/// the exit-code wrapper.
pub(crate) async fn run(cmd: PrCmd, daemon: &trusty_mpm::client::DaemonClient) -> ! {
    let code = match run_inner(cmd, daemon).await {
        Ok(code) => code,
        Err(e) => {
            eprintln!("tm pr: {e:#}");
            EXIT_CHECK_FAILED
        }
    };
    std::process::exit(code)
}

/// The notice every `tm pr` verb prints instead of running in a local-only
/// repository (#8934).
///
/// Why: open, merge, queue-check and cleanup all act on a GitHub PR, which a
/// repository with no `origin` cannot have — and its session's gh is disabled.
/// Skipping with one line, exit 0, keeps a delivery flow that calls `tm pr`
/// from failing on a step that has nothing to do.
/// What: `Some(notice)` when no `--repo` was given and `dir` is inside a
/// local-only repository whose gh is disabled; `None` otherwise. An explicit
/// `--repo` names a remote PR, so the verb runs and fails loudly under the pin
/// rather than printing a skip a caller reads as success (#8934 HIGH 3). The
/// allow-listed supervisor keeps gh, so its verbs run too.
/// Test: `pr_8934_a_local_only_repo_skips_every_verb`,
/// `pr_8934_an_explicit_repo_never_skips`.
pub(crate) fn local_only_skip(
    dir: &std::path::Path,
    repo: Option<&str>,
    mpm: &trusty_mpm::core::config::MpmConfig,
) -> Option<String> {
    use trusty_mpm::core::remote_mode::{LOCAL_ONLY_SKIP, RemoteMode, gh_disabled_for};
    if repo.is_some() {
        return None;
    }
    match trusty_mpm::core::remote_mode::remote_mode(dir).ok()? {
        RemoteMode::LocalOnly { root } if gh_disabled_for(&root, mpm) => Some(format!(
            "tm pr: {LOCAL_ONLY_SKIP}; merge locally with `git merge` instead"
        )),
        _ => None,
    }
}

/// The `--repo` a `tm pr` verb was given, if any.
fn repo_arg(cmd: &PrCmd) -> Option<&str> {
    match cmd {
        PrCmd::Open(args) => args.repo.as_deref(),
        PrCmd::Merge(args) => args.repo.as_deref(),
        PrCmd::QueueCheck(args) => args.repo.as_deref(),
        PrCmd::Cleanup(args) => args.repo.as_deref(),
    }
}

/// The fallible body of [`run`], split out so every error leaves one way.
async fn run_inner(cmd: PrCmd, daemon: &trusty_mpm::client::DaemonClient) -> anyhow::Result<i32> {
    // #8934: a local-only repo has no remote to push to or open a PR on.
    let mpm = trusty_mpm::core::config::MpmConfig::load_default();
    if let Some(notice) = local_only_skip(&std::env::current_dir()?, repo_arg(&cmd), &mpm) {
        println!("{notice}");
        return Ok(EXIT_OK);
    }
    let gh = RealGhRunner::new()?;
    match cmd {
        PrCmd::Open(args) => open::run(&gh, &args, &open::RealPreflight),
        // #6808: merge from the validated body, not GitHub's raw-message squash.
        PrCmd::Merge(args) => {
            // #8301: the cleanup scope is on disk BEFORE the merge; a failed
            // write aborts the merge. #7275: cleanup is the final step after
            // merge CONFIRMATION; under `--auto` the daemon's sweep acts later.
            let registry = trusty_mpm::core::pr_cleanup::CleanupRegistry::production();
            let slug = || repo_slug(&gh, args.repo.as_deref());
            match cleanup::merge_with_recorded_scope(&gh, &args, slug, &registry)? {
                cleanup::PostMerge::NotMerged(code) => Ok(code),
                cleanup::PostMerge::Deferred => {
                    println!(
                        "post-merge cleanup skipped — no worktree or local branch was touched; \
                         run `tm pr cleanup {}` when ready",
                        args.pr
                    );
                    Ok(EXIT_OK)
                }
                cleanup::PostMerge::AwaitSweep => Ok(EXIT_OK),
                cleanup::PostMerge::Cleanup { repo } => {
                    cleanup::after_merge(&args, repo, daemon).await
                }
            }
        }
        PrCmd::QueueCheck(args) => queue_check::run(&gh, &args),
        // #7275: the executor every cleanup trigger shares.
        PrCmd::Cleanup(args) => cleanup::run(&args, daemon).await,
    }
}