diff --git a/commitizen/changelog.py b/commitizen/changelog.py index 9084561c15..ca36015ed9 100644 --- a/commitizen/changelog.py +++ b/commitizen/changelog.py @@ -24,15 +24,17 @@ - [x] hook after changelog is generated (api calls) - [x] add support for change_type maps """ + import os import re -from collections import defaultdict +from collections import OrderedDict, defaultdict from datetime import date from typing import Callable, Dict, Iterable, List, Optional from jinja2 import Environment, PackageLoader from commitizen import defaults +from commitizen.exceptions import InvalidConfigurationError from commitizen.git import GitCommit, GitTag CATEGORIES = [ @@ -98,7 +100,7 @@ def generate_tree_from_commits( "date": current_tag_date, "changes": changes, } - # TODO: Check if tag matches the version pattern, otherwie skip it. + # TODO: Check if tag matches the version pattern, otherwise skip it. # This in order to prevent tags that are not versions. current_tag_name = commit_tag.name current_tag_date = commit_tag.date @@ -128,6 +130,26 @@ def generate_tree_from_commits( yield {"version": current_tag_name, "date": current_tag_date, "changes": changes} +def order_changelog_tree(tree: Iterable, change_type_order: List[str]) -> Iterable: + if len(set(change_type_order)) != len(change_type_order): + raise InvalidConfigurationError( + f"Change types contain duplicates types ({change_type_order})" + ) + + sorted_tree = [] + for entry in tree: + ordered_change_types = change_type_order + sorted( + set(entry["changes"].keys()) - set(change_type_order) + ) + changes = [ + (ct, entry["changes"][ct]) + for ct in ordered_change_types + if ct in entry["changes"] + ] + sorted_tree.append({**entry, **{"changes": OrderedDict(changes)}}) + return sorted_tree + + def render_changelog(tree: Iterable) -> str: loader = PackageLoader("commitizen", "templates") env = Environment(loader=loader, trim_blocks=True) diff --git a/commitizen/commands/changelog.py b/commitizen/commands/changelog.py index 62d175d27d..535632dfc0 100644 --- a/commitizen/commands/changelog.py +++ b/commitizen/commands/changelog.py @@ -43,6 +43,9 @@ def __init__(self, config: BaseConfig, args): self.change_type_map = ( self.config.settings.get("change_type_map") or self.cz.change_type_map ) + self.change_type_order = ( + self.config.settings.get("change_type_order") or self.cz.change_type_order + ) def _find_incremental_rev(self, latest_version: str, tags: List[GitTag]) -> str: """Try to find the 'start_rev'. @@ -109,6 +112,8 @@ def __call__(self): change_type_map=change_type_map, changelog_message_builder_hook=changelog_message_builder_hook, ) + if self.change_type_order: + tree = changelog.order_changelog_tree(tree, self.change_type_order) changelog_out = changelog.render_changelog(tree) changelog_out = changelog_out.lstrip("\n") diff --git a/commitizen/cz/base.py b/commitizen/cz/base.py index 5871879992..734852c868 100644 --- a/commitizen/cz/base.py +++ b/commitizen/cz/base.py @@ -29,6 +29,7 @@ class BaseCommitizen(metaclass=ABCMeta): commit_parser: Optional[str] = r"(?P.*)" changelog_pattern: Optional[str] = r".*" change_type_map: Optional[Dict[str, str]] = None + change_type_order: Optional[List[str]] = None # Executed per message parsed by the commitizen changelog_message_builder_hook: Optional[ diff --git a/commitizen/cz/customize/customize.py b/commitizen/cz/customize/customize.py index 1c0bb98baf..acf205d06e 100644 --- a/commitizen/cz/customize/customize.py +++ b/commitizen/cz/customize/customize.py @@ -16,6 +16,7 @@ class CustomizeCommitsCz(BaseCommitizen): bump_pattern = defaults.bump_pattern bump_map = defaults.bump_map + change_type_order = defaults.change_type_order def __init__(self, config: BaseConfig): super(CustomizeCommitsCz, self).__init__(config) @@ -32,6 +33,10 @@ def __init__(self, config: BaseConfig): if custom_bump_map: self.bump_map = custom_bump_map + custom_change_type_order = self.custom_settings.get("change_type_order") + if custom_change_type_order: + self.change_type_order = custom_change_type_order + def questions(self) -> List[Dict[str, Any]]: return self.custom_settings.get("questions") diff --git a/commitizen/defaults.py b/commitizen/defaults.py index c5f07992f6..77fc4b0a50 100644 --- a/commitizen/defaults.py +++ b/commitizen/defaults.py @@ -40,5 +40,7 @@ ) bump_message = "bump: version $current_version → $new_version" +change_type_order = ["BREAKING CHANGE", "feat", "fix", "refactor", "perf"] + commit_parser = r"^(?Pfeat|fix|refactor|perf|BREAKING CHANGE)(?:\((?P[^()\r\n]*)\)|\()?(?P!)?:\s(?P.*)?" # noqa version_parser = r"(?P([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+)?)" diff --git a/commitizen/exceptions.py b/commitizen/exceptions.py index 84219f7fd1..8293688715 100644 --- a/commitizen/exceptions.py +++ b/commitizen/exceptions.py @@ -23,6 +23,7 @@ class ExitCode(enum.IntEnum): NO_REVISION = 16 CURRENT_VERSION_NOT_FOUND = 17 INVALID_COMMAND_ARGUMENT = 18 + INVALID_CONFIGURATION = 19 class CommitizenException(Exception): @@ -137,3 +138,7 @@ class NoCommandFoundError(CommitizenException): class InvalidCommandArgumentError(CommitizenException): exit_code = ExitCode.INVALID_COMMAND_ARGUMENT + + +class InvalidConfigurationError(CommitizenException): + exit_code = ExitCode.INVALID_CONFIGURATION diff --git a/docs/customization.md b/docs/customization.md index 0d86dc057f..204395ed4b 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -23,6 +23,7 @@ schema = ": " schema_pattern = "(feature|bug fix):(\\s.*)" bump_pattern = "^(break|new|fix|hotfix)" bump_map = {"break" = "MAJOR", "new" = "MINOR", "fix" = "PATCH", "hotfix" = "PATCH"} +change_type_order = ["BREAKING CHANGE", "feat", "fix", "refactor", "perf"] info_path = "cz_customize_info.txt" info = """ This is customized info @@ -64,6 +65,7 @@ The equivalent example for a json config file: "fix": "PATCH", "hotfix": "PATCH" }, + "change_type_order": ["BREAKING CHANGE", "feat", "fix", "refactor", "perf"], "info_path": "cz_customize_info.txt", "info": "This is customized info", "questions": [ @@ -114,6 +116,7 @@ commitizen: new: MINOR fix: PATCH hotfix: PATCH + change_type_order: ["BREAKING CHANGE", "feat", "fix", "refactor", "perf"] info_path: cz_customize_info.txt info: This is customized info questions: @@ -146,6 +149,7 @@ commitizen: | `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) | +| `change_type_order`| `str` | `None` | (OPTIONAL) List of strings used to order the Changelog. All other types will be sorted alphabetically. Default is `["BREAKING CHANGE", "feat", "fix", "refactor", "perf"]` | #### Detailed `questions` content @@ -298,7 +302,7 @@ You can customize it of course, and this are the variables you need to add to yo | 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 | +| `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` | 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` | | `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 | diff --git a/docs/exit_codes.md b/docs/exit_codes.md index d83c93d991..cae66b8bab 100644 --- a/docs/exit_codes.md +++ b/docs/exit_codes.md @@ -26,3 +26,4 @@ These exit codes can be found in `commitizen/exceptions.py::ExitCode`. | NoRevisionError | 16 | No revision found | | CurrentVersionNotFoundError | 17 | current version cannot be found in *version_files* | | InvalidCommandArgumentError | 18 | The argument provide to command is invalid (e.g. `cz check -commit-msg-file filename --rev-range master..`) | +| InvalidConfigurationError | 19 | An error was found in the Commitizen Configuration, such as duplicates in `change_type_order` | diff --git a/pyproject.toml b/pyproject.toml index de30c25821..84e931804a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,7 @@ include_trailing_comma = true force_grid_wrap = 0 combine_as_imports = true line_length = 88 +known_first_party = ["commitizen", "tests"] [tool.coverage] [tool.coverage.report] diff --git a/tests/commands/test_changelog_command.py b/tests/commands/test_changelog_command.py index aceaaf9d96..e5993dbdfa 100644 --- a/tests/commands/test_changelog_command.py +++ b/tests/commands/test_changelog_command.py @@ -249,6 +249,7 @@ def test_changelog_hook(mocker, config): create_file_and_commit("refactor: is in changelog") create_file_and_commit("Merge into master") + config.settings["change_type_order"] = ["Refactor", "Feat"] changelog = Changelog( config, {"unreleased_version": None, "incremental": True, "dry_run": False} ) diff --git a/tests/test_changelog.py b/tests/test_changelog.py index b1dc1dc0e7..055e4fc916 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -1,6 +1,7 @@ import pytest from commitizen import changelog, defaults, git +from commitizen.exceptions import InvalidConfigurationError COMMITS_DATA = [ { @@ -509,293 +510,335 @@ def test_get_commit_tag_is_None(gitcommits, tags): assert current_key is None -def test_generate_tree_from_commits(gitcommits, tags): - parser = defaults.commit_parser - changelog_pattern = defaults.bump_pattern - tree = changelog.generate_tree_from_commits( - gitcommits, tags, parser, changelog_pattern - ) - - assert tuple(tree) == ( - { - "version": "v1.2.0", - "date": "2019-04-19", - "changes": { - "feat": [ - { - "scope": None, - "breaking": None, - "message": "custom cz plugins now support bumping version", - } - ] - }, +COMMITS_TREE = ( + { + "version": "v1.2.0", + "date": "2019-04-19", + "changes": { + "feat": [ + { + "scope": None, + "breaking": None, + "message": "custom cz plugins now support bumping version", + } + ] }, - { - "version": "v1.1.1", - "date": "2019-04-18", - "changes": { - "refactor": [ - { - "scope": None, - "breaking": None, - "message": "changed stdout statements", - }, - { - "scope": "schema", - "breaking": None, - "message": "command logic removed from commitizen base", - }, - { - "scope": "info", - "breaking": None, - "message": "command logic removed from commitizen base", - }, - { - "scope": "example", - "breaking": None, - "message": "command logic removed from commitizen base", - }, - { - "scope": "commit", - "breaking": None, - "message": "moved most of the commit logic to the commit command", - }, - ], - "fix": [ - { - "scope": "bump", - "breaking": None, - "message": "commit message now fits better with semver", - }, - { - "scope": None, - "breaking": None, - "message": "conventional commit 'breaking change' in body instead of title", - }, - ], - }, + }, + { + "version": "v1.1.1", + "date": "2019-04-18", + "changes": { + "refactor": [ + { + "scope": None, + "breaking": None, + "message": "changed stdout statements", + }, + { + "scope": "schema", + "breaking": None, + "message": "command logic removed from commitizen base", + }, + { + "scope": "info", + "breaking": None, + "message": "command logic removed from commitizen base", + }, + { + "scope": "example", + "breaking": None, + "message": "command logic removed from commitizen base", + }, + { + "scope": "commit", + "breaking": None, + "message": "moved most of the commit logic to the commit command", + }, + ], + "fix": [ + { + "scope": "bump", + "breaking": None, + "message": "commit message now fits better with semver", + }, + { + "scope": None, + "breaking": None, + "message": "conventional commit 'breaking change' in body instead of title", + }, + ], }, - { - "version": "v1.1.0", - "date": "2019-04-14", - "changes": { - "feat": [ - { - "scope": None, - "breaking": None, - "message": "new working bump command", - }, - {"scope": None, "breaking": None, "message": "create version tag"}, - { - "scope": None, - "breaking": None, - "message": "update given files with new version", - }, - { - "scope": "config", - "breaking": None, - "message": "new set key, used to set version to cfg", - }, - { - "scope": None, - "breaking": None, - "message": "support for pyproject.toml", - }, - { - "scope": None, - "breaking": None, - "message": "first semantic version bump implementation", - }, - ], - "fix": [ - { - "scope": None, - "breaking": None, - "message": "removed all from commit", - }, - { - "scope": None, - "breaking": None, - "message": "fix config file not working", - }, - ], - "refactor": [ - { - "scope": None, - "breaking": None, - "message": "added commands folder, better integration with decli", - } - ], - }, + }, + { + "version": "v1.1.0", + "date": "2019-04-14", + "changes": { + "feat": [ + { + "scope": None, + "breaking": None, + "message": "new working bump command", + }, + {"scope": None, "breaking": None, "message": "create version tag"}, + { + "scope": None, + "breaking": None, + "message": "update given files with new version", + }, + { + "scope": "config", + "breaking": None, + "message": "new set key, used to set version to cfg", + }, + { + "scope": None, + "breaking": None, + "message": "support for pyproject.toml", + }, + { + "scope": None, + "breaking": None, + "message": "first semantic version bump implementation", + }, + ], + "fix": [ + { + "scope": None, + "breaking": None, + "message": "removed all from commit", + }, + { + "scope": None, + "breaking": None, + "message": "fix config file not working", + }, + ], + "refactor": [ + { + "scope": None, + "breaking": None, + "message": "added commands folder, better integration with decli", + } + ], }, - { - "version": "v1.0.0", - "date": "2019-03-01", - "changes": { - "refactor": [ - { - "scope": None, - "breaking": None, - "message": "removed delegator, added decli and many tests", - } - ], - "BREAKING CHANGE": [ - {"scope": None, "breaking": None, "message": "API is stable"} - ], - }, + }, + { + "version": "v1.0.0", + "date": "2019-03-01", + "changes": { + "refactor": [ + { + "scope": None, + "breaking": None, + "message": "removed delegator, added decli and many tests", + } + ], + "BREAKING CHANGE": [ + {"scope": None, "breaking": None, "message": "API is stable"} + ], }, - {"version": "1.0.0b2", "date": "2019-01-18", "changes": {}}, - { - "version": "v1.0.0b1", - "date": "2019-01-17", - "changes": { - "feat": [ - { - "scope": None, - "breaking": None, - "message": "py3 only, tests and conventional commits 1.0", - } - ] - }, + }, + {"version": "1.0.0b2", "date": "2019-01-18", "changes": {}}, + { + "version": "v1.0.0b1", + "date": "2019-01-17", + "changes": { + "feat": [ + { + "scope": None, + "breaking": None, + "message": "py3 only, tests and conventional commits 1.0", + } + ] }, - { - "version": "v0.9.11", - "date": "2018-12-17", - "changes": { - "fix": [ - { - "scope": "config", - "breaking": None, - "message": "load config reads in order without failing if there is no commitizen section", - } - ] - }, + }, + { + "version": "v0.9.11", + "date": "2018-12-17", + "changes": { + "fix": [ + { + "scope": "config", + "breaking": None, + "message": "load config reads in order without failing if there is no commitizen section", + } + ] }, - { - "version": "v0.9.10", - "date": "2018-09-22", - "changes": { - "fix": [ - { - "scope": None, - "breaking": None, - "message": "parse scope (this is my punishment for not having tests)", - } - ] - }, + }, + { + "version": "v0.9.10", + "date": "2018-09-22", + "changes": { + "fix": [ + { + "scope": None, + "breaking": None, + "message": "parse scope (this is my punishment for not having tests)", + } + ] }, - { - "version": "v0.9.9", - "date": "2018-09-22", - "changes": { - "fix": [ - {"scope": None, "breaking": None, "message": "parse scope empty"} - ] - }, + }, + { + "version": "v0.9.9", + "date": "2018-09-22", + "changes": { + "fix": [{"scope": None, "breaking": None, "message": "parse scope empty"}] }, - { - "version": "v0.9.8", - "date": "2018-09-22", - "changes": { - "fix": [ - { - "scope": "scope", - "breaking": None, - "message": "parse correctly again", - } - ] - }, + }, + { + "version": "v0.9.8", + "date": "2018-09-22", + "changes": { + "fix": [ + { + "scope": "scope", + "breaking": None, + "message": "parse correctly again", + } + ] }, - { - "version": "v0.9.7", - "date": "2018-09-22", - "changes": { - "fix": [ - {"scope": "scope", "breaking": None, "message": "parse correctly"} - ] - }, + }, + { + "version": "v0.9.7", + "date": "2018-09-22", + "changes": { + "fix": [{"scope": "scope", "breaking": None, "message": "parse correctly"}] }, - { - "version": "v0.9.6", - "date": "2018-09-19", - "changes": { - "refactor": [ - { - "scope": "conventionalCommit", - "breaking": None, - "message": "moved filters to questions instead of message", - } - ], - "fix": [ - { - "scope": "manifest", - "breaking": None, - "message": "included missing files", - } - ], - }, + }, + { + "version": "v0.9.6", + "date": "2018-09-19", + "changes": { + "refactor": [ + { + "scope": "conventionalCommit", + "breaking": None, + "message": "moved filters to questions instead of message", + } + ], + "fix": [ + { + "scope": "manifest", + "breaking": None, + "message": "included missing files", + } + ], }, - { - "version": "v0.9.5", - "date": "2018-08-24", - "changes": { - "fix": [ - { - "scope": "config", - "breaking": None, - "message": "home path for python versions between 3.0 and 3.5", - } - ] - }, + }, + { + "version": "v0.9.5", + "date": "2018-08-24", + "changes": { + "fix": [ + { + "scope": "config", + "breaking": None, + "message": "home path for python versions between 3.0 and 3.5", + } + ] }, - { - "version": "v0.9.4", - "date": "2018-08-02", - "changes": { - "feat": [{"scope": "cli", "breaking": None, "message": "added version"}] - }, + }, + { + "version": "v0.9.4", + "date": "2018-08-02", + "changes": { + "feat": [{"scope": "cli", "breaking": None, "message": "added version"}] }, - { - "version": "v0.9.3", - "date": "2018-07-28", - "changes": { - "feat": [ - { - "scope": "committer", - "breaking": None, - "message": "conventional commit is a bit more intelligent now", - } - ] - }, + }, + { + "version": "v0.9.3", + "date": "2018-07-28", + "changes": { + "feat": [ + { + "scope": "committer", + "breaking": None, + "message": "conventional commit is a bit more intelligent now", + } + ] }, - { - "version": "v0.9.2", - "date": "2017-11-11", - "changes": { - "refactor": [ - { - "scope": None, - "breaking": None, - "message": "renamed conventional_changelog to conventional_commits, not backward compatible", - } - ] - }, + }, + { + "version": "v0.9.2", + "date": "2017-11-11", + "changes": { + "refactor": [ + { + "scope": None, + "breaking": None, + "message": "renamed conventional_changelog to conventional_commits, not backward compatible", + } + ] }, - { - "version": "v0.9.1", - "date": "2017-11-11", - "changes": { - "fix": [ - { - "scope": "setup.py", - "breaking": None, - "message": "future is now required for every python version", - } - ] - }, + }, + { + "version": "v0.9.1", + "date": "2017-11-11", + "changes": { + "fix": [ + { + "scope": "setup.py", + "breaking": None, + "message": "future is now required for every python version", + } + ] }, + }, +) + + +def test_generate_tree_from_commits(gitcommits, tags): + parser = defaults.commit_parser + changelog_pattern = defaults.bump_pattern + tree = changelog.generate_tree_from_commits( + gitcommits, tags, parser, changelog_pattern ) + assert tuple(tree) == COMMITS_TREE + + +@pytest.mark.parametrize( + "change_type_order, expected_reordering", + ( + ([], {}), + ( + ["BREAKING CHANGE", "refactor"], + { + "1.1.0": { + "original": ["feat", "fix", "refactor"], + "sorted": ["refactor", "feat", "fix"], + }, + "1.0.0": { + "original": ["refactor", "BREAKING CHANGE"], + "sorted": ["BREAKING CHANGE", "refactor"], + }, + }, + ), + ), +) +def test_order_changelog_tree(change_type_order, expected_reordering): + tree = changelog.order_changelog_tree(COMMITS_TREE, change_type_order) + + for index, entry in enumerate(tuple(tree)): + version = tree[index]["version"] + if version in expected_reordering: + # Verify that all keys are present + assert [*tree[index].keys()] == [*COMMITS_TREE[index].keys()] + # Verify that the reorder only impacted the returned dict and not the original + expected = expected_reordering[version] + assert [*tree[index]["changes"].keys()] == expected["sorted"] + assert [*COMMITS_TREE[index]["changes"].keys()] == expected["original"] + else: + assert [*entry["changes"].keys()] == [*tree[index]["changes"].keys()] + + +def test_order_changelog_tree_raises(): + change_type_order = ["BREAKING CHANGE", "feat", "refactor", "feat"] + with pytest.raises(InvalidConfigurationError) as excinfo: + changelog.order_changelog_tree(COMMITS_TREE, change_type_order) + + assert "Change types contain duplicates types" in str(excinfo) + def test_render_changelog(gitcommits, tags, changelog_content): parser = defaults.commit_parser diff --git a/tests/test_cz_customize.py b/tests/test_cz_customize.py index 479fcef7c4..e919093e04 100644 --- a/tests/test_cz_customize.py +++ b/tests/test_cz_customize.py @@ -13,6 +13,7 @@ bump_pattern = "^(break|new|fix|hotfix)" bump_map = {"break" = "MAJOR", "new" = "MINOR", "fix" = "PATCH", "hotfix" = "PATCH"} + change_type_order = ["perf", "BREAKING CHANGE", "feat", "fix", "refactor"] info = "This is a customized cz." [[tool.commitizen.customize.questions]] @@ -56,6 +57,7 @@ "fix": "PATCH", "hotfix": "PATCH" }, + "change_type_order": ["perf", "BREAKING CHANGE", "feat", "fix", "refactor"], "info": "This is a customized cz.", "questions": [ { @@ -107,6 +109,7 @@ new: MINOR fix: PATCH hotfix: PATCH + change_type_order: ["perf", "BREAKING CHANGE", "feat", "fix", "refactor"] info: This is a customized cz. questions: - type: list @@ -274,6 +277,17 @@ def test_bump_map(config): } +def test_change_type_order(config): + cz = CustomizeCommitsCz(config) + assert cz.change_type_order == [ + "perf", + "BREAKING CHANGE", + "feat", + "fix", + "refactor", + ] + + def test_questions(config): cz = CustomizeCommitsCz(config) questions = cz.questions()