callisto-graph 0.3.1

Dependency graph, cascade, aggregation, and config resolution for callisto.
Documentation
use callisto_model::{CommandRunner, ComposePrBodyReport, SCHEMA_VERSION};

use crate::commands::version::plan_version;
use crate::error::GraphError;
use crate::infer::SeverityInference;
use crate::resolver::DependencyResolver;
use crate::Workspace;

#[derive(Clone, Debug, Default)]
pub struct PrBodyOptions {
    pub existing_body: Option<String>,
    pub labels: Vec<String>,
    pub branch: Option<String>,
}

pub fn compose_pr_body<R: CommandRunner, D: DependencyResolver, I: SeverityInference>(
    ws: &Workspace<'_, R, D>,
    inference: &I,
    opts: &PrBodyOptions,
) -> Result<ComposePrBodyReport, GraphError> {
    let plan = plan_version(
        ws,
        inference,
        &crate::commands::version::VersionOptions::default(),
    )?;
    render_pr_body_from_plan(&plan, opts)
}

pub fn render_pr_body_from_plan(
    plan: &crate::plan::VersionPlan,
    opts: &PrBodyOptions,
) -> Result<ComposePrBodyReport, GraphError> {
    let mut body = String::new();

    // Preserve custom user notes above ## Release Preview if an existing body was passed
    if let Some(ref existing) = opts.existing_body {
        if let Some((prefix, _)) = existing.split_once("## Release Preview") {
            if !prefix.trim().is_empty() {
                body.push_str(prefix);
                if !prefix.ends_with('\n') {
                    body.push('\n');
                }
            }
        }
    }

    body.push_str("## Release Preview\n\n");
    body.push_str("This automated PR was generated by [Callisto](https://github.com/orin-dx/callisto). Merging this PR will publish the updated packages to their release targets.\n\n");

    if !opts.labels.is_empty() {
        body.push_str(&format!(
            "**Suggested PR Labels**: `{}`\n\n",
            opts.labels.join("`, `")
        ));
    }

    if plan.bumps.is_empty() {
        body.push_str("> [!NOTE]\n");
        body.push_str(
            "> **No pending changesets found.** Workspace packages are currently up to date.\n\n",
        );
        let branch = opts
            .branch
            .as_deref()
            .unwrap_or("callisto/version-packages");
        body.push_str("<details>\n<summary><b>Release Workflow Instructions</b></summary>\n\n");
        body.push_str("- **Merging this PR**: Merging into `main` will automatically publish all updated packages to their release registries.\n");
        body.push_str("- **Adding more changesets**: If additional changesets are pushed to `main`, Callisto will automatically re-calculate and update this PR.\n");
        body.push_str(&format!(
            "- **Manual edits**: You can make manual adjustments directly on the `{}` branch if needed.\n\n",
            branch
        ));
        body.push_str("</details>\n");
        return Ok(ComposePrBodyReport {
            schema_version: SCHEMA_VERSION,
            pr_body: body,
            diagnostics: Vec::new(),
        });
    }

    // 1. Executive Summary Table
    body.push_str("### 📊 Release Summary\n\n");
    body.push_str("| Package | Ecosystem | Current | Target | Bump | Reason |\n");
    body.push_str("| :--- | :--- | :--- | :--- | :--- | :--- |\n");

    let mut has_major = false;

    for bump in &plan.bumps {
        if bump.severity == callisto_model::Severity::Major {
            has_major = true;
        }

        let eco = bump
            .package
            .ecosystem()
            .map(|e| e.prefix())
            .unwrap_or("core");

        let reason_str = match &bump.reason {
            Some(callisto_model::BumpReason::Changeset { changesets }) => {
                format!("Changeset (`{}`)", changesets.join("`, `"))
            }
            Some(callisto_model::BumpReason::Cascade { via, .. }) => {
                format!("Cascade from `{}`", via.display_name())
            }
            Some(callisto_model::BumpReason::PeerEscalation { via, .. }) => {
                format!("Peer escalation from `{}`", via.display_name())
            }
            Some(callisto_model::BumpReason::FixedGroupUnion { group }) => {
                format!("Fixed group `{}`", group.as_str())
            }
            Some(callisto_model::BumpReason::LinkedGroupUnion { group }) => {
                format!("Linked group `{}`", group.as_str())
            }
            Some(callisto_model::BumpReason::Inference { commits, .. }) => {
                format!("Inferred ({commits} commits)")
            }
            Some(callisto_model::BumpReason::PreRelease { tag }) => {
                format!("Prerelease (`{tag}`)")
            }
            Some(callisto_model::BumpReason::NewGroupMember { group }) => {
                format!("New member (`{}`)", group.as_str())
            }
            _ => "Package bump".to_string(),
        };

        body.push_str(&format!(
            "| `{}` | `{}` | `{}` | **`{}`** | `{}` | {} |\n",
            bump.package.display_name(),
            eco,
            bump.from.render(),
            bump.to.render(),
            bump.severity,
            reason_str
        ));
    }

    body.push('\n');

    // 2. Alert Callouts
    if has_major {
        body.push_str("> [!IMPORTANT]\n");
        body.push_str("> **Major Version Bumps Detected**: This release contains major breaking changes. Review full changelogs below before merging.\n\n");
    } else {
        body.push_str("> [!NOTE]\n");
        body.push_str(&format!(
            "> **{} package(s)** queued for versioning. All changes are backward compatible.\n\n",
            plan.bumps.len()
        ));
    }

    // 3. Collapsible Package Details
    body.push_str("### 📦 Package Release Details\n\n");

    for bump in &plan.bumps {
        let is_open = bump.severity == callisto_model::Severity::Major
            || bump.severity == callisto_model::Severity::Minor;
        let open_attr = if is_open { " open" } else { "" };

        body.push_str(&format!(
            "<details{}>\n<summary><b>{}</b> <code>{}</code> → <code>{}</code> ({})</summary>\n\n",
            open_attr,
            bump.package.display_name(),
            bump.from.render(),
            bump.to.render(),
            bump.severity
        ));

        body.push_str(&format!(
            "#### Version Change\n`{}` âž” `{}`\n\n",
            bump.from.render(),
            bump.to.render()
        ));

        if let Some(ref reason) = bump.reason {
            body.push_str("#### Release Reason\n");
            match reason {
                callisto_model::BumpReason::Changeset { changesets } => {
                    body.push_str(&format!(
                        "- Associated changesets: `{}`\n\n",
                        changesets.join("`, `")
                    ));
                }
                callisto_model::BumpReason::Cascade {
                    via,
                    spec,
                    dependency_to,
                    ..
                } => {
                    body.push_str(&format!(
                        "- Automatic dependency cascade triggered by `{}` (spec `{}` target `{}`).\n\n",
                        via.display_name(),
                        spec,
                        dependency_to.render()
                    ));
                }
                callisto_model::BumpReason::PeerEscalation { via, spec } => {
                    body.push_str(&format!(
                        "- Peer dependency escalation triggered by `{}` (spec `{}`).\n\n",
                        via.display_name(),
                        spec
                    ));
                }
                callisto_model::BumpReason::FixedGroupUnion { group } => {
                    body.push_str(&format!(
                        "- Fixed group synchronization for group `{}`.\n\n",
                        group.as_str()
                    ));
                }
                callisto_model::BumpReason::LinkedGroupUnion { group } => {
                    body.push_str(&format!(
                        "- Linked group version alignment for group `{}`.\n\n",
                        group.as_str()
                    ));
                }
                _ => {}
            }
        }

        body.push_str("</details>\n\n");
    }

    // 4. Instructions Footer
    let branch = opts
        .branch
        .as_deref()
        .unwrap_or("callisto/version-packages");
    body.push_str("<details>\n<summary><b>Release Workflow Instructions</b></summary>\n\n");
    body.push_str("- **Merging this PR**: Merging into `main` will automatically publish all updated packages to their release registries.\n");
    body.push_str("- **Adding more changesets**: If additional changesets are pushed to `main`, Callisto will automatically re-calculate and update this PR.\n");
    body.push_str(&format!(
        "- **Manual edits**: You can make manual adjustments directly on the `{}` branch if needed.\n\n",
        branch
    ));
    body.push_str("</details>\n");

    Ok(ComposePrBodyReport {
        schema_version: SCHEMA_VERSION,
        pr_body: body,
        diagnostics: Vec::new(),
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::plan::{PlannedBump, VersionPlan};
    use callisto_model::{BumpReason, PackageId, Severity, Version};

    #[test]
    fn test_compose_pr_body_snapshot() {
        let pkg_a = PackageId::parse("core-crate").unwrap();
        let pkg_b = PackageId::parse("@myorg/web-app").unwrap();

        let bump_a = PlannedBump {
            package: pkg_a,
            from: Version::semver(0, 1, 0),
            to: Version::semver(0, 2, 0),
            severity: Severity::Minor,
            governed_by: None,
            reason: Some(BumpReason::Changeset {
                changesets: vec!["swift-foxes-race".to_string()],
            }),
            writes: vec![],
        };

        let bump_b = PlannedBump {
            package: pkg_b,
            from: Version::semver(1, 0, 0),
            to: Version::semver(1, 0, 1),
            severity: Severity::Patch,
            governed_by: None,
            reason: Some(BumpReason::Changeset {
                changesets: vec!["swift-foxes-race".to_string()],
            }),
            writes: vec![],
        };

        let plan = VersionPlan {
            bumps: vec![bump_a, bump_b],
            rewrites: vec![],
            platform_writes: vec![],
            optional_dep_updates: vec![],
            changelog_writes: vec![],
            consumed_changesets: vec![std::path::PathBuf::from(".changeset/swift-foxes-race.md")],
            pre_state_update: None,
            delete_pre_json: false,
            pre_cursor_updates: vec![],
            observed_versions: std::collections::BTreeMap::new(),
            diagnostics: vec![],
        };

        let opts = PrBodyOptions::default();
        let report = render_pr_body_from_plan(&plan, &opts).unwrap();
        insta::assert_snapshot!(report.pr_body);
    }

    #[test]
    fn test_compose_pr_body_custom_branch_override() {
        let plan = VersionPlan {
            bumps: vec![],
            rewrites: vec![],
            platform_writes: vec![],
            optional_dep_updates: vec![],
            changelog_writes: vec![],
            consumed_changesets: vec![],
            pre_state_update: None,
            delete_pre_json: false,
            pre_cursor_updates: vec![],
            observed_versions: std::collections::BTreeMap::new(),
            diagnostics: vec![],
        };

        let opts = PrBodyOptions {
            existing_body: None,
            labels: vec![],
            branch: Some("custom-release-branch".to_string()),
        };
        let report = render_pr_body_from_plan(&plan, &opts).unwrap();
        assert!(report.pr_body.contains("## Release Preview"));
    }
}