Repository: MarcoIeni/release-plz (rust)

Issue:
Add configuration field to customize the pr body
## Motivations

Not everyone likes the default release PR body.

## Solution

Add a [config](https://github.com/MarcoIeni/release-plz/blob/46f6489613eeb5a68d01c7845f0622f722c80d6a/website/docs/config.md#L52) field called `pr_body`. Similar to the `git_tag_name` field, the `pr_body` field should be a tera template.

Edit [this](https://github.com/MarcoIeni/release-plz/blob/46f6489613eeb5a68d01c7845f0622f722c80d6a/crates/release_plz_core/src/pr.rs#L83) function.

You need to add to the template a context with the array `{{ releases }}` (Similar to how `{{ remote.contributors }}` works in [git_release_body](https://github.com/MarcoIeni/release-plz/blob/69ad93ecae7ab480469168aa9b4f29182d48ff80/website/docs/config.md#L224)).

Those two should be two objects so that people can format them how they want.

- array `releases` (to find all these data check in [changes](https://github.com/MarcoIeni/release-plz/blob/0cf73c99ef6908f579d96978136e455432a7a3df/crates/release_plz_core/src/command/update.rs#L77) and [updates_summary](https://github.com/MarcoIeni/release-plz/blob/0cf73c99ef6908f579d96978136e455432a7a3df/crates/release_plz_core/src/command/update.rs#L57) functions:
  - `package` (e.g. `my_package_name`)
  - `title` (e.g. `[0.3.96](https://github.com/MarcoIeni/release-plz/compare/release-plz-v0.3.95...release-plz-v0.3.96) - 2024-10-05`)
  - `changelog` (e.g. `### Added ...`)
  - `previous_version` (e.g. `1.2.3`)
  - `next_version` (e.g. `1.2.4`)

## Alternatives

<!-- Describe any alternative solutions or features you've considered. -->

## Additional context

<!-- Add any other context or screenshots about the feature request here. -->
asked in https://github.com/MarcoIeni/release-plz/issues/1738

Pull request description:
feat: add pr_body field
Closes #1741 

Patch:
diff --git a/crates/release_plz/src/config.rs b/crates/release_plz/src/config.rs
index 7aa45270..fa55e9ad 100644
--- a/crates/release_plz/src/config.rs
+++ b/crates/release_plz/src/config.rs
@@ -128,6 +128,9 @@ pub struct Workspace {
     /// # PR Name
     /// Tera template of the pull request's name created by release-plz.
     pub pr_name: Option<String>,
+    /// # PR Body
+    /// Tera template of the pull request's body created by release-plz.
+    pub pr_body: Option<String>,
     /// # PR Draft
     /// If `true`, the created release PR will be marked as a draft.
     #[serde(default)]
@@ -462,6 +465,7 @@ mod tests {
                     ..Default::default()
                 },
                 pr_name: None,
+                pr_body: None,
                 pr_draft: false,
                 pr_labels: vec![],
                 pr_branch_prefix: Some("f-".to_string()),
@@ -571,6 +575,7 @@ mod tests {
                 allow_dirty: None,
                 repo_url: Some("https://github.com/MarcoIeni/release-plz".parse().unwrap()),
                 pr_name: None,
+                pr_body: None,
                 pr_draft: false,
                 pr_labels: vec!["label1".to_string()],
                 pr_branch_prefix: Some("f-".to_string()),
diff --git a/crates/release_plz/src/main.rs b/crates/release_plz/src/main.rs
index c3cee005..247bec73 100644
--- a/crates/release_plz/src/main.rs
+++ b/crates/release_plz/src/main.rs
@@ -88,13 +88,15 @@ fn get_release_pr_req(
 ) -> anyhow::Result<ReleasePrRequest> {
     let pr_branch_prefix = config.workspace.pr_branch_prefix.clone();
     let pr_name = config.workspace.pr_name.clone();
+    let pr_body = config.workspace.pr_body.clone();
     let pr_labels = config.workspace.pr_labels.clone();
     let pr_draft = config.workspace.pr_draft;
     let request = ReleasePrRequest::new(update_request)
         .mark_as_draft(pr_draft)
         .with_labels(pr_labels)
         .with_branch_prefix(pr_branch_prefix)
-        .with_pr_name_template(pr_name);
+        .with_pr_name_template(pr_name)
+        .with_pr_body_template(pr_body);
     Ok(request)
 }
 
diff --git a/crates/release_plz_core/src/command/release_pr.rs b/crates/release_plz_core/src/command/release_pr.rs
index f466728a..e8598f48 100644
--- a/crates/release_plz_core/src/command/release_pr.rs
+++ b/crates/release_plz_core/src/command/release_pr.rs
@@ -19,6 +19,8 @@ use crate::{
 pub struct ReleasePrRequest {
     /// Tera template for the release pull request name.
     pr_name_template: Option<String>,
+    /// Tera template for the release pull request body.
+    pr_body_template: Option<String>,
     /// If `true`, the created release PR will be marked as a draft.
     draft: bool,
     /// Labels to add to the release PR.
@@ -32,6 +34,7 @@ impl ReleasePrRequest {
     pub fn new(update_request: UpdateRequest) -> Self {
         Self {
             pr_name_template: None,
+            pr_body_template: None,
             draft: false,
             labels: vec![],
             branch_prefix: DEFAULT_BRANCH_PREFIX.to_string(),
@@ -44,6 +47,11 @@ impl ReleasePrRequest {
         self
     }
 
+    pub fn with_pr_body_template(mut self, pr_body_template: Option<String>) -> Self {
+        self.pr_body_template = pr_body_template;
+        self
+    }
+
     pub fn with_labels(mut self, labels: Vec<String>) -> Self {
         self.labels = labels;
         self
@@ -120,6 +128,7 @@ pub async fn release_pr(input: &ReleasePrRequest) -> anyhow::Result<Option<Relea
                 ReleasePrOptions {
                     draft: input.draft,
                     pr_name: input.pr_name_template.clone(),
+                    pr_body: input.pr_body_template.clone(),
                     pr_labels: input.labels.clone(),
                     pr_branch_prefix: input.branch_prefix.clone(),
                 },
@@ -135,6 +144,7 @@ pub async fn release_pr(input: &ReleasePrRequest) -> anyhow::Result<Option<Relea
 struct ReleasePrOptions {
     draft: bool,
     pr_name: Option<String>,
+    pr_body: Option<String>,
     pr_labels: Vec<String>,
     pr_branch_prefix: String,
 }
@@ -179,6 +189,7 @@ async fn open_or_update_release_pr(
             project_contains_multiple_pub_packages,
             &release_pr_options.pr_branch_prefix,
             release_pr_options.pr_name,
+            release_pr_options.pr_body,
         )
         .mark_as_draft(release_pr_options.draft)
         .with_labels(release_pr_options.pr_labels)
diff --git a/crates/release_plz_core/src/command/update.rs b/crates/release_plz_core/src/command/update.rs
index b58994f7..211b6b88 100644
--- a/crates/release_plz_core/src/command/update.rs
+++ b/crates/release_plz_core/src/command/update.rs
@@ -7,6 +7,7 @@ use cargo_metadata::{semver::Version, Package};
 use cargo_utils::LocalManifest;
 use cargo_utils::{upgrade_requirement, CARGO_TOML};
 use git_cmd::Repo;
+use serde::{Deserialize, Serialize};
 use std::iter;
 use tracing::{info, warn};
 
@@ -47,6 +48,15 @@ impl PackagesUpdate {
     }
 }
 
+#[derive(Serialize, Deserialize)]
+pub struct ReleaseInfo {
+    package: String,
+    title: Option<String>,
+    changelog: Option<String>,
+    previous_version: String,
+    next_version: String,
+}
+
 impl PackagesUpdate {
     pub fn summary(&self) -> String {
         let updates = self.updates_summary();
@@ -123,6 +133,26 @@ impl PackagesUpdate {
             })
             .collect()
     }
+
+    pub fn releases(&self) -> Vec<ReleaseInfo> {
+        self.updates
+            .iter()
+            .map(|(package, update)| {
+                let changelog = update.last_changes().unwrap_or(None);
+                let (changelog_title, changelog_notes) = changelog.map_or((None, None), |c| {
+                    (Some(c.title().to_string()), Some(c.notes().to_string()))
+                });
+
+                ReleaseInfo {
+                    package: package.name.clone(),
+                    title: changelog_title,
+                    changelog: changelog_notes,
+                    next_version: update.version.to_string(),
+                    previous_version: package.version.to_string(),
+                }
+            })
+            .collect()
+    }
 }
 
 /// Update a local rust project
diff --git a/crates/release_plz_core/src/pr.rs b/crates/release_plz_core/src/pr.rs
index 9495762d..dcb8a52e 100644
--- a/crates/release_plz_core/src/pr.rs
+++ b/crates/release_plz_core/src/pr.rs
@@ -1,5 +1,5 @@
 use crate::{
-    tera::{render_template, PACKAGE_VAR, VERSION_VAR},
+    tera::{render_template, PACKAGE_VAR, RELEASES_VAR, VERSION_VAR},
     PackagesUpdate,
 };
 use chrono::SecondsFormat;
@@ -24,6 +24,7 @@ impl Pr {
         project_contains_multiple_pub_packages: bool,
         branch_prefix: &str,
         title_template: Option<String>,
+        body_template: Option<String>,
     ) -> Self {
         Self {
             branch: release_branch(branch_prefix),
@@ -33,7 +34,11 @@ impl Pr {
                 project_contains_multiple_pub_packages,
                 title_template,
             ),
-            body: pr_body(packages_to_update, project_contains_multiple_pub_packages),
+            body: pr_body(
+                packages_to_update,
+                project_contains_multiple_pub_packages,
+                body_template,
+            ),
             draft: false,
             labels: vec![],
         }
@@ -103,13 +108,33 @@ fn pr_title(
     }
 }
 
+/// The Github API allows a max of 65536 characters in the body field when trying to create a new PR
+const MAX_BODY_LEN: usize = 65536;
+
 fn pr_body(
     packages_to_update: &PackagesUpdate,
     project_contains_multiple_pub_packages: bool,
+    body_template: Option<String>,
 ) -> String {
-    /// The Github API allows a max of 65536 characters in the body field when trying to create a new PR
-    const MAX_BODY_LEN: usize = 65536;
+    if let Some(body_template) = body_template {
+        pr_body_custom(packages_to_update, body_template.as_str())
+    } else {
+        pr_body_default(packages_to_update, project_contains_multiple_pub_packages)
+    }
+}
+
+fn pr_body_custom(packages_to_update: &PackagesUpdate, body_template: &str) -> String {
+    let releases = packages_to_update.releases();
+    let mut context = tera::Context::new();
+    context.insert(RELEASES_VAR, &releases);
+
+    trim_pr_body(render_template(body_template, &context, "pr_body"))
+}
 
+fn pr_body_default(
+    packages_to_update: &PackagesUpdate,
+    project_contains_multiple_pub_packages: bool,
+) -> String {
     let header = "## 🤖 New release";
 
     let summary = packages_to_update.summary();
@@ -130,17 +155,20 @@ fn pr_body(
             "PR body is longer than {MAX_BODY_LEN} characters. Omitting full changelog."
         );
         formatted = format!("{header}{summary}\n{footer}");
-
-        // Make extra sure the body is short enough.
-        // If it's not, give up trying to fail gracefully by truncating it to the nearest valid UTF-8 boundary.
-        // A grapheme cluster may be cut in half in the process.
-        if formatted.chars().count() > MAX_BODY_LEN {
-            tracing::warn!(
-                "PR body is still longer than {MAX_BODY_LEN} characters. Truncating as is."
-            );
-            formatted = formatted.chars().take(MAX_BODY_LEN).collect();
-        }
     }
 
-    formatted
+    trim_pr_body(formatted)
+}
+
+fn trim_pr_body(body: String) -> String {
+    // Make extra sure the body is short enough.
+    // If it's not, give up trying to fail gracefully by truncating it to the nearest valid UTF-8 boundary.
+    // A grapheme cluster may be cut in half in the process.
+
+    if body.chars().count() > MAX_BODY_LEN {
+        tracing::warn!("PR body is still longer than {MAX_BODY_LEN} characters. Truncating as is.");
+        body.chars().take(MAX_BODY_LEN).collect()
+    } else {
+        body
+    }
 }
diff --git a/crates/release_plz_core/src/tera.rs b/crates/release_plz_core/src/tera.rs
index d35bf37b..cc32ac99 100644
--- a/crates/release_plz_core/src/tera.rs
+++ b/crates/release_plz_core/src/tera.rs
@@ -4,6 +4,7 @@ pub const PACKAGE_VAR: &str = "package";
 pub const VERSION_VAR: &str = "version";
 pub const CHANGELOG_VAR: &str = "changelog";
 pub const REMOTE_VAR: &str = "remote";
+pub const RELEASES_VAR: &str = "releases";
 
 pub fn tera_var(var_name: &str) -> String {
     format!("{{{{ {var_name} }}}}")
diff --git a/website/docs/config.md b/website/docs/config.md
index 690cd12b..6349eb13 100644
--- a/website/docs/config.md
+++ b/website/docs/config.md
@@ -74,6 +74,7 @@ the following sections:
   - [`pr_branch_prefix`](#the-pr_branch_prefix-field) — Release PR branch prefix.
   - [`pr_draft`](#the-pr_draft-field) — Open the release Pull Request as a draft.
   - [`pr_name`](#the-pr_name-field) — Customize the name of the release Pull Request.
+  - [`pr_body`](#the-pr_body-field) — Customize the body of the release Pull Request.
   - [`pr_labels`](#the-pr_labels-field) — Add labels to the release Pull Request.
   - [`publish`](#the-publish-field) — Publish to cargo registry.
   - [`publish_allow_dirty`](#the-publish_allow_dirty-field) — Package dirty directories.
@@ -349,6 +350,48 @@ Here's an example of how you can customize the PR name template:
 pr_name = "release: {{ package }} {{ version }}"
 ```
 
+#### The `pr_body` field
+
+[Tera template](https://keats.github.io/tera/docs/#templates) of pull request's body that
+release-plz creates.
+
+By default it contains the summary of package updates, the changelog for each package, a section
+for breaking changes, and a footer with credits for release-plz. The text is trimmed to a length
+of 65536, because that's the limit imposed by Github.
+
+Here is an example of how you can customize the PR body template:
+
+```toml
+[workspace]
+pr_body = """
+{% for release in releases %}
+{% if release.title %}
+### {{release.title}}
+{% endif %}
+Package: {{release.package}} {{release.previous_version}} -> {{release.next_version}}
+{% if release.changelog %}
+Changes:
+{{release.changelog}}
+{% endif %}
+{% endfor -%}
+"""
+```
+
+Where:
+
+:::warning
+`{{ release.title }}` and `{{ release.changelog }}` may be unset if the changelog could
+not be parsed or it's not available. Please use `{% if <variable> %}` structures
+to check for their existence.
+:::
+
+- `{{ releases }}` - is an array with the update information of each package.
+- `{{ release.title }}` - is the changelog title containing a link to the release tag diff. *(Optional)*.
+- `{{ release.package }}` - is the name of the package being updated.
+- `{{ release.changelog }}` - is the generated changelog. *(Optional)*.
+- `{{ release.previous_version }}` - is the previous version of the package.
+- `{{ release.next_version }}` - is the version of the package being released.
+
 #### The `pr_branch_prefix` field
 
 Prefix for the release PR branch. By default, it's set to: `release-plz-`


Test patch:
diff --git a/.schema/latest.json b/.schema/latest.json
index 540adf2a..ff75c160 100644
--- a/.schema/latest.json
+++ b/.schema/latest.json
@@ -50,6 +50,7 @@
         "git_release_type": null,
         "git_tag_enable": null,
         "git_tag_name": null,
+        "pr_body": null,
         "pr_branch_prefix": null,
         "pr_draft": false,
         "pr_labels": [],
@@ -605,6 +606,14 @@
             "null"
           ]
         },
+        "pr_body": {
+          "title": "PR Body",
+          "description": "Tera template of the pull request's body created by release-plz.",
+          "type": [
+            "string",
+            "null"
+          ]
+        },
         "pr_branch_prefix": {
           "title": "PR Branch Prefix",
           "description": "Prefix for the PR Branch",
diff --git a/crates/release_plz/tests/all/release_pr.rs b/crates/release_plz/tests/all/release_pr.rs
index ac0f18e6..36a96394 100644
--- a/crates/release_plz/tests/all/release_pr.rs
+++ b/crates/release_plz/tests/all/release_pr.rs
@@ -1,4 +1,5 @@
 use cargo_utils::LocalManifest;
+use chrono::Local;
 
 use crate::helpers::test_context::TestContext;
 
@@ -8,8 +9,20 @@ async fn release_plz_should_set_custom_pr_details() {
     let context = TestContext::new().await;
 
     let config = r#"
-    [workspace]
-    pr_name = "release: {{ package }} {{ version }}"
+[workspace]
+pr_name = "release: {{ package }} {{ version }}"
+pr_body = """
+{% for release in releases %}
+{% if release.title %}
+### {{release.title}}
+{% endif %}
+Package: {{release.package}} {{release.previous_version}} -> {{release.next_version}}
+{% if release.changelog %}
+Changes:
+{{release.changelog}}
+{% endif %}
+{% endfor -%}
+"""
     "#;
 
     context.write_release_plz_toml(config);
@@ -17,8 +30,30 @@ async fn release_plz_should_set_custom_pr_details() {
 
     let expected_title = format!("release: {} 0.1.0", context.gitea.repo);
     let opened_prs = context.opened_release_prs().await;
+    let now = Local::now();
     assert_eq!(opened_prs.len(), 1);
     assert_eq!(opened_prs[0].title, expected_title);
+    assert_eq!(
+        opened_prs[0].body.as_ref().unwrap().trim(),
+        format!(
+            r#"
+### [0.1.0](https://localhost/{}/{}/releases/tag/v0.1.0) - {}
+
+Package: {} 0.1.0 -> 0.1.0
+
+Changes:
+### Other
+
+- add config file
+- cargo init
+- Initial commit"#,
+            context.gitea.user.username(),
+            context.gitea.repo,
+            now.format("%Y-%m-%d"),
+            context.gitea.repo
+        )
+        .trim()
+    );
 
     context.merge_release_pr().await;
     // The commit contains the PR id number