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();
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(),
});
}
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');
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()
));
}
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");
}
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"));
}
}