Skip to content
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

adds a changelog tag parser to filter tags (--tag-parser; tag_parser) #537

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
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
119 changes: 74 additions & 45 deletions commitizen/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -74,72 +74,101 @@ 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)
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)
latest_commit: GitCommit = commits[0]

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:
current_tag_name = current_tag.name
current_tag_date = current_tag.date
# 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:
commit_tag = get_commit_tag(commit, tags)

if commit_tag is not None and commit_tag not in used_tags:
used_tags.append(commit_tag)
# 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:
yield {
"version": current_tag_name,
"date": current_tag_date,
"version": used_tags[-1].name,
"date": used_tags[-1].date,
"changes": changes,
}
# 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
used_tags.append(commit_tag)
changes = defaultdict(list)

matches = pat.match(commit.message)
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()
update_changes_for_commit(
changes,
commit,
change_type_map,
changelog_message_builder_hook,
map_pat,
body_map_pat,
)

yield {
"version": used_tags[-1].name,
"date": used_tags[-1].date,
"changes": changes,
}


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 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()

yield {"version": current_tag_name, "date": current_tag_date, "changes": changes}
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:
Expand Down
7 changes: 7 additions & 0 deletions commitizen/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: '.*'"
),
},
],
},
{
Expand Down
6 changes: 6 additions & 0 deletions commitizen/commands/changelog.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os.path
import re
from difflib import SequenceMatcher
from operator import itemgetter
from typing import Callable, Dict, List, Optional
Expand Down Expand Up @@ -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'.
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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] |
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions tests/CHANGELOG_FOR_TEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@

## v1.0.0b1 (2019-01-17)

## user_def (2019-01-10)

### feat

- py3 only, tests and conventional commits 1.0
Expand Down
47 changes: 47 additions & 0 deletions tests/commands/test_changelog_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we rename line as filter_pattern or something similar to make it even more readable?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we could renamed filtered as is_filtered. I'm also a bit confused when reading it.

):
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
62 changes: 55 additions & 7 deletions tests/test_changelog.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import re
from typing import Dict, Iterable, List, Pattern

import pytest

from commitizen import changelog, defaults, git
Expand Down Expand Up @@ -458,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"),
Expand Down Expand Up @@ -642,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": [
{
Expand Down Expand Up @@ -790,14 +795,57 @@ def test_get_commit_tag_is_None(gitcommits, tags):
)


def test_generate_tree_from_commits(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


@pytest.mark.parametrize(
"tag_parser",
[
(None), # backwards compatibility check
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the (, ) added by auto formatter? If not so, should we remove them?

(".*"), # 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
tree = changelog.generate_tree_from_commits(
gitcommits, tags, parser, changelog_pattern
)

assert tuple(tree) == 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)

# compare the contents of each tree
tree = list(tree)
for outcome, expected in zip(tree, expected_tree):
assert outcome == expected


@pytest.mark.parametrize(
Expand Down