Skip to content

Commit b38cdb5

Browse files
committed
chore: add script to generate release notes based on commit history
The script takes the following inputs: - module name: as specified in versions.txt - module directory: path in the monorepo - version: version as found in versions.txt It scans backwards through the git history of versions.txt to find the commit where the version was changed to the provided version. It then finds the commit where the previous non-snapshot version was set. It uses this commit range to generate the commit history affecting that directory.
1 parent 1e633d7 commit b38cdb5

1 file changed

Lines changed: 278 additions & 0 deletions

File tree

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import argparse
2+
import re
3+
import subprocess
4+
import sys
5+
6+
7+
def run_cmd(cmd, cwd=None):
8+
"""Runs a shell command and returns the output."""
9+
result = subprocess.run(
10+
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=cwd
11+
)
12+
if result.returncode != 0:
13+
print(f"Error running command: {' '.join(cmd)}", file=sys.stderr)
14+
print(result.stderr, file=sys.stderr)
15+
sys.exit(result.returncode)
16+
return result.stdout
17+
18+
19+
def main():
20+
parser = argparse.ArgumentParser(
21+
description="Generate release notes based on commit history for a specific module."
22+
)
23+
parser.add_argument(
24+
"--module", required=True, help="Module name as specified in versions.txt"
25+
)
26+
parser.add_argument(
27+
"--directory", required=True, help="Path in the monorepo where the module has code"
28+
)
29+
parser.add_argument("--version", required=True, help="Target version")
30+
parser.add_argument(
31+
"--short-name", help="Module short-name used in commit overrides (e.g., aiplatform). Omit for repo-wide generation."
32+
)
33+
args = parser.parse_args()
34+
35+
module = args.module
36+
directory = args.directory
37+
target_version = args.version
38+
39+
# 1. Scan backwards through git history of versions.txt
40+
# We use -G to find commits that modified lines matching the module name.
41+
# We use --first-parent to ignore merge noise.
42+
log_cmd = [
43+
"git",
44+
"log",
45+
"--oneline",
46+
"--first-parent",
47+
f"-G^{module}:",
48+
"--",
49+
"versions.txt",
50+
]
51+
log_output = run_cmd(log_cmd)
52+
53+
commits = [line.split()[0] for line in log_output.splitlines() if line]
54+
55+
target_commit = None
56+
prev_commit = None
57+
prev_version = None
58+
59+
for commit in commits:
60+
# Get content of versions.txt at this commit
61+
show_cmd = ["git", "show", f"{commit}:versions.txt"]
62+
try:
63+
content = run_cmd(show_cmd)
64+
except SystemExit:
65+
continue # Ignore errors if file couldn't be read
66+
67+
# Find the line for the module
68+
pattern = re.compile(rf"^{module}:([^:]+):([^:]+)$")
69+
for line in content.splitlines():
70+
match = pattern.match(line)
71+
if match:
72+
released_ver = match.group(1)
73+
current_ver = match.group(2)
74+
75+
# Condition for target version
76+
if released_ver == target_version and not target_commit:
77+
target_commit = commit
78+
print(f"Found target version {target_version} at {commit}")
79+
80+
# Condition for previous non-snapshot version
81+
# We ignore snapshot versions by checking both fields.
82+
elif (
83+
target_commit
84+
and released_ver != target_version
85+
and "-SNAPSHOT" not in released_ver
86+
and "-SNAPSHOT" not in current_ver
87+
):
88+
prev_commit = commit
89+
prev_version = released_ver
90+
print(f"Found previous version {released_ver} at {commit}")
91+
break
92+
if prev_commit:
93+
break
94+
95+
if not target_commit:
96+
print(
97+
f"Target version {target_version} not found in history for module {module}."
98+
)
99+
sys.exit(1)
100+
101+
# Fallback for initial version if no previous version found
102+
if not prev_commit:
103+
print(
104+
f"Previous version not found in history for module {module}."
105+
)
106+
# Find the first commit affecting that directory
107+
first_commit_cmd = [
108+
"git",
109+
"log",
110+
"--reverse",
111+
"--oneline",
112+
"--first-parent",
113+
"--",
114+
directory,
115+
]
116+
try:
117+
first_commit_output = run_cmd(first_commit_cmd)
118+
if first_commit_output:
119+
prev_commit = first_commit_output.splitlines()[0].split()[0]
120+
print(f"Using first commit affecting directory as base: {prev_commit}")
121+
else:
122+
print(f"No history found for directory {directory}.")
123+
sys.exit(1)
124+
except SystemExit:
125+
sys.exit(1)
126+
127+
print(
128+
f"Generating notes between {prev_commit} and {target_commit} for directory {directory}"
129+
)
130+
131+
# 2. Generate commit history in that range affecting that directory
132+
# Use --first-parent to ignore merge noise.
133+
# Use format that includes hash, subject, and body
134+
notes_cmd = [
135+
"git",
136+
"log",
137+
"--format=%H %s%n%b%n--END_OF_COMMIT--",
138+
"--first-parent",
139+
f"{prev_commit}..{target_commit}",
140+
"--",
141+
directory,
142+
]
143+
notes_output = run_cmd(notes_cmd)
144+
145+
# Filter commit titles based on allowed prefixes and categorize them
146+
# Supports scopes in parentheses, e.g., feat(spanner):
147+
prefix_regex = re.compile(r"^(feat|fix|deps|docs)(\([^)]+\))?(!)?:")
148+
149+
breaking_changes = []
150+
features = []
151+
bug_fixes = []
152+
dependency_upgrades = []
153+
documentation = []
154+
155+
def categorize_and_append(commit_hash, text):
156+
match = prefix_regex.match(text)
157+
if not match:
158+
return
159+
160+
prefix = match.group(1)
161+
is_breaking = match.group(3) == "!"
162+
163+
if is_breaking:
164+
breaking_changes.append(f"{commit_hash[:11]} {text}")
165+
elif prefix == "feat":
166+
features.append(f"{commit_hash[:11]} {text}")
167+
elif prefix == "fix":
168+
bug_fixes.append(f"{commit_hash[:11]} {text}")
169+
elif prefix == "deps":
170+
dependency_upgrades.append(f"{commit_hash[:11]} {text}")
171+
elif prefix == "docs":
172+
documentation.append(f"{commit_hash[:11]} {text}")
173+
174+
commits_data = notes_output.split("--END_OF_COMMIT--")
175+
176+
for commit_data in commits_data:
177+
commit_data = commit_data.strip()
178+
if not commit_data:
179+
continue
180+
181+
lines = commit_data.splitlines()
182+
if not lines:
183+
continue
184+
185+
header_parts = lines[0].split(" ", 1)
186+
commit_hash = header_parts[0]
187+
subject = header_parts[1] if len(header_parts) > 1 else ""
188+
189+
body = "\n".join(lines[1:])
190+
191+
# Check for override in the entire message
192+
if "BEGIN_COMMIT_OVERRIDE" in body or "BEGIN_COMMIT_OVERRIDE" in subject:
193+
match = re.search(r"BEGIN_COMMIT_OVERRIDE(.*?)END_COMMIT_OVERRIDE", commit_data, re.DOTALL)
194+
if match:
195+
override_content = match.group(1)
196+
current_item = []
197+
in_module_item = False
198+
199+
for line in override_content.splitlines():
200+
line_stripped = line.strip()
201+
if not line_stripped:
202+
continue
203+
204+
# Check if it's a new item using regex
205+
is_new_item = prefix_regex.match(line_stripped)
206+
207+
if is_new_item:
208+
# If we were in an item, save it
209+
if in_module_item and current_item:
210+
categorize_and_append(commit_hash, " ".join(current_item))
211+
current_item = []
212+
in_module_item = False
213+
214+
# Check if this new item is for our module or if we want all
215+
should_include = False
216+
if args.short_name:
217+
if f"[{args.short_name}]" in line_stripped:
218+
should_include = True
219+
else:
220+
should_include = True
221+
222+
if should_include:
223+
in_module_item = True
224+
current_item.append(line_stripped)
225+
elif in_module_item:
226+
# Continuation line
227+
if line_stripped.startswith(("PiperOrigin-RevId:", "Source Link:")):
228+
continue
229+
if line_stripped in ("END_NESTED_COMMIT", "BEGIN_NESTED_COMMIT"):
230+
continue
231+
current_item.append(line_stripped)
232+
233+
# Save the last item if we were in one
234+
if in_module_item and current_item:
235+
categorize_and_append(commit_hash, " ".join(current_item))
236+
237+
# Ignore the title since there was an override
238+
continue
239+
240+
# Fallback to title check if no override
241+
if prefix_regex.match(subject):
242+
categorize_and_append(commit_hash, subject)
243+
244+
print("\nRelease Notes:")
245+
if breaking_changes:
246+
print("### ⚠ BREAKING CHANGES\n")
247+
for item in breaking_changes:
248+
print(f"* {item}")
249+
print()
250+
251+
if features:
252+
print("### Features\n")
253+
for item in features:
254+
print(f"* {item}")
255+
print()
256+
257+
if bug_fixes:
258+
print("### Bug Fixes\n")
259+
for item in bug_fixes:
260+
print(f"* {item}")
261+
print()
262+
263+
if dependency_upgrades:
264+
print("### Dependencies\n")
265+
for item in dependency_upgrades:
266+
print(f"* {item}")
267+
print()
268+
269+
if documentation:
270+
print("### Documentation\n")
271+
for item in documentation:
272+
print(f"* {item}")
273+
print()
274+
275+
276+
277+
if __name__ == "__main__":
278+
main()

0 commit comments

Comments
 (0)