Skip to content

Commit bfd814e

Browse files
committed
feat(changelog): add incremental flag
1 parent e0a1b49 commit bfd814e

File tree

11 files changed

+504
-85
lines changed

11 files changed

+504
-85
lines changed

commitizen/changelog.py

Lines changed: 112 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
Extra:
2121
- Generate full or partial changelog
2222
- Include in tree from file all the extra comments added manually
23+
- hook after message is parsed (add extra information like hyperlinks)
24+
- hook after changelog is generated (api calls)
2325
"""
2426
import re
2527
from collections import defaultdict
@@ -29,7 +31,7 @@
2931
from jinja2 import Template
3032

3133
from commitizen import defaults
32-
from commitizen.git import GitCommit, GitProtocol, GitTag
34+
from commitizen.git import GitCommit, GitTag
3335

3436
CATEGORIES = [
3537
("fix", "fix"),
@@ -55,15 +57,9 @@ def transform_change_type(change_type: str) -> str:
5557
raise ValueError(f"Could not match a change_type with {change_type}")
5658

5759

58-
def get_commit_tag(commit: GitProtocol, tags: List[GitProtocol]) -> Optional[GitTag]:
60+
def get_commit_tag(commit: GitCommit, tags: List[GitTag]) -> Optional[GitTag]:
5961
""""""
60-
try:
61-
tag_index = tags.index(commit)
62-
except ValueError:
63-
return None
64-
else:
65-
tag = tags[tag_index]
66-
return tag
62+
return next((tag for tag in tags if tag.rev == commit.rev), None)
6763

6864

6965
def generate_tree_from_commits(
@@ -109,6 +105,7 @@ def generate_tree_from_commits(
109105
message = map_pat.match(commit.message)
110106
message_body = map_pat.match(commit.body)
111107
if message:
108+
# TODO: add a post hook coming from a rule (CzBase)
112109
parsed_message: Dict = message.groupdict()
113110
# change_type becomes optional by providing None
114111
change_type = parsed_message.pop("change_type", None)
@@ -132,3 +129,109 @@ def render_changelog(tree: Iterable) -> str:
132129
jinja_template = Template(template_file, trim_blocks=True)
133130
changelog: str = jinja_template.render(tree=tree)
134131
return changelog
132+
133+
134+
def parse_version_from_markdown(value: str) -> Optional[str]:
135+
if not value.startswith("#"):
136+
return None
137+
m = re.search(defaults.version_parser, value)
138+
if not m:
139+
return None
140+
return m.groupdict().get("version")
141+
142+
143+
def parse_title_type_of_line(value: str) -> Optional[str]:
144+
md_title_parser = r"^(?P<title>#+)"
145+
m = re.search(md_title_parser, value)
146+
if not m:
147+
return None
148+
return m.groupdict().get("title")
149+
150+
151+
def get_metadata(filepath: str) -> Dict:
152+
unreleased_start: Optional[int] = None
153+
unreleased_end: Optional[int] = None
154+
unreleased_title: Optional[str] = None
155+
latest_version: Optional[str] = None
156+
latest_version_position: Optional[int] = None
157+
with open(filepath, "r") as changelog_file:
158+
for index, line in enumerate(changelog_file):
159+
line = line.strip().lower()
160+
161+
unreleased: Optional[str] = None
162+
if "unreleased" in line:
163+
unreleased = parse_title_type_of_line(line)
164+
# Try to find beginning and end lines of the unreleased block
165+
if unreleased:
166+
unreleased_start = index
167+
unreleased_title = unreleased
168+
continue
169+
elif (
170+
isinstance(unreleased_title, str)
171+
and parse_title_type_of_line(line) == unreleased_title
172+
):
173+
unreleased_end = index
174+
175+
# Try to find the latest release done
176+
version = parse_version_from_markdown(line)
177+
if version:
178+
latest_version = version
179+
latest_version_position = index
180+
break # there's no need for more info
181+
if unreleased_start is not None and unreleased_end is None:
182+
unreleased_end = index
183+
return {
184+
"unreleased_start": unreleased_start,
185+
"unreleased_end": unreleased_end,
186+
"latest_version": latest_version,
187+
"latest_version_position": latest_version_position,
188+
}
189+
190+
191+
def incremental_build(new_content: str, lines: List, metadata: Dict) -> List:
192+
"""Takes the original lines and updates with new_content.
193+
194+
The metadata holds information enough to remove the old unreleased and
195+
where to place the new content
196+
197+
Arguments:
198+
lines -- the lines from the changelog
199+
new_content -- this should be placed somewhere in the lines
200+
metadata -- information about the changelog
201+
202+
Returns:
203+
List -- updated lines
204+
"""
205+
unreleased_start = metadata.get("unreleased_start")
206+
unreleased_end = metadata.get("unreleased_end")
207+
latest_version_position = metadata.get("latest_version_position")
208+
skip = False
209+
output_lines: List = []
210+
for index, line in enumerate(lines):
211+
if index == unreleased_start:
212+
skip = True
213+
elif index == unreleased_end:
214+
skip = False
215+
if (
216+
latest_version_position is None
217+
or isinstance(latest_version_position, int)
218+
and isinstance(unreleased_end, int)
219+
and latest_version_position > unreleased_end
220+
):
221+
continue
222+
223+
if skip:
224+
continue
225+
226+
if (
227+
isinstance(latest_version_position, int)
228+
and index == latest_version_position
229+
):
230+
231+
output_lines.append(new_content)
232+
output_lines.append("\n")
233+
234+
output_lines.append(line)
235+
if not isinstance(latest_version_position, int):
236+
output_lines.append(new_content)
237+
return output_lines

commitizen/cli.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@
131131
"default": False,
132132
"help": "show changelog to stdout",
133133
},
134+
{
135+
"name": "--file-name",
136+
"help": "file name of changelog (default: 'CHANGELOG.md')",
137+
},
134138
{
135139
"name": "--incremental",
136140
"action": "store_true",
@@ -140,10 +144,6 @@
140144
"useful if the changelog has been manually modified"
141145
),
142146
},
143-
{
144-
"name": "--file-name",
145-
"help": "file name of changelog (default: 'CHANGELOG.md')",
146-
},
147147
{
148148
"name": "--start-rev",
149149
"default": None,

commitizen/commands/changelog.py

Lines changed: 62 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1-
import re
2-
from collections import OrderedDict
3-
4-
import pkg_resources
5-
from jinja2 import Template
1+
import os.path
2+
from difflib import SequenceMatcher
3+
from operator import itemgetter
4+
from typing import Dict, List
65

76
from commitizen import changelog, factory, git, out
87
from commitizen.config import BaseConfig
9-
from commitizen.error_codes import NO_COMMITS_FOUND, NO_PATTERN_MAP, TAG_FAILED
8+
from commitizen.error_codes import NO_COMMITS_FOUND, NO_PATTERN_MAP, NO_REVISION
9+
from commitizen.git import GitTag
10+
11+
12+
def similar(a, b):
13+
return SequenceMatcher(None, a, b).ratio()
1014

1115

1216
class Changelog:
@@ -21,69 +25,78 @@ def __init__(self, config: BaseConfig, args):
2125
self.dry_run = args["dry_run"]
2226
self.start_rev = args["start_rev"]
2327

28+
def _find_incremental_rev(self, latest_version: str, tags: List[GitTag]) -> str:
29+
"""Try to find the 'start_rev'.
30+
31+
We use a similarity approach. We know how to parse the version from the markdown
32+
changelog, but not the whole tag, we don't even know how's the tag made.
33+
34+
This 'smart' function tries to find a similarity between the found version number
35+
and the available tag.
36+
37+
The SIMILARITY_THRESHOLD is an empirical value, it may have to be adjusted based
38+
on our experience.
39+
"""
40+
SIMILARITY_THRESHOLD = 0.89
41+
tag_ratio = map(
42+
lambda tag: (SequenceMatcher(None, latest_version, tag.name).ratio(), tag),
43+
tags,
44+
)
45+
try:
46+
score, tag = max(tag_ratio, key=itemgetter(0))
47+
except ValueError:
48+
raise SystemExit(NO_REVISION)
49+
if score < SIMILARITY_THRESHOLD:
50+
raise SystemExit(NO_REVISION)
51+
start_rev = tag.name
52+
return start_rev
53+
2454
def __call__(self):
25-
# changelog_map = self.cz.changelog_map
2655
commit_parser = self.cz.commit_parser
2756
changelog_pattern = self.cz.changelog_pattern
57+
start_rev = self.start_rev
58+
changelog_meta: Dict = {}
2859

2960
if not changelog_pattern or not commit_parser:
3061
out.error(
3162
f"'{self.config.settings['name']}' rule does not support changelog"
3263
)
3364
raise SystemExit(NO_PATTERN_MAP)
34-
# pat = re.compile(changelog_pattern)
35-
36-
commits = git.get_commits(start=self.start_rev)
37-
if not commits:
38-
out.error("No commits found")
39-
raise SystemExit(NO_COMMITS_FOUND)
4065

4166
tags = git.get_tags()
4267
if not tags:
4368
tags = []
4469

70+
if self.incremental:
71+
changelog_meta = changelog.get_metadata(self.file_name)
72+
latest_version = changelog_meta.get("latest_version")
73+
if latest_version:
74+
start_rev = self._find_incremental_rev(latest_version, tags)
75+
76+
commits = git.get_commits(start=start_rev)
77+
if not commits:
78+
out.error("No commits found")
79+
raise SystemExit(NO_COMMITS_FOUND)
80+
4581
tree = changelog.generate_tree_from_commits(
4682
commits, tags, commit_parser, changelog_pattern
4783
)
4884
changelog_out = changelog.render_changelog(tree)
49-
# tag_map = {tag.rev: tag.name for tag in git.get_tags()}
50-
51-
# entries = OrderedDict()
52-
# # The latest commit is not tagged
53-
# latest_commit = commits[0]
54-
# if latest_commit.rev not in tag_map:
55-
# current_key = "Unreleased"
56-
# entries[current_key] = OrderedDict(
57-
# {value: [] for value in changelog_map.values()}
58-
# )
59-
# else:
60-
# current_key = tag_map[latest_commit.rev]
61-
62-
# for commit in commits:
63-
# if commit.rev in tag_map:
64-
# current_key = tag_map[commit.rev]
65-
# entries[current_key] = OrderedDict(
66-
# {value: [] for value in changelog_map.values()}
67-
# )
68-
69-
# matches = pat.match(commit.message)
70-
# if not matches:
71-
# continue
72-
73-
# processed_commit = self.cz.process_commit(commit.message)
74-
# for group_name, commit_type in changelog_map.items():
75-
# if matches.group(group_name):
76-
# entries[current_key][commit_type].append(processed_commit)
77-
# break
78-
79-
# template_file = pkg_resources.resource_string(
80-
# __name__, "../templates/keep_a_changelog_template.j2"
81-
# ).decode("utf-8")
82-
# jinja_template = Template(template_file)
83-
# changelog_str = jinja_template.render(entries=entries)
85+
8486
if self.dry_run:
8587
out.write(changelog_out)
8688
raise SystemExit(0)
8789

90+
lines = []
91+
if self.incremental and os.path.isfile(self.file_name):
92+
with open(self.file_name, "r") as changelog_file:
93+
lines = changelog_file.readlines()
94+
8895
with open(self.file_name, "w") as changelog_file:
89-
changelog_file.write(changelog_out)
96+
if self.incremental:
97+
new_lines = changelog.incremental_build(
98+
changelog_out, lines, changelog_meta
99+
)
100+
changelog_file.writelines(new_lines)
101+
else:
102+
changelog_file.write(changelog_out)

commitizen/defaults.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,4 @@
3434
bump_message = "bump: version $current_version → $new_version"
3535

3636
commit_parser = r"^(?P<change_type>feat|fix|refactor|perf|BREAKING CHANGE)(?:\((?P<scope>[^()\r\n]*)\)|\()?(?P<breaking>!)?:\s(?P<message>.*)?" # noqa
37+
version_parser = r"(?P<version>([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+)?)"

commitizen/error_codes.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,6 @@
2222
# Check
2323
NO_COMMIT_MSG = 13
2424
INVALID_COMMIT_MSG = 14
25+
26+
# Changelog
27+
NO_REVISION = 16

commitizen/git.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ class GitProtocol(Protocol):
1515

1616
class GitObject:
1717
rev: str
18+
name: str
19+
date: str
1820

1921
def __eq__(self, other) -> bool:
2022
if not hasattr(other, "rev"):

docs/changelog.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ cz changelog
1414
cz ch
1515
```
1616

17+
It has support for incremental changelog:
18+
19+
- Build from latest version found in changelog, this is useful if you have a different changelog and want to use chommitizen
20+
- Update unreleased area
21+
- Allows users to manually touch the changelog without being rewritten.
22+
23+
## Constrains
24+
25+
At the moment this features is constrained only to markdown files.
26+
1727
## Description
1828

1929
These are the variables used by the changelog generator.
@@ -26,7 +36,7 @@ These are the variables used by the changelog generator.
2636
- **<scope>**: <message>
2737
```
2838

29-
It will create a full of the above block per version found in the tags.
39+
It will create a full block like above per version found in the tags.
3040
And it will create a list of the commits found.
3141
The `change_type` and the `scope` are optional, they don't need to be provided,
3242
but if your regex does they will be rendered.
@@ -45,5 +55,10 @@ and the following variables are expected:
4555

4656
- **required**: is the only one required to be parsed by the regex
4757

58+
## TODO
59+
60+
- [ ] 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
61+
- [ ] support for map: allow the usage of a `change_type` mapper, to convert from feat to feature for example.
62+
4863
[keepachangelog]: https://keepachangelog.com/
4964
[semver]: https://semver.org/

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ mypy = "^0.770"
6666
mkdocs = "^1.0"
6767
mkdocs-material = "^4.1"
6868
isort = "^4.3.21"
69+
freezegun = "^0.3.15"
6970

7071
[tool.poetry.scripts]
7172
cz = "commitizen.cli:main"

0 commit comments

Comments
 (0)