Repository: MarcoIeni/release-plz (rust)

Issue:
Add configuration field to customize release PR name
## Motivations

Not everyone likes the format `chore: release v0.2.31`.

## Solution

Add a [config](https://github.com/MarcoIeni/release-plz/blob/46f6489613eeb5a68d01c7845f0622f722c80d6a/website/docs/config.md#L52) field called `pr_name`. Similar to the `git_tag_name` field, the `pr_name` field should be a tera template.

The field `{{ version }}` should be available

## Alternatives

<!-- Describe any alternative solutions or features you've considered. -->

## Additional context

<!-- Add any other context or screenshots about the feature request here. -->

Requested in https://github.com/MarcoIeni/release-plz/issues/1738


Pull request description:
feat: add pr_name config field
Close #1740 

Patch:
diff --git a/crates/release_plz/src/config.rs b/crates/release_plz/src/config.rs
index ef173b1f..7aa45270 100644
--- a/crates/release_plz/src/config.rs
+++ b/crates/release_plz/src/config.rs
@@ -125,6 +125,9 @@ pub struct Workspace {
     /// - If `true`, update all the dependencies in the Cargo.lock file by running `cargo update`.
     /// - If `false` or [`Option::None`], only update the workspace packages by running `cargo update --workspace`.
     pub dependencies_update: Option<bool>,
+    /// # PR Name
+    /// Tera template of the pull request's name created by release-plz.
+    pub pr_name: Option<String>,
     /// # PR Draft
     /// If `true`, the created release PR will be marked as a draft.
     #[serde(default)]
@@ -458,6 +461,7 @@ mod tests {
                     git_release_draft: Some(false),
                     ..Default::default()
                 },
+                pr_name: None,
                 pr_draft: false,
                 pr_labels: vec![],
                 pr_branch_prefix: Some("f-".to_string()),
@@ -566,6 +570,7 @@ mod tests {
                 changelog_config: Some("../git-cliff.toml".into()),
                 allow_dirty: None,
                 repo_url: Some("https://github.com/MarcoIeni/release-plz".parse().unwrap()),
+                pr_name: 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 d428137d..3861d6fa 100644
--- a/crates/release_plz/src/main.rs
+++ b/crates/release_plz/src/main.rs
@@ -83,12 +83,14 @@ fn get_release_pr_req(
     update_request: UpdateRequest,
 ) -> anyhow::Result<ReleasePrRequest> {
     let pr_branch_prefix = config.workspace.pr_branch_prefix.clone();
+    let pr_name = config.workspace.pr_name.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_branch_prefix(pr_branch_prefix)
+        .with_pr_name_template(pr_name);
     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 0ab6ed31..f466728a 100644
--- a/crates/release_plz_core/src/command/release_pr.rs
+++ b/crates/release_plz_core/src/command/release_pr.rs
@@ -17,6 +17,8 @@ use crate::{
 
 #[derive(Debug)]
 pub struct ReleasePrRequest {
+    /// Tera template for the release pull request name.
+    pr_name_template: Option<String>,
     /// If `true`, the created release PR will be marked as a draft.
     draft: bool,
     /// Labels to add to the release PR.
@@ -29,6 +31,7 @@ pub struct ReleasePrRequest {
 impl ReleasePrRequest {
     pub fn new(update_request: UpdateRequest) -> Self {
         Self {
+            pr_name_template: None,
             draft: false,
             labels: vec![],
             branch_prefix: DEFAULT_BRANCH_PREFIX.to_string(),
@@ -36,6 +39,11 @@ impl ReleasePrRequest {
         }
     }
 
+    pub fn with_pr_name_template(mut self, pr_name_template: Option<String>) -> Self {
+        self.pr_name_template = pr_name_template;
+        self
+    }
+
     pub fn with_labels(mut self, labels: Vec<String>) -> Self {
         self.labels = labels;
         self
@@ -109,9 +117,12 @@ pub async fn release_pr(input: &ReleasePrRequest) -> anyhow::Result<Option<Relea
                 &packages_to_update,
                 &git_client,
                 &repo,
-                input.draft,
-                input.labels.clone(),
-                input.branch_prefix.clone(),
+                ReleasePrOptions {
+                    draft: input.draft,
+                    pr_name: input.pr_name_template.clone(),
+                    pr_labels: input.labels.clone(),
+                    pr_branch_prefix: input.branch_prefix.clone(),
+                },
             )
             .await?;
             return Ok(Some(pr));
@@ -121,17 +132,22 @@ pub async fn release_pr(input: &ReleasePrRequest) -> anyhow::Result<Option<Relea
     Ok(None)
 }
 
+struct ReleasePrOptions {
+    draft: bool,
+    pr_name: Option<String>,
+    pr_labels: Vec<String>,
+    pr_branch_prefix: String,
+}
+
 async fn open_or_update_release_pr(
     local_manifest: &Utf8Path,
     packages_to_update: &PackagesUpdate,
     git_client: &GitClient,
     repo: &Repo,
-    draft: bool,
-    pr_labels: Vec<String>,
-    pr_branch_prefix: String,
+    release_pr_options: ReleasePrOptions,
 ) -> anyhow::Result<ReleasePr> {
     let mut opened_release_prs = git_client
-        .opened_prs(&pr_branch_prefix)
+        .opened_prs(&release_pr_options.pr_branch_prefix)
         .await
         .context("cannot get opened release-plz prs")?;
 
@@ -161,14 +177,22 @@ async fn open_or_update_release_pr(
             repo.original_branch(),
             packages_to_update,
             project_contains_multiple_pub_packages,
-            &pr_branch_prefix,
+            &release_pr_options.pr_branch_prefix,
+            release_pr_options.pr_name,
         )
-        .mark_as_draft(draft)
-        .with_labels(pr_labels)
+        .mark_as_draft(release_pr_options.draft)
+        .with_labels(release_pr_options.pr_labels)
     };
     match opened_release_prs.first() {
         Some(opened_pr) => {
-            handle_opened_pr(git_client, opened_pr, repo, &new_pr, &pr_branch_prefix).await
+            handle_opened_pr(
+                git_client,
+                opened_pr,
+                repo,
+                &new_pr,
+                &release_pr_options.pr_branch_prefix,
+            )
+            .await
         }
         None => create_pr(git_client, repo, &new_pr).await,
     }
@@ -231,9 +255,9 @@ async fn handle_opened_pr(
 async fn create_pr(git_client: &GitClient, repo: &Repo, pr: &Pr) -> anyhow::Result<ReleasePr> {
     repo.checkout_new_branch(&pr.branch)?;
     if matches!(git_client.backend, BackendType::Github) {
-        github_create_release_branch(git_client, repo, &pr.branch).await?;
+        github_create_release_branch(git_client, repo, &pr.branch, &pr.title).await?;
     } else {
-        create_release_branch(repo, &pr.branch)?;
+        create_release_branch(repo, &pr.branch, &pr.title)?;
     }
     debug!("changes committed to release branch {}", pr.branch);
 
@@ -338,7 +362,7 @@ fn reset_branch(
 }
 
 fn force_push(pr: &GitPr, repository: &Repo) -> anyhow::Result<()> {
-    add_changes_and_commit(repository)?;
+    add_changes_and_commit(repository, &pr.title)?;
     repository.force_push(pr.branch())?;
     Ok(())
 }
@@ -362,7 +386,7 @@ async fn github_force_push(
     // - If we revert the last commit of the release PR branch, GitHub will close the release PR
     //   because the branch is the same as the default branch. So we can't revert the latest release-plz commit and push the new one.
     // To learn more, see https://github.com/MarcoIeni/release-plz/issues/1487
-    github_create_release_branch(client, repository, &tmp_release_branch.name).await?;
+    github_create_release_branch(client, repository, &tmp_release_branch.name, &pr.title).await?;
 
     repository.fetch(&tmp_release_branch.name)?;
 
@@ -403,8 +427,12 @@ impl Drop for TmpBranch<'_> {
     }
 }
 
-fn create_release_branch(repository: &Repo, release_branch: &str) -> anyhow::Result<()> {
-    add_changes_and_commit(repository)?;
+fn create_release_branch(
+    repository: &Repo,
+    release_branch: &str,
+    commit_message: &str,
+) -> anyhow::Result<()> {
+    add_changes_and_commit(repository, commit_message)?;
     repository.push(release_branch)?;
     Ok(())
 }
@@ -413,14 +441,15 @@ async fn github_create_release_branch(
     client: &GitClient,
     repository: &Repo,
     release_branch: &str,
+    commit_message: &str,
 ) -> anyhow::Result<()> {
     repository.push(release_branch)?;
-    github_graphql::commit_changes(client, repository, "chore: release", release_branch).await
+    github_graphql::commit_changes(client, repository, commit_message, release_branch).await
 }
 
-fn add_changes_and_commit(repository: &Repo) -> anyhow::Result<()> {
+fn add_changes_and_commit(repository: &Repo, commit_message: &str) -> anyhow::Result<()> {
     let changes_expect_typechanges = repository.changes_except_typechanges()?;
     repository.add(&changes_expect_typechanges)?;
-    repository.commit_signed("chore: release")?;
+    repository.commit_signed(commit_message)?;
     Ok(())
 }
diff --git a/crates/release_plz_core/src/pr.rs b/crates/release_plz_core/src/pr.rs
index 01b2c574..9495762d 100644
--- a/crates/release_plz_core/src/pr.rs
+++ b/crates/release_plz_core/src/pr.rs
@@ -1,4 +1,7 @@
-use crate::PackagesUpdate;
+use crate::{
+    tera::{render_template, PACKAGE_VAR, VERSION_VAR},
+    PackagesUpdate,
+};
 use chrono::SecondsFormat;
 
 pub const DEFAULT_BRANCH_PREFIX: &str = "release-plz-";
@@ -20,11 +23,16 @@ impl Pr {
         packages_to_update: &PackagesUpdate,
         project_contains_multiple_pub_packages: bool,
         branch_prefix: &str,
+        title_template: Option<String>,
     ) -> Self {
         Self {
             branch: release_branch(branch_prefix),
             base_branch: default_branch.to_string(),
-            title: pr_title(packages_to_update, project_contains_multiple_pub_packages),
+            title: pr_title(
+                packages_to_update,
+                project_contains_multiple_pub_packages,
+                title_template,
+            ),
             body: pr_body(packages_to_update, project_contains_multiple_pub_packages),
             draft: false,
             labels: vec![],
@@ -54,6 +62,7 @@ fn release_branch(prefix: &str) -> String {
 fn pr_title(
     packages_to_update: &PackagesUpdate,
     project_contains_multiple_pub_packages: bool,
+    title_template: Option<String>,
 ) -> String {
     let updates = packages_to_update.updates();
     let first_version = &updates[0].1.version;
@@ -64,7 +73,20 @@ fn pr_title(
             .all(|(_, update)| &update.version == first_version)
     };
 
-    if updates.len() == 1 && project_contains_multiple_pub_packages {
+    if let Some(title_template) = title_template {
+        let mut context = tera::Context::new();
+
+        if updates.len() == 1 {
+            let (package, _) = &updates[0];
+            context.insert(PACKAGE_VAR, &package.name);
+        }
+
+        if are_all_versions_equal() {
+            context.insert(VERSION_VAR, first_version.to_string().as_str());
+        }
+
+        render_template(&title_template, &context, "pr_name")
+    } else if updates.len() == 1 && project_contains_multiple_pub_packages {
         let (package, _) = &updates[0];
         // The project is a workspace with multiple public packages and we are only updating one of them.
         // Specify which package is being updated in the PR title.
diff --git a/crates/release_plz_core/src/project.rs b/crates/release_plz_core/src/project.rs
index eae3ab99..9af3e109 100644
--- a/crates/release_plz_core/src/project.rs
+++ b/crates/release_plz_core/src/project.rs
@@ -33,6 +33,12 @@ pub struct Project {
     contains_multiple_pub_packages: bool,
 }
 
+#[derive(Clone, Copy, Debug)]
+enum TemplateField {
+    ReleaseName,
+    GitTagName,
+}
+
 impl Project {
     pub fn new(
         local_manifest: &Utf8Path,
@@ -117,41 +123,37 @@ impl Project {
     }
 
     pub fn git_tag(&self, package_name: &str, version: &str) -> String {
-        let mut tera = tera::Tera::default();
-        let context = tera_context(package_name, version);
-
-        let tag_template = self
-            .release_metadata
-            .get(package_name)
-            .and_then(|m| m.tag_name_template.clone())
-            .unwrap_or({
-                if self.contains_multiple_pub_packages {
-                    format!("{}-v{}", tera_var(PACKAGE_VAR), tera_var(VERSION_VAR))
-                } else {
-                    format!("v{}", tera_var(VERSION_VAR))
-                }
-            });
-
-        crate::tera::render_template(&mut tera, &tag_template, &context, "tag_name")
+        self.render_template(package_name, version, TemplateField::GitTagName)
     }
 
     pub fn release_name(&self, package_name: &str, version: &str) -> String {
-        let mut tera = tera::Tera::default();
-        let context = tera_context(package_name, version);
+        self.render_template(package_name, version, TemplateField::ReleaseName)
+    }
 
-        let name_template = self
-            .release_metadata
-            .get(package_name)
-            .and_then(|m| m.release_name_template.clone())
-            .unwrap_or({
-                if self.contains_multiple_pub_packages {
-                    format!("{}-v{}", tera_var(PACKAGE_VAR), tera_var(VERSION_VAR))
-                } else {
-                    format!("v{}", tera_var(VERSION_VAR))
-                }
-            });
-
-        crate::tera::render_template(&mut tera, &name_template, &context, "release_name")
+    fn render_template(&self, package_name: &str, version: &str, field: TemplateField) -> String {
+        let release_metadata = self.release_metadata.get(package_name);
+
+        let (template_name, template) = match field {
+            TemplateField::GitTagName => (
+                "tag_name",
+                release_metadata.and_then(|m| m.tag_name_template.clone()),
+            ),
+            TemplateField::ReleaseName => (
+                "release_name",
+                release_metadata.and_then(|m| m.release_name_template.clone()),
+            ),
+        };
+
+        let template = template.unwrap_or({
+            if self.contains_multiple_pub_packages {
+                format!("{}-v{}", tera_var(PACKAGE_VAR), tera_var(VERSION_VAR))
+            } else {
+                format!("v{}", tera_var(VERSION_VAR))
+            }
+        });
+
+        let context = tera_context(package_name, version);
+        crate::tera::render_template(&template, &context, template_name)
     }
 
     pub fn cargo_lock_path(&self) -> Utf8PathBuf {
diff --git a/crates/release_plz_core/src/tera.rs b/crates/release_plz_core/src/tera.rs
index 08183b0b..d35bf37b 100644
--- a/crates/release_plz_core/src/tera.rs
+++ b/crates/release_plz_core/src/tera.rs
@@ -16,7 +16,6 @@ pub fn release_body_from_template(
     remote: &Remote,
     body_template: Option<&str>,
 ) -> String {
-    let mut tera = tera::Tera::default();
     let mut context = tera_context(package_name, version);
     context.insert(CHANGELOG_VAR, changelog);
     context.insert(REMOTE_VAR, remote);
@@ -24,15 +23,12 @@ pub fn release_body_from_template(
     let default_body_template = tera_var(CHANGELOG_VAR);
     let body_template = body_template.unwrap_or(&default_body_template);
 
-    render_template(&mut tera, body_template, &context, "release_body")
+    render_template(body_template, &context, "release_body")
 }
 
-pub fn render_template(
-    tera: &mut tera::Tera,
-    template: &str,
-    context: &tera::Context,
-    template_name: &str,
-) -> String {
+pub fn render_template(template: &str, context: &tera::Context, template_name: &str) -> String {
+    let mut tera = tera::Tera::default();
+
     tera.add_raw_template(template_name, template)
         .expect("failed to add release_body raw template");
 
diff --git a/website/docs/config.md b/website/docs/config.md
index 41e980b9..effdaf40 100644
--- a/website/docs/config.md
+++ b/website/docs/config.md
@@ -22,6 +22,7 @@ changelog_update = false # disable changelog updates
 dependencies_update = true # update dependencies with `cargo update`
 git_release_enable = false # disable GitHub/Gitea releases
 pr_branch_prefix = "release-plz-" # PR branch prefix
+pr_name = "Release {{ package }} v{{ version }}" # template for the PR name
 pr_labels = ["release"] # add the `release` label to the release Pull Request
 publish_allow_dirty = true # add `--allow-dirty` to `cargo publish`
 semver_check = false # disable API breaking changes checks
@@ -72,6 +73,7 @@ the following sections:
   - [`git_tag_name`](#the-git_tag_name-field) — Customize git tag pattern.
   - [`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_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.
@@ -314,6 +316,39 @@ Where:
 - `{{ package }}` is the name of the package.
 - `{{ version }}` is the new version of the package.
 
+#### The `pr_name` field
+
+[Tera template](https://keats.github.io/tera/docs/#templates) of pull request's name that
+release-plz creates.
+
+By default, it's:
+
+- `chore({{ package }}): release v{{ version }}` for releasing only one package from a workspace with
+multiple publishable packages.
+  This happens when only one package changed.
+- `chore: release v{{ version }}` for releasing either:
+  - the only package of the project
+  - multiple packages with the same version
+- `chore: release` for releasing multiple packages with different versions.
+
+Where:
+
+- `{{ package }}` is the name of the package.
+- `{{ version }}` is the new version of the package(s).
+
+When using a custom template:
+
+- `{{ package }}` is populated only when releasing a single package.
+- `{{ version }}` is populated only when releasing a single package or multiple packages with the
+  same version.
+
+Here's an example of how you can customize the PR name template:
+
+```toml
+[workspace]
+pr_name = "release: {{ package }} {{ version }}"
+```
+
 #### 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 6444f682..540adf2a 100644
--- a/.schema/latest.json
+++ b/.schema/latest.json
@@ -53,6 +53,7 @@
         "pr_branch_prefix": null,
         "pr_draft": false,
         "pr_labels": [],
+        "pr_name": null,
         "publish": null,
         "publish_allow_dirty": null,
         "publish_features": null,
@@ -627,6 +628,14 @@
             "type": "string"
           }
         },
+        "pr_name": {
+          "title": "PR Name",
+          "description": "Tera template of the pull request's name created by release-plz.",
+          "type": [
+            "string",
+            "null"
+          ]
+        },
         "publish": {
           "title": "Publish",
           "description": "If `false`, don't run `cargo publish`.",
diff --git a/crates/release_plz/tests/all/release_pr.rs b/crates/release_plz/tests/all/release_pr.rs
index d2a1c2ea..ac0f18e6 100644
--- a/crates/release_plz/tests/all/release_pr.rs
+++ b/crates/release_plz/tests/all/release_pr.rs
@@ -2,6 +2,51 @@ use cargo_utils::LocalManifest;
 
 use crate::helpers::test_context::TestContext;
 
+#[tokio::test]
+#[cfg_attr(not(feature = "docker-tests"), ignore)]
+async fn release_plz_should_set_custom_pr_details() {
+    let context = TestContext::new().await;
+
+    let config = r#"
+    [workspace]
+    pr_name = "release: {{ package }} {{ version }}"
+    "#;
+
+    context.write_release_plz_toml(config);
+    context.run_release_pr().success();
+
+    let expected_title = format!("release: {} 0.1.0", context.gitea.repo);
+    let opened_prs = context.opened_release_prs().await;
+    assert_eq!(opened_prs.len(), 1);
+    assert_eq!(opened_prs[0].title, expected_title);
+
+    context.merge_release_pr().await;
+    // The commit contains the PR id number
+    let expected_commit = format!("{expected_title} (#1)");
+    assert_eq!(
+        context.repo.current_commit_message().unwrap(),
+        expected_commit
+    );
+}
+
+#[tokio::test]
+#[cfg_attr(not(feature = "docker-tests"), ignore)]
+async fn release_plz_should_fail_for_multi_package_pr() {
+    let context = TestContext::new_workspace(&["crates/one", "crates/two"]).await;
+
+    let config = r#"
+    [workspace]
+    pr_name = "release: {{ package }} {{ version }}"
+    "#;
+
+    context.write_release_plz_toml(config);
+    // This should fail because the workspace contains multiple packages
+    // so the `package` variable is not available
+    let outcome = context.run_release_pr().failure();
+    let stderr = String::from_utf8_lossy(&outcome.get_output().stderr);
+    assert!(stderr.contains("failed to render pr_name"));
+}
+
 #[tokio::test]
 #[ignore = "This test fails in CI, but works locally on MacOS. TODO: fix this."]
 #[cfg_attr(not(feature = "docker-tests"), ignore)]