Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 57 additions & 27 deletions tools/make_changelog.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
#!/usr/bin/env -S uv run

# /// script
# dependencies = ["ghapi", "rich"]
# dependencies = ["ghapi>=2", "rich"]
# ///

from __future__ import annotations

import os
import re
import subprocess

import ghapi.all
from rich import print
from rich.syntax import Syntax

MD_ENTRY = re.compile(
r"""
\#\#\ Suggested\ changelog\ entry: # Match the heading exactly
^\#+\ Suggested\ changelog\ entry:?\s*$ # Match the heading, colon optional
(?:\s*<!--.*?-->)? # Optionally match one comment
(?P<content>.*?) # Lazily capture content until...
(?= # Lookahead for one of the following:
Expand All @@ -26,33 +28,57 @@
""",
re.DOTALL | re.VERBOSE | re.MULTILINE,
)
# Conventional commit prefix, such as "fix(cmake)!:"
TITLE_CAT = re.compile(r"(?P<cat>\w+)(?:\((?P<sub>[^)]+)\))?!?:")

print()


api = ghapi.all.GhApi(owner="pybind", repo="pybind11")
def get_token() -> str | None:
"""
Unauthenticated requests get a shared CDN cache ("Cache-Control: public"),
which returns stale bodies and labels. A token makes the cache private.
"""
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
if token:
return token
try:
result = subprocess.run(
["gh", "auth", "token"], capture_output=True, text=True, check=True
)
except (OSError, subprocess.CalledProcessError):
print("[yellow]No GitHub token found; results may be stale (cached).")
return None
return result.stdout.strip()


LABEL = "needs changelog"

issues_pages = ghapi.page.paged(
api.issues.list_for_repo, labels="needs changelog", state="closed"
api = ghapi.all.GhApi(owner="pybind", repo="pybind11", token=get_token(), sync=True)

issues_pages = ghapi.page.sync_paged(
api.issues.list_for_repo, labels=LABEL, state="closed", per_page=100
)
# The server-side label filter lags behind label removals, so re-check each issue
issues = (
issue
for page in issues_pages
for issue in page
if any(label.name == LABEL for label in issue.labels)
)
issues = (issue for page in issues_pages for issue in page)
missing = []
old = []
cats_descr = {
"feat": "New Features",
"feat(types)": "",
"feat(cmake)": "",
"fix": "Bug fixes",
"fix(types)": "",
"fix(cmake)": "",
"fix(free-threading)": "",
"docs": "Documentation",
"tests": "Tests",
"ci": "CI",
"chore": "Other",
"chore(cmake)": "",
"unknown": "Uncategorised",
}
cats: dict[str, list[str]] = {c: [] for c in cats_descr}
# Each main category maps its subcategories ("" if there is none) to entries
cats: dict[str, dict[str, list[str]]] = {c: {} for c in cats_descr}

for issue in issues:
if "```rst" in issue.body:
Expand All @@ -79,22 +105,26 @@
continue

msg += f"\n [#{issue.number}]({issue.html_url})"
for cat, cat_list in cats.items():
if issue.title.lower().startswith(f"{cat}:"):
cat_list.append(msg)
break
else:
cats["unknown"].append(msg)

for cat, msgs in cats.items():
if msgs:
desc = cats_descr[cat]
print(f"[bold]{desc}:" if desc else f"<!-- {cat} -->")
title = TITLE_CAT.match(issue.title)
cat = title.group("cat").lower() if title else "unknown"
sub = (title.group("sub") or "").lower() if title else ""
if cat not in cats:
cat, sub = "unknown", ""
cats[cat].setdefault(sub, []).append(msg)

for cat, subs in cats.items():
if subs:
print(f"[bold]{cats_descr[cat]}:")
print()
for msg in msgs:
print(Syntax(msg, "md", theme="ansi_light", word_wrap=True))
# An empty subcategory sorts first, so plain entries lead the section
for sub in sorted(subs):
if sub:
print(f"<!-- {cat}({sub}) -->")
print()
for msg in subs[sub]:
print(Syntax(msg, "md", theme="ansi_light", word_wrap=True))
print()
print()
print()

if missing:
print()
Expand Down
Loading