diff --git a/commitizen/changelog.py b/commitizen/changelog.py index 28481a1dea..5c713af0e6 100644 --- a/commitizen/changelog.py +++ b/commitizen/changelog.py @@ -31,7 +31,7 @@ from collections import OrderedDict, defaultdict from dataclasses import dataclass from datetime import date -from typing import TYPE_CHECKING, Callable, Iterable +from typing import TYPE_CHECKING, Iterable from jinja2 import ( BaseLoader, @@ -52,6 +52,7 @@ ) if TYPE_CHECKING: + from commitizen.cz.base import MessageBuilderHook from commitizen.version_schemes import VersionScheme @@ -111,7 +112,7 @@ def generate_tree_from_commits( changelog_pattern: str, unreleased_version: str | None = None, change_type_map: dict[str, str] | None = None, - changelog_message_builder_hook: Callable | None = None, + changelog_message_builder_hook: MessageBuilderHook | None = None, merge_prerelease: bool = False, scheme: VersionScheme = DEFAULT_SCHEME, ) -> Iterable[dict]: @@ -156,39 +157,48 @@ def generate_tree_from_commits( continue # Process subject from commit message - message = map_pat.match(commit.message) - if message: - parsed_message: dict = message.groupdict() - - if changelog_message_builder_hook: - parsed_message = changelog_message_builder_hook(parsed_message, commit) - if parsed_message: - change_type = parsed_message.pop("change_type", None) - if change_type_map: - change_type = change_type_map.get(change_type, change_type) - changes[change_type].append(parsed_message) + if message := map_pat.match(commit.message): + process_commit_message( + changelog_message_builder_hook, + message, + commit, + changes, + change_type_map, + ) # Process body from commit message body_parts = commit.body.split("\n\n") for body_part in body_parts: - message_body = body_map_pat.match(body_part) - if not message_body: - continue - parsed_message_body: dict = message_body.groupdict() - - if changelog_message_builder_hook: - parsed_message_body = changelog_message_builder_hook( - parsed_message_body, commit + if message := body_map_pat.match(body_part): + process_commit_message( + changelog_message_builder_hook, + message, + commit, + changes, + change_type_map, ) - if parsed_message_body: - change_type = parsed_message_body.pop("change_type", None) - if change_type_map: - change_type = change_type_map.get(change_type, change_type) - changes[change_type].append(parsed_message_body) yield {"version": current_tag_name, "date": current_tag_date, "changes": changes} +def process_commit_message( + hook: MessageBuilderHook | None, + parsed: re.Match[str], + commit: GitCommit, + changes: dict[str | None, list], + change_type_map: dict[str, str] | None = None, +): + message: dict = parsed.groupdict() + + if processed := hook(message, commit) if hook else message: + messages = [processed] if isinstance(processed, dict) else processed + for msg in messages: + change_type = msg.pop("change_type", None) + if change_type_map: + change_type = change_type_map.get(change_type, change_type) + changes[change_type].append(msg) + + def order_changelog_tree(tree: Iterable, change_type_order: list[str]) -> Iterable: if len(set(change_type_order)) != len(change_type_order): raise InvalidConfigurationError( diff --git a/commitizen/cz/base.py b/commitizen/cz/base.py index 766c252eee..5a84d1f101 100644 --- a/commitizen/cz/base.py +++ b/commitizen/cz/base.py @@ -1,7 +1,7 @@ from __future__ import annotations from abc import ABCMeta, abstractmethod -from typing import Any, Callable, Protocol +from typing import Any, Callable, Iterable, Protocol from jinja2 import BaseLoader, PackageLoader from prompt_toolkit.styles import Style, merge_styles @@ -14,7 +14,7 @@ class MessageBuilderHook(Protocol): def __call__( self, message: dict[str, Any], commit: git.GitCommit - ) -> dict[str, Any] | None: ... + ) -> dict[str, Any] | Iterable[dict[str, Any]] | None: ... class BaseCommitizen(metaclass=ABCMeta): diff --git a/docs/customization.md b/docs/customization.md index bc51cd2179..7d352f0313 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -318,7 +318,7 @@ You can customize it of course, and this are the variables you need to add to yo | `commit_parser` | `str` | NO | Regex which should provide the variables explained in the [changelog description][changelog-des] | | `changelog_pattern` | `str` | NO | Regex to validate the commits, this is useful to skip commits that don't meet your ruling standards like a Merge. Usually the same as bump_pattern | | `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 | 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_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 | ```python @@ -339,7 +339,7 @@ class StrangeCommitizen(BaseCommitizen): def changelog_message_builder_hook( self, parsed_message: dict, commit: git.GitCommit - ) -> dict | None: + ) -> dict | list | None: rev = commit.rev m = parsed_message["message"] parsed_message[ diff --git a/tests/test_changelog.py b/tests/test_changelog.py index af7846f6f0..7944f66dd8 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -1347,6 +1347,32 @@ def changelog_message_builder_hook(message: dict, commit: git.GitCommit): assert RE_HEADER.match(line), f"Line {no} should not be there: {line}" +def test_render_changelog_with_changelog_message_builder_hook_multiple_entries( + gitcommits, tags, any_changelog_format: ChangelogFormat +): + def changelog_message_builder_hook(message: dict, commit: git.GitCommit): + messages = [message.copy(), message.copy(), message.copy()] + for idx, msg in enumerate(messages): + msg["message"] = "Message #{idx}" + return messages + + parser = ConventionalCommitsCz.commit_parser + changelog_pattern = ConventionalCommitsCz.changelog_pattern + loader = ConventionalCommitsCz.template_loader + template = any_changelog_format.template + tree = changelog.generate_tree_from_commits( + gitcommits, + tags, + parser, + changelog_pattern, + changelog_message_builder_hook=changelog_message_builder_hook, + ) + result = changelog.render_changelog(tree, loader, template) + + for idx in range(3): + assert "Message #{idx}" in result + + def test_changelog_message_builder_hook_can_access_and_modify_change_type( gitcommits, tags, any_changelog_format: ChangelogFormat ):