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,
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()
));
let matching_write = plan.changelog_writes.iter().find(|w| w.input.package == bump.package);
let mut rendered = false;
if let Some(w) = matching_write {
if let Ok(section) = callisto_changelog::render_section(&w.input) {
body.push_str("#### Release Reason\n");
body.push_str(§ion);
body.push('\n');
rendered = true;
}
}
if !rendered {
body.push_str("#### Release Reason\n");
match &bump.reason {
Some(reason) => 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()
));
}
callisto_model::BumpReason::Inference { commits, .. } => {
body.push_str(&format!(
"- Inferred from {commits} commit(s) since the last release.\n\n"
));
}
callisto_model::BumpReason::PreRelease { tag } => {
body.push_str(&format!("- Pre-release finalization (tag `{tag}`).\n\n"));
}
callisto_model::BumpReason::NewGroupMember { group } => {
body.push_str(&format!("- New member of group `{}`.\n\n", group.as_str()));
}
_ => {}
},
None => {
body.push_str("_No changelog entries available._\n\n");
}
}
}
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,
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: None,
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.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: None,
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.body.contains("## Release Preview"));
}
fn plan_with_single_bump(reason: BumpReason) -> VersionPlan {
VersionPlan {
bumps: vec![PlannedBump {
package: PackageId::parse("pkg-a").unwrap(),
from: Version::semver(1, 0, 0),
to: Version::semver(1, 1, 0),
severity: Severity::Minor,
governed_by: None,
reason: Some(reason),
writes: vec![],
}],
rewrites: vec![],
platform_writes: vec![],
optional_dep_updates: vec![],
changelog_writes: vec![],
consumed_changesets: vec![],
pre_state_update: None,
delete_pre_json: None,
pre_cursor_updates: vec![],
observed_versions: std::collections::BTreeMap::new(),
diagnostics: vec![],
}
}
fn assert_release_reason_has_content(body: &str) {
let start = body
.find("#### Release Reason")
.expect("must contain '#### Release Reason'");
let after_heading = &body[start + "#### Release Reason".len()..];
let end = after_heading.find("</details>").unwrap_or(after_heading.len());
let section = after_heading[..end].trim();
assert!(
!section.is_empty(),
"'#### Release Reason' section must have non-whitespace content, got body: {body}"
);
}
#[test]
fn ac002_inference_fallback_is_non_empty() {
let plan = plan_with_single_bump(BumpReason::Inference {
commits: 3,
remapped: false,
});
let report = render_pr_body_from_plan(&plan, &PrBodyOptions::default()).unwrap();
assert_release_reason_has_content(&report.body);
}
#[test]
fn ac002_prerelease_fallback_is_non_empty() {
let plan = plan_with_single_bump(BumpReason::PreRelease {
tag: "alpha".to_string(),
});
let report = render_pr_body_from_plan(&plan, &PrBodyOptions::default()).unwrap();
assert_release_reason_has_content(&report.body);
}
#[test]
fn ac002_new_group_member_fallback_is_non_empty() {
let plan = plan_with_single_bump(BumpReason::NewGroupMember {
group: callisto_model::GroupName("grp".to_string()),
});
let report = render_pr_body_from_plan(&plan, &PrBodyOptions::default()).unwrap();
assert_release_reason_has_content(&report.body);
}
#[test]
fn ac001_render_section_takes_priority_over_fallback_when_both_available() {
let pkg_a = PackageId::parse("pkg-a").unwrap();
let bump = PlannedBump {
package: pkg_a.clone(),
from: Version::semver(1, 0, 0),
to: Version::semver(1, 1, 0),
severity: Severity::Minor,
governed_by: None,
reason: Some(BumpReason::Cascade {
via: PackageId::parse("pkg-core").unwrap(),
dep_kind: callisto_model::DepKind::Runtime,
spec: "^1.0.0".to_string(),
dependency_to: Version::semver(2, 0, 0),
}),
writes: vec![],
};
let input = callisto_changelog::ChangelogInput {
package: pkg_a.clone(),
from: Version::semver(1, 0, 0),
to: Some(Version::semver(1, 1, 0)),
entries: vec![callisto_changelog::ChangelogEntry {
severity: Severity::Minor,
source: callisto_changelog::ChangeSource::Changeset {
filename: "swift-foxes.md".to_string(),
summary: "A real changeset summary.".to_string(),
},
}],
};
let expected_section = callisto_changelog::render_section(&input).unwrap();
let plan = VersionPlan {
bumps: vec![bump],
rewrites: vec![],
platform_writes: vec![],
optional_dep_updates: vec![],
changelog_writes: vec![crate::plan::ChangelogWrite {
changelog_path: std::path::PathBuf::from("CHANGELOG.md"),
input,
}],
consumed_changesets: vec![],
pre_state_update: None,
delete_pre_json: None,
pre_cursor_updates: vec![],
observed_versions: std::collections::BTreeMap::new(),
diagnostics: vec![],
};
let report = render_pr_body_from_plan(&plan, &PrBodyOptions::default()).unwrap();
assert!(
report.body.contains(&expected_section),
"body must contain render_section's verbatim output; body: {}",
report.body
);
assert!(
!report.body.contains("Automatic dependency cascade triggered by"),
"body must NOT contain the fallback Cascade text when render_section succeeded; body: {}",
report.body
);
}
#[test]
fn ac007_empty_entries_falls_back_and_other_package_renders_normally() {
let pkg_a = PackageId::parse("pkg-a").unwrap();
let pkg_b = PackageId::parse("pkg-b").unwrap();
let bump_a = PlannedBump {
package: pkg_a.clone(),
from: Version::semver(1, 0, 0),
to: Version::semver(1, 1, 0),
severity: Severity::Minor,
governed_by: None,
reason: Some(BumpReason::Cascade {
via: PackageId::parse("pkg-core").unwrap(),
dep_kind: callisto_model::DepKind::Runtime,
spec: "^1.0.0".to_string(),
dependency_to: Version::semver(2, 0, 0),
}),
writes: vec![],
};
let bump_b = PlannedBump {
package: pkg_b.clone(),
from: Version::semver(1, 0, 0),
to: Version::semver(1, 0, 1),
severity: Severity::Patch,
governed_by: None,
reason: Some(BumpReason::Changeset {
changesets: vec!["fix-1".to_string()],
}),
writes: vec![],
};
let good_input = callisto_changelog::ChangelogInput {
package: pkg_b.clone(),
from: Version::semver(1, 0, 0),
to: Some(Version::semver(1, 0, 1)),
entries: vec![callisto_changelog::ChangelogEntry {
severity: Severity::Patch,
source: callisto_changelog::ChangeSource::Changeset {
filename: "fix-1.md".to_string(),
summary: "A fix.".to_string(),
},
}],
};
let expected_section = callisto_changelog::render_section(&good_input).unwrap();
let plan = VersionPlan {
bumps: vec![bump_a, bump_b],
rewrites: vec![],
platform_writes: vec![],
optional_dep_updates: vec![],
changelog_writes: vec![
crate::plan::ChangelogWrite {
changelog_path: std::path::PathBuf::from("CHANGELOG.md"),
input: callisto_changelog::ChangelogInput {
package: pkg_a.clone(),
from: Version::semver(1, 0, 0),
to: Some(Version::semver(1, 1, 0)),
entries: vec![],
},
},
crate::plan::ChangelogWrite {
changelog_path: std::path::PathBuf::from("CHANGELOG.md"),
input: good_input,
},
],
consumed_changesets: vec![],
pre_state_update: None,
delete_pre_json: None,
pre_cursor_updates: vec![],
observed_versions: std::collections::BTreeMap::new(),
diagnostics: vec![],
};
let report = render_pr_body_from_plan(&plan, &PrBodyOptions::default()).unwrap();
assert!(
report.body.contains("Automatic dependency cascade triggered by"),
"pkg-a must use the AC-002 fallback text since its ChangelogWrite has empty entries; body: {}",
report.body
);
assert!(
report.body.contains(&expected_section),
"pkg-b must still render normally via render_section; body: {}",
report.body
);
}
#[test]
fn ac007_severity_none_entry_falls_back() {
let pkg_a = PackageId::parse("pkg-a").unwrap();
let bump_a = PlannedBump {
package: pkg_a.clone(),
from: Version::semver(1, 0, 0),
to: Version::semver(1, 1, 0),
severity: Severity::Minor,
governed_by: None,
reason: Some(BumpReason::Cascade {
via: PackageId::parse("pkg-core").unwrap(),
dep_kind: callisto_model::DepKind::Runtime,
spec: "^1.0.0".to_string(),
dependency_to: Version::semver(2, 0, 0),
}),
writes: vec![],
};
let plan = VersionPlan {
bumps: vec![bump_a],
rewrites: vec![],
platform_writes: vec![],
optional_dep_updates: vec![],
changelog_writes: vec![crate::plan::ChangelogWrite {
changelog_path: std::path::PathBuf::from("CHANGELOG.md"),
input: callisto_changelog::ChangelogInput {
package: pkg_a.clone(),
from: Version::semver(1, 0, 0),
to: Some(Version::semver(1, 1, 0)),
entries: vec![callisto_changelog::ChangelogEntry {
severity: Severity::None,
source: callisto_changelog::ChangeSource::Changeset {
filename: "x.md".to_string(),
summary: "whatever".to_string(),
},
}],
},
}],
consumed_changesets: vec![],
pre_state_update: None,
delete_pre_json: None,
pre_cursor_updates: vec![],
observed_versions: std::collections::BTreeMap::new(),
diagnostics: vec![],
};
let report = render_pr_body_from_plan(&plan, &PrBodyOptions::default()).unwrap();
assert!(
report.body.contains("Automatic dependency cascade triggered by"),
"body: {}",
report.body
);
}
#[test]
fn ac007_empty_entries_and_no_reason_uses_no_changelog_entries_literal() {
let pkg_a = PackageId::parse("pkg-a").unwrap();
let bump_a = PlannedBump {
package: pkg_a.clone(),
from: Version::semver(1, 0, 0),
to: Version::semver(1, 1, 0),
severity: Severity::Minor,
governed_by: None,
reason: None,
writes: vec![],
};
let plan = VersionPlan {
bumps: vec![bump_a],
rewrites: vec![],
platform_writes: vec![],
optional_dep_updates: vec![],
changelog_writes: vec![crate::plan::ChangelogWrite {
changelog_path: std::path::PathBuf::from("CHANGELOG.md"),
input: callisto_changelog::ChangelogInput {
package: pkg_a.clone(),
from: Version::semver(1, 0, 0),
to: Some(Version::semver(1, 1, 0)),
entries: vec![],
},
}],
consumed_changesets: vec![],
pre_state_update: None,
delete_pre_json: None,
pre_cursor_updates: vec![],
observed_versions: std::collections::BTreeMap::new(),
diagnostics: vec![],
};
let report = render_pr_body_from_plan(&plan, &PrBodyOptions::default()).unwrap();
assert!(
report.body.contains("_No changelog entries available._"),
"body: {}",
report.body
);
}
}