use std::io::Write as _;
use std::path::Path;
use anyhow::Context as _;
use serde::Deserialize;
use super::body;
use super::{EXIT_BLOCKED, EXIT_OK, GhRunner, argv};
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,
}
#[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"
.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 was not deleted — {}. Run `tm pr cleanup {pr}` to \
remove the worktree holding `{head}` and then the branch.",
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}");
}
}
match decide(&view, &report.merge_failures()) {
Decision::Refuse(reason) => {
eprintln!(
"tm pr merge: refusing to merge #{} — {reason}; `gh pr merge` was not called",
args.pr
);
Ok(EXIT_BLOCKED)
}
Decision::Merge => {
let mut tmp = tempfile::NamedTempFile::new()
.context("cannot create the temp file holding the squash commit body")?;
tmp.write_all(view.body.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)
}
}
}