Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit a7b3688

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

File tree

11 files changed

+516
-85
lines changed

11 files changed

+516
-85
lines changed

‎commitizen/changelog.py‎

Lines changed: 114 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,11 @@
2626
from typing import Dict, Iterable, List, Optional
2727

2828
import pkg_resources
29+
from devtools import debug
2930
from jinja2 import Template
3031

3132
from commitizen import defaults
32-
from commitizen.git import GitCommit, GitProtocol, GitTag
33+
from commitizen.git import GitCommit, GitTag
3334

3435
CATEGORIES = [
3536
("fix", "fix"),
@@ -55,15 +56,9 @@ def transform_change_type(change_type: str) -> str:
5556
raise ValueError(f"Could not match a change_type with {change_type}")
5657

5758

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

6863

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

AltStyle によって変換されたページ (->オリジナル) /