use std::io::Write as _;
use std::path::Path;
use anyhow::Context as _;
use serde::Deserialize;
use super::body;
use super::check_gate::{self, CheckGate};
use super::rollup::RollupEntry;
use super::{EXIT_BLOCKED, EXIT_OK, GhRunner, argv, repo_slug};
use crate::cli::PrMergeArgs;
const HOLD_LABEL: &str = "do-not-merge";
#[derive(Debug, Deserialize)]
pub(crate) struct Label {
pub(crate) name: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct MergeView {
#[serde(default)]
pub(crate) number: u64,
#[serde(default)]
pub(crate) title: String,
#[serde(default)]
pub(crate) body: String,
#[serde(default, rename = "isDraft")]
pub(crate) is_draft: bool,
#[serde(default)]
pub(crate) labels: Vec<Label>,
#[serde(default, rename = "reviewDecision")]
pub(crate) review_decision: Option<String>,
#[serde(default, rename = "mergeStateStatus")]
pub(crate) merge_state_status: Option<String>,
#[serde(default)]
pub(crate) mergeable: Option<String>,
#[serde(default, rename = "headRefName")]
pub(crate) head_ref_name: String,
#[serde(default)]
pub(crate) state: String,
#[serde(default, rename = "statusCheckRollup")]
pub(crate) rollup: Vec<RollupEntry>,
#[serde(default, rename = "baseRefName")]
pub(crate) base_ref_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Decision {
Merge,
Refuse(String),
}
pub(crate) fn decide(view: &MergeView, body_failures: &[String]) -> Decision {
if !view.state.is_empty() && !view.state.eq_ignore_ascii_case("OPEN") {
return Decision::Refuse(format!(
"the PR is already {} — nothing to merge",
view.state.trim().to_uppercase()
));
}
if !body_failures.is_empty() {
return Decision::Refuse(format!(
"PR body fails the same check `tm pr open` runs: {}",
body_failures.join("; ")
));
}
if view.is_draft {
return Decision::Refuse("the PR is a draft".to_string());
}
if let Some(l) = view
.labels
.iter()
.find(|l| l.name.trim().eq_ignore_ascii_case(HOLD_LABEL))
{
return Decision::Refuse(format!("the PR carries the `{}` label", l.name.trim()));
}
if view
.review_decision
.as_deref()
.is_some_and(|d| d.eq_ignore_ascii_case("CHANGES_REQUESTED"))
{
return Decision::Refuse("the review decision is CHANGES_REQUESTED".to_string());
}
if let Some(field) = conflict_field(view) {
return Decision::Refuse(format!(
"the PR has merge conflicts ({field}) — resolve them with `gh pr update-branch {}`",
view.number
));
}
Decision::Merge
}
fn conflict_field(view: &MergeView) -> Option<&'static str> {
if view
.mergeable
.as_deref()
.is_some_and(|m| m.eq_ignore_ascii_case("CONFLICTING"))
{
return Some("mergeable CONFLICTING");
}
if view
.merge_state_status
.as_deref()
.is_some_and(|s| s.eq_ignore_ascii_case("DIRTY"))
{
return Some("mergeStateStatus DIRTY");
}
None
}
pub(crate) fn plan(args: &PrMergeArgs, view: &MergeView, body_file: &Path) -> Vec<String> {
let n = args.pr.to_string();
let mut a = argv(&["pr", "merge", &n]);
push_repo(&mut a, args);
a.push("--squash".to_string());
if !args.no_delete_branch {
a.push("--delete-branch".to_string());
}
if args.auto {
a.push("--auto".to_string());
}
a.push("--subject".to_string());
a.push(format!("{} (#{})", view.title.trim(), args.pr));
a.push("--body-file".to_string());
a.push(body_file.display().to_string());
a
}
fn push_repo(a: &mut Vec<String>, args: &PrMergeArgs) {
if let Some(repo) = args
.repo
.as_deref()
.map(str::trim)
.filter(|r| !r.is_empty())
{
a.push("--repo".to_string());
a.push(repo.to_string());
}
}
fn pr_view<R: GhRunner>(gh: &R, args: &PrMergeArgs) -> anyhow::Result<MergeView> {
let n = args.pr.to_string();
let mut a = argv(&["pr", "view", &n]);
push_repo(&mut a, args);
a.push("--json".to_string());
a.push(
"number,title,body,isDraft,labels,reviewDecision,mergeStateStatus,mergeable,headRefName,\
state,statusCheckRollup,baseRefName"
.to_string(),
);
let stdout = gh.run(&a)?.stdout_ok(&a)?;
serde_json::from_str(&stdout)
.map_err(|e| anyhow::anyhow!("cannot parse `gh pr view {}` JSON: {e}", args.pr))
}
#[derive(Debug, Deserialize)]
struct MergedState {
#[serde(default)]
state: String,
#[serde(default, rename = "mergeCommit")]
merge_commit: Option<MergeCommit>,
}
#[derive(Debug, Deserialize)]
struct MergeCommit {
#[serde(default)]
oid: String,
}
impl MergedState {
fn commit_suffix(&self) -> String {
match self.merge_commit.as_ref().map(|c| c.oid.trim()) {
Some(oid) if !oid.is_empty() => format!(" as {oid}"),
_ => String::new(),
}
}
}
fn is_branch_delete_failure(stderr: &str) -> bool {
let s = stderr.to_ascii_lowercase();
s.contains("cannot delete branch") || s.contains("failed to delete local branch")
}
pub(crate) fn cleanup_deferred_report(
pr: u64,
head: &str,
commit_suffix: &str,
stderr: &str,
) -> String {
format!(
"MERGE LANDED: #{pr} ({head}) was squash-merged{commit_suffix}.\n\
CLEANUP DEFERRED: the local branch, and possibly the remote branch \
too (#8391), were not deleted — {}. Run `tm pr cleanup {pr}` to \
remove the worktree holding `{head}` and then both branches.",
stderr.trim()
)
}
fn merged_after_failure<R: GhRunner>(gh: &R, args: &PrMergeArgs) -> Option<MergedState> {
let n = args.pr.to_string();
let mut a = argv(&["pr", "view", &n]);
push_repo(&mut a, args);
a.push("--json".to_string());
a.push("state,mergeCommit".to_string());
let run = gh.run(&a).ok()?;
if !run.success {
return None;
}
let state: MergedState = serde_json::from_str(&run.stdout).ok()?;
(state.state.eq_ignore_ascii_case("MERGED")).then_some(state)
}
pub(crate) fn run<R: GhRunner>(gh: &R, args: &PrMergeArgs) -> anyhow::Result<i32> {
let view = pr_view(gh, args)?;
let report = body::validate(&view.body);
let gaps = report.contract_gaps();
if !gaps.is_empty() {
eprintln!(
"tm pr merge: #{}: the body does not fill every field of the nine-field contract — \
reported, not a refusal (#7868):",
args.pr
);
for gap in &gaps {
eprintln!(" - {gap}");
}
}
if let Decision::Refuse(reason) = decide(&view, &report.merge_failures()) {
return Ok(refuse(args.pr, &reason));
}
let required =
if check_gate::needs_required(&view.rollup, &args.allow_failing, args.allow_no_checks) {
let base = view.base_ref_name.trim();
anyhow::ensure!(
!base.is_empty(),
"`gh pr view {}` reported no baseRefName; cannot read its required checks",
args.pr
);
let slug = repo_slug(gh, args.repo.as_deref())?;
check_gate::read_required(gh, &slug, base)?
} else {
None
};
let gate = CheckGate {
auto: args.auto,
allow_failing: &args.allow_failing,
allow_no_checks: args.allow_no_checks,
required: required.as_deref(),
};
if let Some(reason) = check_gate::refusal(&view.rollup, &gate, args.pr) {
return Ok(refuse(args.pr, &reason));
}
let waived = check_gate::waived(&view.rollup, &gate);
if !waived.is_empty() {
eprintln!(
"tm pr merge: #{}: merging over waived check(s) (--allow-failing): {}",
args.pr,
waived.join(", ")
);
}
let mut tmp = tempfile::NamedTempFile::new()
.context("cannot create the temp file holding the squash commit body")?;
tmp.write_all(check_gate::commit_body(&view.body, &waived).as_bytes())
.and_then(|()| tmp.flush())
.context("cannot write the squash commit body to its temp file")?;
let a = plan(args, &view, tmp.path());
let out = gh.run(&a)?;
if !out.success {
let cleanup_shaped = !args.no_delete_branch && is_branch_delete_failure(&out.stderr);
let landed = cleanup_shaped
.then(|| merged_after_failure(gh, args))
.flatten();
let Some(landed) = landed else {
anyhow::bail!("`gh pr merge {}` failed: {}", args.pr, out.stderr.trim());
};
let report = cleanup_deferred_report(
args.pr,
&view.head_ref_name,
&landed.commit_suffix(),
&out.stderr,
);
eprintln!("tm pr merge: warning — {report}");
println!("squash-merged #{} ({})", args.pr, view.head_ref_name);
return Ok(EXIT_OK);
}
if args.auto {
println!(
"auto-merge armed on #{} ({}) — GitHub applies the supplied subject and body when it fires",
args.pr, view.head_ref_name
);
} else {
println!("squash-merged #{} ({})", args.pr, view.head_ref_name);
}
Ok(EXIT_OK)
}
fn refuse(pr: u64, reason: &str) -> i32 {
eprintln!("tm pr merge: refusing to merge #{pr} — {reason}; `gh pr merge` was not called");
EXIT_BLOCKED
}