#!/usr/bin/env python3 """Remind contributors about news fragments. Always exits 0 (non-blocking). Two messages: - When any file under changelog.d/ is staged: confirm that the fragment will be rolled into the next release's notes by towncrier and validate the filename matches the expected pattern. - When source changes are substantial but no fragment is staged: nudge the contributor to add one. Replaces the old shared docs/release_notes/.md model — see changelog.d/README.md for the rationale and conventions. """ import subprocess import sys import tomllib from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from _changelog_fragments import classify_fragment, load_categories from _commit_analysis import analyze_commit # Minimum added source lines before the nudge fires MIN_SOURCE_ADDED = 20 REPO_ROOT = Path(__file__).resolve().parent.parent PYPROJECT = REPO_ROOT / "pyproject.toml" def _load_categories(): """Load the canonical category list, falling back to a sensible default if pyproject.toml or its towncrier section is unreadable — this hook is non-blocking, so a degraded mode is preferable to a hard failure here (the blocking check-changelog-fragments hook is the one that fails loud on a broken config).""" try: return load_categories(PYPROJECT) except ( OSError, tomllib.TOMLDecodeError, KeyError, TypeError, ValueError, ): return ("breaking", "security", "feature", "bugfix", "removal", "misc") CATEGORIES = _load_categories() # Color helpers: only emit ANSI when stdout is a TTY. CI logs and Windows # terminals without VT processing render the raw escape sequences as # visible garbage. _USE_COLOR = sys.stdout.isatty() _CYAN = "\033[36m" if _USE_COLOR else "" _YELLOW = "\033[33m" if _USE_COLOR else "" _RESET = "\033[0m" if _USE_COLOR else "" def _fragments_staged(): """Return the list of staged files under changelog.d/, excluding README.md and other non-fragment files.""" result = subprocess.run( ["git", "diff", "--cached", "--name-only", "--", "changelog.d/"], capture_output=True, text=True, ) files = [line for line in result.stdout.strip().splitlines() if line] return [ f for f in files if f.endswith(".md") and Path(f).name != "README.md" ] def _print_staged_notice(staged): """Inform the committer that a news fragment was staged.""" print() print(f" {_CYAN}News Fragment Staged{_RESET}") print(" " + "-" * 40) for f in staged: print(f" - {f}") print() print(" Files under changelog.d/ are rendered into") print(" docs/release_notes/.md at release prep time by") print(" `pdm run towncrier build --version --yes`, then") print(" surfaced in the GitHub release body by") print(" .github/workflows/release.yml.") # Validate filenames — non-blocking, but a typo'd category silently # falls through towncrier's "no fragments matched" branch and the # contributor's note vanishes from the release. issues = [] for f in staged: kind, value = classify_fragment(Path(f).name, CATEGORIES) if kind != "ok": issues.append((f, kind, value)) if issues: print() print(f" {_YELLOW}⚠ Fragment filename problems:{_RESET}") for f, kind, value in issues: if kind == "bad-name": print( f" {f} — does not match `..md` or " f"`+..md`" ) else: print( f" {f} — unknown category `{value}`. Use one of: " f"{', '.join(CATEGORIES)}" ) print() print(" See changelog.d/README.md for the convention.") print() print(" Format tips:") print(" - One sentence is usually enough; longer prose is fine for") print(" breaking changes that need a 'what to do' line.") print(" - Markdown is supported. The PR/issue link is auto-appended") print(" based on the fragment id (no need to add `(#NNNN)`).") print(" - Skip dependency bumps, internal CI tweaks, and refactors") print(" with no user-visible behavior — the auto-PR-list catches") print(" those without a fragment.") print() def _print_missing_notice(analysis): """Nudge the committer to add a news fragment for a substantial change.""" print() print(f" {_CYAN}News Fragment Reminder{_RESET}") print(" " + "-" * 40) print( f" You're adding {analysis.total_source_added} lines across " f"{len(analysis.source_files)} source file(s)" ) print(" but no changelog.d/ fragment is staged.") print() print(" Changed source files:") for f in analysis.source_files: print(f" - {f.path} (+{f.added})") print() print(" If this change is user-facing, drop a fragment under") print(" changelog.d/ named `..md` (categories:") print(f" {', '.join(CATEGORIES)}). See changelog.d/README.md.") print() def main(): staged = _fragments_staged() # Always inform when a fragment is staged — contributors should know # the file gets rendered into the release, and any naming mistakes # need to surface before the fragment silently goes ignored. if staged: _print_staged_notice(staged) return 0 analysis = analyze_commit() # Silent exit: no source files staged if not analysis.source_files: return 0 # Silent exit: trivial change (less than MIN_SOURCE_ADDED added lines) if analysis.total_source_added < MIN_SOURCE_ADDED: return 0 _print_missing_notice(analysis) return 0 if __name__ == "__main__": sys.exit(main())