diff --git a/commitizen/changelog.py b/commitizen/changelog.py index 5c713af0e6..7caa64c640 100644 --- a/commitizen/changelog.py +++ b/commitizen/changelog.py @@ -43,6 +43,7 @@ from commitizen import out from commitizen.bump import normalize_tag +from commitizen.cz.base import ChangelogReleaseHook from commitizen.exceptions import InvalidConfigurationError, NoCommitsFoundError from commitizen.git import GitCommit, GitTag from commitizen.version_schemes import ( @@ -113,6 +114,7 @@ def generate_tree_from_commits( unreleased_version: str | None = None, change_type_map: dict[str, str] | None = None, changelog_message_builder_hook: MessageBuilderHook | None = None, + changelog_release_hook: ChangelogReleaseHook | None = None, merge_prerelease: bool = False, scheme: VersionScheme = DEFAULT_SCHEME, ) -> Iterable[dict]: @@ -143,11 +145,14 @@ def generate_tree_from_commits( commit_tag, used_tags, merge_prerelease, scheme=scheme ): used_tags.append(commit_tag) - yield { + release = { "version": current_tag_name, "date": current_tag_date, "changes": changes, } + if changelog_release_hook: + release = changelog_release_hook(release, commit_tag) + yield release current_tag_name = commit_tag.name current_tag_date = commit_tag.date changes = defaultdict(list) @@ -178,7 +183,14 @@ def generate_tree_from_commits( change_type_map, ) - yield {"version": current_tag_name, "date": current_tag_date, "changes": changes} + release = { + "version": current_tag_name, + "date": current_tag_date, + "changes": changes, + } + if changelog_release_hook: + release = changelog_release_hook(release, commit_tag) + yield release def process_commit_message( diff --git a/commitizen/commands/changelog.py b/commitizen/commands/changelog.py index 5d710ed5d0..1babf69d0b 100644 --- a/commitizen/commands/changelog.py +++ b/commitizen/commands/changelog.py @@ -9,7 +9,7 @@ from commitizen import bump, changelog, defaults, factory, git, out from commitizen.config import BaseConfig -from commitizen.cz.base import MessageBuilderHook +from commitizen.cz.base import MessageBuilderHook, ChangelogReleaseHook from commitizen.exceptions import ( DryRunExit, NoCommitsFoundError, @@ -154,6 +154,9 @@ def __call__(self): changelog_message_builder_hook: MessageBuilderHook | None = ( self.cz.changelog_message_builder_hook ) + changelog_release_hook: ChangelogReleaseHook | None = ( + self.cz.changelog_release_hook + ) merge_prerelease = self.merge_prerelease if self.export_template_to: @@ -207,6 +210,7 @@ def __call__(self): unreleased_version, change_type_map=change_type_map, changelog_message_builder_hook=changelog_message_builder_hook, + changelog_release_hook=changelog_release_hook, merge_prerelease=merge_prerelease, scheme=self.scheme, ) diff --git a/commitizen/cz/base.py b/commitizen/cz/base.py index 5a84d1f101..bd116ceb02 100644 --- a/commitizen/cz/base.py +++ b/commitizen/cz/base.py @@ -17,6 +17,12 @@ def __call__( ) -> dict[str, Any] | Iterable[dict[str, Any]] | None: ... +class ChangelogReleaseHook(Protocol): + def __call__( + self, release: dict[str, Any], tag: git.GitTag | None + ) -> dict[str, Any]: ... + + class BaseCommitizen(metaclass=ABCMeta): bump_pattern: str | None = None bump_map: dict[str, str] | None = None @@ -48,6 +54,9 @@ class BaseCommitizen(metaclass=ABCMeta): # Executed only at the end of the changelog generation changelog_hook: Callable[[str, str | None], str] | None = None + # Executed for each release in the changelog + changelog_release_hook: ChangelogReleaseHook | None = None + # Plugins can override templates and provide extra template data template_loader: BaseLoader = PackageLoader("commitizen", "templates") template_extras: dict[str, Any] = {} diff --git a/docs/customization.md b/docs/customization.md index 7d352f0313..24f749a1eb 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -320,6 +320,7 @@ You can customize it of course, and this are the variables you need to add to yo | `change_type_map` | `dict` | NO | Convert the title of the change type that will appear in the changelog, if a value is not found, the original will be provided | | `changelog_message_builder_hook` | `method: (dict, git.GitCommit) -> dict | list | None` | NO | Customize with extra information your message output, like adding links, this function is executed per parsed commit. Each GitCommit contains the following attrs: `rev`, `title`, `body`, `author`, `author_email`. Returning a falsy value ignore the commit. | | `changelog_hook` | `method: (full_changelog: str, partial_changelog: Optional[str]) -> str` | NO | Receives the whole and partial (if used incremental) changelog. Useful to send slack messages or notify a compliance department. Must return the full_changelog | +| `changelog_release_hook` | `method: (release: dict, tag: git.GitTag) -> dict` | NO | Receives each generated changelog release and its associated tag. Useful to enrich a releases before they are rendered. Must return the update release ```python from commitizen.cz.base import BaseCommitizen @@ -347,6 +348,10 @@ class StrangeCommitizen(BaseCommitizen): ] = f"{m} {rev} [{commit.author}]({commit.author_email})" return parsed_message + def changelog_release_hook(self, release: dict, tag: git.GitTag) -> dict: + release["author"] = tag.author + return release + def changelog_hook( self, full_changelog: str, partial_changelog: Optional[str] ) -> str: diff --git a/tests/commands/test_changelog_command.py b/tests/commands/test_changelog_command.py index 971d04cce5..62e5fba0bb 100644 --- a/tests/commands/test_changelog_command.py +++ b/tests/commands/test_changelog_command.py @@ -323,6 +323,28 @@ def test_changelog_hook_customize(mocker: MockFixture, config_customize): changelog_hook_mock.assert_called_with(full_changelog, full_changelog) +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_release_hook(mocker: MockFixture, config): + def changelog_release_hook(release: dict, tag: git.GitTag) -> dict: + return release + + for i in range(3): + create_file_and_commit("feat: new file") + create_file_and_commit("refactor: is in changelog") + create_file_and_commit("Merge into master") + git.tag(f"0.{i + 1}.0") + + # changelog = Changelog(config, {}) + changelog = Changelog( + config, {"unreleased_version": None, "incremental": True, "dry_run": False} + ) + mocker.patch.object(changelog.cz, "changelog_release_hook", changelog_release_hook) + spy = mocker.spy(changelog.cz, "changelog_release_hook") + changelog() + + assert spy.call_count == 3 + + @pytest.mark.usefixtures("tmp_commitizen_project") def test_changelog_with_non_linear_merges_commit_order( mocker: MockFixture, config_customize diff --git a/tests/test_changelog.py b/tests/test_changelog.py index 7944f66dd8..831e013e3c 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -1,6 +1,7 @@ import re from pathlib import Path +from typing import Optional import pytest from jinja2 import FileSystemLoader @@ -1404,6 +1405,26 @@ def changelog_message_builder_hook(message: dict, commit: git.GitCommit): ), f"Line {no}: type {change_type} should have been overridden" +def test_render_changelog_with_changelog_release_hook( + gitcommits, tags, any_changelog_format: ChangelogFormat +): + def changelog_release_hook(release: dict, tag: Optional[git.GitTag]) -> dict: + release["extra"] = "whatever" + return release + + parser = ConventionalCommitsCz.commit_parser + changelog_pattern = ConventionalCommitsCz.changelog_pattern + tree = changelog.generate_tree_from_commits( + gitcommits, + tags, + parser, + changelog_pattern, + changelog_release_hook=changelog_release_hook, + ) + for release in tree: + assert release["extra"] == "whatever" + + def test_get_smart_tag_range_returns_an_extra_for_a_range(tags): start, end = ( tags[0],