Skip to content

Commit 5838132

Browse files
authored
Add script to generate draft changelog entries (#16430)
The script format changelog entries based on commit history and has some rules to filter out some changes, such as typeshed sync and changes cherry-picked to the previous release branch. Example of how to run it: ``` $ python misc/generate_changelog.py 1.7 Generating changelog for 1.7 Previous release was 1.6 Merge base: d7b2451 NOTE: Drop "Fix crash on ParamSpec unification (for real)", since it was in previous release branch NOTE: Drop "Fix crash on ParamSpec unification", since it was in previous release branch NOTE: Drop "Fix mypyc regression with pretty", since it was in previous release branch NOTE: Drop "Clear cache when adding --new-type-inference", since it was in previous release branch NOTE: Drop "Match note error codes to import error codes", since it was in previous release branch NOTE: Drop "Make PEP 695 constructs give a reasonable error message", since it was in previous release branch NOTE: Drop "Fix ParamSpec inference for callback protocols", since it was in previous release branch NOTE: Drop "Try upgrading tox", since it was in previous release branch NOTE: Drop "Optimize Unpack for failures", since it was in previous release branch * Fix crash on unpack call special-casing (Ivan Levkivskyi, PR [16381](#16381)) * Fix file reloading in dmypy with --export-types (Ivan Levkivskyi, PR [16359](#16359)) * Fix daemon crash caused by deleted submodule (Jukka Lehtosalo, PR [16370](#16370)) ... ```
1 parent a1864d4 commit 5838132

File tree

1 file changed

+201
-0
lines changed

1 file changed

+201
-0
lines changed

misc/generate_changelog.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
"""Generate the changelog for a mypy release."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import re
7+
import subprocess
8+
import sys
9+
from dataclasses import dataclass
10+
11+
12+
def find_all_release_branches() -> list[tuple[int, int]]:
13+
result = subprocess.run(["git", "branch", "-r"], text=True, capture_output=True, check=True)
14+
versions = []
15+
for line in result.stdout.splitlines():
16+
line = line.strip()
17+
if m := re.match(r"origin/release-([0-9]+)\.([0-9]+)$", line):
18+
major = int(m.group(1))
19+
minor = int(m.group(2))
20+
versions.append((major, minor))
21+
return versions
22+
23+
24+
def git_merge_base(rev1: str, rev2: str) -> str:
25+
result = subprocess.run(
26+
["git", "merge-base", rev1, rev2], text=True, capture_output=True, check=True
27+
)
28+
return result.stdout.strip()
29+
30+
31+
@dataclass
32+
class CommitInfo:
33+
commit: str
34+
author: str
35+
title: str
36+
pr_number: int | None
37+
38+
39+
def normalize_author(author: str) -> str:
40+
# Some ad-hoc rules to get more consistent author names.
41+
if author == "AlexWaygood":
42+
return "Alex Waygood"
43+
elif author == "jhance":
44+
return "Jared Hance"
45+
return author
46+
47+
48+
def git_commit_log(rev1: str, rev2: str) -> list[CommitInfo]:
49+
result = subprocess.run(
50+
["git", "log", "--pretty=%H\t%an\t%s", f"{rev1}..{rev2}"],
51+
text=True,
52+
capture_output=True,
53+
check=True,
54+
)
55+
commits = []
56+
for line in result.stdout.splitlines():
57+
commit, author, title = line.strip().split("\t", 2)
58+
pr_number = None
59+
if m := re.match(r".*\(#([0-9]+)\) *$", title):
60+
pr_number = int(m.group(1))
61+
title = re.sub(r" *\(#[0-9]+\) *$", "", title)
62+
63+
author = normalize_author(author)
64+
entry = CommitInfo(commit, author, title, pr_number)
65+
commits.append(entry)
66+
return commits
67+
68+
69+
def filter_omitted_commits(commits: list[CommitInfo]) -> list[CommitInfo]:
70+
result = []
71+
for c in commits:
72+
title = c.title
73+
keep = True
74+
if title.startswith("Sync typeshed"):
75+
# Typeshed syncs aren't mentioned in release notes
76+
keep = False
77+
if title.startswith(
78+
(
79+
"Revert sum literal integer change",
80+
"Remove use of LiteralString in builtins",
81+
"Revert typeshed ctypes change",
82+
"Revert use of `ParamSpec` for `functools.wraps`",
83+
)
84+
):
85+
# These are generated by a typeshed sync.
86+
keep = False
87+
if re.search(r"(bump|update).*version.*\+dev", title.lower()):
88+
# Version number updates aren't mentioned
89+
keep = False
90+
if "pre-commit autoupdate" in title:
91+
keep = False
92+
if title.startswith(("Update commit hashes", "Update hashes")):
93+
# Internal tool change
94+
keep = False
95+
if keep:
96+
result.append(c)
97+
return result
98+
99+
100+
def normalize_title(title: str) -> str:
101+
# We sometimes add a title prefix when cherry-picking commits to a
102+
# release branch. Attempt to remove these prefixes so that we can
103+
# match them to the corresponding master branch.
104+
if m := re.match(r"\[release [0-9.]+\] *", title, flags=re.I):
105+
title = title.replace(m.group(0), "")
106+
return title
107+
108+
109+
def filter_out_commits_from_old_release_branch(
110+
new_commits: list[CommitInfo], old_commits: list[CommitInfo]
111+
) -> list[CommitInfo]:
112+
old_titles = {normalize_title(commit.title) for commit in old_commits}
113+
result = []
114+
for commit in new_commits:
115+
drop = False
116+
if normalize_title(commit.title) in old_titles:
117+
drop = True
118+
if normalize_title(f"{commit.title} (#{commit.pr_number})") in old_titles:
119+
drop = True
120+
if not drop:
121+
result.append(commit)
122+
else:
123+
print(f'NOTE: Drop "{commit.title}", since it was in previous release branch')
124+
return result
125+
126+
127+
def find_changes_between_releases(old_branch: str, new_branch: str) -> list[CommitInfo]:
128+
merge_base = git_merge_base(old_branch, new_branch)
129+
print(f"Merge base: {merge_base}")
130+
new_commits = git_commit_log(merge_base, new_branch)
131+
old_commits = git_commit_log(merge_base, old_branch)
132+
133+
# Filter out some commits that won't be mentioned in release notes.
134+
new_commits = filter_omitted_commits(new_commits)
135+
136+
# Filter out commits cherry-picked to old branch.
137+
new_commits = filter_out_commits_from_old_release_branch(new_commits, old_commits)
138+
139+
return new_commits
140+
141+
142+
def format_changelog_entry(c: CommitInfo) -> str:
143+
"""
144+
s = f" * {c.commit[:9]} - {c.title}"
145+
if c.pr_number:
146+
s += f" (#{c.pr_number})"
147+
s += f" ({c.author})"
148+
"""
149+
s = f" * {c.title} ({c.author}"
150+
if c.pr_number:
151+
s += f", PR [{c.pr_number}](https://github.com/python/mypy/pull/{c.pr_number})"
152+
s += ")"
153+
154+
return s
155+
156+
157+
def main() -> None:
158+
parser = argparse.ArgumentParser()
159+
parser.add_argument("version", help="target mypy version (form X.Y)")
160+
parser.add_argument("--local", action="store_true")
161+
args = parser.parse_args()
162+
version: str = args.version
163+
local: bool = args.local
164+
165+
if not re.match(r"[0-9]+\.[0-9]+$", version):
166+
sys.exit(f"error: Release must be of form X.Y (not {version!r})")
167+
major, minor = (int(component) for component in version.split("."))
168+
169+
if not local:
170+
print("Running 'git fetch' to fetch all release branches...")
171+
subprocess.run(["git", "fetch"], check=True)
172+
173+
if minor > 0:
174+
prev_major = major
175+
prev_minor = minor - 1
176+
else:
177+
# For a x.0 release, the previous release is the most recent (x-1).y release.
178+
all_releases = sorted(find_all_release_branches())
179+
if (major, minor) not in all_releases:
180+
sys.exit(f"error: Can't find release branch for {major}.{minor} at origin")
181+
for i in reversed(range(len(all_releases))):
182+
if all_releases[i][0] == major - 1:
183+
prev_major, prev_minor = all_releases[i]
184+
break
185+
else:
186+
sys.exit("error: Could not determine previous release")
187+
print(f"Generating changelog for {major}.{minor}")
188+
print(f"Previous release was {prev_major}.{prev_minor}")
189+
190+
new_branch = f"origin/release-{major}.{minor}"
191+
old_branch = f"origin/release-{prev_major}.{prev_minor}"
192+
193+
changes = find_changes_between_releases(old_branch, new_branch)
194+
195+
print()
196+
for c in changes:
197+
print(format_changelog_entry(c))
198+
199+
200+
if __name__ == "__main__":
201+
main()

0 commit comments

Comments
 (0)