From b795f41d0ff58d5b43c7d64f306e62678acc94dd Mon Sep 17 00:00:00 2001 From: bhelgs <33836927+bhelgs@users.noreply.github.com> Date: Tue, 19 Jul 2022 17:43:33 -0400 Subject: [PATCH 1/4] feat(changelog): adds a tag parser to filter tags (--tag-parser; tag_parser) The git tag parser is used to filter out undesired git tags from changelog. regex default is .* (all). --- commitizen/changelog.py | 24 +++++++++--- commitizen/cli.py | 7 ++++ commitizen/commands/changelog.py | 6 +++ docs/changelog.md | 18 +++++++++ docs/config.md | 2 + tests/commands/test_changelog_command.py | 47 ++++++++++++++++++++++++ tests/test_changelog.py | 40 ++++++++++++++++++++ 7 files changed, 138 insertions(+), 6 deletions(-) diff --git a/commitizen/changelog.py b/commitizen/changelog.py index 5e43e2fef9..dea1ea48b9 100644 --- a/commitizen/changelog.py +++ b/commitizen/changelog.py @@ -29,7 +29,7 @@ import re from collections import OrderedDict, defaultdict from datetime import date -from typing import Callable, Dict, Iterable, List, Optional, Tuple +from typing import Callable, Dict, Iterable, List, Optional, Pattern, Tuple from jinja2 import Environment, PackageLoader @@ -74,6 +74,7 @@ def generate_tree_from_commits( unreleased_version: Optional[str] = None, change_type_map: Optional[Dict[str, str]] = None, changelog_message_builder_hook: Optional[Callable] = None, + tag_pattern: Pattern = re.compile(".*"), ) -> Iterable[Dict]: pat = re.compile(changelog_pattern) map_pat = re.compile(commit_parser, re.MULTILINE) @@ -87,24 +88,35 @@ def generate_tree_from_commits( current_tag_date: str = "" if unreleased_version is not None: current_tag_date = date.today().isoformat() - if current_tag is not None and current_tag.name: + if ( + current_tag is not None + and current_tag.name + and tag_pattern.fullmatch(current_tag.name) + ): current_tag_name = current_tag.name current_tag_date = current_tag.date changes: Dict = defaultdict(list) used_tags: List = [current_tag] for commit in commits: - commit_tag = get_commit_tag(commit, tags) - if commit_tag is not None and commit_tag not in used_tags: + # determine if we found a new matching tag + commit_tag = get_commit_tag(commit, tags) + is_tag_match = False + if commit_tag: + matches = tag_pattern.fullmatch(commit_tag.name) + if matches and (commit_tag not in used_tags): + is_tag_match = True + + # new node if we have a tag match + if is_tag_match: used_tags.append(commit_tag) yield { "version": current_tag_name, "date": current_tag_date, "changes": changes, } - # TODO: Check if tag matches the version pattern, otherwise skip it. - # This in order to prevent tags that are not versions. + assert commit_tag is not None # for mypy current_tag_name = commit_tag.name current_tag_date = commit_tag.date changes = defaultdict(list) diff --git a/commitizen/cli.py b/commitizen/cli.py index 90abc55453..34039e42ea 100644 --- a/commitizen/cli.py +++ b/commitizen/cli.py @@ -220,6 +220,13 @@ "If not set, it will generate changelog from the start" ), }, + { + "name": "--tag-parser", + "help": ( + "regex match for tags represented " + "within the changelog. default: '.*'" + ), + }, ], }, { diff --git a/commitizen/commands/changelog.py b/commitizen/commands/changelog.py index 58c0ceaeef..16a22cd3fe 100644 --- a/commitizen/commands/changelog.py +++ b/commitizen/commands/changelog.py @@ -1,4 +1,5 @@ import os.path +import re from difflib import SequenceMatcher from operator import itemgetter from typing import Callable, Dict, List, Optional @@ -51,6 +52,10 @@ def __init__(self, config: BaseConfig, args): self.tag_format = args.get("tag_format") or self.config.settings.get( "tag_format" ) + tag_parser = args.get("tag_parser") + if tag_parser is None: + tag_parser = self.config.settings.get("tag_parser", r".*") + self.tag_pattern = re.compile(tag_parser) def _find_incremental_rev(self, latest_version: str, tags: List[GitTag]) -> str: """Try to find the 'start_rev'. @@ -154,6 +159,7 @@ def __call__(self): unreleased_version, change_type_map=change_type_map, changelog_message_builder_hook=changelog_message_builder_hook, + tag_pattern=self.tag_pattern, ) if self.change_type_order: tree = changelog.order_changelog_tree(tree, self.change_type_order) diff --git a/docs/changelog.md b/docs/changelog.md index 6f92bb21cd..01640545c9 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -161,6 +161,24 @@ cz changelog --start-rev="v0.2.0" changelog_start_rev = "v0.2.0" ``` +### `tag-parser` + +This value can be set in the `toml` file with the key `tag_parser` under `tools.commitizen` + +The default the changelog will capture all git tags (e.g. regex `.*`). +The user may specify a regex pattern of their own display only +specific tags within the changelog. + +```bash +cz changelog --tag-parser=".*" +``` + +```toml +[tools.commitizen] +# ... +tag_parser = "v[0-9]*\\.[0-9]*\\.[0-9]*" +``` + ## Hooks Supported hook methods: diff --git a/docs/config.md b/docs/config.md index 6a7838e245..e1a3bbd466 100644 --- a/docs/config.md +++ b/docs/config.md @@ -128,6 +128,7 @@ commitizen: | `version` | `str` | `None` | Current version. Example: "0.1.2" | | `version_files` | `list` | `[ ]` | Files were the version will be updated. A pattern to match a line, can also be specified, separated by `:` [See more][version_files] | | `tag_format` | `str` | `None` | Format for the git tag, useful for old projects, that use a convention like `"v1.2.1"`. [See more][tag_format] | +| `tag_parser ` | `str` | `.*` | Generate changelog using only tags matching the regex pattern (e.g. `"v([0-9.])*"`). [See more][tag_parser] | | `update_changelog_on_bump` | `bool` | `false` | Create changelog when running `cz bump` | | `annotated_tag` | `bool` | `false` | Use annotated tags instead of lightweight tags. [See difference][annotated-tags-vs-lightweight] | | `bump_message` | `str` | `None` | Create custom commit message, useful to skip ci. [See more][bump_message] | @@ -142,6 +143,7 @@ commitizen: [version_files]: bump.md#version_files [tag_format]: bump.md#tag_format [bump_message]: bump.md#bump_message +[tag_parser]: changelog.md#tag_parser [allow_abort]: check.md#allow-abort [additional-features]: https://github.com/tmbo/questionary#additional-features [customization]: customization.md diff --git a/tests/commands/test_changelog_command.py b/tests/commands/test_changelog_command.py index 3f90800e7e..e862f0cb16 100644 --- a/tests/commands/test_changelog_command.py +++ b/tests/commands/test_changelog_command.py @@ -872,3 +872,50 @@ def test_changelog_from_rev_latest_version_dry_run( out, _ = capsys.readouterr() file_regression.check(out, extension=".md") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.freeze_time("2022-02-13") +@pytest.mark.parametrize( + "cli_args, line, filtered", + [ + ([], r'tag_parser = "v[0-9]*\\.[0-9]*\\.[0-9]*"', True), # version filter + (["--tag-parser", r"v[0-9]*\.[0-9]*\.[0-9]*"], "", True), # cli arg filter + ([], "", False), # default tag_parser + ], +) +def test_changelog_tag_parser_config( + mocker, config_path, changelog_path, cli_args, line, filtered +): + mocker.patch("commitizen.git.GitTag.date", "2022-02-13") + + with open(config_path, "a") as f: + # f.write('tag_format = "$version"\n') + f.write(line) + + # create a valid start tag + create_file_and_commit("feat: initial") + git.tag("v1.0.0") + + # create a tag for this test + create_file_and_commit("feat: add new") + git.tag("v1.1.0-beta") + + # create a valid end tag + create_file_and_commit("feat: add more") + git.tag("v1.1.0") + + # call CLI + command = ["cz", "changelog"] + command.extend(cli_args) + mocker.patch.object(sys, "argv", command) + cli.main() + + # open CLI output + with open(changelog_path, "r") as f: + out = f.read() + + # test if cli is handling tag_format + assert "v1.0.0" in out + assert ("v1.1.0-beta" in out) is not filtered + assert "v1.1.0" in out diff --git a/tests/test_changelog.py b/tests/test_changelog.py index e68a3abdcf..6423bb6d40 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -1,3 +1,6 @@ +import re +from typing import Dict, Iterable, List, Pattern + import pytest from commitizen import changelog, defaults, git @@ -790,6 +793,26 @@ def test_get_commit_tag_is_None(gitcommits, tags): ) +def _filter_tree(tag_pattern: Pattern, tree: Iterable[Dict]) -> List[Dict[str, str]]: + """filters the tree. commits with invalid tags are kept within the current node""" + + current = None + out = [] + for node in tree: + if not current or tag_pattern.fullmatch(node["version"]) or not out: + current = node.copy() + out.append(current) + else: + changes = current["changes"] + for key, value in node["changes"].items(): + if key in changes: + changes[key].extend(value) + else: + changes[key] = value + + return out + + def test_generate_tree_from_commits(gitcommits, tags): parser = defaults.commit_parser changelog_pattern = defaults.bump_pattern @@ -800,6 +823,23 @@ def test_generate_tree_from_commits(gitcommits, tags): assert tuple(tree) == COMMITS_TREE +def test_generate_tree_from_commits_release_filter(gitcommits, tags): + parser = defaults.commit_parser + changelog_pattern = defaults.bump_pattern + + # match release tags only + tag_parser = r"v([0-9]+)\.([0-9]+)\.([0-9]+)" + tag_pattern = re.compile(tag_parser) + + tree = changelog.generate_tree_from_commits( + gitcommits, tags, parser, changelog_pattern, tag_pattern=tag_pattern + ) + + expected_tree = _filter_tree(tag_pattern, COMMITS_TREE) + + assert list(tree) == expected_tree + + @pytest.mark.parametrize( "change_type_order, expected_reordering", ( From e1a780e1ace864e0ea7fdf65666e777bf9b27be7 Mon Sep 17 00:00:00 2001 From: bhelgs <33836927+bhelgs@users.noreply.github.com> Date: Tue, 19 Jul 2022 17:45:55 -0400 Subject: [PATCH 2/4] test(changelog): test a user defined tag (independent of bump) This new tag was added to a "feat:" rather than "bump:" commit. --- tests/CHANGELOG_FOR_TEST.md | 2 ++ tests/test_changelog.py | 53 +++++++++++++++++++++---------------- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/tests/CHANGELOG_FOR_TEST.md b/tests/CHANGELOG_FOR_TEST.md index e92ca1ce39..3bf4daf246 100644 --- a/tests/CHANGELOG_FOR_TEST.md +++ b/tests/CHANGELOG_FOR_TEST.md @@ -54,6 +54,8 @@ ## v1.0.0b1 (2019-01-17) +## user_def (2019-01-10) + ### feat - py3 only, tests and conventional commits 1.0 diff --git a/tests/test_changelog.py b/tests/test_changelog.py index 6423bb6d40..66ce3653c8 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -461,6 +461,7 @@ ("v1.0.0", "aa44a92d68014d0da98965c0c2cb8c07957d4362", "2019-03-01"), ("1.0.0b2", "aab33d13110f26604fb786878856ec0b9e5fc32b", "2019-01-18"), ("v1.0.0b1", "7c7e96b723c2aaa1aec3a52561f680adf0b60e97", "2019-01-17"), + ("user_def", "ed830019581c83ba633bfd734720e6758eca6061", "2019-01-10"), ("v0.9.11", "c52eca6f74f844ab3ffbde61d98ef96071e132b7", "2018-12-17"), ("v0.9.10", "b3f89892222340150e32631ae6b7aab65230036f", "2018-09-22"), ("v0.9.9", "684e0259cc95c7c5e94854608cd3dcebbd53219e", "2018-09-22"), @@ -645,9 +646,10 @@ def test_get_commit_tag_is_None(gitcommits, tags): }, }, {"version": "1.0.0b2", "date": "2019-01-18", "changes": {}}, + {"version": "v1.0.0b1", "date": "2019-01-17", "changes": {}}, { - "version": "v1.0.0b1", - "date": "2019-01-17", + "version": "user_def", + "date": "2019-01-10", "changes": { "feat": [ { @@ -813,31 +815,36 @@ def _filter_tree(tag_pattern: Pattern, tree: Iterable[Dict]) -> List[Dict[str, s return out -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 - - -def test_generate_tree_from_commits_release_filter(gitcommits, tags): +@pytest.mark.parametrize( + "tag_parser", + [ + (None), # backwards compatibility check + (".*"), # default tag_parser + (r"v[0-9]*\.[0-9]*\.[0-9]*"), # version filter + ], +) +def test_generate_tree_from_commits(gitcommits, tags, tag_parser): parser = defaults.commit_parser changelog_pattern = defaults.bump_pattern - # match release tags only - tag_parser = r"v([0-9]+)\.([0-9]+)\.([0-9]+)" - tag_pattern = re.compile(tag_parser) - - tree = changelog.generate_tree_from_commits( - gitcommits, tags, parser, changelog_pattern, tag_pattern=tag_pattern - ) - - expected_tree = _filter_tree(tag_pattern, COMMITS_TREE) + # generate the tree and expected_tree + if tag_parser is None: + tree = changelog.generate_tree_from_commits( + gitcommits, tags, parser, changelog_pattern + ) + # commits tree is unfiltered + expected_tree = COMMITS_TREE + else: + tag_pattern = re.compile(tag_parser) + tree = changelog.generate_tree_from_commits( + gitcommits, tags, parser, changelog_pattern, tag_pattern=tag_pattern + ) + # filter the COMMITS_TREE to what we expect it to be + expected_tree = _filter_tree(tag_pattern, COMMITS_TREE) - assert list(tree) == expected_tree + # compare the contents of each tree + for outcome, expected in zip(tree, expected_tree): + assert outcome == expected @pytest.mark.parametrize( From c6df32164829c632fdcd8917c42ce6f4c5767419 Mon Sep 17 00:00:00 2001 From: bhelgs <33836927+bhelgs@users.noreply.github.com> Date: Tue, 19 Jul 2022 17:54:38 -0400 Subject: [PATCH 3/4] refactor(changelog): reduces complexity of generate_tree_from_commits The linter was complaining. Likely best to refactor this further. --- commitizen/changelog.py | 69 ++++++++++++++++++++++++++--------------- 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/commitizen/changelog.py b/commitizen/changelog.py index dea1ea48b9..ba5751b8fa 100644 --- a/commitizen/changelog.py +++ b/commitizen/changelog.py @@ -125,35 +125,54 @@ def generate_tree_from_commits( if not matches: continue - # Process subject from commit message - message = map_pat.match(commit.message) - if message: - 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) - - # 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() - - 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) + update_changes_for_commit( + changes, + commit, + change_type_map, + changelog_message_builder_hook, + map_pat, + body_map_pat, + ) yield {"version": current_tag_name, "date": current_tag_date, "changes": changes} +def update_changes_for_commit( + changes: Dict, + commit: GitCommit, + change_type_map: Optional[Dict[str, str]], + changelog_message_builder_hook: Optional[Callable], + map_pat: Pattern, + body_map_pat: Pattern, +): + """Processes the commit message and will update changes if applicable.""" + # Process subject from commit message + message = map_pat.match(commit.message) + if message: + 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) + + # 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() + + 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) + + def order_changelog_tree(tree: Iterable, change_type_order: List[str]) -> Iterable: if len(set(change_type_order)) != len(change_type_order): raise InvalidConfigurationError( From fbabc2a37c2552db6d9b6830db0e09e7a87eac90 Mon Sep 17 00:00:00 2001 From: bhelgs <33836927+bhelgs@users.noreply.github.com> Date: Tue, 19 Jul 2022 17:56:20 -0400 Subject: [PATCH 4/4] refactor(changelog): refactor generate_tree_from_commits Treats 'Unreleased' as if it is a git tag. --- commitizen/changelog.py | 42 ++++++++++++++++++++--------------------- tests/test_changelog.py | 1 + 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/commitizen/changelog.py b/commitizen/changelog.py index ba5751b8fa..bf716cb1f7 100644 --- a/commitizen/changelog.py +++ b/commitizen/changelog.py @@ -81,23 +81,20 @@ def generate_tree_from_commits( body_map_pat = re.compile(commit_parser, re.MULTILINE | re.DOTALL) # Check if the latest commit is not tagged - latest_commit = commits[0] - current_tag: Optional[GitTag] = get_commit_tag(latest_commit, tags) - - current_tag_name: str = unreleased_version or "Unreleased" - current_tag_date: str = "" - if unreleased_version is not None: - current_tag_date = date.today().isoformat() - if ( - current_tag is not None - and current_tag.name - and tag_pattern.fullmatch(current_tag.name) - ): - current_tag_name = current_tag.name - current_tag_date = current_tag.date + latest_commit: GitCommit = commits[0] + + # create the first_tag + # Note: Changelog has no date for "Unreleased". + if unreleased_version: + first_tag = GitTag( + unreleased_version, latest_commit.rev, date.today().isoformat() + ) + else: + unreleased_tag = GitTag("Unreleased", latest_commit.rev, "") + first_tag = get_commit_tag(latest_commit, tags) or unreleased_tag changes: Dict = defaultdict(list) - used_tags: List = [current_tag] + used_tags: List = [first_tag] for commit in commits: # determine if we found a new matching tag @@ -110,15 +107,12 @@ def generate_tree_from_commits( # new node if we have a tag match if is_tag_match: - used_tags.append(commit_tag) yield { - "version": current_tag_name, - "date": current_tag_date, + "version": used_tags[-1].name, + "date": used_tags[-1].date, "changes": changes, } - assert commit_tag is not None # for mypy - current_tag_name = commit_tag.name - current_tag_date = commit_tag.date + used_tags.append(commit_tag) changes = defaultdict(list) matches = pat.match(commit.message) @@ -134,7 +128,11 @@ def generate_tree_from_commits( body_map_pat, ) - yield {"version": current_tag_name, "date": current_tag_date, "changes": changes} + yield { + "version": used_tags[-1].name, + "date": used_tags[-1].date, + "changes": changes, + } def update_changes_for_commit( diff --git a/tests/test_changelog.py b/tests/test_changelog.py index 66ce3653c8..156f2ff0fe 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -843,6 +843,7 @@ def test_generate_tree_from_commits(gitcommits, tags, tag_parser): expected_tree = _filter_tree(tag_pattern, COMMITS_TREE) # compare the contents of each tree + tree = list(tree) for outcome, expected in zip(tree, expected_tree): assert outcome == expected