* chore(deps): update pre-commit hooks updates: - [github.com/pre-commit/mirrors-clang-format: v22.1.5 → v22.1.8](https://github.com/pre-commit/mirrors-clang-format/compare/v22.1.5...v22.1.8) - [github.com/astral-sh/ruff-pre-commit: v0.15.20 → v0.16.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.20...v0.16.1) - [github.com/pre-commit/mirrors-mypy: v2.1.0 → v2.3.0](https://github.com/pre-commit/mirrors-mypy/compare/v2.1.0...v2.3.0) - [github.com/codespell-project/codespell: v2.4.2 → v2.4.3](https://github.com/codespell-project/codespell/compare/v2.4.2...v2.4.3) * style: pre-commit fixes * fix: adapt to the ruff 0.16 default rule set The hook update pulled in ruff 0.16, which expanded its default rules and autofixed `self: S -> S` to `-> Self`. That fix added a runtime `typing_extensions` import to setup_helpers.py, which must stay dependency-free; it broke every test job whose environment does not supply that package. The import is now guarded by TYPE_CHECKING. Also replace the `exec` in docs/conf.py with an importlib module load, parenthesize implicit string concatenations in list literals, mark unused unpacked variables with a leading underscore, and make noxfile.py executable to match its shebang. The remaining new rules are silenced per-file, with reasons: the chrono tests check naive local time on purpose, and the other rules conflict with how the tests are written. Assisted-by: ClaudeCode:claude-opus-5 --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Henry Schreiner <henryfs@princeton.edu>
151 lines
4.2 KiB
Python
Executable File
151 lines
4.2 KiB
Python
Executable File
#!/usr/bin/env -S uv run
|
|
|
|
# /// script
|
|
# 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:?\s*$ # Match the heading, colon optional
|
|
(?:\s*<!--.*?-->)? # Optionally match one comment
|
|
(?P<content>.*?) # Lazily capture content until...
|
|
(?= # Lookahead for one of the following:
|
|
^-{3,}\s*$ # A line with 3 or more dashes
|
|
| ^<!--\s*readthedocs # A comment starting with 'readthedocs'
|
|
| ^\#\# # A new heading
|
|
| \Z # End of string
|
|
)
|
|
""",
|
|
re.DOTALL | re.VERBOSE | re.MULTILINE,
|
|
)
|
|
# Conventional commit prefix, such as "fix(cmake)!:"
|
|
TITLE_CAT = re.compile(r"(?P<cat>\w+)(?:\((?P<sub>[^)]+)\))?!?:")
|
|
|
|
print()
|
|
|
|
|
|
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"
|
|
|
|
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)
|
|
)
|
|
missing = []
|
|
old = []
|
|
cats_descr = {
|
|
"feat": "New Features",
|
|
"fix": "Bug fixes",
|
|
"docs": "Documentation",
|
|
"tests": "Tests",
|
|
"ci": "CI",
|
|
"chore": "Other",
|
|
"unknown": "Uncategorised",
|
|
}
|
|
# 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:
|
|
old.append(issue)
|
|
continue
|
|
|
|
changelog = MD_ENTRY.search(issue.body or "")
|
|
if not changelog:
|
|
missing.append(issue)
|
|
continue
|
|
|
|
msg = changelog.group("content").strip()
|
|
if not msg:
|
|
missing.append(issue)
|
|
continue
|
|
msg = msg.removeprefix("* ")
|
|
if not msg.startswith("- "):
|
|
msg = "- " + msg
|
|
if not msg.endswith("."):
|
|
msg += "."
|
|
if msg == "- Placeholder.":
|
|
missing.append(issue)
|
|
continue
|
|
|
|
msg += f"\n [#{issue.number}]({issue.html_url})"
|
|
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()
|
|
# 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()
|
|
|
|
if missing:
|
|
print()
|
|
print("[blue]" + "-" * 30)
|
|
print()
|
|
|
|
for issue in missing:
|
|
print(f"[red bold]Missing:[/red bold][red] {issue.title}")
|
|
print(f"[red] {issue.html_url}\n")
|
|
|
|
print("[bold]Template:\n")
|
|
msg = "## Suggested changelog entry:"
|
|
print(Syntax(msg, "md", theme="ansi_light"))
|
|
|
|
if old:
|
|
print()
|
|
print("[red]" + "-" * 30)
|
|
print()
|
|
|
|
for issue in old:
|
|
print(f"[red bold]Old:[/red bold][red] {issue.title}")
|
|
print(f"[red] {issue.html_url}\n")
|
|
|
|
print()
|