Skip to content

Commit 718b881

Browse files
authored
Merge pull request #126 from Lee-W/changelog
Generate Changelog from commits
2 parents 6280d92 + 5c152a4 commit 718b881

File tree

17 files changed

+626
-53
lines changed

17 files changed

+626
-53
lines changed

README.rst

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,8 @@ Usage
148148

149149
$ cz --help
150150
usage: cz [-h] [--debug] [-n NAME] [--version]
151-
{ls,commit,c,example,info,schema,bump} ...
151+
{init,commit,c,ls,example,info,schema,bump,changelog,ch,check,version}
152+
...
152153

153154
Commitizen is a cli tool to generate conventional commits.
154155
For more information about the topic go to https://conventionalcommits.org/
@@ -161,18 +162,20 @@ Usage
161162
--version get the version of the installed commitizen
162163

163164
commands:
164-
{ls,commit,c,example,info,schema,bump,version,check,init}
165-
ls show available commitizens
165+
{init,commit,c,ls,example,info,schema,bump,changelog,ch,check,version}
166+
init init commitizen configuration
166167
commit (c) create new commit
168+
ls show available commitizens
167169
example show commit example
168170
info show information about the cz
169171
schema show commit schema
170172
bump bump semantic version based on the git log
171-
version get the version of the installed commitizen or the
172-
current project (default: installed commitizen)
173+
changelog (ch) generate changelog (note that it will overwrite
174+
existing file)
173175
check validates that a commit message matches the commitizen
174176
schema
175-
init init commitizen configuration
177+
version get the version of the installed commitizen or the
178+
current project (default: installed commitizen)
176179

177180
Contributing
178181
============

commitizen/changelog.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""
2+
# DESIGN
3+
4+
## Parse CHANGELOG.md
5+
6+
1. Get LATEST VERSION from CONFIG
7+
1. Parse the file version to version
8+
2. Build a dict (tree) of that particular version
9+
3. Transform tree into markdown again
10+
11+
## Parse git log
12+
13+
1. get commits between versions
14+
2. filter commits with the current cz rules
15+
3. parse commit information
16+
4. generate tree
17+
18+
Options:
19+
- Generate full or partial changelog
20+
"""
21+
from typing import Generator, List, Dict, Iterable
22+
import re
23+
24+
MD_VERSION_RE = r"^##\s(?P<version>[a-zA-Z0-9.+]+)\s?\(?(?P<date>[0-9-]+)?\)?"
25+
MD_CATEGORY_RE = r"^###\s(?P<category>[a-zA-Z0-9.+\s]+)"
26+
MD_MESSAGE_RE = r"^-\s(\*{2}(?P<scope>[a-zA-Z0-9]+)\*{2}:\s)?(?P<message>.+)"
27+
md_version_c = re.compile(MD_VERSION_RE)
28+
md_category_c = re.compile(MD_CATEGORY_RE)
29+
md_message_c = re.compile(MD_MESSAGE_RE)
30+
31+
32+
CATEGORIES = [
33+
("fix", "fix"),
34+
("breaking", "BREAKING CHANGES"),
35+
("feat", "feat"),
36+
("refactor", "refactor"),
37+
("perf", "perf"),
38+
("test", "test"),
39+
("build", "build"),
40+
("ci", "ci"),
41+
("chore", "chore"),
42+
]
43+
44+
45+
def find_version_blocks(filepath: str) -> Generator:
46+
"""
47+
version block: contains all the information about a version.
48+
49+
E.g:
50+
```
51+
## 1.2.1 (2019-07-20)
52+
53+
## Bug fixes
54+
55+
- username validation not working
56+
57+
## Features
58+
59+
- new login system
60+
61+
```
62+
"""
63+
with open(filepath, "r") as f:
64+
block: list = []
65+
for line in f:
66+
line = line.strip("\n")
67+
if not line:
68+
continue
69+
70+
if line.startswith("## "):
71+
if len(block) > 0:
72+
yield block
73+
block = [line]
74+
else:
75+
block.append(line)
76+
yield block
77+
78+
79+
def parse_md_version(md_version: str) -> Dict:
80+
m = md_version_c.match(md_version)
81+
if not m:
82+
return {}
83+
return m.groupdict()
84+
85+
86+
def parse_md_category(md_category: str) -> Dict:
87+
m = md_category_c.match(md_category)
88+
if not m:
89+
return {}
90+
return m.groupdict()
91+
92+
93+
def parse_md_message(md_message: str) -> Dict:
94+
m = md_message_c.match(md_message)
95+
if not m:
96+
return {}
97+
return m.groupdict()
98+
99+
100+
def transform_category(category: str) -> str:
101+
_category_lower = category.lower()
102+
for match_value, output in CATEGORIES:
103+
if re.search(match_value, _category_lower):
104+
return output
105+
else:
106+
raise ValueError(f"Could not match a category with {category}")
107+
108+
109+
def generate_block_tree(block: List[str]) -> Dict:
110+
tree: Dict = {"commits": []}
111+
category = None
112+
for line in block:
113+
if line.startswith("## "):
114+
category = None
115+
tree = {**tree, **parse_md_version(line)}
116+
elif line.startswith("### "):
117+
result = parse_md_category(line)
118+
if not result:
119+
continue
120+
category = transform_category(result.get("category", ""))
121+
122+
elif line.startswith("- "):
123+
commit = parse_md_message(line)
124+
commit["category"] = category
125+
tree["commits"].append(commit)
126+
else:
127+
print("it's something else: ", line)
128+
return tree
129+
130+
131+
def generate_full_tree(blocks: Iterable) -> Iterable[Dict]:
132+
for block in blocks:
133+
yield generate_block_tree(block)

commitizen/cli.py

Lines changed: 49 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@
3434
# "required": True,
3535
"commands": [
3636
{
37-
"name": "ls",
38-
"help": "show available commitizens",
39-
"func": commands.ListCz,
37+
"name": ["init"],
38+
"help": "init commitizen configuration",
39+
"func": commands.Init,
4040
},
4141
{
4242
"name": ["commit", "c"],
@@ -55,6 +55,11 @@
5555
},
5656
],
5757
},
58+
{
59+
"name": "ls",
60+
"help": "show available commitizens",
61+
"func": commands.ListCz,
62+
},
5863
{
5964
"name": "example",
6065
"help": "show commit example",
@@ -114,33 +119,29 @@
114119
],
115120
},
116121
{
117-
"name": ["version"],
122+
"name": ["changelog", "ch"],
118123
"help": (
119-
"get the version of the installed commitizen or the current project"
120-
" (default: installed commitizen)"
124+
"generate changelog (note that it will overwrite existing file)"
121125
),
122-
"func": commands.Version,
126+
"func": commands.Changelog,
123127
"arguments": [
124128
{
125-
"name": ["-p", "--project"],
126-
"help": "get the version of the current project",
129+
"name": "--dry-run",
127130
"action": "store_true",
128-
"exclusive_group": "group1",
131+
"default": False,
132+
"help": "show changelog to stdout",
129133
},
130134
{
131-
"name": ["-c", "--commitizen"],
132-
"help": "get the version of the installed commitizen",
133-
"action": "store_true",
134-
"exclusive_group": "group1",
135+
"name": "--file-name",
136+
"help": "file name of changelog (default: 'CHANGELOG.md')",
135137
},
136138
{
137-
"name": ["-v", "--verbose"],
139+
"name": "--start-rev",
140+
"default": None,
138141
"help": (
139-
"get the version of both the installed commitizen "
140-
"and the current project"
142+
"start rev of the changelog."
143+
"If not set, it will generate changelog from the start"
141144
),
142-
"action": "store_true",
143-
"exclusive_group": "group1",
144145
},
145146
],
146147
},
@@ -161,9 +162,35 @@
161162
],
162163
},
163164
{
164-
"name": ["init"],
165-
"help": "init commitizen configuration",
166-
"func": commands.Init,
165+
"name": ["version"],
166+
"help": (
167+
"get the version of the installed commitizen or the current project"
168+
" (default: installed commitizen)"
169+
),
170+
"func": commands.Version,
171+
"arguments": [
172+
{
173+
"name": ["-p", "--project"],
174+
"help": "get the version of the current project",
175+
"action": "store_true",
176+
"exclusive_group": "group1",
177+
},
178+
{
179+
"name": ["-c", "--commitizen"],
180+
"help": "get the version of the installed commitizen",
181+
"action": "store_true",
182+
"exclusive_group": "group1",
183+
},
184+
{
185+
"name": ["-v", "--verbose"],
186+
"help": (
187+
"get the version of both the installed commitizen "
188+
"and the current project"
189+
),
190+
"action": "store_true",
191+
"exclusive_group": "group1",
192+
},
193+
],
167194
},
168195
],
169196
},

commitizen/commands/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
from .schema import Schema
88
from .version import Version
99
from .init import Init
10+
from .changelog import Changelog
1011

1112

1213
__all__ = (
1314
"Bump",
1415
"Check",
1516
"Commit",
17+
"Changelog",
1618
"Example",
1719
"Info",
1820
"ListCz",

commitizen/commands/changelog.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import re
2+
import pkg_resources
3+
from collections import OrderedDict
4+
5+
from jinja2 import Template
6+
7+
from commitizen import factory, out, git
8+
from commitizen.config import BaseConfig
9+
from commitizen.error_codes import NO_COMMITS_FOUND, NO_PATTERN_MAP
10+
11+
12+
class Changelog:
13+
"""Generate a changelog based on the commit history."""
14+
15+
def __init__(self, config: BaseConfig, args):
16+
self.config: BaseConfig = config
17+
self.cz = factory.commiter_factory(self.config)
18+
19+
self.file_name = args["file_name"] or self.config.settings.get("changelog_file")
20+
self.dry_run = args["dry_run"]
21+
self.start_rev = args["start_rev"]
22+
23+
def __call__(self):
24+
changelog_map = self.cz.changelog_map
25+
changelog_pattern = self.cz.changelog_pattern
26+
if not changelog_map or not changelog_pattern:
27+
out.error(
28+
f"'{self.config.settings['name']}' rule does not support changelog"
29+
)
30+
raise SystemExit(NO_PATTERN_MAP)
31+
32+
pat = re.compile(changelog_pattern)
33+
34+
commits = git.get_commits(start=self.start_rev)
35+
if not commits:
36+
out.error("No commits found")
37+
raise SystemExit(NO_COMMITS_FOUND)
38+
39+
tag_map = {tag.rev: tag.name for tag in git.get_tags()}
40+
41+
entries = OrderedDict()
42+
# The latest commit is not tagged
43+
latest_commit = commits[0]
44+
if latest_commit.rev not in tag_map:
45+
current_key = "Unreleased"
46+
entries[current_key] = OrderedDict(
47+
{value: [] for value in changelog_map.values()}
48+
)
49+
else:
50+
current_key = tag_map[latest_commit.rev]
51+
52+
for commit in commits:
53+
if commit.rev in tag_map:
54+
current_key = tag_map[commit.rev]
55+
entries[current_key] = OrderedDict(
56+
{value: [] for value in changelog_map.values()}
57+
)
58+
59+
matches = pat.match(commit.message)
60+
if not matches:
61+
continue
62+
63+
processed_commit = self.cz.process_commit(commit.message)
64+
for group_name, commit_type in changelog_map.items():
65+
if matches.group(group_name):
66+
entries[current_key][commit_type].append(processed_commit)
67+
break
68+
69+
template_file = pkg_resources.resource_string(
70+
__name__, "../templates/keep_a_changelog_template.j2"
71+
).decode("utf-8")
72+
jinja_template = Template(template_file)
73+
changelog_str = jinja_template.render(entries=entries)
74+
if self.dry_run:
75+
out.write(changelog_str)
76+
raise SystemExit(0)
77+
78+
with open(self.file_name, "w") as changelog_file:
79+
changelog_file.write(changelog_str)

commitizen/cz/base.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
class BaseCommitizen(metaclass=ABCMeta):
88
bump_pattern: Optional[str] = None
99
bump_map: Optional[dict] = None
10+
changelog_pattern: Optional[str] = None
11+
changelog_map: Optional[dict] = None
1012
default_style_config: List[Tuple[str, str]] = [
1113
("qmark", "fg:#ff9d00 bold"),
1214
("question", "bold"),
@@ -57,3 +59,10 @@ def schema_pattern(self) -> str:
5759
def info(self) -> str:
5860
"""Information about the standardized commit message."""
5961
raise NotImplementedError("Not Implemented yet")
62+
63+
def process_commit(self, commit: str) -> str:
64+
"""Process commit for changelog.
65+
66+
If not overwritten, it returns the first line of commit.
67+
"""
68+
return commit.split("\n")[0]

0 commit comments

Comments
 (0)