cmcfarlen commented on code in PR #13063: URL: https://github.com/apache/trafficserver/pull/13063#discussion_r3120976091
########## tools/changelog/changelog.py: ########## @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Generate a changelog from merged PRs in a GitHub milestone. + +Usage: + uv run --project tools/changelog python tools/changelog/changelog.py \ + -o apache -r trafficserver -m 10.2.0 + +Output is written to stdout in the format used by CHANGELOG-* files: + + Changes with Apache Traffic Server 10.2.0 + #11945 - Make directory operations methods on `Directory` + #12026 - Static link opentelemetry-cpp libraries to otel_tracer plugin + ... + +To generate a changelog file for a release: + + uv run --project tools/changelog python tools/changelog/changelog.py \ + -o apache -r trafficserver -m 10.2.0 > CHANGELOG-10.2.0 + +Use --doc to include extra metadata (merge commit SHA, full commit message, +labels) for each PR, useful for generating release documentation: + + uv run --project tools/changelog python tools/changelog/changelog.py \ + -o apache -r trafficserver -m 10.2.0 --doc --format yaml > changelog.yaml + +Requires a GitHub token via GH_TOKEN env var or -a flag to avoid rate limits. +""" + +import argparse +import json +import os +import subprocess +import sys + +import httpx + +try: + import yaml +except ImportError: + yaml = None + +API_URL = "https://api.github.com" + + +def gh_cli_available() -> bool: + try: + subprocess.run(["gh", "--version"], capture_output=True, check=True) + return True + except (FileNotFoundError, subprocess.CalledProcessError): + return False + + +def changelog_via_gh(owner: str, repo: str, milestone: str, verbose: bool, doc: bool) -> list[dict]: + """Use the gh CLI to fetch milestone PRs (avoids API rate limits).""" + milestone_id = None + result = subprocess.run( + ["gh", "api", f"/repos/{owner}/{repo}/milestones", "--paginate"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f"gh api error: {result.stderr}", file=sys.stderr) + sys.exit(1) + + milestones = json.loads(result.stdout) + for ms in milestones: + if ms["title"] == milestone: + milestone_id = ms["number"] + break + + if milestone_id is None: + print(f"Milestone not found: {milestone}", file=sys.stderr) + sys.exit(1) + + print(f"Looking for issues from Milestone {milestone}", file=sys.stderr) + + changelog = [] + page = 1 + while True: + print(f"Page {page}", file=sys.stderr) + result = subprocess.run( + [ + "gh", + "api", + f"/repos/{owner}/{repo}/issues?milestone={milestone_id}&state=closed&page={page}&per_page=100", + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f"gh api error: {result.stderr}", file=sys.stderr) + sys.exit(1) + + issues = json.loads(result.stdout) + if not issues: + break + + for issue in issues: + number = issue["number"] + title = issue["title"] + if verbose: + print(f"Issue #{number} - {title} ", end="", file=sys.stderr) + + if "pull_request" not in issue: + if verbose: + print("not a PR.", file=sys.stderr) + continue + + merge_result = subprocess.run( + ["gh", "api", f"/repos/{owner}/{repo}/pulls/{number}/merge"], + capture_output=True, + text=True, + ) + if merge_result.returncode != 0: + if verbose: + print("not merged.", file=sys.stderr) + continue + + if verbose: + print("added.", file=sys.stderr) + + entry: dict = {"number": number, "title": title} + if doc: + labels = [label["name"] for label in issue.get("labels", [])] + entry["labels"] = labels + pr_detail = subprocess.run( + ["gh", "api", f"/repos/{owner}/{repo}/pulls/{number}"], + capture_output=True, + text=True, + ) + if pr_detail.returncode == 0: + pr_data = json.loads(pr_detail.stdout) + entry["sha"] = pr_data.get("merge_commit_sha", "") + entry["body"] = pr_data.get("body", "") or "" + else: + entry["sha"] = "" + entry["body"] = "" + changelog.append(entry) + + page += 1 + + return changelog + + +def changelog_via_api( + owner: str, + repo: str, + milestone: str, + token: str | None, + verbose: bool, + doc: bool, +) -> list[dict]: + """Use httpx to call the GitHub REST API directly.""" + headers = { + "Accept": "application/vnd.github.v3+json", + "User-Agent": "ATS-Changelog-Tool", + } + if token: + headers["Authorization"] = f"Bearer {token}" + + with httpx.Client(base_url=API_URL, headers=headers, timeout=30) as client: + milestone_id = _lookup_milestone(client, owner, repo, milestone) + if milestone_id is None: + print(f"Milestone not found: {milestone}", file=sys.stderr) + sys.exit(1) + + print(f"Looking for issues from Milestone {milestone}", file=sys.stderr) + + changelog = [] + page = 1 + while True: + print(f"Page {page}", file=sys.stderr) + resp = client.get( + f"/repos/{owner}/{repo}/issues", + params={ + "milestone": milestone_id, + "state": "closed", + "page": page, + "per_page": 100, + }, + ) + _check_rate_limit(resp) + resp.raise_for_status() + issues = resp.json() + + if not issues: + break + + for issue in issues: + number = issue["number"] + title = issue["title"] + if verbose: + print(f"Issue #{number} - {title} ", end="", file=sys.stderr) + + if "pull_request" not in issue: + if verbose: + print("not a PR.", file=sys.stderr) + continue + + if not _is_merged(client, owner, repo, number): + if verbose: + print("not merged.", file=sys.stderr) + continue + + if verbose: + print("added.", file=sys.stderr) + + entry: dict = {"number": number, "title": title} + if doc: + labels = [label["name"] for label in issue.get("labels", [])] + entry["labels"] = labels + pr_resp = client.get(f"/repos/{owner}/{repo}/pulls/{number}") + _check_rate_limit(pr_resp) + pr_resp.raise_for_status() + pr_data = pr_resp.json() + entry["sha"] = pr_data.get("merge_commit_sha", "") + entry["body"] = pr_data.get("body", "") or "" + changelog.append(entry) + + page += 1 + + return changelog + + +def _lookup_milestone(client: httpx.Client, owner: str, repo: str, title: str) -> int | None: + resp = client.get(f"/repos/{owner}/{repo}/milestones") + _check_rate_limit(resp) + resp.raise_for_status() + for ms in resp.json(): + if ms["title"] == title: + return ms["number"] + return None + + Review Comment: took this advice in case we want to make changelogs for closed milestones -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
