Skip to content

Commit db958d4

Browse files
authored
ci: add script to generate changelog for new release (#2356)
* ci: add script to generate changelog for new release * format the changelog with markdown sections * Add docstring and missing typing * Better error handling * Format title to match json format if needed * Better error when fail to decode line as json * Add move docstring * log the line and the json error when parse line fail * break to functions * Add docstring * Only try to format line for json if load json failed * rename `e` >> `ex` * fix: Handle nested quotes in changelog title The original implementation for parsing JSON lines in the changelog generation script used regular expressions to handle nested quotes within the "title" field. This commit simplifies the logic by using string manipulation methods, making the code more readable and maintainable. This fixes an issue where the script would fail to parse commits with titles containing escaped quotes.
1 parent 44b4c3a commit db958d4

File tree

4 files changed

+159
-19
lines changed

4 files changed

+159
-19
lines changed

.flake8

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ fcn_exclude_functions =
5959
ast,
6060
filecmp,
6161
datetime,
62+
subprocess,
6263

6364
enable-extensions =
6465
FCN,

.release-it.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"push": true,
2020
"pushArgs": ["--follow-tags"],
2121
"pushRepo": "",
22-
"changelog": "git log --pretty=format:\"* %s (%h) by %an on %as\" ${from}...${to}"
22+
"changelog": "uv run scripts/generate_changelog.py ${from} ${to}"
2323
},
2424
"github": {
2525
"release": true,

scripts/generate_changelog.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import json
2+
import shlex
3+
import subprocess
4+
import sys
5+
from collections import OrderedDict
6+
7+
8+
def json_line(line: str) -> dict:
9+
"""
10+
Format str line to str that can be parsed with json.
11+
12+
In case line is not formatted for json for example:
13+
'{"title": "Revert "feat: Use git cliff to generate the change log. (#2322)" (#2324)", "commit": "137331fd", "author": "Meni Yakove", "date": "2025-02-16"}'
14+
title have `"` inside the external `"` `"Revert "feat: Use git cliff to generate the change log. (#2322)" (#2324)"`
15+
"""
16+
try:
17+
return json.loads(line)
18+
except json.JSONDecodeError:
19+
# split line like by `,`
20+
# '{"title": "Revert "feat: Use git cliff to generate the change log. (#2322)" (#2324)", "commit": "137331fd", "author": "Meni Yakove", "date": "2025-02-16"}'
21+
line_split = line.split(",")
22+
23+
# Pop and save `title key` and `title body` from '{"title": "Revert "feat: Use git cliff to generate the change log. (#2322)" (#2324)"'
24+
title_key, title_body = line_split.pop(0).split(":", 1)
25+
26+
if title_body.count('"') > 2:
27+
# reconstruct the `title_body` without the extra `"`
28+
# "Revert "feat: Use git cliff to generate the change log. (#2322)" (#2324)"'
29+
# replace all `"` with empty char and add `"` char to the beginning and the end of the string
30+
stripted_body = title_body.replace('"', "")
31+
title_body = f'"{stripted_body}"'
32+
33+
line_split.append(f"{title_key}: {title_body.strip()}")
34+
line = ",".join(line_split)
35+
36+
return json.loads(line)
37+
38+
39+
def execute_git_log(from_tag: str, to_tag: str) -> str:
40+
"""Executes git log and returns the output, or raises an exception on error."""
41+
_format: str = '{"title": "%s", "commit": "%h", "author": "%an", "date": "%as"}'
42+
43+
try:
44+
command = f"git log --pretty=format:'{_format}' {from_tag}...{to_tag}"
45+
proc = subprocess.run(
46+
shlex.split(command), stdout=subprocess.PIPE, text=True, check=True
47+
) # Use check=True to raise an exception for non-zero return codes
48+
return proc.stdout
49+
except subprocess.CalledProcessError as ex:
50+
print(f"Error executing git log: {ex}")
51+
sys.exit(1)
52+
except FileNotFoundError:
53+
print("Error: git not found. Please ensure git is installed and in your PATH.")
54+
sys.exit(1)
55+
56+
57+
def parse_commit_line(line: str) -> dict:
58+
"""Parses a single JSON formatted git log line."""
59+
try:
60+
return json_line(line=line)
61+
except json.decoder.JSONDecodeError as ex:
62+
print(f"Error parsing JSON: {line} - {ex}")
63+
return {}
64+
65+
66+
def categorize_commit(commit: dict, title_to_type_map: dict, default_category: str = "Other Changes:") -> str:
67+
"""Categorizes a commit based on its title prefix."""
68+
if not commit or "title" not in commit:
69+
return default_category
70+
title = commit["title"]
71+
try:
72+
prefix = title.split(":", 1)[0].lower() # Extract the prefix before the first colon
73+
return title_to_type_map.get(prefix, default_category)
74+
except IndexError:
75+
return default_category
76+
77+
78+
def format_changelog_entry(change: dict, section: str) -> str:
79+
"""Formats a single changelog entry."""
80+
title = change["title"].split(":", 1)[1] if section != "Other Changes:" else change["title"]
81+
return f"- {title} ({change['commit']}) by {change['author']} on {change['date']}\n"
82+
83+
84+
def main(from_tag: str, to_tag: str) -> str:
85+
title_to_type_map: dict[str, str] = {
86+
"ci": "CI:",
87+
"docs": "Docs:",
88+
"feat": "New Feature:",
89+
"fix": "Bugfixes:",
90+
"refactor": "Refactor:",
91+
"test": "Tests:",
92+
"release": "New Release:",
93+
"cherrypicked": "Cherry Pick:",
94+
"merge": "Merge:",
95+
}
96+
changelog_dict: OrderedDict[str, list[dict[str, str]]] = OrderedDict([
97+
("New Feature:", []),
98+
("Bugfixes:", []),
99+
("CI:", []),
100+
("New Release:", []),
101+
("Docs:", []),
102+
("Refactor:", []),
103+
("Tests:", []),
104+
("Other Changes:", []),
105+
("Cherry Pick:", []),
106+
("Merge:", []),
107+
])
108+
109+
changelog: str = "## What's Changed\n"
110+
111+
res = execute_git_log(from_tag=from_tag, to_tag=to_tag)
112+
113+
for line in res.splitlines():
114+
commit = parse_commit_line(line=line)
115+
if commit:
116+
category = categorize_commit(commit=commit, title_to_type_map=title_to_type_map)
117+
changelog_dict[category].append(commit)
118+
119+
for section, changes in changelog_dict.items():
120+
if not changes:
121+
continue
122+
123+
changelog += f"#### {section}\n"
124+
for change in changes:
125+
changelog += format_changelog_entry(change, section)
126+
changelog += "\n"
127+
128+
changelog += (
129+
f"**Full Changelog**: https://github.com/RedHatQE/openshift-python-wrapper/compare/{from_tag}...{to_tag}"
130+
)
131+
132+
return changelog
133+
134+
135+
if __name__ == "__main__":
136+
"""
137+
Generate a changelog between two Git tags, formatted as markdown.
138+
139+
This script parses Git commit logs between two specified tags and categorizes them
140+
by commit type (feat, fix, ci, etc.). It formats the output as a markdown document
141+
with sections for different types of changes, intended for use with release-it.
142+
143+
Each commit is expected to follow the conventional commit format:
144+
<type>: <description>
145+
146+
where <type> is one of: feat, fix, docs, style, refactor, test, chore, etc.
147+
Commits that don't follow this format are categorized under "Other Changes".
148+
149+
Generate a changelog between two tags, output as markdown
150+
151+
Usage: python generate-changelog.py <from_tag> <to_tag>
152+
"""
153+
if len(sys.argv) != 3:
154+
print("Usage: python generate-changelog.py <from_tag> <to_tag>")
155+
sys.exit(1)
156+
157+
print(main(from_tag=sys.argv[1], to_tag=sys.argv[2]))

scripts/k3d-runner.sh

Lines changed: 0 additions & 18 deletions
This file was deleted.

0 commit comments

Comments
 (0)