| """Subcommand to process news files and version markers for a release.""" |
| |
| import argparse |
| import dataclasses |
| import datetime |
| import logging |
| import pathlib |
| import re |
| import sys |
| |
| from dev.release import changelog_news |
| from dev.release.gh import ( |
| GetPrError, |
| GitHub, |
| GitHubInterface, |
| InvalidPrRefError, |
| ) |
| from dev.release.utils import replace_version_next_in_files |
| |
| logger = logging.getLogger(__name__) |
| |
| |
| def _release_version_type(value: str) -> str: |
| """Argparse type validator for release versions (X.Y or X.Y.Z).""" |
| if not re.match(r"^\d+\.\d+(\.\d+)?(rc\d+)?$", value): |
| raise argparse.ArgumentTypeError( |
| f"'{value}' is not a valid release version (X.Y or X.Y.Z)" |
| ) |
| return value |
| |
| |
| @dataclasses.dataclass(frozen=True) |
| class NewsFileTarget: |
| """Represents a direct news file target.""" |
| |
| path: pathlib.Path |
| |
| |
| @dataclasses.dataclass(frozen=True) |
| class PrTarget: |
| """Represents a PR target with its news files and code files.""" |
| |
| pr_num: int |
| news_files: tuple[pathlib.Path, ...] |
| code_files: tuple[pathlib.Path, ...] |
| |
| |
| ResolvedTarget = NewsFileTarget | PrTarget |
| |
| |
| def resolve_news_file_target(target: str) -> NewsFileTarget | None: |
| """Attempts to resolve a target as a direct news file path on disk.""" |
| path = pathlib.Path(target) |
| if path.exists() and changelog_news.is_news_file(path): |
| return NewsFileTarget(path=path) |
| return None |
| |
| |
| def resolve_pr_target(target: str, gh: GitHubInterface) -> PrTarget | None: |
| """Attempts to resolve a target as a PR reference and discover its files.""" |
| try: |
| pr_num = gh.resolve_pr_number(target) |
| except InvalidPrRefError: |
| return None |
| |
| logger.info("Resolving files for PR #%d via GitHub CLI...", pr_num) |
| try: |
| pr_file_paths = gh.get_pr_files(pr_num) |
| except GetPrError as e: |
| logger.error("Failed to get PR files for #%d: %s", pr_num, e) |
| print(f"::error::Failed to get files for PR #{pr_num}: {e}") |
| raise |
| |
| news_files: list[pathlib.Path] = [] |
| code_files: list[pathlib.Path] = [] |
| |
| # Check local news directory for any news/<pr_num>.*.md |
| news_dir = pathlib.Path("news") |
| if news_dir.is_dir(): |
| for p in news_dir.iterdir(): |
| if p.name.startswith(f"{pr_num}.") and changelog_news.is_news_file(p): |
| if p not in news_files: |
| news_files.append(p) |
| |
| for f in pr_file_paths: |
| p = pathlib.Path(f) |
| if changelog_news.is_news_file(p): |
| if p.exists() and p not in news_files: |
| news_files.append(p) |
| else: |
| if p.exists() and p not in code_files: |
| code_files.append(p) |
| |
| if not pr_file_paths and not news_files: |
| msg = f"No news files or PR files found for PR #{pr_num}." |
| logger.error(msg) |
| print(f"::error::{msg}") |
| raise GetPrError(msg) |
| |
| return PrTarget( |
| pr_num=pr_num, |
| news_files=tuple(news_files), |
| code_files=tuple(code_files), |
| ) |
| |
| |
| def resolve_target(target: str, gh: GitHubInterface) -> ResolvedTarget: |
| """Resolves a target string into a ResolvedTarget. |
| |
| Raises: |
| ValueError: If target cannot be resolved. |
| """ |
| if news_target := resolve_news_file_target(target): |
| return news_target |
| |
| try: |
| if pr_target := resolve_pr_target(target, gh): |
| return pr_target |
| except (InvalidPrRefError, GetPrError): |
| raise |
| |
| path = pathlib.Path(target) |
| if path.exists(): |
| msg = f"File is not a valid news file: {path} (expected <id>.<category>.md)" |
| else: |
| msg = ( |
| f"Target '{target}' is neither an existing news file nor a valid PR" |
| " reference." |
| ) |
| print(f"::error::{msg}") |
| raise ValueError(msg) |
| |
| |
| def process_news_file_target( |
| target: NewsFileTarget, |
| version: str, |
| changelog_path: pathlib.Path, |
| release_date: str | None = None, |
| ) -> None: |
| """Processes a direct news file target.""" |
| if release_date is None: |
| release_date = datetime.date.today().strftime("%Y-%m-%d") |
| logger.info("Processing news file: %s", target.path) |
| changelog_news.update_changelog( |
| version=version, |
| release_date=release_date, |
| changelog_path=changelog_path, |
| news_files=[target.path], |
| delete_news=True, |
| ) |
| print(f"::notice::Processed news file {target.path} into {changelog_path}.") |
| |
| |
| def process_pr_target( |
| target: PrTarget, |
| version: str, |
| changelog_path: pathlib.Path, |
| release_date: str | None = None, |
| ) -> None: |
| """Processes a PR target: merges news files and updates version markers.""" |
| if release_date is None: |
| release_date = datetime.date.today().strftime("%Y-%m-%d") |
| logger.info("Processing PR #%d...", target.pr_num) |
| if target.news_files: |
| changelog_news.update_changelog( |
| version=version, |
| release_date=release_date, |
| changelog_path=changelog_path, |
| news_files=list(target.news_files), |
| delete_news=True, |
| ) |
| news_list_str = ", ".join(str(f) for f in target.news_files) |
| print( |
| f"::notice::Processed news file(s) for PR #{target.pr_num} into" |
| f" {changelog_path}: {news_list_str}" |
| ) |
| |
| if target.code_files: |
| modified_files = replace_version_next_in_files(target.code_files, version) |
| if modified_files: |
| mod_str = ", ".join(str(f) for f in modified_files) |
| print( |
| f"::notice::Updated version-next markers for PR #{target.pr_num} in:" |
| f" {mod_str}" |
| ) |
| else: |
| logger.info( |
| "No version-next markers found to update for PR #%d.", |
| target.pr_num, |
| ) |
| |
| |
| class ProcessNews: |
| """Class to process news files into CHANGELOG.md for a release.""" |
| |
| def __init__(self, args, gh: GitHubInterface): |
| self.args = args |
| self.gh = gh |
| |
| def run(self) -> int: |
| """Executes the process-news subcommand.""" |
| args = self.args |
| version = args.version |
| if len(version.split(".")) == 2: |
| version = f"{version}.0" |
| |
| # Validate that target version exists in CHANGELOG.md |
| changelog_path = pathlib.Path("CHANGELOG.md") |
| if not changelog_path.exists(): |
| print( |
| f"::error::Changelog file not found at {changelog_path}", |
| file=sys.stderr, |
| ) |
| return 1 |
| |
| header_version = version.replace(".", "-") |
| version_anchor = f"{{#v{header_version}}}" |
| |
| release_date = args.release_date or datetime.date.today().strftime("%Y-%m-%d") |
| |
| # Phase 1: Resolve all targets in order |
| resolved_targets: list[ResolvedTarget] = [] |
| for target in args.targets: |
| try: |
| resolved = resolve_target(target, self.gh) |
| resolved_targets.append(resolved) |
| except Exception as e: |
| logger.error("Failed to resolve target '%s': %s", target, e) |
| print( |
| f"::error::Failed to resolve target '{target}': {e}", |
| file=sys.stderr, |
| ) |
| return 1 |
| |
| # Phase 2: Process all resolved targets in the given order |
| for target in resolved_targets: |
| if isinstance(target, NewsFileTarget): |
| process_news_file_target( |
| target, version, changelog_path, release_date=release_date |
| ) |
| elif isinstance(target, PrTarget): |
| process_pr_target( |
| target, version, changelog_path, release_date=release_date |
| ) |
| else: |
| logger.warning( |
| "Unexpected target type encountered: %s (%r)", |
| type(target), |
| target, |
| ) |
| |
| current_changelog = changelog_path.read_text(encoding="utf-8") |
| if version_anchor not in current_changelog: |
| logger.info( |
| "Version anchor %s not created by targets; creating empty release section.", |
| version_anchor, |
| ) |
| changelog_news.update_changelog( |
| version=version, |
| release_date=release_date, |
| changelog_path=changelog_path, |
| news_files=[], |
| delete_news=False, |
| ) |
| |
| return 0 |
| |
| @classmethod |
| def add_parser(cls, subparsers): |
| """Adds parser for process-news subcommand.""" |
| parser = subparsers.add_parser( |
| "process-news", |
| help=( |
| "Process news files and update version-next markers into" |
| " CHANGELOG.md for a release version." |
| ), |
| ) |
| parser.add_argument( |
| "version", |
| type=_release_version_type, |
| help="The target release version (e.g., 2.3.0 or 2.3).", |
| ) |
| parser.add_argument( |
| "targets", |
| nargs="+", |
| metavar="TARGET", |
| help=( |
| "One or more news file paths (e.g., news/3997.added.md) or PR" |
| " references (e.g., 3997, #3997, or PR URL) to process." |
| ), |
| ) |
| parser.add_argument( |
| "--release-date", |
| type=str, |
| default=None, |
| help=( |
| "Release date (YYYY-MM-DD) to use if creating a new version" |
| " section (defaults to today)." |
| ), |
| ) |
| parser.set_defaults(command=cls.run_from_args) |
| |
| @classmethod |
| def run_from_args(cls, args): |
| """Instantiates and runs the command from parsed args.""" |
| return cls(args, gh=GitHub()).run() |