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

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