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


##########
tools/changelog/changelog.py:
##########
@@ -0,0 +1,514 @@
+#!/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, PR body, labels) for 
each
+PR, useful for generating release documentation. With --from-git the body is 
the
+commit message body rather than a PR body:
+
+    uv run --project tools/changelog python tools/changelog/changelog.py \
+        -o apache -r trafficserver -m 10.2.0 --doc --format yaml > 
changelog.yaml
+
+For security releases whose fixes land directly on a release branch without
+public pull requests (and therefore never appear in a milestone), use
+--from-git to source the changelog from a git commit range. This is merged
+with the milestone PRs (deduplicated by PR number) so the result includes both
+the direct-to-branch commits and any milestone PRs. The -m value is still used
+as the version label in the output:
+
+    uv run --project tools/changelog python tools/changelog/changelog.py \
+        -o apache -r trafficserver -m 10.1.4 \
+        --from-git 10.1.3..upstream/10.1.x
+
+Requires a GitHub token via GH_TOKEN env var or -a flag to avoid rate limits.
+"""
+
+import argparse
+import json
+import os
+import re
+import subprocess
+import sys
+
+import httpx
+import yaml
+
+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?state=all", 
"--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
+

Review Comment:
   `gh api --paginate` commonly emits multiple JSON documents (one per page) 
rather than a single valid JSON array, which will make 
`json.loads(result.stdout)` fail on repos with multiple milestone pages. Prefer 
manual pagination (like the httpx path) or use a `gh api` invocation that 
guarantees a single JSON document (e.g., using `--jq` to stream items and parse 
line-by-line, or otherwise aggregating pages in Python before decoding).



##########
tools/changelog/changelog.py:
##########
@@ -0,0 +1,514 @@
+#!/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, PR body, labels) for 
each
+PR, useful for generating release documentation. With --from-git the body is 
the
+commit message body rather than a PR body:
+
+    uv run --project tools/changelog python tools/changelog/changelog.py \
+        -o apache -r trafficserver -m 10.2.0 --doc --format yaml > 
changelog.yaml
+
+For security releases whose fixes land directly on a release branch without
+public pull requests (and therefore never appear in a milestone), use
+--from-git to source the changelog from a git commit range. This is merged
+with the milestone PRs (deduplicated by PR number) so the result includes both
+the direct-to-branch commits and any milestone PRs. The -m value is still used
+as the version label in the output:
+
+    uv run --project tools/changelog python tools/changelog/changelog.py \
+        -o apache -r trafficserver -m 10.1.4 \
+        --from-git 10.1.3..upstream/10.1.x
+
+Requires a GitHub token via GH_TOKEN env var or -a flag to avoid rate limits.
+"""
+
+import argparse
+import json
+import os
+import re
+import subprocess
+import sys
+
+import httpx
+import yaml
+
+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?state=all", 
"--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)

Review Comment:
   `gh api --paginate` commonly emits multiple JSON documents (one per page) 
rather than a single valid JSON array, which will make 
`json.loads(result.stdout)` fail on repos with multiple milestone pages. Prefer 
manual pagination (like the httpx path) or use a `gh api` invocation that 
guarantees a single JSON document (e.g., using `--jq` to stream items and parse 
line-by-line, or otherwise aggregating pages in Python before decoding).



##########
tools/changelog/changelog.py:
##########
@@ -0,0 +1,514 @@
+#!/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, PR body, labels) for 
each
+PR, useful for generating release documentation. With --from-git the body is 
the
+commit message body rather than a PR body:
+
+    uv run --project tools/changelog python tools/changelog/changelog.py \
+        -o apache -r trafficserver -m 10.2.0 --doc --format yaml > 
changelog.yaml
+
+For security releases whose fixes land directly on a release branch without
+public pull requests (and therefore never appear in a milestone), use
+--from-git to source the changelog from a git commit range. This is merged
+with the milestone PRs (deduplicated by PR number) so the result includes both
+the direct-to-branch commits and any milestone PRs. The -m value is still used
+as the version label in the output:
+
+    uv run --project tools/changelog python tools/changelog/changelog.py \
+        -o apache -r trafficserver -m 10.1.4 \
+        --from-git 10.1.3..upstream/10.1.x
+
+Requires a GitHub token via GH_TOKEN env var or -a flag to avoid rate limits.
+"""
+
+import argparse
+import json
+import os
+import re
+import subprocess
+import sys
+
+import httpx
+import yaml
+
+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?state=all", 
"--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
+
+            if not _gh_is_merged(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_detail = subprocess.run(
+                    ["gh", "api", f"/repos/{owner}/{repo}/pulls/{number}"],
+                    capture_output=True,
+                    text=True,
+                )
+                if pr_detail.returncode != 0:
+                    print(f"gh api error fetching PR #{number}: 
{pr_detail.stderr.strip()}", file=sys.stderr)
+                    sys.exit(1)
+                pr_data = json.loads(pr_detail.stdout)
+                entry["sha"] = pr_data.get("merge_commit_sha", "")
+                entry["body"] = pr_data.get("body", "") or ""
+            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()

Review Comment:
   On non-rate-limit HTTP failures (e.g., 401 bad token, 403 insufficient 
scopes), `raise_for_status()` will raise an `httpx.HTTPStatusError` and emit a 
full traceback, which is noisy for a CLI tool. Consider catching 
`httpx.HTTPError`/`HTTPStatusError` at the appropriate level (per-request or 
around `changelog_via_api()` in `main()`) and exiting with a concise, 
user-directed message that includes the status code and response body (or 
GitHub message) when safe.



##########
tools/changelog/changelog.py:
##########
@@ -0,0 +1,514 @@
+#!/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, PR body, labels) for 
each
+PR, useful for generating release documentation. With --from-git the body is 
the
+commit message body rather than a PR body:
+
+    uv run --project tools/changelog python tools/changelog/changelog.py \
+        -o apache -r trafficserver -m 10.2.0 --doc --format yaml > 
changelog.yaml
+
+For security releases whose fixes land directly on a release branch without
+public pull requests (and therefore never appear in a milestone), use
+--from-git to source the changelog from a git commit range. This is merged
+with the milestone PRs (deduplicated by PR number) so the result includes both
+the direct-to-branch commits and any milestone PRs. The -m value is still used
+as the version label in the output:
+
+    uv run --project tools/changelog python tools/changelog/changelog.py \
+        -o apache -r trafficserver -m 10.1.4 \
+        --from-git 10.1.3..upstream/10.1.x
+
+Requires a GitHub token via GH_TOKEN env var or -a flag to avoid rate limits.
+"""
+
+import argparse
+import json
+import os
+import re
+import subprocess
+import sys
+
+import httpx
+import yaml
+
+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?state=all", 
"--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
+
+            if not _gh_is_merged(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_detail = subprocess.run(
+                    ["gh", "api", f"/repos/{owner}/{repo}/pulls/{number}"],
+                    capture_output=True,
+                    text=True,
+                )
+                if pr_detail.returncode != 0:
+                    print(f"gh api error fetching PR #{number}: 
{pr_detail.stderr.strip()}", file=sys.stderr)
+                    sys.exit(1)
+                pr_data = json.loads(pr_detail.stdout)
+                entry["sha"] = pr_data.get("merge_commit_sha", "")
+                entry["body"] = pr_data.get("body", "") or ""
+            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:

Review Comment:
   On non-rate-limit HTTP failures (e.g., 401 bad token, 403 insufficient 
scopes), `raise_for_status()` will raise an `httpx.HTTPStatusError` and emit a 
full traceback, which is noisy for a CLI tool. Consider catching 
`httpx.HTTPError`/`HTTPStatusError` at the appropriate level (per-request or 
around `changelog_via_api()` in `main()`) and exiting with a concise, 
user-directed message that includes the status code and response body (or 
GitHub message) when safe.



##########
tools/changelog/changelog.py:
##########
@@ -0,0 +1,514 @@
+#!/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, PR body, labels) for 
each
+PR, useful for generating release documentation. With --from-git the body is 
the
+commit message body rather than a PR body:
+
+    uv run --project tools/changelog python tools/changelog/changelog.py \
+        -o apache -r trafficserver -m 10.2.0 --doc --format yaml > 
changelog.yaml
+
+For security releases whose fixes land directly on a release branch without
+public pull requests (and therefore never appear in a milestone), use
+--from-git to source the changelog from a git commit range. This is merged
+with the milestone PRs (deduplicated by PR number) so the result includes both
+the direct-to-branch commits and any milestone PRs. The -m value is still used
+as the version label in the output:
+
+    uv run --project tools/changelog python tools/changelog/changelog.py \
+        -o apache -r trafficserver -m 10.1.4 \
+        --from-git 10.1.3..upstream/10.1.x
+
+Requires a GitHub token via GH_TOKEN env var or -a flag to avoid rate limits.
+"""
+
+import argparse
+import json
+import os
+import re
+import subprocess
+import sys
+
+import httpx
+import yaml
+
+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?state=all", 
"--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
+
+            if not _gh_is_merged(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_detail = subprocess.run(
+                    ["gh", "api", f"/repos/{owner}/{repo}/pulls/{number}"],
+                    capture_output=True,
+                    text=True,
+                )
+                if pr_detail.returncode != 0:
+                    print(f"gh api error fetching PR #{number}: 
{pr_detail.stderr.strip()}", file=sys.stderr)
+                    sys.exit(1)
+                pr_data = json.loads(pr_detail.stdout)
+                entry["sha"] = pr_data.get("merge_commit_sha", "")
+                entry["body"] = pr_data.get("body", "") or ""
+            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 changelog_via_git(git_range: str, verbose: bool, doc: bool) -> list[dict]:
+    """Build the changelog from a git commit range.
+
+    Used for security releases whose fixes are committed directly to a release
+    branch without public PRs, so they never appear in a GitHub milestone. Each
+    commit becomes an entry; a trailing "(#N)" in the subject is captured as 
the
+    PR number when present, but is not required.
+    """
+    field_sep = "\x1f"
+    record_sep = "\x1e"
+    fmt = f"%H{field_sep}%s{field_sep}%b{record_sep}"
+    result = subprocess.run(
+        ["git", "log", "--no-merges", "--reverse", f"--pretty=format:{fmt}", 
git_range],
+        capture_output=True,
+        text=True,
+    )
+    if result.returncode != 0:
+        print(f"git log error: {result.stderr.strip()}", file=sys.stderr)
+        sys.exit(1)
+
+    print(f"Reading commits from git range {git_range}", file=sys.stderr)
+
+    changelog = []
+    for record in result.stdout.split(record_sep):
+        record = record.strip("\n")
+        if not record:
+            continue
+        sha, subject, body = record.split(field_sep, 2)
+        # Only a trailing "(#N)" is a PR number. A bare "#N" anywhere in the 
subject
+        # is usually an issue reference, and treating it as a PR number would 
merge
+        # unrelated entries in merge_changelogs().
+        match = re.search(r"\(#(\d+)\)$", subject)
+        number = int(match.group(1)) if match else None
+        if verbose:
+            label = f"#{number}" if number else sha[:12]
+            print(f"{label} - {subject} added.", file=sys.stderr)
+
+        entry: dict = {"number": number, "title": subject}
+        if doc:
+            entry["sha"] = sha
+            entry["body"] = body.strip()
+        changelog.append(entry)
+
+    return changelog
+
+
+def merge_changelogs(milestone_entries: list[dict], git_entries: list[dict]) 
-> list[dict]:
+    """Union the milestone-sourced and git-sourced entries, deduplicated by PR 
number.
+
+    Git commits keep their chronological order and carry commit-derived 
metadata.
+    Where a git commit's PR number matches a milestone PR, the milestone 
labels are
+    grafted onto the git entry. Milestone PRs with no corresponding commit in 
the
+    git range (e.g. folded into a combined backport) are appended, sorted by 
number.
+    """
+    ms_by_number = {e["number"]: e for e in milestone_entries}
+
+    # A PR can appear more than once in a git range -- a revert and reapply, 
or the
+    # same commit cherry-picked twice -- and both commits carry the same 
trailing
+    # "(#N)". Keep the first, so the surviving entry is the chronological one.
+    deduped: list[dict] = []
+    seen: set[int] = set()
+    for ge in git_entries:
+        num = ge.get("number")
+        if num is not None:
+            if num in seen:
+                continue
+            seen.add(num)
+        deduped.append(ge)
+
+    for ge in deduped:
+        num = ge.get("number")
+        if num in ms_by_number and ms_by_number[num].get("labels") and 
"labels" not in ge:
+            ge["labels"] = ms_by_number[num]["labels"]
+
+    extras = [e for e in milestone_entries if e["number"] not in seen]
+    extras.sort(key=lambda x: x["number"])
+
+    return deduped + extras
+
+
+def _lookup_milestone(client: httpx.Client, owner: str, repo: str, title: str) 
-> int | None:
+    page = 1
+    while True:
+        resp = client.get(f"/repos/{owner}/{repo}/milestones", 
params={"state": "all", "per_page": 100, "page": page})
+        _check_rate_limit(resp)
+        resp.raise_for_status()
+        data = resp.json()
+        if not data:
+            break
+        for ms in data:
+            if ms["title"] == title:
+                return ms["number"]
+        page += 1
+    return None
+
+
+def _http_status(response: str) -> int | None:
+    """Status code from the status line that `gh api --include` prints 
first."""
+    match = re.match(r"HTTP/[\d.]+\s+(\d{3})", response.split("\n", 1)[0])
+    return int(match.group(1)) if match else None
+
+
+def _gh_is_merged(owner: str, repo: str, pr_number: int) -> bool:
+    """Whether pr_number is merged, via the gh CLI.
+
+    gh exits non-zero for any HTTP error, so without the status line --include
+    supplies, a real 404 is indistinguishable from 403 rate limiting or a 5xx.
+    Anything but 204/404 is fatal; skipping would drop merged PRs and still 
exit 0.
+    """
+    result = subprocess.run(
+        ["gh", "api", "--include", 
f"/repos/{owner}/{repo}/pulls/{pr_number}/merge"],
+        capture_output=True,
+        text=True,
+    )
+    status = _http_status(result.stdout)
+    if status == 204:
+        return True
+    if status == 404:
+        return False
+
+    print(
+        f"gh api error checking whether PR #{pr_number} is merged "
+        f"(HTTP {status if status else 'unknown'}): {result.stderr.strip()}",

Review Comment:
   `_gh_is_merged()` does not check `result.returncode`. If `gh api` fails 
(auth, network, config), `result.stdout` may be empty, `status` becomes `None`, 
and the error message will be less actionable. Treat non-zero return codes 
explicitly (include stderr, and consider including stdout when present) before 
attempting to parse the HTTP status line.



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