Skip to content

Commit d44d359

Browse files
authored
Add release notes automation tool (cvxpy#3090)
Generates the "PR by author" list for GitHub release notes. Compares against the previous release branch to exclude PRs that were cherry-picked into patch releases, even when those patches were squashed.
1 parent 30a666c commit d44d359

File tree

2 files changed

+191
-0
lines changed

2 files changed

+191
-0
lines changed

PROCEDURES.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@ If this file has changed between versions, the old patch will fail to apply and
113113
## Creating a release on GitHub
114114
Go to the [Releases](https://github.com/cvxpy/cvxpy/releases) tab and click "Draft a new release". Select the previously created tag and write release notes. For minor releases, this includes a summary of new features and deprecations. Additionally, we mention the PRs contained in the release and their contributors. Take care to select the "set as the latest release" only for minor releases or patches to the most recent major release.
115115

116+
To generate the list of PRs and contributors, use the `tools/release_notes.py` script:
117+
```
118+
python tools/release_notes.py v1.8.0 # minor release
119+
python tools/release_notes.py v1.7.5 # patch release
120+
```
121+
For minor releases, the script automatically excludes PRs that were cherry-picked into the previous release branch's patch releases. For patch releases, it compares against the previous patch tag.
122+
116123
## Deploying updated documentation to gh-pages
117124

118125
The web documentation is built and deployed using a GitHub action that can be found [here](https://github.com/cvxpy/cvxpy/blob/master/.github/workflows/docs.yml).

tools/release_notes.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"""
2+
Copyright, the CVXPY authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
16+
Generate the "PR by author" list for CVXPY release notes.
17+
18+
Compares the release tag against the previous release branch so that
19+
cherry-picked patch PRs are not duplicated.
20+
21+
Usage:
22+
python tools/release_notes.py v1.8.0 release/1.7.x
23+
python tools/release_notes.py v1.8.0 # auto-detects previous release branch
24+
"""
25+
26+
import json
27+
import re
28+
import subprocess
29+
import sys
30+
31+
32+
def run(cmd: list[str]) -> str:
33+
return subprocess.check_output(cmd, text=True).strip()
34+
35+
36+
def parse_tag(tag: str) -> tuple[int, int, int]:
37+
"""Parse a tag like v1.8.0 into (major, minor, micro)."""
38+
m = re.match(r"v(\d+)\.(\d+)\.(\d+)", tag)
39+
if not m:
40+
raise ValueError(f"Cannot parse tag: {tag}")
41+
return int(m.group(1)), int(m.group(2)), int(m.group(3))
42+
43+
44+
def get_previous_release_branch(tag: str) -> str:
45+
"""Infer the previous release branch from a tag like v1.8.0."""
46+
major, minor, _ = parse_tag(tag)
47+
return f"origin/release/{major}.{minor - 1}.x"
48+
49+
50+
def get_previous_release_tag(tag: str) -> str:
51+
"""Infer the previous minor release tag from a tag like v1.8.0."""
52+
major, minor, _ = parse_tag(tag)
53+
return f"v{major}.{minor - 1}.0"
54+
55+
56+
def extract_pr_numbers(log_output: str) -> set[int]:
57+
"""Extract PR numbers from git log output.
58+
59+
Matches the (#NNNN) pattern at the end of a line, which is the
60+
convention for merge and squash commit subject lines. This avoids
61+
false positives from inline issue references like "Closes #1000".
62+
"""
63+
prs = set()
64+
for m in re.finditer(r"\(#(\d+)\)\s*$", log_output, re.MULTILINE):
65+
prs.add(int(m.group(1)))
66+
return prs
67+
68+
69+
def get_pr_author(pr_number: int) -> str | None:
70+
"""Look up the GitHub username for a PR."""
71+
try:
72+
result = run([
73+
"gh", "pr", "view", str(pr_number),
74+
"--json", "author", "-q", ".author.login",
75+
])
76+
return result
77+
except subprocess.CalledProcessError:
78+
return None
79+
80+
81+
def main():
82+
if len(sys.argv) < 2:
83+
print(f"Usage: {sys.argv[0]} <tag>")
84+
print(f" e.g. {sys.argv[0]} v1.8.0 (minor release)")
85+
print(f" e.g. {sys.argv[0]} v1.7.5 (patch release)")
86+
sys.exit(1)
87+
88+
tag = sys.argv[1]
89+
major, minor, micro = parse_tag(tag)
90+
91+
if micro == 0:
92+
# Minor release: compare against previous minor's .0 tag,
93+
# excluding PRs cherry-picked onto the previous release branch.
94+
prev_tag = get_previous_release_tag(tag)
95+
prev_branch = get_previous_release_branch(tag)
96+
97+
release_log = run(
98+
["git", "log", "--format=%B", f"{prev_tag}..{tag}"]
99+
)
100+
release_prs = extract_pr_numbers(release_log)
101+
102+
patch_log = run(
103+
["git", "log", "--format=%B", f"{prev_tag}..{prev_branch}"]
104+
)
105+
patch_prs = extract_pr_numbers(patch_log)
106+
107+
pr_numbers = sorted(release_prs - patch_prs)
108+
109+
print(
110+
f"Found {len(release_prs)} PRs in {prev_tag}..{tag}, "
111+
f"excluding {len(patch_prs)} patch PRs from "
112+
f"{prev_branch}, {len(pr_numbers)} remaining.",
113+
file=sys.stderr,
114+
)
115+
else:
116+
# Patch release: compare against the previous tag on the
117+
# same release branch.
118+
prev_tag = f"v{major}.{minor}.{micro - 1}"
119+
120+
release_log = run(
121+
["git", "log", "--format=%B", f"{prev_tag}..{tag}"]
122+
)
123+
pr_numbers = sorted(extract_pr_numbers(release_log))
124+
125+
print(
126+
f"Found {len(pr_numbers)} PRs in {prev_tag}..{tag}.",
127+
file=sys.stderr,
128+
)
129+
130+
print("Looking up authors...", file=sys.stderr)
131+
132+
# Look up authors via gh CLI, batch with graphql to reduce API calls
133+
author_prs: dict[str, list[int]] = {}
134+
skipped: list[int] = []
135+
136+
# Use graphql to batch lookups (100 at a time)
137+
for i in range(0, len(pr_numbers), 100):
138+
batch = pr_numbers[i:i + 100]
139+
# Build a graphql query for this batch
140+
parts = []
141+
for j, pr in enumerate(batch):
142+
parts.append(
143+
f'pr{j}: pullRequest(number: {pr}) {{ author {{ login }} }}'
144+
)
145+
query = "{ repository(owner: \"cvxpy\", name: \"cvxpy\") { " + " ".join(parts) + " } }"
146+
try:
147+
result = run(["gh", "api", "graphql", "-f", f"query={query}"])
148+
data = json.loads(result)["data"]["repository"]
149+
for j, pr in enumerate(batch):
150+
node = data.get(f"pr{j}")
151+
if node and node.get("author"):
152+
login = node["author"]["login"]
153+
if "dependabot" in login:
154+
continue
155+
author_prs.setdefault(login, []).append(pr)
156+
else:
157+
skipped.append(pr)
158+
except subprocess.CalledProcessError:
159+
# Fall back to individual lookups
160+
for pr in batch:
161+
author = get_pr_author(pr)
162+
if author is None:
163+
skipped.append(pr)
164+
elif "dependabot" in author:
165+
continue
166+
else:
167+
author_prs.setdefault(author, []).append(pr)
168+
169+
# Count PRs (excluding dependabot and skipped)
170+
total_prs = sum(len(prs) for prs in author_prs.values())
171+
total_authors = len(author_prs)
172+
173+
print(f"\nThis new release totaled {total_prs} PRs from {total_authors} contributors.\n")
174+
for author in sorted(author_prs, key=lambda a: a.lower()):
175+
prs = ", ".join(f"#{pr}" for pr in sorted(author_prs[author]))
176+
print(f"- @{author} | {prs}")
177+
178+
if skipped:
179+
skipped_str = ", ".join(f"#{pr}" for pr in skipped)
180+
print(f"\nSkipped PRs (not found): {skipped_str}", file=sys.stderr)
181+
182+
183+
if __name__ == "__main__":
184+
main()

0 commit comments

Comments
 (0)