use std::path::Path;
use anyhow::Context as _;
use trusty_mpm::core::component_labels::CrateOwnership;
use trusty_mpm::core::policy_labels;
use trusty_mpm::core::trusty_tools_config::ResolvedTicketing;
use super::body::{self, IssueLink};
use super::metadata_apply;
use super::{EXIT_CHECK_FAILED, EXIT_OK, EXIT_PARTIAL, GhRunner, argv};
use crate::cli::PrOpenArgs;
pub(crate) trait Preflight {
fn session_name(&self) -> Option<String>;
fn changelog_gate(&self, base: &str, head: &str) -> anyhow::Result<ChangelogVerdict>;
fn base_freshness(
&self,
base: &str,
mode: trusty_mpm::core::base_ref_freshness::RefreshMode,
) -> trusty_mpm::core::base_ref_freshness::BaseFreshness;
fn changed_paths(&self, base: &str, head: &str) -> anyhow::Result<Vec<String>>;
fn ownership(&self) -> CrateOwnership;
fn cleanup_registry(&self) -> trusty_mpm::core::pr_cleanup::CleanupRegistry;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ChangelogVerdict {
Pass,
Skipped,
Fail(String),
HeadElsewhere,
}
pub(crate) struct RealPreflight;
impl Preflight for RealPreflight {
fn session_name(&self) -> Option<String> {
std::env::var("TM_SESSION_NAME")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(crate::commands::statusline::branch::tmux_session_name)
}
fn cleanup_registry(&self) -> trusty_mpm::core::pr_cleanup::CleanupRegistry {
trusty_mpm::core::pr_cleanup::CleanupRegistry::production()
}
fn changelog_gate(&self, base: &str, head: &str) -> anyhow::Result<ChangelogVerdict> {
let root = repo_root()?;
if !head_is_checkout(&root, head) {
return Ok(ChangelogVerdict::HeadElsewhere);
}
let script = root.join("scripts/check_changelog_fragment.sh");
if !script.exists() {
return Ok(ChangelogVerdict::Skipped);
}
let out = std::process::Command::new("bash")
.arg(&script)
.arg("--base")
.arg(format!("origin/{base}"))
.current_dir(&root)
.output()
.with_context(|| format!("cannot run {}", script.display()))?;
if out.status.success() {
return Ok(ChangelogVerdict::Pass);
}
let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
text.push_str(&String::from_utf8_lossy(&out.stderr));
Ok(ChangelogVerdict::Fail(text.trim().to_string()))
}
fn base_freshness(
&self,
base: &str,
mode: trusty_mpm::core::base_ref_freshness::RefreshMode,
) -> trusty_mpm::core::base_ref_freshness::BaseFreshness {
match repo_root() {
Ok(root) => trusty_mpm::core::base_ref_freshness::check(
&trusty_mpm::core::base_ref_freshness::RealBaseRefs::at(&root),
base,
mode,
),
Err(e) => trusty_mpm::core::base_ref_freshness::BaseFreshness::Undetermined {
reason: format!("{e:#}"),
},
}
}
fn changed_paths(&self, base: &str, head: &str) -> anyhow::Result<Vec<String>> {
let root = repo_root()?;
let out = std::process::Command::new("git")
.args(["diff", "--name-only", &format!("origin/{base}...{head}")])
.current_dir(&root)
.output()
.context("cannot run `git diff --name-only`")?;
anyhow::ensure!(
out.status.success(),
"`git diff --name-only origin/{base}...{head}` failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_owned)
.collect())
}
fn ownership(&self) -> CrateOwnership {
CrateOwnership::resolve(std::env::current_dir().ok().as_deref())
}
}
fn head_branch(args: &PrOpenArgs) -> Option<&str> {
args.head
.as_deref()
.map(str::trim)
.filter(|h| !h.is_empty())
}
fn head_elsewhere_refusal(args: &PrOpenArgs) -> String {
let head = head_branch(args).unwrap_or("HEAD");
format!(
"--head `{head}` is not the commit this checkout stands on, and \
scripts/check_changelog_fragment.sh takes only --base — it would judge \
origin/{}...HEAD, this checkout, rather than `{head}`. Check `{head}` out, \
or pass --docs-only if this PR changes no crate source.",
args.base
)
}
fn head_is_checkout(root: &Path, head: &str) -> bool {
if head == "HEAD" {
return true;
}
let Some(here) = resolve_commit(root, "HEAD") else {
return false;
};
head_rev_candidates(head)
.iter()
.any(|rev| resolve_commit(root, rev).is_some_and(|c| c == here))
}
pub(crate) fn head_rev_candidates(head: &str) -> [String; 2] {
[head.to_string(), format!("origin/{head}")]
}
fn resolve_commit(root: &Path, rev: &str) -> Option<String> {
let peeled = format!("{rev}^{{commit}}");
let out = std::process::Command::new("git")
.args(["rev-parse", "--verify", "--quiet", peeled.as_str()])
.current_dir(root)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!sha.is_empty()).then_some(sha)
}
pub(crate) fn diff_head(args: &PrOpenArgs) -> &str {
head_branch(args).unwrap_or("HEAD")
}
fn repo_root() -> anyhow::Result<std::path::PathBuf> {
let out = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
.context("cannot run `git rev-parse --show-toplevel`")?;
anyhow::ensure!(out.status.success(), "not inside a git repository");
let root = String::from_utf8_lossy(&out.stdout).trim().to_string();
Ok(std::path::PathBuf::from(root))
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct OpenPlan {
pub(crate) argv: Vec<String>,
pub(crate) workstream_label: String,
pub(crate) label_seed_argv: Vec<String>,
pub(crate) supplied: Vec<&'static str>,
pub(crate) body: String,
pub(crate) assignee: String,
}
impl OpenPlan {
pub(crate) fn create_labels(&self) -> Vec<String> {
vec![
policy_labels::CONVENTION_LABEL.to_string(),
self.workstream_label.clone(),
]
}
}
pub(crate) fn plan(
args: &PrOpenArgs,
body_text: &str,
session: Option<&str>,
changelog: ChangelogVerdict,
ticketing: &ResolvedTicketing,
) -> Result<OpenPlan, Vec<String>> {
let mut failures: Vec<String> = Vec::new();
let report = body::validate(body_text);
if args.minimal {
failures.extend(report.merge_failures());
} else {
failures.extend(report.failures());
}
let link = if args.closes {
IssueLink::Closes
} else {
IssueLink::Refs
};
let linked = match body::apply_issue_link(body_text, args.issue, link) {
Ok(text) => text,
Err(e) => {
failures.push(e);
body_text.to_string()
}
};
let session = session.map(str::trim).filter(|s| !s.is_empty());
if session.is_none() {
failures.push(
"cannot resolve the workstream session name for the `ws/<session>` label; \
set $TM_SESSION_NAME, run inside tmux, or pass --session <name>"
.to_string(),
);
}
match changelog {
ChangelogVerdict::Pass | ChangelogVerdict::Skipped => {}
ChangelogVerdict::Fail(output) => failures.push(format!(
"scripts/check_changelog_fragment.sh failed for origin/{}...HEAD \
(pass --docs-only if this PR changes no crate source):\n{output}",
args.base
)),
ChangelogVerdict::HeadElsewhere => failures.push(head_elsewhere_refusal(args)),
}
if !failures.is_empty() {
return Err(failures);
}
let Some(workstream) = session.and_then(policy_labels::workstream_label) else {
return Err(vec![
"cannot derive the `ws/<session>` label from the resolved session name".to_string(),
]);
};
let label_seed_argv = policy_labels::create_label_argv(
&workstream,
args.repo.as_deref().filter(|r| !r.trim().is_empty()),
policy_labels::is_owned_namespace(&workstream.name),
);
let label = workstream.name;
let mut gh_argv = argv(&["pr", "create"]);
if let Some(repo) = args.repo.as_deref().filter(|r| !r.trim().is_empty()) {
gh_argv.push("--repo".to_string());
gh_argv.push(repo.to_string());
}
gh_argv.push("--base".to_string());
gh_argv.push(args.base.clone());
if let Some(head) = head_branch(args) {
gh_argv.push("--head".to_string());
gh_argv.push(head.to_string());
}
gh_argv.push("--title".to_string());
gh_argv.push(args.title.clone());
gh_argv.push("--body".to_string());
gh_argv.push(linked.clone());
gh_argv.push("--assignee".to_string());
gh_argv.push(ticketing.default_assignee.clone());
gh_argv.push("--label".to_string());
gh_argv.push(policy_labels::CONVENTION_LABEL.to_string());
gh_argv.push("--label".to_string());
gh_argv.push(label.clone());
Ok(OpenPlan {
argv: gh_argv,
workstream_label: label,
label_seed_argv,
supplied: report.supplied.iter().map(|f| f.heading()).collect(),
body: linked,
assignee: ticketing.default_assignee.clone(),
})
}
pub(crate) fn run<R: GhRunner, P: Preflight>(
gh: &R,
args: &PrOpenArgs,
pre: &P,
) -> anyhow::Result<i32> {
let body_text = read_body(&args.body_file)?;
let mode = if args.dry_run {
trusty_mpm::core::base_ref_freshness::RefreshMode::CompareOnly
} else {
trusty_mpm::core::base_ref_freshness::RefreshMode::FetchOnDrift
};
if let Some(reason) = pre.base_freshness(&args.base, mode).refusal() {
eprintln!(
"tm pr open: origin/{} is not usable as a diff base; gh was not called",
args.base
);
eprintln!(" - {reason}");
eprintln!(
" fix it with `git fetch origin {}`, then re-run — and re-run the credential scan \
against the refreshed base",
args.base
);
return Ok(EXIT_CHECK_FAILED);
}
let session = match args.session.clone() {
Some(s) => Some(s),
None => pre.session_name(),
};
let changelog = if args.docs_only {
ChangelogVerdict::Skipped
} else {
pre.changelog_gate(&args.base, diff_head(args))?
};
let ticketing = trusty_mpm::core::trusty_tools_config::resolve_ticketing(
&trusty_mpm::core::trusty_tools_config::TrustyToolsConfig::load(),
)?;
let plan = match plan(args, &body_text, session.as_deref(), changelog, &ticketing) {
Ok(p) => p,
Err(failures) => {
eprintln!(
"tm pr open: {} check(s) failed; gh was not called",
failures.len()
);
for f in &failures {
eprintln!(" - {f}");
}
if let Some(skeleton) = skeleton_hint(&failures) {
eprintln!("\nrequired body skeleton — paste this and fill each section:\n");
eprintln!("{skeleton}");
}
return Ok(EXIT_CHECK_FAILED);
}
};
if args.dry_run {
println!("gh {}", shell_render(&plan.label_seed_argv));
println!("gh {}", shell_render(&plan.argv));
return Ok(EXIT_OK);
}
match gh.run(&plan.label_seed_argv) {
Ok(seed) if !seed.success => eprintln!(
" warning: could not seed the `{}` label: {}",
plan.workstream_label,
seed.stderr.trim()
),
Err(e) => eprintln!(
" warning: could not seed the `{}` label: {e:#}",
plan.workstream_label
),
Ok(_) => {}
}
let repo = args.repo.as_deref().filter(|r| !r.trim().is_empty());
let (out, dropped) = super::missing_label::create(gh, &plan, repo)?;
let created_partially = if out.success {
false
} else {
anyhow::ensure!(
created_pr_url(&out.stdout).is_some(),
"`gh pr create` failed: {}",
out.stderr.trim()
);
eprintln!(
"tm pr open: `gh pr create` exited non-zero AFTER creating the PR: {}",
out.stderr.trim()
);
true
};
let url = out
.stdout
.lines()
.rev()
.find(|l| l.contains("http"))
.unwrap_or("")
.trim();
let number = url.rsplit('/').next().unwrap_or("?");
println!("opened PR #{number} — {url}");
let applied: Vec<String> = plan
.create_labels()
.into_iter()
.filter(|l| !dropped.contains(l))
.collect();
println!(" labels: {}", applied.join(", "));
if let Some(rung) = args.rung {
println!(" test-ladder rung claimed: {rung}");
}
println!(" body fields supplied: {}", plan.supplied.join(", "));
let mut missing: Vec<String> = Vec::new();
if created_partially {
missing.extend(metadata_apply::retry_create_defaults(gh, args, number, &plan).missing());
}
missing.extend(metadata_apply::apply(gh, args, pre, number, &plan.body).missing());
record_for_cleanup(pre, url, number);
if missing.is_empty() {
return Ok(EXIT_OK);
}
eprintln!(
"tm pr open: PR #{number} EXISTS ({url}) but this metadata could not be applied: {}",
missing.join("; ")
);
Ok(EXIT_PARTIAL)
}
pub(crate) fn skeleton_hint(failures: &[String]) -> Option<String> {
failures
.iter()
.any(|f| f.starts_with(body::MISSING_FIELD_PREFIX))
.then(body::skeleton)
}
pub(crate) fn created_pr_url(stdout: &str) -> Option<&str> {
stdout
.lines()
.map(str::trim)
.rev()
.find(|l| l.starts_with("http") && l.contains("/pull/"))
}
fn record_for_cleanup<P: Preflight>(pre: &P, url: &str, number: &str) {
let Ok(pr) = number.trim().parse::<u64>() else {
eprintln!("tm pr open: could not read the new PR's number; not recording it for cleanup");
return;
};
let (Some(repo), Ok(root)) = (repo_from_pr_url(url), std::env::current_dir()) else {
eprintln!("tm pr open: could not resolve the repo or cwd; not recording #{pr} for cleanup");
return;
};
let entry = trusty_mpm::core::pr_cleanup::OpenedPr {
pr,
repo,
repo_root: root,
opened_at: chrono::Utc::now(),
cleaned_at: None,
scope: Default::default(),
};
if let Err(e) = pre.cleanup_registry().record_open(entry) {
eprintln!("tm pr open: could not record #{pr} for post-merge cleanup: {e:#}");
}
}
fn repo_from_pr_url(url: &str) -> Option<String> {
let (before, _) = url.trim().rsplit_once("/pull/")?;
let mut parts = before.rsplitn(3, '/');
let repo = parts.next()?;
let owner = parts.next()?;
(!repo.is_empty() && !owner.is_empty() && !owner.contains(':'))
.then(|| format!("{owner}/{repo}"))
}
fn read_body(path: &Path) -> anyhow::Result<String> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("cannot read PR body file {}", path.display()))?;
anyhow::ensure!(
!text.trim().is_empty(),
"PR body file {} is empty",
path.display()
);
Ok(text)
}
pub(crate) fn shell_render(args: &[String]) -> String {
args.iter()
.map(|a| {
if a.is_empty()
|| a.chars()
.any(|c| c.is_whitespace() || c == '\'' || c == '"')
{
format!("'{}'", a.replace('\'', r"'\''"))
} else {
a.clone()
}
})
.collect::<Vec<_>>()
.join(" ")
}