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

fix(changelog): handle custom tag_format in changelog generation #995

Open
wants to merge 6 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
21 changes: 13 additions & 8 deletions commitizen/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
from commitizen import out
from commitizen.bump import normalize_tag
from commitizen.cz.base import ChangelogReleaseHook
from commitizen.defaults import get_tag_regexes
from commitizen.exceptions import InvalidConfigurationError, NoCommitsFoundError
from commitizen.git import GitCommit, GitTag
from commitizen.version_schemes import (
Expand Down Expand Up @@ -93,16 +94,21 @@ def tag_included_in_changelog(
return True


def get_version_tags(scheme: type[BaseVersion], tags: list[GitTag]) -> list[GitTag]:
def get_version_tags(
scheme: type[BaseVersion], tags: list[GitTag], tag_format: str
) -> list[GitTag]:
valid_tags: list[GitTag] = []
TAG_FORMAT_REGEXS = get_tag_regexes(scheme.parser.pattern)
tag_format_regex = tag_format
for pattern, regex in TAG_FORMAT_REGEXS.items():
tag_format_regex = tag_format_regex.replace(pattern, regex)
for tag in tags:
try:
scheme(tag.name)
except InvalidVersion:
out.warn(f"InvalidVersion {tag}")
else:
if re.match(tag_format_regex, tag.name):
valid_tags.append(tag)

else:
out.warn(
f"InvalidVersion {tag.name} doesn't match configured tag format {tag_format}"
)
return valid_tags


Expand Down Expand Up @@ -351,7 +357,6 @@ def get_oldest_and_newest_rev(
oldest, newest = version.split("..")
except ValueError:
newest = version

newest_tag = normalize_tag(newest, tag_format=tag_format, scheme=scheme)

oldest_tag = None
Expand Down
14 changes: 13 additions & 1 deletion commitizen/changelog_formats/asciidoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,19 @@ def parse_version_from_title(self, line: str) -> str | None:
matches = list(re.finditer(self.version_parser, m.group("title")))
if not matches:
return None
return matches[-1].group("version")
if "version" in matches[-1].groupdict():
return matches[-1].group("version")
partial_matches = matches[-1].groupdict()
try:
partial_version = f"{partial_matches['major']}.{partial_matches['minor']}.{partial_matches['patch']}"
except KeyError:
return None

if partial_matches.get("prerelease"):
partial_version = f"{partial_version}-{partial_matches['prerelease']}"
if partial_matches.get("devrelease"):
partial_version = f"{partial_version}{partial_matches['devrelease']}"
return partial_version

def parse_title_level(self, line: str) -> int | None:
m = self.RE_TITLE.match(line)
Expand Down
10 changes: 9 additions & 1 deletion commitizen/changelog_formats/base.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
from __future__ import annotations

import os
import re
from abc import ABCMeta
from re import Pattern
from typing import IO, Any, ClassVar

from commitizen.changelog import Metadata
from commitizen.config.base_config import BaseConfig
from commitizen.defaults import get_tag_regexes
from commitizen.version_schemes import get_version_scheme

from . import ChangelogFormat
Expand All @@ -25,10 +27,16 @@ def __init__(self, config: BaseConfig):
# See: https://bugs.python.org/issue44807
self.config = config
self.encoding = self.config.settings["encoding"]
self.tag_format = self.config.settings["tag_format"]

@property
def version_parser(self) -> Pattern:
return get_version_scheme(self.config).parser
tag_regex: str = self.tag_format
version_regex = get_version_scheme(self.config).parser.pattern
TAG_FORMAT_REGEXS = get_tag_regexes(version_regex)
for pattern, regex in TAG_FORMAT_REGEXS.items():
tag_regex = tag_regex.replace(pattern, regex)
return re.compile(tag_regex)

def get_metadata(self, filepath: str) -> Metadata:
if not os.path.isfile(filepath):
Expand Down
16 changes: 15 additions & 1 deletion commitizen/changelog_formats/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,21 @@ def parse_version_from_title(self, line: str) -> str | None:
m = re.search(self.version_parser, m.group("title"))
if not m:
return None
return m.group("version")
if "version" in m.groupdict():
return m.group("version")
matches = m.groupdict()
try:
partial_version = (
f"{matches['major']}.{matches['minor']}.{matches['patch']}"
)
except KeyError:
return None

if matches.get("prerelease"):
partial_version = f"{partial_version}-{matches['prerelease']}"
if matches.get("devrelease"):
partial_version = f"{partial_version}{matches['devrelease']}"
return partial_version

def parse_title_level(self, line: str) -> int | None:
m = self.RE_TITLE.match(line)
Expand Down
28 changes: 23 additions & 5 deletions commitizen/changelog_formats/restructuredtext.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ def get_metadata_from_file(self, file: IO[Any]) -> Metadata:
third = third.strip().lower()
title: str | None = None
kind: TitleKind | None = None

if self.is_overlined_title(first, second, third):
title = second
kind = (first[0], third[0])
Expand All @@ -67,10 +66,29 @@ def get_metadata_from_file(self, file: IO[Any]) -> Metadata:
# Try to find the latest release done
m = re.search(self.version_parser, title)
if m:
version = m.group("version")
meta.latest_version = version
meta.latest_version_position = index
break # there's no need for more info
matches = m.groupdict()
if "version" in matches:
version = m.group("version")
meta.latest_version = version
meta.latest_version_position = index
break # there's no need for more info
try:
partial_version = (
f"{matches['major']}.{matches['minor']}.{matches['patch']}"
)
if matches.get("prerelease"):
partial_version = (
f"{partial_version}-{matches['prerelease']}"
)
if matches.get("devrelease"):
partial_version = (
f"{partial_version}{matches['devrelease']}"
)
meta.latest_version = partial_version
meta.latest_version_position = index
break
except KeyError:
Lee-W marked this conversation as resolved.
Show resolved Hide resolved
pass
if meta.unreleased_start is not None and meta.unreleased_end is None:
meta.unreleased_end = (
meta.latest_version_position if meta.latest_version else index + 1
Expand Down
19 changes: 18 additions & 1 deletion commitizen/changelog_formats/textile.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,24 @@ def parse_version_from_title(self, line: str) -> str | None:
m = re.search(self.version_parser, line)
if not m:
return None
return m.group("version")
if "version" in m.groupdict():
return m.group("version")
matches = m.groupdict()
if not all(
[
version_segment in matches
for version_segment in ("major", "minor", "patch")
]
):
return None

partial_version = f"{matches['major']}.{matches['minor']}.{matches['patch']}"

if matches.get("prerelease"):
partial_version = f"{partial_version}-{matches['prerelease']}"
if matches.get("devrelease"):
partial_version = f"{partial_version}{matches['devrelease']}"
return partial_version

def parse_title_level(self, line: str) -> int | None:
m = self.RE_TITLE.match(line)
Expand Down
9 changes: 4 additions & 5 deletions commitizen/commands/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,10 @@ def __call__(self):
# Don't continue if no `file_name` specified.
assert self.file_name

tags = changelog.get_version_tags(self.scheme, git.get_tags()) or []

tags = (
changelog.get_version_tags(self.scheme, git.get_tags(), self.tag_format)
or []
)
end_rev = ""
if self.incremental:
changelog_meta = self.changelog_format.get_metadata(self.file_name)
Expand All @@ -182,21 +184,18 @@ def __call__(self):
start_rev = self._find_incremental_rev(
strip_local_version(latest_tag_version), tags
)

if self.rev_range:
start_rev, end_rev = changelog.get_oldest_and_newest_rev(
tags,
version=self.rev_range,
tag_format=self.tag_format,
scheme=self.scheme,
)

commits = git.get_commits(start=start_rev, end=end_rev, args="--topo-order")
if not commits and (
self.current_version is None or not self.current_version.is_prerelease
):
raise NoCommitsFoundError("No commits found")

tree = changelog.generate_tree_from_commits(
commits,
tags,
Expand Down
19 changes: 19 additions & 0 deletions commitizen/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,22 @@ class Settings(TypedDict, total=False):
)
change_type_order = ["BREAKING CHANGE", "Feat", "Fix", "Refactor", "Perf"]
bump_message = "bump: version $current_version → $new_version"


def get_tag_regexes(
version_regex: str,
) -> dict[str, str]:
return {
"$version": version_regex,
"$major": r"(?P<major>\d+)",
"$minor": r"(?P<minor>\d+)",
"$patch": r"(?P<patch>\d+)",
"$prerelease": r"(?P<prerelease>\w+\d+)?",
"$devrelease": r"(?P<devrelease>\.dev\d+)?",
"${version}": version_regex,
"${major}": r"(?P<major>\d+)",
"${minor}": r"(?P<minor>\d+)",
"${patch}": r"(?P<patch>\d+)",
"${prerelease}": r"(?P<prerelease>\w+\d+)?",
"${devrelease}": r"(?P<devrelease>\.dev\d+)?",
Copy link
Member

Choose a reason for hiding this comment

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

nitpick: not sure we can deduplicate $.* and ${.*} 🤔 Non-blocker for this PR

}
10 changes: 2 additions & 8 deletions commitizen/providers/scm_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import re
from typing import Callable

from commitizen.defaults import get_tag_regexes
from commitizen.git import get_tags
from commitizen.providers.base_provider import VersionProvider
from commitizen.version_schemes import (
Expand All @@ -22,14 +23,7 @@ class ScmProvider(VersionProvider):
It is meant for `setuptools-scm` or any package manager `*-scm` provider.
"""

TAG_FORMAT_REGEXS = {
"$version": r"(?P<version>.+)",
"$major": r"(?P<major>\d+)",
"$minor": r"(?P<minor>\d+)",
"$patch": r"(?P<patch>\d+)",
"$prerelease": r"(?P<prerelease>\w+\d+)?",
"$devrelease": r"(?P<devrelease>\.dev\d+)?",
}
TAG_FORMAT_REGEXS = get_tag_regexes(r"(?P<version>.+)")
Lee-W marked this conversation as resolved.
Show resolved Hide resolved

def _tag_format_matcher(self) -> Callable[[str], VersionProtocol | None]:
version_scheme = get_version_scheme(self.config)
Expand Down
18 changes: 9 additions & 9 deletions docs/commands/bump.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,18 +381,18 @@ In your `pyproject.toml` or `.cz.toml`
tag_format = "v$major.$minor.$patch$prerelease"
```

The variables must be preceded by a `$` sign. Default is `$version`.
The variables must be preceded by a `$` sign and optionally can be wrapped in `{}` . Default is `$version`.

Supported variables:

| Variable | Description |
| ------------- | ------------------------------------------- |
| `$version` | full generated version |
| `$major` | MAJOR increment |
| `$minor` | MINOR increment |
| `$patch` | PATCH increment |
| `$prerelease` | Prerelease (alpha, beta, release candidate) |
| `$devrelease` | Development release |
| Variable | Description |
|--------------------------------|---------------------------------------------|
| `$version`, `${version}` | full generated version |
| `$major`, `${major}` | MAJOR increment |
| `$minor`, `${minor}` | MINOR increment |
| `$patch`, `${patch}` | PATCH increment |
| `$prerelease`, `${prerelease}` | Prerelease (alpha, beta, release candidate) |
| `$devrelease`, ${devrelease}` | Development release |

---

Expand Down
41 changes: 41 additions & 0 deletions docs/tutorials/monorepo_guidance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Configuring commitizen in a monorepo

This tutorial assumes the monorepo layout is designed with multiple components that can be released independently of each
other, it also assumes that conventional commits with scopes are in use. Some suggested layouts:

```
.
├── library-b
│   └── .cz.toml
└── library-z
└── .cz.toml
```

```
src
├── library-b
│   └── .cz.toml
└── library-z
└── .cz.toml
```

Each component will have its own changelog, commits will need to use scopes so only relevant commits are included in the
Copy link
Member

Choose a reason for hiding this comment

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

What if you are not using conventional commits and you don't have scopes?

Copy link
Author

Choose a reason for hiding this comment

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

Sorry, I guess I made an assumption here that everyone uses them. Should I just reword this to reflect that assumption?

appropriate change log for a given component. Example config and commit for `library-b`

```toml
[tool.commitizen]
name = "cz_customize"
version = "0.0.0"
tag_format = "${version}-library-b" # the component name can be a prefix or suffix with or without a separator
update_changelog_on_bump = true

[tool.commitizen.customize]
changelog_pattern = "^(feat|fix)\\(library-b\\)(!)?:" #the pattern on types can be a wild card or any types you wish to include
```

example commit message for the above

`fix:(library-b) Some awesome message`

If the above is followed and the `cz bump --changelog` is run in the directory containing the component the changelog
should be generated in the same directory with only commits scoped to the component.
Loading
Loading