diff --git a/commitizen/changelog.py b/commitizen/changelog.py index c701f4fcd9..bde2d51763 100644 --- a/commitizen/changelog.py +++ b/commitizen/changelog.py @@ -29,7 +29,7 @@ import re from collections import defaultdict from datetime import date -from typing import Dict, Iterable, List, Optional +from typing import Callable, Dict, Iterable, List, Optional import pkg_resources from jinja2 import Template @@ -72,6 +72,8 @@ def generate_tree_from_commits( commit_parser: str, changelog_pattern: str = defaults.bump_pattern, unreleased_version: Optional[str] = None, + change_type_map: Optional[Dict[str, str]] = None, + changelog_message_builder_hook: Optional[Callable] = None, ) -> Iterable[Dict]: pat = re.compile(changelog_pattern) map_pat = re.compile(commit_parser) @@ -112,10 +114,14 @@ def generate_tree_from_commits( message = map_pat.match(commit.message) message_body = map_pat.match(commit.body) if message: - # TODO: add a post hook coming from a rule (CzBase) parsed_message: Dict = message.groupdict() # change_type becomes optional by providing None change_type = parsed_message.pop("change_type", None) + + if change_type_map: + change_type = change_type_map.get(change_type, change_type) + if changelog_message_builder_hook: + parsed_message = changelog_message_builder_hook(parsed_message, commit) changes[change_type].append(parsed_message) if message_body: parsed_message_body: Dict = message_body.groupdict() diff --git a/commitizen/commands/changelog.py b/commitizen/commands/changelog.py index 0d6f8bb426..93cd26d793 100644 --- a/commitizen/commands/changelog.py +++ b/commitizen/commands/changelog.py @@ -1,7 +1,7 @@ import os.path from difflib import SequenceMatcher from operator import itemgetter -from typing import Dict, List +from typing import Callable, Dict, List, Optional from commitizen import changelog, factory, git, out from commitizen.config import BaseConfig @@ -27,6 +27,9 @@ def __init__(self, config: BaseConfig, args): self.incremental = args["incremental"] self.dry_run = args["dry_run"] self.unreleased_version = args["unreleased_version"] + self.change_type_map = ( + self.config.settings.get("change_type_map") or self.cz.change_type_map + ) def _find_incremental_rev(self, latest_version: str, tags: List[GitTag]) -> str: """Try to find the 'start_rev'. @@ -60,6 +63,11 @@ def __call__(self): start_rev = self.start_rev unreleased_version = self.unreleased_version changelog_meta: Dict = {} + change_type_map: Optional[Dict] = self.change_type_map + changelog_message_builder_hook: Optional[ + Callable + ] = self.cz.changelog_message_builder_hook + changelog_hook: Optional[Callable] = self.cz.changelog_hook if not changelog_pattern or not commit_parser: out.error( @@ -83,7 +91,13 @@ def __call__(self): raise SystemExit(NO_COMMITS_FOUND) tree = changelog.generate_tree_from_commits( - commits, tags, commit_parser, changelog_pattern, unreleased_version + commits, + tags, + commit_parser, + changelog_pattern, + unreleased_version, + change_type_map=change_type_map, + changelog_message_builder_hook=changelog_message_builder_hook, ) changelog_out = changelog.render_changelog(tree) @@ -97,10 +111,14 @@ def __call__(self): lines = changelog_file.readlines() with open(self.file_name, "w") as changelog_file: + partial_changelog: Optional[str] = None if self.incremental: new_lines = changelog.incremental_build( changelog_out, lines, changelog_meta ) - changelog_file.writelines(new_lines) - else: - changelog_file.write(changelog_out) + changelog_out = "".join(new_lines) + partial_changelog = changelog_out + + if changelog_hook: + changelog_out = changelog_hook(changelog_out, partial_changelog) + changelog_file.write(changelog_out) diff --git a/commitizen/commands/version.py b/commitizen/commands/version.py index 344920c72a..143a0c7351 100644 --- a/commitizen/commands/version.py +++ b/commitizen/commands/version.py @@ -16,14 +16,14 @@ def __call__(self): if version: out.write(f"{version}") else: - out.error(f"No project information in this project.") + out.error("No project information in this project.") elif self.parameter.get("verbose"): out.write(f"Installed Commitizen Version: {__version__}") version = self.config.settings["version"] if version: out.write(f"Project Version: {version}") else: - out.error(f"No project information in this project.") + out.error("No project information in this project.") else: # if no argument is given, show installed commitizen version out.write(f"{__version__}") diff --git a/commitizen/cz/base.py b/commitizen/cz/base.py index c6e74d95b0..ddf0c7997c 100644 --- a/commitizen/cz/base.py +++ b/commitizen/cz/base.py @@ -1,8 +1,9 @@ from abc import ABCMeta, abstractmethod -from typing import List, Optional, Tuple +from typing import Callable, Dict, List, Optional, Tuple from prompt_toolkit.styles import Style, merge_styles +from commitizen import git from commitizen.config.base_config import BaseConfig @@ -27,7 +28,15 @@ class BaseCommitizen(metaclass=ABCMeta): # It can be modified per rule commit_parser: Optional[str] = r"(?P.*)" changelog_pattern: Optional[str] = r".*" - changelog_map: Optional[dict] = None # TODO: Use it + change_type_map: Optional[Dict[str, str]] = None + + # Executed per message parsed by the commitizen + changelog_message_builder_hook: Optional[ + Callable[[Dict, git.GitCommit], Dict] + ] = None + + # Executed only at the end of the changelog generation + changelog_hook: Optional[Callable[[str, Optional[str]], str]] = None def __init__(self, config: BaseConfig): self.config = config diff --git a/commitizen/cz/conventional_commits/conventional_commits.py b/commitizen/cz/conventional_commits/conventional_commits.py index b932bd6682..e3fbb8c269 100644 --- a/commitizen/cz/conventional_commits/conventional_commits.py +++ b/commitizen/cz/conventional_commits/conventional_commits.py @@ -32,6 +32,12 @@ class ConventionalCommitsCz(BaseCommitizen): bump_map = defaults.bump_map commit_parser = defaults.commit_parser changelog_pattern = defaults.bump_pattern + change_type_map = { + "feat": "Feat", + "fix": "Fix", + "refactor": "Refactor", + "perf": "Perf", + } def questions(self) -> List[Dict[str, Any]]: questions: List[Dict[str, Any]] = [ diff --git a/commitizen/templates/keep_a_changelog_template.j2 b/commitizen/templates/keep_a_changelog_template.j2 index be0d0d26e6..381fe88bf3 100644 --- a/commitizen/templates/keep_a_changelog_template.j2 +++ b/commitizen/templates/keep_a_changelog_template.j2 @@ -5,7 +5,7 @@ {% for change_key, changes in entry.changes.items() %} {% if change_key %} -### {{ change_key|title }} +### {{ change_key }} {% endif %} {% for change in changes %} diff --git a/docs/changelog.md b/docs/changelog.md index fab38ea2ad..2c78761324 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -124,10 +124,15 @@ Benefits: cz changelog --incremental ``` -## TODO +## Hooks -- [ ] support for hooks: this would allow introduction of custom information in the commiter, like a github or jira url. Eventually we could build a `CzConventionalGithub`, which would add links to commits -- [ ] support for map: allow the usage of a `change_type` mapper, to convert from feat to feature for example. +Supported hook methods: + +- per parsed message: useful to add links +- end of changelog generation: useful to send slack or chat message, or notify another department + +Read more about hooks in the [customization page][customization] [keepachangelog]: https://keepachangelog.com/ [semver]: https://semver.org/ +[customization]: ./customization.md diff --git a/docs/customization.md b/docs/customization.md index bae9f047af..721ade60b3 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -106,12 +106,12 @@ If you feel like it should be part of this repo, create a PR. ### Custom bump rules -You need to define 2 parameters inside `BaseCommitizen`. +You need to define 2 parameters inside your custom `BaseCommitizen`. -| Parameter | Type | Default | Description | -| --------- | ---- | ------- | ----------- | -| `bump_pattern` | `str` | `None` | Regex to extract information from commit (subject and body) | -| `bump_map` | `dict` | `None` | Dictionary mapping the extracted information to a `SemVer` increment type (`MAJOR`, `MINOR`, `PATCH`) | +| Parameter | Type | Default | Description | +| -------------- | ------ | ------- | ----------------------------------------------------------------------------------------------------- | +| `bump_pattern` | `str` | `None` | Regex to extract information from commit (subject and body) | +| `bump_map` | `dict` | `None` | Dictionary mapping the extracted information to a `SemVer` increment type (`MAJOR`, `MINOR`, `PATCH`) | Let's see an example. @@ -132,6 +132,60 @@ cz -n cz_strange bump [convcomms]: https://github.com/commitizen-tools/commitizen/blob/master/commitizen/cz/conventional_commits/conventional_commits.py +## Custom changelog generator + +The changelog generator should just work in a very basic manner without touching anything. +You can customize it of course, and this are the variables you need to add to your custom `BaseCommitizen`. + +| Parameter | Type | Required | Description | +| ------------------- | ------------------------------------------------------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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 rulling 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` | NO | Customize with extra information your message output, like adding links, this function is executed per parsed 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 +from commitizen.cz.base import BaseCommitizen +import chat +import compliance + +class StrangeCommitizen(BaseCommitizen): + changelog_pattern = r"^(break|new|fix|hotfix)" + commit_parser = r"^(?Pfeat|fix|refactor|perf|BREAKING CHANGE)(?:\((?P[^()\r\n]*)\)|\()?(?P!)?:\s(?P.*)?" + change_type_map = { + "feat": "Features", + "fix": "Bug Fixes", + "refactor": "Code Refactor", + "perf": "Performance improvements" + } + + def changelog_message_builder_hook(self, parsed_message: dict, commit: git.GitCommit) -> dict: + rev = commit.rev + m = parsed_message["message"] + parsed_message["message"] = f"{m} {rev}" + return parsed_message + + def changelog_hook(self, full_changelog: str, partial_changelog: Optional[str]) -> str: + """Executed at the end of the changelog generation + + full_changelog: it's the output about to being written into the file + partial_changelog: it's the new stuff, this is useful to send slack messages or + similar + + Return: + the new updated full_changelog + """ + if partial_changelog: + chat.room("#commiters").notify(partial_changelog) + if full_changelog: + compliance.send(full_changelog) + full_changelog.replace(' fix ', ' **fix** ') + return full_changelog +``` + +[changelog-des]: ./changelog.md#description + ### Raise Customize Exception If you want `commitizen` to catch your exception and print the message, you'll have to inherit `CzException`. @@ -148,6 +202,7 @@ class NoSubjectProvidedException(CzException): **This is only supported when configuring through `toml` (e.g., `pyproject.toml`, `.cz`, and `.cz.toml`)** The basic steps are: + 1. Define your custom committing or bumping rules in the configuration file. 2. Declare `name = "cz_customize"` in your configuration file, or add `-n cz_customize` when running commitizen. @@ -187,24 +242,24 @@ message = "Do you want to add body message in commit?" ### Customize configuration -| Parameter | Type | Default | Description | -| --------- | ---- | ------- | ----------- | -| `question` | `dict` | `None` | Questions regarding the commit message. Detailed below. | -| `message_template` | `str` | `None` | The template for generating message from the given answers. `message_template` should either follow the [string.Template](https://docs.python.org/3/library/string.html#template-strings) or [Jinja2](https://jinja.palletsprojects.com/en/2.10.x/) formatting specification, and all the variables in this template should be defined in `name` in `questions`. Note that `Jinja2` is not installed by default. If not installed, commitizen will use `string.Template` formatting. | -| `example` | `str` | `None` | (OPTIONAL) Provide an example to help understand the style. Used by `cz example`. | -| `schema` | `str` | `None` | (OPTIONAL) Show the schema used. Used by `cz schema`. | -| `info_path` | `str` | `None` | (OPTIONAL) The path to the file that contains explanation of the commit rules. Used by `cz info`. If not provided `cz info`, will load `info` instead. | -| `info` | `str` | `None` | (OPTIONAL) Explanation of the commit rules. Used by `cz info`. | -| `bump_map` | `dict` | `None` | (OPTIONAL) Dictionary mapping the extracted information to a `SemVer` increment type (`MAJOR`, `MINOR`, `PATCH`) | -| `bump_pattern` | `str` | `None` | (OPTIONAL) Regex to extract information from commit (subject and body) | +| Parameter | Type | Default | Description | +| ------------------ | ------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `question` | `dict` | `None` | Questions regarding the commit message. Detailed below. | +| `message_template` | `str` | `None` | The template for generating message from the given answers. `message_template` should either follow the [string.Template](https://docs.python.org/3/library/string.html#template-strings) or [Jinja2](https://jinja.palletsprojects.com/en/2.10.x/) formatting specification, and all the variables in this template should be defined in `name` in `questions`. Note that `Jinja2` is not installed by default. If not installed, commitizen will use `string.Template` formatting. | +| `example` | `str` | `None` | (OPTIONAL) Provide an example to help understand the style. Used by `cz example`. | +| `schema` | `str` | `None` | (OPTIONAL) Show the schema used. Used by `cz schema`. | +| `info_path` | `str` | `None` | (OPTIONAL) The path to the file that contains explanation of the commit rules. Used by `cz info`. If not provided `cz info`, will load `info` instead. | +| `info` | `str` | `None` | (OPTIONAL) Explanation of the commit rules. Used by `cz info`. | +| `bump_map` | `dict` | `None` | (OPTIONAL) Dictionary mapping the extracted information to a `SemVer` increment type (`MAJOR`, `MINOR`, `PATCH`) | +| `bump_pattern` | `str` | `None` | (OPTIONAL) Regex to extract information from commit (subject and body) | #### Detailed `question` content -| Parameter | Type | Default | Description | -| --------- | ---- | ------- | ----------- | -| `type` | `str` | `None` | The type of questions. Valid type: `list`, `input` and etc. [See More](https://github.com/tmbo/questionary#different-question-types) | -| `name` | `str` | `None` | The key for the value answered by user. It's used in `message_template` | -| `message` | `str` | `None` | Detail description for the question. | -| `choices` | `list` | `None` | (OPTIONAL) The choices when `type = choice`. It should be list of dictionaries with `name` and `value`. (e.g., `[{value = "feature", name = "feature: A new feature."}, {value = "bug fix", name = "bug fix: A bug fix."}]`) | -| `default` | `Any` | `None` | (OPTIONAL) The default value for this question. | -| `filter` | `str` | `None` | (Optional) Validator for user's answer. **(Work in Progress)** | +| Parameter | Type | Default | Description | +| --------- | ------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `type` | `str` | `None` | The type of questions. Valid type: `list`, `input` and etc. [See More](https://github.com/tmbo/questionary#different-question-types) | +| `name` | `str` | `None` | The key for the value answered by user. It's used in `message_template` | +| `message` | `str` | `None` | Detail description for the question. | +| `choices` | `list` | `None` | (OPTIONAL) The choices when `type = choice`. It should be list of dictionaries with `name` and `value`. (e.g., `[{value = "feature", name = "feature: A new feature."}, {value = "bug fix", name = "bug fix: A bug fix."}]`) | +| `default` | `Any` | `None` | (OPTIONAL) The default value for this question. | +| `filter` | `str` | `None` | (Optional) Validator for user's answer. **(Work in Progress)** | diff --git a/setup.cfg b/setup.cfg index ac1f11ee38..de0172c175 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,4 +35,4 @@ exclude = build, dist max-line-length = 88 -max-complexity = 10 +max-complexity = 11 diff --git a/tests/CHANGELOG_FOR_TEST.md b/tests/CHANGELOG_FOR_TEST.md index 7605bbed47..798b07545a 100644 --- a/tests/CHANGELOG_FOR_TEST.md +++ b/tests/CHANGELOG_FOR_TEST.md @@ -1,13 +1,13 @@ ## v1.2.0 (2019-04-19) -### Feat +### feat - custom cz plugins now support bumping version ## v1.1.1 (2019-04-18) -### Refactor +### refactor - changed stdout statements - **schema**: command logic removed from commitizen base @@ -15,14 +15,14 @@ - **example**: command logic removed from commitizen base - **commit**: moved most of the commit logic to the commit command -### Fix +### fix - **bump**: commit message now fits better with semver - conventional commit 'breaking change' in body instead of title ## v1.1.0 (2019-04-14) -### Feat +### feat - new working bump command - create version tag @@ -31,22 +31,22 @@ - support for pyproject.toml - first semantic version bump implementaiton -### Fix +### fix - removed all from commit - fix config file not working -### Refactor +### refactor - added commands folder, better integration with decli ## v1.0.0 (2019-03-01) -### Refactor +### refactor - removed delegator, added decli and many tests -### Breaking Change +### BREAKING CHANGE - API is stable @@ -54,76 +54,76 @@ ## v1.0.0b1 (2019-01-17) -### Feat +### feat - py3 only, tests and conventional commits 1.0 ## v0.9.11 (2018-12-17) -### Fix +### fix - **config**: load config reads in order without failing if there is no commitizen section ## v0.9.10 (2018-09-22) -### Fix +### fix - parse scope (this is my punishment for not having tests) ## v0.9.9 (2018-09-22) -### Fix +### fix - parse scope empty ## v0.9.8 (2018-09-22) -### Fix +### fix - **scope**: parse correctly again ## v0.9.7 (2018-09-22) -### Fix +### fix - **scope**: parse correctly ## v0.9.6 (2018-09-19) -### Refactor +### refactor - **conventionalCommit**: moved fitlers to questions instead of message -### Fix +### fix - **manifest**: inluded missing files ## v0.9.5 (2018-08-24) -### Fix +### fix - **config**: home path for python versions between 3.0 and 3.5 ## v0.9.4 (2018-08-02) -### Feat +### feat - **cli**: added version ## v0.9.3 (2018-07-28) -### Feat +### feat - **commiter**: conventional commit is a bit more intelligent now ## v0.9.2 (2017-11-11) -### Refactor +### refactor - renamed conventional_changelog to conventional_commits, not backward compatible ## v0.9.1 (2017-11-11) -### Fix +### fix - **setup.py**: future is now required for every python version diff --git a/tests/commands/test_changelog_command.py b/tests/commands/test_changelog_command.py index d78d04bee2..a676295c4c 100644 --- a/tests/commands/test_changelog_command.py +++ b/tests/commands/test_changelog_command.py @@ -5,6 +5,7 @@ import pytest from commitizen import cli, git +from commitizen.commands.changelog import Changelog from tests.utils import create_file_and_commit @@ -234,3 +235,22 @@ def test_changlog_incremental_keep_a_changelog_sample(mocker, capsys): out == """# Changelog\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n\n## Unreleased \n\n### Feat\n\n- add more stuff\n- add new output\n\n### Fix\n\n- mama gotta work\n- output glitch\n\n## [1.0.0] - 2017-06-20\n### Added\n- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8).\n- Version navigation.\n\n### Changed\n- Start using "changelog" over "change log" since it\'s the common usage.\n\n### Removed\n- Section about "changelog" vs "CHANGELOG".\n\n## [0.3.0] - 2015-12-03\n### Added\n- RU translation from [@aishek](https://github.com/aishek).\n""" ) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changlog_hook(mocker, config): + changelog_hook_mock = mocker.Mock() + changelog_hook_mock.return_value = "cool changelog hook" + + create_file_and_commit("feat: new file") + create_file_and_commit("refactor: is in changelog") + create_file_and_commit("Merge into master") + + changelog = Changelog( + config, {"unreleased_version": None, "incremental": True, "dry_run": False}, + ) + mocker.patch.object(changelog.cz, "changelog_hook", changelog_hook_mock) + changelog() + full_changelog = "\n## Unreleased \n\n### Refactor\n\n- is in changelog\n\n### Feat\n\n- new file\n" + + changelog_hook_mock.assert_called_with(full_changelog, full_changelog) diff --git a/tests/test_changelog.py b/tests/test_changelog.py index 94c3c47017..29b644619f 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -701,3 +701,33 @@ def test_render_changelog_tag_and_unreleased(gitcommits, tags): assert "Unreleased" in result assert "## v1.1.1" in result + + +def test_render_changelog_with_change_type(gitcommits, tags): + new_title = ":some-emoji: feature" + change_type_map = {"feat": new_title} + parser = defaults.commit_parser + changelog_pattern = defaults.bump_pattern + tree = changelog.generate_tree_from_commits( + gitcommits, tags, parser, changelog_pattern, change_type_map=change_type_map + ) + result = changelog.render_changelog(tree) + assert new_title in result + + +def test_render_changelog_with_changelog_message_builder_hook(gitcommits, tags): + def changelog_message_builder_hook(message: dict, _) -> dict: + message["message"] = f"{message['message']} [link](github.com/232323232)" + return message + + parser = defaults.commit_parser + changelog_pattern = defaults.bump_pattern + tree = changelog.generate_tree_from_commits( + gitcommits, + tags, + parser, + changelog_pattern, + changelog_message_builder_hook=changelog_message_builder_hook, + ) + result = changelog.render_changelog(tree) + assert "[link](github.com/232323232)" in result