Copilot commented on code in PR #13063:
URL: https://github.com/apache/trafficserver/pull/13063#discussion_r3114104993


##########
doc/developer-guide/release-process/index.en.rst:
##########
@@ -67,8 +67,8 @@ Build
 
 #. Generate or update the CHANGELOG for the next release.  ::
 
-      ./tools/git/changelog.pl -o apache -r trafficserver -m X.Y.Z >
-      CHANGELOG-X.Y.Z
+      uv run --project tools/changelog python tools/changelog/changelog.py \
+        -o apache -r trafficserver -m X.Y.Z --use-gh > CHANGELOG-X.Y.Z

Review Comment:
   This step now depends on both `uv` and an authenticated `gh` CLI 
(`--use-gh`), but the surrounding release-process instructions don’t mention 
those prerequisites. Consider adding a brief note (or an alternative invocation 
path) so release managers know what needs to be installed/configured before 
running this command.



##########
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:
   Milestone lookup uses the default GitHub API behavior (only open milestones, 
default page size). This will fail to find closed milestones (common for past 
releases) and can miss milestones beyond the first page. Consider requesting 
`state=all` and handling pagination (or `per_page=100` + paginate Link header) 
so changelog generation works reliably.
   



##########
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
+

Review Comment:
   If PyYAML isn’t installed, `--format yaml` silently falls back to JSON 
output. Since `--format` still advertises YAML, consider either making PyYAML a 
hard requirement (no try/except) or exiting with a clear error when `--format 
yaml` is requested but PyYAML is unavailable.
   



##########
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
+
+
+def _is_merged(client: httpx.Client, owner: str, repo: str, pr_number: int) -> 
bool:
+    resp = client.get(f"/repos/{owner}/{repo}/pulls/{pr_number}/merge")
+    if resp.status_code == 204:
+        return True
+    if resp.status_code == 404:
+        return False
+    _check_rate_limit(resp)
+    resp.raise_for_status()
+    return False
+
+
+def _check_rate_limit(resp: httpx.Response) -> None:
+    if resp.status_code == 403:
+        print(
+            "You have exceeded your rate limit. Try using an auth token.",
+            file=sys.stderr,
+        )
+        sys.exit(2)
+
+
+def main():

Review Comment:
   Repository Python tooling generally uses return type annotations on `main` 
functions (e.g., `def main() -> None:`). Adding `-> None` here would align with 
that style and make typing expectations clearer.
   



##########
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"],

Review Comment:
   `gh api /milestones --paginate` will still only return open milestones by 
default; closed milestones won’t be found. Consider adding `?state=all` (or an 
explicit `--field state=all`) so the tool can generate changelogs for 
already-closed release milestones too.
   



##########
doc/developer-guide/release-process/index.en.rst:
##########
@@ -67,8 +67,8 @@ Build
 
 #. Generate or update the CHANGELOG for the next release.  ::
 
-      ./tools/git/changelog.pl -o apache -r trafficserver -m X.Y.Z >
-      CHANGELOG-X.Y.Z
+      uv run --project tools/changelog python tools/changelog/changelog.py \
+        -o apache -r trafficserver -m X.Y.Z --use-gh > CHANGELOG-X.Y.Z

Review Comment:
   PR description says this change “replaces tools/git/changelog.pl”, but that 
script still exists in the repository. If it’s intended to be 
deprecated/removed, consider deleting it (and updating any remaining 
references) or clarifying in docs/description that it remains available as a 
legacy option.



##########
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
+
+
+def _is_merged(client: httpx.Client, owner: str, repo: str, pr_number: int) -> 
bool:
+    resp = client.get(f"/repos/{owner}/{repo}/pulls/{pr_number}/merge")
+    if resp.status_code == 204:
+        return True
+    if resp.status_code == 404:
+        return False
+    _check_rate_limit(resp)
+    resp.raise_for_status()
+    return False
+
+
+def _check_rate_limit(resp: httpx.Response) -> None:
+    if resp.status_code == 403:
+        print(
+            "You have exceeded your rate limit. Try using an auth token.",
+            file=sys.stderr,
+        )
+        sys.exit(2)
+
+
+def main():
+    parser = argparse.ArgumentParser(description="Generate changelog from 
merged PRs in a GitHub milestone.")
+    parser.add_argument("-o", "--owner", required=True, help="Repository 
owner")
+    parser.add_argument("-r", "--repo", required=True, help="Repository name")
+    parser.add_argument("-m", "--milestone", required=True, help="Milestone 
title")
+    parser.add_argument("-a", "--auth", default=None, help="GitHub auth token 
(or set GH_TOKEN env var)")
+    parser.add_argument("-v", "--verbose", action="store_true", help="Verbose 
output")
+    parser.add_argument(
+        "--doc",
+        action="store_true",
+        help="Include extra metadata (merge SHA, full commit message, labels) 
for documentation",
+    )

Review Comment:
   The `--doc` help text says it includes the “full commit message”, but the 
implementation stores `pr_data['body']` (PR description) and prints it as 
“Body:”. Update the help/docstring wording (or fetch the actual merge commit 
message if that’s what’s intended) to avoid misleading output.



-- 
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]


Reply via email to