Skip to content

Commit f066953

Browse files
cmcfarlenclaude
andcommitted
Add changelog generation tool for GitHub milestones
Replaces tools/git/changelog.pl with a Python implementation that generates changelogs from merged PRs in a milestone using the GitHub API or gh CLI. Default output matches the existing CHANGELOG-* file format. The --doc mode includes merge SHAs, labels, and full PR descriptions to guide AI-assisted release documentation updates. Supports text and YAML output formats. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent da065fb commit f066953

3 files changed

Lines changed: 538 additions & 0 deletions

File tree

tools/changelog/changelog.py

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
#!/usr/bin/env python3
2+
#
3+
# Licensed to the Apache Software Foundation (ASF) under one
4+
# or more contributor license agreements. See the NOTICE file
5+
# distributed with this work for additional information
6+
# regarding copyright ownership. The ASF licenses this file
7+
# to you under the Apache License, Version 2.0 (the
8+
# "License"); you may not use this file except in compliance
9+
# with the License. You may obtain a copy of the License at
10+
#
11+
# http://www.apache.org/licenses/LICENSE-2.0
12+
#
13+
# Unless required by applicable law or agreed to in writing, software
14+
# distributed under the License is distributed on an "AS IS" BASIS,
15+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
# See the License for the specific language governing permissions and
17+
# limitations under the License.
18+
19+
"""Generate a changelog from merged PRs in a GitHub milestone.
20+
21+
Usage:
22+
uv run --project tools/changelog python tools/changelog/changelog.py \
23+
-o apache -r trafficserver -m 10.2.0
24+
25+
Output is written to stdout in the format used by CHANGELOG-* files:
26+
27+
Changes with Apache Traffic Server 10.2.0
28+
#11945 - Make directory operations methods on `Directory`
29+
#12026 - Static link opentelemetry-cpp libraries to otel_tracer plugin
30+
...
31+
32+
To generate a changelog file for a release:
33+
34+
uv run --project tools/changelog python tools/changelog/changelog.py \
35+
-o apache -r trafficserver -m 10.2.0 > CHANGELOG-10.2.0
36+
37+
Use --doc to include extra metadata (merge commit SHA, full commit message,
38+
labels) for each PR, useful for generating release documentation:
39+
40+
uv run --project tools/changelog python tools/changelog/changelog.py \
41+
-o apache -r trafficserver -m 10.2.0 --doc --format yaml > changelog.yaml
42+
43+
Requires a GitHub token via GH_TOKEN env var or -a flag to avoid rate limits.
44+
"""
45+
46+
import argparse
47+
import json
48+
import os
49+
import subprocess
50+
import sys
51+
52+
import httpx
53+
54+
try:
55+
import yaml
56+
except ImportError:
57+
yaml = None
58+
59+
API_URL = "https://api.github.com"
60+
61+
62+
def gh_cli_available() -> bool:
63+
try:
64+
subprocess.run(["gh", "--version"], capture_output=True, check=True)
65+
return True
66+
except (FileNotFoundError, subprocess.CalledProcessError):
67+
return False
68+
69+
70+
def changelog_via_gh(owner: str, repo: str, milestone: str, verbose: bool, doc: bool) -> list[dict]:
71+
"""Use the gh CLI to fetch milestone PRs (avoids API rate limits)."""
72+
milestone_id = None
73+
result = subprocess.run(
74+
["gh", "api", f"/repos/{owner}/{repo}/milestones", "--paginate"],
75+
capture_output=True,
76+
text=True,
77+
)
78+
if result.returncode != 0:
79+
print(f"gh api error: {result.stderr}", file=sys.stderr)
80+
sys.exit(1)
81+
82+
milestones = json.loads(result.stdout)
83+
for ms in milestones:
84+
if ms["title"] == milestone:
85+
milestone_id = ms["number"]
86+
break
87+
88+
if milestone_id is None:
89+
print(f"Milestone not found: {milestone}", file=sys.stderr)
90+
sys.exit(1)
91+
92+
print(f"Looking for issues from Milestone {milestone}", file=sys.stderr)
93+
94+
changelog = []
95+
page = 1
96+
while True:
97+
print(f"Page {page}", file=sys.stderr)
98+
result = subprocess.run(
99+
[
100+
"gh", "api",
101+
f"/repos/{owner}/{repo}/issues?milestone={milestone_id}&state=closed&page={page}&per_page=100",
102+
],
103+
capture_output=True,
104+
text=True,
105+
)
106+
if result.returncode != 0:
107+
print(f"gh api error: {result.stderr}", file=sys.stderr)
108+
sys.exit(1)
109+
110+
issues = json.loads(result.stdout)
111+
if not issues:
112+
break
113+
114+
for issue in issues:
115+
number = issue["number"]
116+
title = issue["title"]
117+
if verbose:
118+
print(f"Issue #{number} - {title} ", end="", file=sys.stderr)
119+
120+
if "pull_request" not in issue:
121+
if verbose:
122+
print("not a PR.", file=sys.stderr)
123+
continue
124+
125+
merge_result = subprocess.run(
126+
["gh", "api", f"/repos/{owner}/{repo}/pulls/{number}/merge"],
127+
capture_output=True,
128+
text=True,
129+
)
130+
if merge_result.returncode != 0:
131+
if verbose:
132+
print("not merged.", file=sys.stderr)
133+
continue
134+
135+
if verbose:
136+
print("added.", file=sys.stderr)
137+
138+
entry: dict = {"number": number, "title": title}
139+
if doc:
140+
labels = [label["name"] for label in issue.get("labels", [])]
141+
entry["labels"] = labels
142+
pr_detail = subprocess.run(
143+
["gh", "api", f"/repos/{owner}/{repo}/pulls/{number}"],
144+
capture_output=True,
145+
text=True,
146+
)
147+
if pr_detail.returncode == 0:
148+
pr_data = json.loads(pr_detail.stdout)
149+
entry["sha"] = pr_data.get("merge_commit_sha", "")
150+
entry["body"] = pr_data.get("body", "") or ""
151+
else:
152+
entry["sha"] = ""
153+
entry["body"] = ""
154+
changelog.append(entry)
155+
156+
page += 1
157+
158+
return changelog
159+
160+
161+
def changelog_via_api(
162+
owner: str, repo: str, milestone: str, token: str | None, verbose: bool,
163+
doc: bool,
164+
) -> list[dict]:
165+
"""Use httpx to call the GitHub REST API directly."""
166+
headers = {
167+
"Accept": "application/vnd.github.v3+json",
168+
"User-Agent": "ATS-Changelog-Tool",
169+
}
170+
if token:
171+
headers["Authorization"] = f"Bearer {token}"
172+
173+
with httpx.Client(base_url=API_URL, headers=headers, timeout=30) as client:
174+
milestone_id = _lookup_milestone(client, owner, repo, milestone)
175+
if milestone_id is None:
176+
print(f"Milestone not found: {milestone}", file=sys.stderr)
177+
sys.exit(1)
178+
179+
print(f"Looking for issues from Milestone {milestone}", file=sys.stderr)
180+
181+
changelog = []
182+
page = 1
183+
while True:
184+
print(f"Page {page}", file=sys.stderr)
185+
resp = client.get(
186+
f"/repos/{owner}/{repo}/issues",
187+
params={
188+
"milestone": milestone_id,
189+
"state": "closed",
190+
"page": page,
191+
"per_page": 100,
192+
},
193+
)
194+
_check_rate_limit(resp)
195+
resp.raise_for_status()
196+
issues = resp.json()
197+
198+
if not issues:
199+
break
200+
201+
for issue in issues:
202+
number = issue["number"]
203+
title = issue["title"]
204+
if verbose:
205+
print(f"Issue #{number} - {title} ", end="", file=sys.stderr)
206+
207+
if "pull_request" not in issue:
208+
if verbose:
209+
print("not a PR.", file=sys.stderr)
210+
continue
211+
212+
if not _is_merged(client, owner, repo, number):
213+
if verbose:
214+
print("not merged.", file=sys.stderr)
215+
continue
216+
217+
if verbose:
218+
print("added.", file=sys.stderr)
219+
220+
entry: dict = {"number": number, "title": title}
221+
if doc:
222+
labels = [label["name"] for label in issue.get("labels", [])]
223+
entry["labels"] = labels
224+
pr_resp = client.get(f"/repos/{owner}/{repo}/pulls/{number}")
225+
_check_rate_limit(pr_resp)
226+
pr_resp.raise_for_status()
227+
pr_data = pr_resp.json()
228+
entry["sha"] = pr_data.get("merge_commit_sha", "")
229+
entry["body"] = pr_data.get("body", "") or ""
230+
changelog.append(entry)
231+
232+
page += 1
233+
234+
return changelog
235+
236+
237+
def _lookup_milestone(
238+
client: httpx.Client, owner: str, repo: str, title: str
239+
) -> int | None:
240+
resp = client.get(f"/repos/{owner}/{repo}/milestones")
241+
_check_rate_limit(resp)
242+
resp.raise_for_status()
243+
for ms in resp.json():
244+
if ms["title"] == title:
245+
return ms["number"]
246+
return None
247+
248+
249+
def _is_merged(client: httpx.Client, owner: str, repo: str, pr_number: int) -> bool:
250+
resp = client.get(f"/repos/{owner}/{repo}/pulls/{pr_number}/merge")
251+
if resp.status_code == 204:
252+
return True
253+
if resp.status_code == 404:
254+
return False
255+
_check_rate_limit(resp)
256+
resp.raise_for_status()
257+
return False
258+
259+
260+
def _check_rate_limit(resp: httpx.Response) -> None:
261+
if resp.status_code == 403:
262+
print(
263+
"You have exceeded your rate limit. Try using an auth token.",
264+
file=sys.stderr,
265+
)
266+
sys.exit(2)
267+
268+
269+
def main():
270+
parser = argparse.ArgumentParser(
271+
description="Generate changelog from merged PRs in a GitHub milestone."
272+
)
273+
parser.add_argument("-o", "--owner", required=True, help="Repository owner")
274+
parser.add_argument("-r", "--repo", required=True, help="Repository name")
275+
parser.add_argument("-m", "--milestone", required=True, help="Milestone title")
276+
parser.add_argument(
277+
"-a", "--auth", default=None, help="GitHub auth token (or set GH_TOKEN env var)"
278+
)
279+
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
280+
parser.add_argument(
281+
"--doc",
282+
action="store_true",
283+
help="Include extra metadata (merge SHA, full commit message, labels) for documentation",
284+
)
285+
parser.add_argument(
286+
"--format",
287+
choices=["text", "yaml"],
288+
default="text",
289+
help="Output format (default: text)",
290+
)
291+
parser.add_argument(
292+
"--use-gh",
293+
action="store_true",
294+
help="Use gh CLI instead of direct API calls (avoids rate limits)",
295+
)
296+
args = parser.parse_args()
297+
298+
token = args.auth or os.environ.get("GH_TOKEN")
299+
300+
if not token and not args.use_gh:
301+
print(
302+
"WARNING: No GitHub token provided. Unauthenticated requests are limited\n"
303+
"to 60 per hour, which is usually not enough to generate a full changelog.\n"
304+
"\n"
305+
"Provide a token via -a or the GH_TOKEN environment variable.\n"
306+
"\n"
307+
"To create a token:\n"
308+
" 1. Go to https://github.com/settings/tokens\n"
309+
" 2. Click 'Generate new token' -> 'Generate new token (classic)'\n"
310+
" 3. Select the 'public_repo' scope (sufficient for public repositories)\n"
311+
" 4. For fine-grained tokens, grant read-only access to Issues and\n"
312+
" Pull Requests on the target repository\n"
313+
"\n"
314+
"Alternatively, use --use-gh to use the gh CLI with its existing auth.\n",
315+
file=sys.stderr,
316+
)
317+
318+
if args.use_gh:
319+
if not gh_cli_available():
320+
print("gh CLI not found. Install it or omit --use-gh.", file=sys.stderr)
321+
sys.exit(1)
322+
changelog = changelog_via_gh(args.owner, args.repo, args.milestone, args.verbose, args.doc)
323+
else:
324+
changelog = changelog_via_api(
325+
args.owner, args.repo, args.milestone, token, args.verbose, args.doc,
326+
)
327+
328+
if changelog:
329+
changelog.sort(key=lambda x: x["number"])
330+
331+
if args.format == "yaml":
332+
output = {
333+
"milestone": args.milestone,
334+
"owner": args.owner,
335+
"repo": args.repo,
336+
"entries": changelog,
337+
}
338+
if yaml is not None:
339+
yaml.dump(output, sys.stdout, default_flow_style=False, sort_keys=False, allow_unicode=True)
340+
else:
341+
json.dump(output, sys.stdout, indent=2)
342+
print()
343+
else:
344+
print(f"Changes with Apache Traffic Server {args.milestone}")
345+
for entry in changelog:
346+
print(f" #{entry['number']} - {entry['title']}")
347+
if args.doc:
348+
if entry.get("sha"):
349+
print(f" SHA: {entry['sha']}")
350+
if entry.get("labels"):
351+
print(f" Labels: {', '.join(entry['labels'])}")
352+
if entry.get("body"):
353+
body_lines = entry["body"].strip().split("\n")
354+
print(f" Body:")
355+
for line in body_lines:
356+
print(f" {line}")
357+
358+
359+
if __name__ == "__main__":
360+
main()

tools/changelog/pyproject.toml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
[project]
18+
name = "changelog"
19+
version = "0.1.0"
20+
description = "Generate changelog from GitHub milestones for Apache Traffic Server"
21+
requires-python = ">=3.11"
22+
dependencies = [
23+
"httpx>=0.27",
24+
"pyyaml>=6.0",
25+
]
26+
27+
[project.scripts]
28+
changelog = "changelog:main"

0 commit comments

Comments
 (0)