"""Local release-notes parsing — Python port of utils/releaseNotes.ts. The Python runtime has no network/cache layer, so this module only reads a local CHANGELOG.md (typically in the project root). The npm version fetches from GitHub and caches under ~/.claude/cache/changelog.md; here, callers provide the changelog text or pass the project cwd so we can read it. """ from __future__ import annotations import re from pathlib import Path from typing import Iterable MAX_RELEASE_NOTES_SHOWN = 5 def parse_changelog(content: str) -> dict[str, list[str]]: """Parse a markdown CHANGELOG into {version: [bullet, ...]}. Recognises sections starting with `## ` (optionally followed by ` - YYYY-MM-DD`). Bullet lines starting with `- ` become entries. """ if not content: return {} notes: dict[str, list[str]] = {} sections = re.split(r'^## ', content, flags=re.MULTILINE)[1:] for section in sections: lines = section.strip().splitlines() if not lines: continue version = lines[0].split(' - ')[0].strip() if not version: continue bullets: list[str] = [] for line in lines[1:]: stripped = line.strip() if stripped.startswith('- '): text = stripped[2:].strip() if text: bullets.append(text) if bullets: notes[version] = bullets return notes def _coerce_version(value: str | None) -> tuple[int, ...] | None: if value is None: return None match = re.match(r'^(\d+)(?:\.(\d+))?(?:\.(\d+))?', value.strip()) if match is None: return None return tuple(int(part) if part else 0 for part in match.groups()) def _gt(a: str, b: str) -> bool: parsed_a = _coerce_version(a) parsed_b = _coerce_version(b) if parsed_a is None or parsed_b is None: return False return parsed_a > parsed_b def get_recent_release_notes( current_version: str, previous_version: str | None, changelog_content: str, ) -> list[str]: """Return up to MAX_RELEASE_NOTES_SHOWN bullets newer than previous_version.""" notes = parse_changelog(changelog_content) base_current = _coerce_version(current_version) base_previous = _coerce_version(previous_version) if base_previous is not None and base_current is not None and base_current <= base_previous: return [] relevant = [ (version, bullets) for version, bullets in notes.items() if base_previous is None or _gt(version, previous_version or '0') ] relevant.sort(key=lambda item: _coerce_version(item[0]) or (), reverse=True) flat: list[str] = [] for _, bullets in relevant: flat.extend(bullets) return flat[:MAX_RELEASE_NOTES_SHOWN] def get_all_release_notes(changelog_content: str) -> list[tuple[str, list[str]]]: """Return all [(version, bullets)] entries sorted oldest-first.""" notes = parse_changelog(changelog_content) versions = sorted(notes.keys(), key=lambda v: _coerce_version(v) or ()) return [(version, notes[version]) for version in versions if notes[version]] def read_local_changelog(cwd: Path) -> str: """Read CHANGELOG.md from the workspace root, returning '' if missing.""" path = cwd / 'CHANGELOG.md' try: return path.read_text(encoding='utf-8') except (OSError, UnicodeDecodeError): return '' def check_for_release_notes( current_version: str, last_seen_version: str | None, cwd: Path | None = None, changelog_content: str | None = None, ) -> dict: """Return {'hasReleaseNotes': bool, 'releaseNotes': [...]}. If `changelog_content` is supplied it is used directly; otherwise the workspace CHANGELOG.md is read. Mirrors the npm `checkForReleaseNotes` return shape (without the network fetch — no cache update). """ content = ( changelog_content if changelog_content is not None else read_local_changelog(cwd or Path.cwd()) ) bullets = get_recent_release_notes(current_version, last_seen_version, content) return { 'hasReleaseNotes': bool(bullets), 'releaseNotes': bullets, } __all__ = [ 'MAX_RELEASE_NOTES_SHOWN', 'parse_changelog', 'get_recent_release_notes', 'get_all_release_notes', 'read_local_changelog', 'check_for_release_notes', ]