Ctrl K
ComunidadGuías…

scripts/check-changelog.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Validate CHANGELOG.md and extract a tagged release's notes."""

from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path

REPO = Path(__file__).resolve().parent.parent
CHANGELOG = REPO / "CHANGELOG.md"
CARGO_TOML = REPO / "Cargo.toml"

REQUIRED_SECTIONS = ("Added", "Changed", "Deprecated", "Fixed", "Improved")
HEADING_RE = re.compile(r"^## \[(\d+\.\d+\.\d+)\] - (\d{4}-\d{2}-\d{2})\s*$")
WORKSPACE_VERSION_RE = re.compile(
    r"^\[workspace\.package\]\s*$|^version\s*=\s*\"(\d+\.\d+\.\d+)\"\s*$",
    re.MULTILINE,
)
VERSION_LINE_RE = re.compile(r"^version\s*=\s*\"(\d+\.\d+\.\d+)\"\s*$")


class ChangelogError(Exception):
    """The changelog cannot satisfy the requested operation."""


def workspace_version(cargo_toml: Path) -> str:
    text = cargo_toml.read_text(encoding="utf-8")
    in_workspace_package = False
    for line in text.splitlines():
        if line.strip() == "[workspace.package]":
            in_workspace_package = True
            continue
        if in_workspace_package:
            if line.startswith("["):
                break
            match = VERSION_LINE_RE.match(line)
            if match:
                return match.group(1)
    raise ChangelogError(f"{cargo_toml}: no [workspace.package] version")


def _split_releases(text: str) -> list[tuple[str, str, str]]:
    """Return (version, date, full_block) for each ## [x.y.z] heading."""
    lines = text.splitlines()
    starts: list[tuple[int, str, str]] = []
    for index, line in enumerate(lines):
        match = HEADING_RE.match(line)
        if match:
            starts.append((index, match.group(1), match.group(2)))

    releases: list[tuple[str, str, str]] = []
    for offset, (index, version, date) in enumerate(starts):
        end = starts[offset + 1][0] if offset + 1 < len(starts) else len(lines)
        block = "\n".join(lines[index:end]).rstrip() + "\n"
        releases.append((version, date, block))
    return releases


def validate_text(text: str, expected_version: str) -> list[str]:
    problems: list[str] = []
    if not text.lstrip().startswith("# Changelog"):
        problems.append("CHANGELOG.md must start with '# Changelog'")

    releases = _split_releases(text)
    if not releases:
        problems.append(
            "CHANGELOG.md needs a version heading like '## [0.1.0] - YYYY-MM-DD'"
        )
        return problems

    latest_version, _date, block = releases[0]
    if latest_version != expected_version:
        problems.append(
            f"latest heading is [{latest_version}], expected workspace {expected_version}"
        )

    headings = [
        line[4:]
        for line in block.splitlines()
        if line.startswith("### ")
    ]
    if tuple(headings) != REQUIRED_SECTIONS:
        problems.append(
            "release "
            f"{latest_version} must have sections in order: "
            + ", ".join(REQUIRED_SECTIONS)
            + f" (found {', '.join(headings) or 'none'})"
        )

    for section in REQUIRED_SECTIONS:
        marker = f"### {section}"
        if marker not in block:
            problems.append(f"missing ### {section}")
            continue
        after = block.split(marker, 1)[1]
        next_heading = after.find("\n### ")
        body = after if next_heading < 0 else after[:next_heading]
        if not re.search(r"(?m)^\s*[-*]\s+\S", body):
            problems.append(f"### {section} needs at least one list item")

    return problems


def validate_path(path: Path, expected_version: str) -> list[str]:
    if not path.is_file():
        return [f"{path}: missing CHANGELOG.md"]
    return validate_text(path.read_text(encoding="utf-8"), expected_version)


def extract_notes(text: str, tag: str) -> str:
    version = tag[1:] if tag.startswith("v") else tag
    for found, _date, block in _split_releases(text):
        if found == version:
            return block
    raise ChangelogError(f"CHANGELOG.md has no heading for {version}")


def previous_version(text: str, tag: str) -> str | None:
    """Return the tag of the release listed directly below `tag`.

    Release notes credit whoever worked on that release, so the contributor
    range starts here. `None` means `tag` is the oldest release documented,
    which has no predecessor to compare against.
    """
    version = tag[1:] if tag.startswith("v") else tag
    releases = _split_releases(text)
    for index, (found, _date, _block) in enumerate(releases):
        if found != version:
            continue
        if index + 1 >= len(releases):
            return None
        return f"v{releases[index + 1][0]}"
    raise ChangelogError(f"CHANGELOG.md has no heading for {version}")


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--extract",
        metavar="TAG",
        help="print the CHANGELOG section for a git tag (e.g. v0.1.0)",
    )
    parser.add_argument(
        "--previous",
        metavar="TAG",
        help="print the tag released before TAG; prints nothing for the oldest",
    )
    args = parser.parse_args(argv)

    try:
        version = workspace_version(CARGO_TOML)
    except ChangelogError as error:
        print(error)
        return 1

    if args.extract or args.previous:
        if not CHANGELOG.is_file():
            print(f"{CHANGELOG}: missing CHANGELOG.md")
            return 1
        changelog_text = CHANGELOG.read_text(encoding="utf-8")
        try:
            if args.extract:
                sys.stdout.write(extract_notes(changelog_text, args.extract))
            else:
                earlier = previous_version(changelog_text, args.previous)
                if earlier:
                    print(earlier)
        except ChangelogError as error:
            print(error)
            return 1
        return 0

    problems = validate_path(CHANGELOG, version)
    if problems:
        print(f"Changelog check failed with {len(problems)} problem(s).")
        for problem in problems:
            print(f"  {problem}")
        return 1

    print(
        f"CHANGELOG.md has [{version}] with "
        + ", ".join(REQUIRED_SECTIONS)
        + "."
    )
    return 0


if __name__ == "__main__":
    sys.exit(main())