485 lines
18 KiB
Python
485 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime as dt
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from difflib import SequenceMatcher
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT_DIR = Path(__file__).resolve().parents[1]
|
|
CHANGELOG_PATH = ROOT_DIR / "CHANGELOG.md"
|
|
FRAGMENT_DIR = ROOT_DIR / "changes" / "unreleased"
|
|
FRONT_MATTER_RE = re.compile(r"\A---\n(.*?)\n---\n?", re.DOTALL)
|
|
VERSION_HEADER_RE = re.compile(r"(?m)^## \[([^\]]+)\](?: - .*)?$")
|
|
SECTION_HEADER_RE = re.compile(r"(?m)^### ([^\n]+)$")
|
|
APP_TAG_RE = re.compile(r"^v\d+\.\d+\.\d+(?:[.-][0-9A-Za-z.-]+)?$")
|
|
CJK_RE = re.compile(r"[\u3400-\u9fff]")
|
|
|
|
TYPE_ORDER = [
|
|
"summary",
|
|
"added",
|
|
"changed",
|
|
"deprecated",
|
|
"removed",
|
|
"fixed",
|
|
"security",
|
|
"maintenance",
|
|
]
|
|
TYPE_TITLES = {
|
|
"summary": "Summary",
|
|
"added": "Added",
|
|
"changed": "Changed",
|
|
"deprecated": "Deprecated",
|
|
"removed": "Removed",
|
|
"fixed": "Fixed",
|
|
"security": "Security",
|
|
"maintenance": "Maintenance",
|
|
}
|
|
RELEASE_TARGETS = {"app", "plugin"}
|
|
MAX_ENTRY_CHARS = 220
|
|
MAX_ENTRY_SENTENCES = 2
|
|
NEAR_DUPLICATE_THRESHOLD = 0.96
|
|
|
|
|
|
class ChangelogError(RuntimeError):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Fragment:
|
|
path: Path
|
|
release: str
|
|
type: str
|
|
area: str | None
|
|
entries: list[str]
|
|
|
|
|
|
def fail(message: str) -> None:
|
|
raise ChangelogError(message)
|
|
|
|
|
|
def relative(path: Path) -> str:
|
|
return path.relative_to(ROOT_DIR).as_posix()
|
|
|
|
|
|
def strip_optional_quotes(value: str) -> str:
|
|
value = value.strip()
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
|
return value[1:-1].strip()
|
|
return value
|
|
|
|
|
|
def parse_front_matter(text: str, path: Path) -> tuple[dict[str, str], str]:
|
|
match = FRONT_MATTER_RE.match(text)
|
|
if not match:
|
|
fail(f"{relative(path)} must start with YAML front matter.")
|
|
|
|
metadata: dict[str, str] = {}
|
|
for index, raw_line in enumerate(match.group(1).splitlines(), start=1):
|
|
line = raw_line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if ":" not in line:
|
|
fail(f"{relative(path)} front matter line {index} must use `key: value`.")
|
|
key, value = line.split(":", 1)
|
|
key = key.strip()
|
|
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]*", key):
|
|
fail(f"{relative(path)} front matter key is invalid: {key}")
|
|
metadata[key] = strip_optional_quotes(value)
|
|
return metadata, text[match.end() :]
|
|
|
|
|
|
def clean_entry(value: str) -> str:
|
|
value = re.sub(r"\s+", " ", value).strip()
|
|
value = value.removeprefix("- ").strip()
|
|
return value
|
|
|
|
|
|
def split_entries(body: str, path: Path) -> list[str]:
|
|
lines = body.splitlines()
|
|
bullet_entries: list[str] = []
|
|
current: list[str] = []
|
|
|
|
def flush_bullet() -> None:
|
|
if not current:
|
|
return
|
|
entry = clean_entry(" ".join(current))
|
|
if entry:
|
|
bullet_entries.append(entry)
|
|
current.clear()
|
|
|
|
for raw_line in lines:
|
|
line = raw_line.rstrip()
|
|
stripped = line.strip()
|
|
if stripped.startswith("- "):
|
|
flush_bullet()
|
|
current.append(stripped[2:])
|
|
continue
|
|
if current and (not stripped or line.startswith((" ", "\t"))):
|
|
if stripped:
|
|
current.append(stripped)
|
|
continue
|
|
if current:
|
|
fail(f"{relative(path)} mixes bullet entries with plain text. Use one style.")
|
|
if stripped.startswith("#"):
|
|
fail(f"{relative(path)} must not include markdown headings.")
|
|
flush_bullet()
|
|
|
|
if bullet_entries:
|
|
return bullet_entries
|
|
|
|
paragraphs: list[str] = []
|
|
current_paragraph: list[str] = []
|
|
for raw_line in lines:
|
|
stripped = raw_line.strip()
|
|
if not stripped:
|
|
if current_paragraph:
|
|
paragraphs.append(clean_entry(" ".join(current_paragraph)))
|
|
current_paragraph.clear()
|
|
continue
|
|
current_paragraph.append(stripped)
|
|
if current_paragraph:
|
|
paragraphs.append(clean_entry(" ".join(current_paragraph)))
|
|
return [paragraph for paragraph in paragraphs if paragraph]
|
|
|
|
|
|
def sentence_count(entry: str) -> int:
|
|
return len(re.findall(r"[.!?](?:\s|$)", entry))
|
|
|
|
|
|
def validate_entry(entry: str, path: Path) -> None:
|
|
if CJK_RE.search(entry):
|
|
fail(f"{relative(path)} entries must be written in English.")
|
|
if len(entry) > MAX_ENTRY_CHARS:
|
|
fail(
|
|
f"{relative(path)} entry is too long ({len(entry)} chars, max {MAX_ENTRY_CHARS}): "
|
|
f"{entry}"
|
|
)
|
|
if sentence_count(entry) > MAX_ENTRY_SENTENCES:
|
|
fail(f"{relative(path)} entry should stay concise: {entry}")
|
|
if entry.endswith((".", "!", "?")) is False:
|
|
fail(f"{relative(path)} entry must end with punctuation: {entry}")
|
|
|
|
|
|
def parse_fragment(path: Path) -> Fragment:
|
|
metadata, body = parse_front_matter(path.read_text(encoding="utf-8"), path)
|
|
release = metadata.get("release", "").strip().lower()
|
|
if release not in RELEASE_TARGETS:
|
|
allowed = ", ".join(sorted(RELEASE_TARGETS))
|
|
fail(f"{relative(path)} has invalid release `{release}`. Expected one of: {allowed}.")
|
|
|
|
fragment_type = metadata.get("type", "").strip().lower()
|
|
if fragment_type not in TYPE_TITLES:
|
|
allowed = ", ".join(TYPE_ORDER)
|
|
fail(f"{relative(path)} has invalid type `{fragment_type}`. Expected one of: {allowed}.")
|
|
|
|
entries = split_entries(body, path)
|
|
if not entries:
|
|
fail(f"{relative(path)} has no changelog entries.")
|
|
if fragment_type == "summary" and len(entries) > 1:
|
|
fail(f"{relative(path)} summary fragments must contain one concise paragraph.")
|
|
for entry in entries:
|
|
validate_entry(entry, path)
|
|
|
|
area = metadata.get("area") or None
|
|
return Fragment(path=path, release=release, type=fragment_type, area=area, entries=entries)
|
|
|
|
|
|
def pending_fragment_paths() -> list[Path]:
|
|
if not FRAGMENT_DIR.exists():
|
|
return []
|
|
return sorted(path for path in FRAGMENT_DIR.glob("*.md") if path.is_file())
|
|
|
|
|
|
def load_fragments(require_pending: bool, release: str | None) -> list[Fragment]:
|
|
paths = pending_fragment_paths()
|
|
fragments = [parse_fragment(path) for path in paths]
|
|
validate_no_near_duplicates(fragments)
|
|
if release is not None:
|
|
fragments = [fragment for fragment in fragments if fragment.release == release]
|
|
if require_pending and not fragments:
|
|
scope = f" {release}" if release else ""
|
|
fail(f"No pending{scope} changelog fragments found in changes/unreleased/*.md.")
|
|
return fragments
|
|
|
|
|
|
def normalized_entry(entry: str) -> str:
|
|
return re.sub(r"[^a-z0-9]+", " ", entry.lower()).strip()
|
|
|
|
|
|
def validate_no_near_duplicates(fragments: list[Fragment]) -> None:
|
|
seen: list[tuple[str, Fragment, str]] = []
|
|
for fragment in fragments:
|
|
for entry in fragment.entries:
|
|
normalized = normalized_entry(entry)
|
|
for previous_normalized, previous_fragment, previous_entry in seen:
|
|
if normalized == previous_normalized:
|
|
fail(
|
|
"Duplicate changelog entries found in "
|
|
f"{relative(previous_fragment.path)} and {relative(fragment.path)}: {entry}"
|
|
)
|
|
score = SequenceMatcher(None, previous_normalized, normalized).ratio()
|
|
if score >= NEAR_DUPLICATE_THRESHOLD:
|
|
fail(
|
|
"Near-duplicate changelog entries found in "
|
|
f"{relative(previous_fragment.path)} and {relative(fragment.path)}:\n"
|
|
f"- {previous_entry}\n"
|
|
f"- {entry}"
|
|
)
|
|
seen.append((normalized, fragment, entry))
|
|
|
|
|
|
def grouped_entries(fragments: list[Fragment]) -> dict[str, list[str]]:
|
|
grouped: dict[str, list[str]] = {entry_type: [] for entry_type in TYPE_ORDER}
|
|
seen: set[str] = set()
|
|
for fragment in fragments:
|
|
for entry in fragment.entries:
|
|
key = normalized_entry(entry)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
grouped[fragment.type].append(entry)
|
|
return {entry_type: entries for entry_type, entries in grouped.items() if entries}
|
|
|
|
|
|
def render_release(title: str, date: str, fragments: list[Fragment]) -> str:
|
|
sections = grouped_entries(fragments)
|
|
lines = [f"## [{title}] - {date}", ""]
|
|
for entry_type in TYPE_ORDER:
|
|
entries = sections.get(entry_type)
|
|
if not entries:
|
|
continue
|
|
lines.extend([f"### {TYPE_TITLES[entry_type]}", ""])
|
|
if entry_type == "summary":
|
|
for entry in entries:
|
|
lines.extend([entry, ""])
|
|
else:
|
|
lines.extend(f"- {entry}" for entry in entries)
|
|
lines.append("")
|
|
return "\n".join(lines).rstrip() + "\n"
|
|
|
|
|
|
def default_changelog() -> str:
|
|
return (
|
|
"# Changelog\n\n"
|
|
"All notable changes to this project are documented here. The format follows\n"
|
|
"[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses\n"
|
|
"[Semantic Versioning](https://semver.org/).\n\n"
|
|
"Pending release notes live in `changes/unreleased/*.md` and are compiled during\n"
|
|
"the app and plugin release processes.\n"
|
|
)
|
|
|
|
|
|
def insert_release(content: str, title: str, release_text: str) -> str:
|
|
if re.search(rf"(?m)^## \[{re.escape(title)}\](?: - .*)?$", content):
|
|
fail(f"CHANGELOG.md already has an entry for {title}.")
|
|
|
|
stripped = content.rstrip()
|
|
match = VERSION_HEADER_RE.search(stripped)
|
|
if match:
|
|
return stripped[: match.start()].rstrip() + "\n\n" + release_text.rstrip() + "\n\n" + stripped[match.start() :].lstrip() + "\n"
|
|
return stripped + "\n\n" + release_text.rstrip() + "\n"
|
|
|
|
|
|
def prepare(release: str, title: str, release_date: str, dry_run: bool) -> None:
|
|
fragments = load_fragments(require_pending=True, release=release)
|
|
release_text = render_release(title, release_date, fragments)
|
|
if dry_run:
|
|
sys.stdout.write(release_text)
|
|
return
|
|
|
|
content = CHANGELOG_PATH.read_text(encoding="utf-8") if CHANGELOG_PATH.exists() else default_changelog()
|
|
CHANGELOG_PATH.write_text(insert_release(content, title, release_text), encoding="utf-8")
|
|
for fragment in fragments:
|
|
fragment.path.unlink()
|
|
|
|
|
|
def release_notes_from_match(content: str, matches: list[re.Match[str]], index: int) -> str:
|
|
match = matches[index]
|
|
start = content.find("\n", match.end())
|
|
if start == -1:
|
|
fail(f"CHANGELOG.md entry for {match.group(1)} is empty.")
|
|
end = matches[index + 1].start() if index + 1 < len(matches) else len(content)
|
|
notes = content[start + 1 : end].strip()
|
|
if not notes:
|
|
fail(f"CHANGELOG.md entry for {match.group(1)} is empty.")
|
|
return notes + "\n"
|
|
|
|
|
|
def extract_from_content(content: str, title: str) -> str:
|
|
matches = list(VERSION_HEADER_RE.finditer(content))
|
|
for index, match in enumerate(matches):
|
|
if match.group(1) != title:
|
|
continue
|
|
return release_notes_from_match(content, matches, index)
|
|
fail(f"CHANGELOG.md has no entry for {title}.")
|
|
|
|
|
|
def extract(title: str) -> str:
|
|
if not CHANGELOG_PATH.exists():
|
|
fail("CHANGELOG.md does not exist.")
|
|
return extract_from_content(CHANGELOG_PATH.read_text(encoding="utf-8"), title)
|
|
|
|
|
|
def parsed_release_sections(notes: str) -> dict[str, list[str]]:
|
|
title_to_type = {title: entry_type for entry_type, title in TYPE_TITLES.items()}
|
|
sections: dict[str, list[str]] = {}
|
|
matches = list(SECTION_HEADER_RE.finditer(notes))
|
|
for index, match in enumerate(matches):
|
|
entry_type = title_to_type.get(match.group(1).strip())
|
|
if entry_type is None:
|
|
continue
|
|
start = notes.find("\n", match.end())
|
|
if start == -1:
|
|
continue
|
|
end = matches[index + 1].start() if index + 1 < len(matches) else len(notes)
|
|
body = notes[start + 1 : end].strip()
|
|
if not body:
|
|
continue
|
|
if entry_type == "summary":
|
|
entries = [clean_entry(paragraph) for paragraph in re.split(r"\n\s*\n", body)]
|
|
else:
|
|
entries = [clean_entry(line) for line in body.splitlines() if line.strip().startswith("- ")]
|
|
sections.setdefault(entry_type, []).extend(entry for entry in entries if entry)
|
|
return sections
|
|
|
|
|
|
def plugin_sections_since_previous_app(content: str, title: str) -> dict[str, list[str]]:
|
|
if not APP_TAG_RE.fullmatch(title):
|
|
fail(f"Sparkle notes require an app tag, got {title}.")
|
|
|
|
matches = list(VERSION_HEADER_RE.finditer(content))
|
|
target_index = next((index for index, match in enumerate(matches) if match.group(1) == title), None)
|
|
if target_index is None:
|
|
fail(f"CHANGELOG.md has no entry for {title}.")
|
|
|
|
grouped: dict[str, list[str]] = {entry_type: [] for entry_type in TYPE_ORDER}
|
|
seen: set[str] = set()
|
|
for index in range(target_index + 1, len(matches)):
|
|
release_title = matches[index].group(1)
|
|
if APP_TAG_RE.fullmatch(release_title):
|
|
break
|
|
if not release_title.startswith("plugins-"):
|
|
continue
|
|
sections = parsed_release_sections(release_notes_from_match(content, matches, index))
|
|
for entry_type, entries in sections.items():
|
|
for entry in entries:
|
|
key = normalized_entry(entry)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
grouped[entry_type].append(entry)
|
|
return {entry_type: entries for entry_type, entries in grouped.items() if entries}
|
|
|
|
|
|
def render_note_sections(sections: dict[str, list[str]]) -> str:
|
|
lines: list[str] = []
|
|
for entry_type in TYPE_ORDER:
|
|
entries = sections.get(entry_type)
|
|
if not entries:
|
|
continue
|
|
lines.extend([f"### {TYPE_TITLES[entry_type]}", ""])
|
|
if entry_type == "summary":
|
|
for entry in entries:
|
|
lines.extend([entry, ""])
|
|
else:
|
|
lines.extend(f"- {entry}" for entry in entries)
|
|
lines.append("")
|
|
return "\n".join(lines).rstrip()
|
|
|
|
|
|
def compose_sparkle_notes(content: str, title: str, app_notes: str) -> str:
|
|
app_notes = app_notes.strip()
|
|
if not app_notes:
|
|
fail("App release notes are empty.")
|
|
|
|
parts = ["## App Updates", "", app_notes]
|
|
plugin_sections = plugin_sections_since_previous_app(content, title)
|
|
if plugin_sections:
|
|
parts.extend(["", "## Plugin Updates", "", render_note_sections(plugin_sections)])
|
|
return "\n".join(parts).rstrip() + "\n"
|
|
|
|
|
|
def validate(require_pending: bool, release: str | None) -> None:
|
|
load_fragments(require_pending=require_pending, release=release)
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Manage MacTools changelog fragments.")
|
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
|
|
validate_parser = subparsers.add_parser("validate", help="Validate pending changelog fragments.")
|
|
validate_parser.add_argument("--release", choices=sorted(RELEASE_TARGETS), help="Validate one release target.")
|
|
validate_parser.add_argument(
|
|
"--require-pending",
|
|
action="store_true",
|
|
help="Fail when changes/unreleased contains no markdown fragments.",
|
|
)
|
|
|
|
prepare_parser = subparsers.add_parser("prepare", help="Compile pending fragments into CHANGELOG.md.")
|
|
prepare_parser.add_argument("--release", choices=sorted(RELEASE_TARGETS), required=True)
|
|
prepare_parser.add_argument("--tag", required=True, help="Release tag, for example v1.0.28 or plugins-1.0.29.")
|
|
prepare_parser.add_argument(
|
|
"--date",
|
|
default=dt.date.today().isoformat(),
|
|
help="Release date in YYYY-MM-DD format. Defaults to today.",
|
|
)
|
|
prepare_parser.add_argument("--dry-run", action="store_true", help="Print the release entry only.")
|
|
|
|
extract_parser = subparsers.add_parser("extract", help="Extract one version from CHANGELOG.md.")
|
|
extract_parser.add_argument("--tag", required=True, help="Release tag, for example v1.0.28 or plugins-1.0.29.")
|
|
extract_parser.add_argument("--output", help="Write notes to this file instead of stdout.")
|
|
|
|
sparkle_parser = subparsers.add_parser(
|
|
"compose-sparkle",
|
|
help="Compose app and intervening plugin notes for the Sparkle update dialog.",
|
|
)
|
|
sparkle_parser.add_argument("--tag", required=True, help="App release tag, for example v1.0.31.")
|
|
sparkle_parser.add_argument("--app-notes", required=True, help="App-only release notes file.")
|
|
sparkle_parser.add_argument("--output", help="Write notes to this file instead of stdout.")
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
try:
|
|
if args.command == "validate":
|
|
validate(require_pending=args.require_pending, release=args.release)
|
|
elif args.command == "prepare":
|
|
prepare(args.release, args.tag, args.date, args.dry_run)
|
|
elif args.command == "extract":
|
|
notes = extract(args.tag)
|
|
if args.output:
|
|
Path(args.output).write_text(notes, encoding="utf-8")
|
|
else:
|
|
sys.stdout.write(notes)
|
|
elif args.command == "compose-sparkle":
|
|
if not CHANGELOG_PATH.exists():
|
|
fail("CHANGELOG.md does not exist.")
|
|
app_notes_path = Path(args.app_notes)
|
|
if not app_notes_path.is_file():
|
|
fail(f"App release notes file does not exist: {app_notes_path}")
|
|
notes = compose_sparkle_notes(
|
|
CHANGELOG_PATH.read_text(encoding="utf-8"),
|
|
args.tag,
|
|
app_notes_path.read_text(encoding="utf-8"),
|
|
)
|
|
if args.output:
|
|
Path(args.output).write_text(notes, encoding="utf-8")
|
|
else:
|
|
sys.stdout.write(notes)
|
|
else:
|
|
fail(f"Unknown command: {args.command}")
|
|
except ChangelogError as error:
|
|
print(f"[changelog] error: {error}", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|