Skip to content

Commit 79480e0

Browse files
authored
chore: add script to generate release notes based on commit history (#12900)
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. This also adds missing release notes for spanner and bigquery. Fixes #12864.
1 parent 4bfa589 commit 79480e0

7 files changed

Lines changed: 513 additions & 3 deletions

File tree

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
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 find_version_boundaries(file_path, pattern, target_version, module=None):
20+
"""Scans history of a file to find release boundaries moving forward."""
21+
log_cmd = [
22+
"git",
23+
"log",
24+
"--oneline",
25+
"--all",
26+
"--",
27+
file_path,
28+
]
29+
try:
30+
log_output = run_cmd(log_cmd)
31+
commits = [line.split()[0] for line in log_output.splitlines() if line]
32+
commits.reverse() # Move forward in time!
33+
34+
first_prev_commit = None
35+
target_release_commit = None
36+
prev_version = None
37+
38+
for commit in commits:
39+
# Check if file exists at that commit to avoid noisy errors
40+
check_cmd = ["git", "cat-file", "-e", f"{commit}:{file_path}"]
41+
check_result = subprocess.run(check_cmd, stderr=subprocess.PIPE)
42+
if check_result.returncode != 0:
43+
continue
44+
45+
show_cmd = ["git", "show", f"{commit}:{file_path}"]
46+
try:
47+
content = run_cmd(show_cmd)
48+
except SystemExit:
49+
continue
50+
51+
found_ver = None
52+
match = pattern.search(content)
53+
if match:
54+
found_ver = match.group(1)
55+
56+
if found_ver:
57+
if found_ver == target_version:
58+
target_release_commit = commit
59+
break # Stop as soon as we find the target release!
60+
61+
# Track the first occurrence of the latest stable version before target
62+
if found_ver != target_version and "-SNAPSHOT" not in found_ver and (not prev_version or found_ver != prev_version):
63+
prev_version = found_ver
64+
first_prev_commit = commit
65+
66+
return first_prev_commit, target_release_commit, prev_version
67+
except SystemExit:
68+
return None, None, None
69+
70+
71+
def verify_commit(commit_hash, directory, module, allowed_versions):
72+
"""Verifies if a commit belongs to the release based on file state."""
73+
if directory == ".":
74+
pom_path = "gapic-libraries-bom/pom.xml"
75+
else:
76+
pom_path = f"{directory}/pom.xml"
77+
78+
# Check if file exists at that commit to avoid noisy errors
79+
check_cmd = ["git", "cat-file", "-e", f"{commit_hash}:{pom_path}"]
80+
check_result = subprocess.run(check_cmd, stderr=subprocess.PIPE)
81+
if check_result.returncode != 0:
82+
return False
83+
84+
try:
85+
content = run_cmd(["git", "show", f"{commit_hash}:{pom_path}"])
86+
# Allow optional <packaging> tag in between artifactId and version
87+
pattern = re.compile(rf"<artifactId>{re.escape(module)}</artifactId>\s*(?:<packaging>[^<]+</packaging>\s*)?<version>([^<]+)</version>", re.DOTALL)
88+
89+
match = pattern.search(content)
90+
if match and match.group(1) in allowed_versions:
91+
return True
92+
except SystemExit:
93+
pass
94+
95+
return False
96+
97+
98+
def parse_commit_overrides(commit_data, short_name, prefix_regex, commit_hash, categorize_callback):
99+
"""Parses commit overrides and calls callback for each item."""
100+
match = re.search(r"BEGIN_COMMIT_OVERRIDE(.*?)END_COMMIT_OVERRIDE", commit_data, re.DOTALL)
101+
if not match:
102+
return False
103+
104+
override_content = match.group(1)
105+
current_item = []
106+
in_module_item = False
107+
108+
for line in override_content.splitlines():
109+
line_stripped = line.strip()
110+
if not line_stripped:
111+
continue
112+
113+
is_new_item = prefix_regex.match(line_stripped)
114+
115+
if is_new_item:
116+
if in_module_item and current_item:
117+
categorize_callback(commit_hash, " ".join(current_item))
118+
current_item = []
119+
in_module_item = False
120+
121+
should_include = False
122+
if short_name:
123+
if f"[{short_name}]" in line_stripped:
124+
should_include = True
125+
else:
126+
should_include = True
127+
128+
if should_include:
129+
in_module_item = True
130+
current_item.append(line_stripped)
131+
elif in_module_item:
132+
if line_stripped.startswith(("PiperOrigin-RevId:", "Source Link:")):
133+
continue
134+
if line_stripped in ("END_NESTED_COMMIT", "BEGIN_NESTED_COMMIT"):
135+
continue
136+
current_item.append(line_stripped)
137+
138+
if in_module_item and current_item:
139+
categorize_callback(commit_hash, " ".join(current_item))
140+
141+
return True
142+
143+
144+
def get_tag_or_commit(commit_hash, target_version):
145+
"""Returns the tag pointing at the commit if there is exactly one, else the commit hash."""
146+
if not commit_hash:
147+
return None
148+
try:
149+
# Remove ~1 if present to find the actual tag pointing at the commit
150+
clean_hash = commit_hash.split("~")[0]
151+
tags_output = run_cmd(["git", "tag", "--points-at", clean_hash])
152+
tags = [line.strip() for line in tags_output.splitlines() if line.strip()]
153+
if len(tags) == 1:
154+
return tags[0]
155+
elif len(tags) > 1:
156+
for tag in tags:
157+
if target_version in tag:
158+
return tag
159+
except SystemExit:
160+
pass
161+
return commit_hash
162+
163+
164+
def main():
165+
parser = argparse.ArgumentParser(
166+
description="Generate release notes based on commit history for a specific module."
167+
)
168+
parser.add_argument(
169+
"--module", required=True, help="Module name as specified in versions.txt"
170+
)
171+
parser.add_argument(
172+
"--directory", required=True, help="Path in the monorepo where the module has code"
173+
)
174+
parser.add_argument("--version", required=True, help="Target version")
175+
parser.add_argument(
176+
"--short-name", help="Module short-name used in commit overrides (e.g., aiplatform). Omit for repo-wide generation."
177+
)
178+
args = parser.parse_args()
179+
180+
module = args.module
181+
directory = args.directory
182+
target_version = args.version
183+
184+
# 1. Scan history of pom.xml
185+
if directory == ".":
186+
pom_path = "gapic-libraries-bom/pom.xml"
187+
else:
188+
pom_path = f"{directory}/pom.xml"
189+
pom_pattern = re.compile(r"<version>([^<]+)</version>")
190+
191+
prev_commit, target_release_commit, prev_version = find_version_boundaries(pom_path, pom_pattern, target_version)
192+
193+
target_commit = None
194+
if target_release_commit:
195+
target_commit = target_release_commit
196+
print(f"Found target release commit at {target_release_commit}. Using inclusive upper boundary {target_commit}", file=sys.stderr)
197+
198+
if not target_commit:
199+
print(f"Target version {target_version} not found in history of {pom_path}.", file=sys.stderr)
200+
sys.exit(1)
201+
202+
range_desc = f"between {prev_commit} and {target_commit}" if prev_commit else f"up to {target_commit}"
203+
print(
204+
f"Generating notes {range_desc} for directory {directory}", file=sys.stderr
205+
)
206+
207+
# 2. Generate commit history in that range affecting that directory
208+
# Use format that includes hash, subject, and body
209+
notes_cmd = [
210+
"git",
211+
"log",
212+
"--format=%H %s%n%b%n--END_OF_COMMIT--",
213+
f"{prev_commit}~1..{target_commit}" if prev_commit else target_commit,
214+
]
215+
if directory != ".":
216+
notes_cmd.extend(["--", directory])
217+
notes_output = run_cmd(notes_cmd)
218+
219+
220+
221+
# Filter commit titles based on allowed prefixes and categorize them
222+
# Supports scopes in parentheses, e.g., feat(spanner):
223+
prefix_regex = re.compile(r"^(feat|fix|deps|docs|chore\(deps\)|build\(deps\))(\([^)]+\))?(!)?:")
224+
225+
breaking_changes = []
226+
features = []
227+
bug_fixes = []
228+
dependency_upgrades = []
229+
documentation = []
230+
231+
def categorize_and_append(commit_hash, text):
232+
match = prefix_regex.match(text)
233+
if not match:
234+
return
235+
236+
prefix = match.group(1)
237+
is_breaking = match.group(3) == "!"
238+
239+
commit_link = f"([{commit_hash[:7]}](https://github.com/googleapis/google-cloud-java/commit/{commit_hash}))"
240+
full_item = f"{text} {commit_link}"
241+
242+
if is_breaking:
243+
breaking_changes.append(full_item)
244+
elif prefix == "feat":
245+
features.append(full_item)
246+
elif prefix == "fix":
247+
bug_fixes.append(full_item)
248+
elif prefix == "deps" or prefix in ("chore(deps)", "build(deps)"):
249+
dependency_upgrades.append(full_item)
250+
elif prefix == "docs":
251+
documentation.append(full_item)
252+
253+
commits_data = notes_output.split("--END_OF_COMMIT--")
254+
255+
for commit_data in commits_data:
256+
commit_data = commit_data.strip()
257+
if not commit_data:
258+
continue
259+
260+
lines = commit_data.splitlines()
261+
if not lines:
262+
continue
263+
264+
header_parts = lines[0].split(" ", 1)
265+
commit_hash = header_parts[0]
266+
subject = header_parts[1] if len(header_parts) > 1 else ""
267+
268+
body = "\n".join(lines[1:])
269+
270+
# Verify if commit belongs to this release based on file state
271+
target_snapshot = f"{target_version}-SNAPSHOT"
272+
allowed_versions = (prev_version, target_snapshot) if prev_version else (target_snapshot,)
273+
274+
target_module = "gapic-libraries-bom" if directory == "." else module
275+
if not verify_commit(commit_hash, directory, target_module, allowed_versions):
276+
continue
277+
278+
# Check for override in the entire message
279+
if "BEGIN_COMMIT_OVERRIDE" in body or "BEGIN_COMMIT_OVERRIDE" in subject:
280+
if parse_commit_overrides(commit_data, args.short_name, prefix_regex, commit_hash, categorize_and_append):
281+
continue
282+
283+
# Fallback to title check if no override
284+
if prefix_regex.match(subject):
285+
categorize_and_append(commit_hash, subject)
286+
287+
# Get dates and build header
288+
target_date = run_cmd(["git", "log", "-1", "--format=%cI", target_commit]).strip()
289+
date_str = target_date.split("T")[0] # Get YYYY-MM-DD
290+
291+
prev_ref = get_tag_or_commit(prev_commit, prev_version) if prev_version else prev_commit
292+
target_ref = get_tag_or_commit(target_commit, target_version)
293+
294+
compare_url = f"https://github.com/googleapis/google-cloud-java/compare/{prev_ref}...{target_ref}" if prev_ref else f"https://github.com/googleapis/google-cloud-java/commit/{target_ref}"
295+
296+
print(f"## [{target_version}]({compare_url}) ({date_str})")
297+
print()
298+
299+
if not any([breaking_changes, features, bug_fixes, dependency_upgrades, documentation]):
300+
print("* No change")
301+
else:
302+
if breaking_changes:
303+
print("### ⚠ BREAKING CHANGES\n")
304+
for item in breaking_changes:
305+
print(f"* {item}")
306+
print()
307+
308+
if features:
309+
print("### Features\n")
310+
for item in features:
311+
print(f"* {item}")
312+
print()
313+
314+
if bug_fixes:
315+
print("### Bug Fixes\n")
316+
for item in bug_fixes:
317+
print(f"* {item}")
318+
print()
319+
320+
if documentation:
321+
print("### Documentation\n")
322+
for item in documentation:
323+
print(f"* {item}")
324+
print()
325+
326+
if dependency_upgrades:
327+
print("### Dependencies\n")
328+
for item in dependency_upgrades:
329+
print(f"* {item}")
330+
print()
331+
332+
333+
334+
if __name__ == "__main__":
335+
main()
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import subprocess
2+
import unittest
3+
from pathlib import Path
4+
5+
6+
class TestGenerateModuleNotes(unittest.TestCase):
7+
8+
def setUp(self):
9+
self.script_path = Path(
10+
".github/release-note-generation/generate_module_notes.py"
11+
)
12+
self.testdata_dir = Path(".github/release-note-generation/testdata")
13+
14+
def test_java_run_generation(self):
15+
golden_file = self.testdata_dir / "golden_java-run_0.71.0.txt"
16+
with open(golden_file, "r") as f:
17+
expected_output = f.read()
18+
19+
cmd = [
20+
"python3",
21+
str(self.script_path),
22+
"--module",
23+
"google-cloud-run",
24+
"--directory",
25+
"java-run",
26+
"--version",
27+
"0.71.0",
28+
"--short-name",
29+
"run",
30+
]
31+
result = subprocess.run(
32+
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
33+
)
34+
35+
self.assertEqual(result.returncode, 0)
36+
self.assertEqual(result.stdout, expected_output)
37+
38+
def test_root_generation(self):
39+
golden_file = self.testdata_dir / "golden_root_1.85.0.txt"
40+
with open(golden_file, "r") as f:
41+
expected_output = f.read()
42+
43+
cmd = [
44+
"python3",
45+
str(self.script_path),
46+
"--module",
47+
"google-cloud-java",
48+
"--directory",
49+
".",
50+
"--version",
51+
"1.85.0",
52+
]
53+
result = subprocess.run(
54+
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
55+
)
56+
57+
self.assertEqual(result.returncode, 0)
58+
self.assertEqual(result.stdout, expected_output)
59+
60+
61+
if __name__ == "__main__":
62+
unittest.main()

0 commit comments

Comments
 (0)