pub(crate) mod body;
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;
pub(crate) mod phase_title;
pub(crate) mod queue_check;
pub(crate) mod rollup;
#[cfg(test)]
#[path = "tests.rs"]
mod tests;
use anyhow::Context as _;
use crate::cli::PrCmd;
pub(crate) const EXIT_OK: i32 = 0;
pub(crate) const EXIT_BLOCKED: i32 = 1;
pub(crate) const EXIT_CHECK_FAILED: i32 = 2;
pub(crate) const EXIT_PARTIAL: i32 = 3;
#[derive(Debug, Clone)]
pub(crate) struct GhRun {
pub(crate) success: bool,
pub(crate) stdout: String,
pub(crate) stderr: String,
}
impl GhRun {
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())
}
}
pub(crate) trait GhRunner {
fn run(&self, args: &[String]) -> anyhow::Result<GhRun>;
}
pub(crate) struct RealGhRunner {
env: Vec<(String, String)>,
unset: Vec<String>,
}
impl RealGhRunner {
pub(crate) fn new() -> anyhow::Result<Self> {
let gh_env = crate::gh_identity::load_gh_env()?;
Ok(Self {
env: gh_env.vars().to_vec(),
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);
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,
})
}
}
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,
})
}
}
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)
}
pub(crate) fn argv(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| (*s).to_string()).collect()
}
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)
}
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,
}
}
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(),
}
}
async fn run_inner(cmd: PrCmd, daemon: &trusty_mpm::client::DaemonClient) -> anyhow::Result<i32> {
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),
PrCmd::Merge(args) => {
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, ®istry)? {
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),
PrCmd::Cleanup(args) => cleanup::run(&args, daemon).await,
}
}