shipit 2.1.3

Shipit is an open source command line interface for managing merge requests, changelogs, tags, and releases using a plan and apply interface. Built with coding agent integration in mind.
Documentation
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, PlatformConfig, RunOptions, TetheredGit};

/// Entry point for `shipit b2b plan`.
///
/// Constructs a [`TetheredGit`] for the source branch, then delegates all logic
/// to [`run_plan`]. Splitting construction from logic keeps the inner function
/// unit-testable without a real remote.
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,
        Some(&args.target),
        PlatformConfig { domain: ctx.settings.platform.domain.clone(), token: ctx.settings.platform.token.clone() },
        RunOptions { allow_dirty: args.allow_dirty, yes: args.yes },
    ).await?;
    run_plan(tethered_git, args, &path).await
}

/// Core logic for `shipit b2b plan`.
///
/// Collects commits between the source and target branches, generates (or accepts a
/// user-provided) description and title, then serializes the result as a [`Plan`] YAML
/// file under `<path>/.shipit/plans/`.
///
/// Commit messages are skipped when both `--title` and `--description` are provided.
/// When `--conventional-commits` is set the description is grouped by commit type;
/// otherwise each message becomes a raw bullet-list entry.
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(())
}

/// Entry point for `shipit b2b apply`.
///
/// Reads the plan file at `<dir>/.shipit/plans/<plan>`, constructs a [`TetheredGit`]
/// for the source branch recorded in the plan, then delegates to [`run_apply`].
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,
        Some(&plan.target),
        PlatformConfig { domain: ctx.settings.platform.domain.clone(), token: ctx.settings.platform.token.clone() },
        RunOptions { allow_dirty: args.allow_dirty, yes: args.yes },
    ).await?;

    run_apply(tethered_git, plan).await
}

/// Core logic for `shipit b2b apply`.
///
/// Calls [`Platform::open_request`] with the source, target, title, and description
/// from `plan`, then prints the resulting URL.
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");

        // Create target branch pointing at initial commit
        let head = repo.head().unwrap().target().unwrap();
        repo.branch("main", &repo.find_commit(head).unwrap(), false).unwrap();
        repo.reference("refs/remotes/origin/main", head, false, "test").unwrap();
        let source_oid = make_commit(&repo, "feat: add feature");
        repo.reference("refs/remotes/origin/master", source_oid, false, "test").unwrap();

        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");

        // Create target branch pointing at 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();
        repo.reference("refs/remotes/origin/main", base_oid, false, "test").unwrap();
        make_commit(&repo, "feat: add new feature");
        let source_oid = make_commit(&repo, "fix: correct a bug");
        repo.reference("refs/remotes/origin/master", source_oid, false, "test").unwrap();

        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();
        repo.reference("refs/remotes/origin/main", base_oid, false, "test").unwrap();
        make_commit(&repo, "feat: alpha");
        let source_oid = make_commit(&repo, "fix: beta");
        repo.reference("refs/remotes/origin/master", source_oid, false, "test").unwrap();

        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);
    }
}