Skip to content

feat(changelog): changelog_message_build_hook can now generate multiple changelog entries from a single commit #1003

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 36 additions & 26 deletions commitizen/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -52,6 +52,7 @@
)

if TYPE_CHECKING:
from commitizen.cz.base import MessageBuilderHook
from commitizen.version_schemes import VersionScheme


Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions commitizen/cz/base.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand Down
4 changes: 2 additions & 2 deletions docs/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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[
Expand Down
26 changes: 26 additions & 0 deletions tests/test_changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
):
Expand Down