use std::path::{Path, PathBuf};
use crate::cli::{B2bApplyArgs, B2bPlanArgs};
use crate::context::Context;
use crate::error::ShipItError;
use crate::git::{categorize_commits, generate_summary, next_version, TetheredGit};
pub async fn plan(ctx: &Context, args: B2bPlanArgs) -> Result<(), ShipItError> {
let path = args.dir.as_deref().map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
let tethered_git = TetheredGit::new(
&path,
&args.remote,
&args.source,
&ctx.settings.platform.domain,
&ctx.settings.platform.token,
args.allow_dirty,
args.yes,
).await?;
run_plan(tethered_git, args, &path).await
}
pub(crate) async fn run_plan(tethered_git: TetheredGit, args: B2bPlanArgs, path: &Path) -> Result<(), ShipItError> {
let msgs = if args.description.is_some() {
vec![]
} else {
let mut msgs = tethered_git.collect_messages(&args.target, &args.only_merges)?;
let sp = crate::output::start_spinner("Enriching commit messages...");
msgs = tethered_git.platform.enrich_messages(&msgs).await;
sp.finish_and_clear();
msgs
};
let description_from_user = args.description.is_some();
let refs: Vec<&str> = msgs.iter().map(|s| s.as_str()).collect();
let categorized = categorize_commits(&refs);
let mut summary = if let Some(provided) = args.description {
provided
} else {
if msgs.is_empty() {
tracing::warn!("No commits found between '{}' and '{}'. Nothing to do.", args.source, args.target);
return Ok(());
}
if args.conventional_commits {
generate_summary(&categorized)
} else {
msgs.iter().map(|m| format!("- {}", m)).collect::<Vec<_>>().join("\n")
}
};
crate::output::print_content("The merge request description is:", &summary);
if !args.no_sign {
summary += "\n\n\n*This request was generated by [Shipit](https://gitshipit.net)* 🚢";
}
let title_from_user = args.title.is_some();
let title = if let Some(provided) = args.title {
provided
} else {
let latest_tag = tethered_git.get_latest_tag(&args.target)?;
let suggested = if let Some(tag) = latest_tag {
let version = next_version(&categorized, &tag).expect("Expected to be able to generate a version using the latest tag!");
format!("Release Candidate {}", version)
} else {
format!("{} to {}", &args.source, &args.target)
};
if args.yes {
suggested
} else {
crate::output::prompt_mr_title(&suggested)
.map_err(|e| ShipItError::Error(format!("Failed to read input: {}", e)))?
}
};
crate::output::print_content("The merge request title is:", &title);
let description_source = if description_from_user {
"user".to_string()
} else if args.conventional_commits {
"conventional-commits".to_string()
} else {
"raw".to_string()
};
let generated_at = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
let plan = crate::plan::Plan {
shipit_version: env!("CARGO_PKG_VERSION").to_string(),
generated_at,
source: args.source.clone(),
target: args.target.clone(),
title: crate::plan::FieldWithSource {
value: title,
generated_by: if title_from_user { "user".to_string() } else { "default".to_string() },
},
description: crate::plan::FieldWithSource {
value: summary,
generated_by: description_source,
},
commits: msgs,
};
let plan_path = crate::plan::write_plan(&plan, path)?;
crate::output::print_plan_saved(&plan_path);
if args.yaml {
let filename = plan_path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let yaml = crate::output::build_plan_yaml(&plan, &filename)?;
print!("{}", yaml);
}
Ok(())
}
pub async fn apply(ctx: &Context, args: B2bApplyArgs) -> Result<(), ShipItError> {
let dir = args.dir.as_deref().map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
let plan_path = dir.join(".shipit").join("plans").join(&args.plan);
let content = std::fs::read_to_string(&plan_path)
.map_err(|e| ShipItError::Error(format!("Failed to read plan file '{}': {}", plan_path.display(), e)))?;
let plan: crate::plan::Plan = serde_yaml::from_str(&content)
.map_err(|e| ShipItError::Error(format!("Failed to parse plan file: {}", e)))?;
crate::output::print_content("Applying plan:", &format!(
" title: {}\n source: {} → target: {}",
plan.title.value, plan.source, plan.target
));
let tethered_git = TetheredGit::new(
&dir,
&args.remote,
&plan.source,
&ctx.settings.platform.domain,
&ctx.settings.platform.token,
args.allow_dirty,
args.yes,
).await?;
run_apply(tethered_git, plan).await
}
pub(crate) async fn run_apply(tethered_git: TetheredGit, plan: crate::plan::Plan) -> Result<(), ShipItError> {
let sp = crate::output::start_spinner("Creating pull request...");
let url_result = tethered_git.platform.open_request(
&plan.source,
&plan.target,
&plan.title.value,
&plan.description.value,
).await;
sp.finish_and_clear();
let url = url_result?;
crate::output::print_url(&url);
Ok(())
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use crate::cli::B2bPlanArgs;
use crate::git::test_helpers::{init_repo_with_remote, make_commit, make_tethered_git};
use crate::git::MockPlatform;
use crate::plan::{FieldWithSource, Plan};
fn make_b2b_plan_args(source: &str, target: &str) -> B2bPlanArgs {
B2bPlanArgs {
source: source.to_string(),
target: target.to_string(),
conventional_commits: false,
dir: None,
remote: "origin".to_string(),
title: None,
description: None,
only_merges: false,
no_sign: true,
yes: true,
yaml: false,
allow_dirty: false,
}
}
fn make_plan(source: &str, target: &str) -> Plan {
Plan {
shipit_version: "0.0.0".to_string(),
generated_at: "2024-01-01T00:00:00Z".to_string(),
source: source.to_string(),
target: target.to_string(),
title: FieldWithSource {
value: "Release Candidate v1.1.0".to_string(),
generated_by: "default".to_string(),
},
description: FieldWithSource {
value: "- feat: add feature".to_string(),
generated_by: "raw".to_string(),
},
commits: vec!["feat: add feature".to_string()],
}
}
#[tokio::test]
async fn test_run_plan_no_tags_no_desc_no_title() {
let work_dir = TempDir::new().unwrap();
let bare_dir = TempDir::new().unwrap();
let repo = init_repo_with_remote(work_dir.path(), bare_dir.path());
let _oid = make_commit(&repo, "feat: initial commit");
let head = repo.head().unwrap().target().unwrap();
repo.branch("main", &repo.find_commit(head).unwrap(), false).unwrap();
make_commit(&repo, "feat: add feature");
let mut mock = MockPlatform::new();
mock.expect_enrich_messages()
.returning(|msgs| msgs.to_vec());
let tmp_out = TempDir::new().unwrap();
let out_path = tmp_out.path().to_path_buf();
let tethered = make_tethered_git(repo, work_dir.path().to_path_buf(), "master", Box::new(mock));
let args = make_b2b_plan_args("master", "main");
let result = super::run_plan(tethered, args, &out_path).await;
assert!(result.is_ok(), "run_plan failed: {:?}", result);
let plans_dir = out_path.join(".shipit").join("plans");
let entries: Vec<_> = std::fs::read_dir(&plans_dir).unwrap().collect();
assert_eq!(entries.len(), 1, "expected one plan file");
let content = std::fs::read_to_string(entries[0].as_ref().unwrap().path()).unwrap();
assert!(content.contains("master to main"));
assert!(content.contains("generated_by: default"));
}
#[tokio::test]
async fn test_run_plan_explicit_title_and_description() {
let work_dir = TempDir::new().unwrap();
let bare_dir = TempDir::new().unwrap();
let repo = init_repo_with_remote(work_dir.path(), bare_dir.path());
let _oid = make_commit(&repo, "feat: initial commit");
let head = repo.head().unwrap().target().unwrap();
repo.branch("main", &repo.find_commit(head).unwrap(), false).unwrap();
make_commit(&repo, "feat: add feature");
let mut mock = MockPlatform::new();
mock.expect_enrich_messages().never();
let tmp_out = TempDir::new().unwrap();
let out_path = tmp_out.path().to_path_buf();
let tethered = make_tethered_git(repo, work_dir.path().to_path_buf(), "master", Box::new(mock));
let mut args = make_b2b_plan_args("master", "main");
args.title = Some("My Title".to_string());
args.description = Some("My Description".to_string());
let result = super::run_plan(tethered, args, &out_path).await;
assert!(result.is_ok(), "run_plan failed: {:?}", result);
let plans_dir = out_path.join(".shipit").join("plans");
let entries: Vec<_> = std::fs::read_dir(&plans_dir).unwrap().collect();
assert_eq!(entries.len(), 1, "expected one plan file");
let content = std::fs::read_to_string(entries[0].as_ref().unwrap().path()).unwrap();
assert!(content.contains("My Title"));
assert!(content.contains("My Description"));
assert!(content.contains("generated_by: user"));
}
#[tokio::test]
async fn test_run_plan_conventional_commits() {
let work_dir = TempDir::new().unwrap();
let bare_dir = TempDir::new().unwrap();
let repo = init_repo_with_remote(work_dir.path(), bare_dir.path());
let base_oid = make_commit(&repo, "chore: initial setup");
repo.branch("main", &repo.find_commit(base_oid).unwrap(), false).unwrap();
make_commit(&repo, "feat: add new feature");
make_commit(&repo, "fix: correct a bug");
let mut mock = MockPlatform::new();
mock.expect_enrich_messages()
.returning(|msgs| msgs.to_vec());
let tmp_out = TempDir::new().unwrap();
let out_path = tmp_out.path().to_path_buf();
let tethered = make_tethered_git(repo, work_dir.path().to_path_buf(), "master", Box::new(mock));
let mut args = make_b2b_plan_args("master", "main");
args.conventional_commits = true;
args.title = Some("Release v1.1.0".to_string());
let result = super::run_plan(tethered, args, &out_path).await;
assert!(result.is_ok(), "run_plan failed: {:?}", result);
let plans_dir = out_path.join(".shipit").join("plans");
let entries: Vec<_> = std::fs::read_dir(&plans_dir).unwrap().collect();
assert_eq!(entries.len(), 1);
let content = std::fs::read_to_string(entries[0].as_ref().unwrap().path()).unwrap();
assert!(content.contains("conventional-commits"));
}
#[tokio::test]
async fn test_run_plan_raw_format_bullet_list() {
let work_dir = TempDir::new().unwrap();
let bare_dir = TempDir::new().unwrap();
let repo = init_repo_with_remote(work_dir.path(), bare_dir.path());
let base_oid = make_commit(&repo, "chore: initial");
repo.branch("main", &repo.find_commit(base_oid).unwrap(), false).unwrap();
make_commit(&repo, "feat: alpha");
make_commit(&repo, "fix: beta");
let mut mock = MockPlatform::new();
mock.expect_enrich_messages()
.returning(|msgs| msgs.to_vec());
let tmp_out = TempDir::new().unwrap();
let out_path = tmp_out.path().to_path_buf();
let tethered = make_tethered_git(repo, work_dir.path().to_path_buf(), "master", Box::new(mock));
let mut args = make_b2b_plan_args("master", "main");
args.title = Some("My Release".to_string());
let result = super::run_plan(tethered, args, &out_path).await;
assert!(result.is_ok(), "run_plan failed: {:?}", result);
let plans_dir = out_path.join(".shipit").join("plans");
let entries: Vec<_> = std::fs::read_dir(&plans_dir).unwrap().collect();
let content = std::fs::read_to_string(entries[0].as_ref().unwrap().path()).unwrap();
assert!(content.contains("- "), "expected bullet list in description: {}", content);
assert!(content.contains("generated_by: raw"));
}
#[tokio::test]
async fn test_run_apply_calls_open_request() {
let work_dir = TempDir::new().unwrap();
let bare_dir = TempDir::new().unwrap();
let repo = init_repo_with_remote(work_dir.path(), bare_dir.path());
make_commit(&repo, "initial");
let plan = make_plan("feature-branch", "main");
let mut mock = MockPlatform::new();
mock.expect_open_request()
.withf(|src, tgt, title, desc| {
src == "feature-branch"
&& tgt == "main"
&& title == "Release Candidate v1.1.0"
&& desc.contains("feat: add feature")
})
.returning(|_, _, _, _| Ok("https://github.com/org/repo/pull/42".to_string()));
let tethered = make_tethered_git(repo, work_dir.path().to_path_buf(), "master", Box::new(mock));
let result = super::run_apply(tethered, plan).await;
assert!(result.is_ok(), "run_apply failed: {:?}", result);
}
}