#!/usr/bin/env python3 # Copyright (C) 2024 Maxwell G # SPDX-License-Identifier: GPL-3.0-or-later # GNU General Public License v3.0+ # (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) """ Script to handle tagging versions in the ansible-documentation repo in sync with ansible-core. """ from __future__ import annotations import datetime from collections.abc import Iterable from dataclasses import dataclass from pathlib import Path from string import Template from types import SimpleNamespace from typing import Any, List, NamedTuple, NoReturn, Optional import click import git import git.objects.util import typer from packaging.version import Version MESSAGE = Template( """\ ${version_str} This tag contains a snapshot of the ansible-documentation ${branch} branch at the time of the ansible-core ${version_str} release. """ ) # hacking/tagger HERE = Path(__file__).resolve().parent ROOT = HERE.parent.parent DEFAULT_ANSIBLE_CORE_CHECKOUT = ROOT.parent.joinpath("ansible") DEFAULT_REMOTE = "origin" DEFAULT_ACTIVE_BRANCHES: tuple[str, ...] = ( "stable-2.16", "stable-2.17", "stable-2.18", "stable-2.19", ) def get_tags(repo: git.Repo) -> list[str]: """ Args: repo: A repo object Returns: A list of tag names as strings """ return [tag.name.removeprefix("refs/tags/") for tag in repo.tags] def filter_tags(tags: Iterable[str], major_minor: str) -> dict[str, Version]: """ Args: tags: Iterable of tag names as strings major_minor: `{version.major}.{version.minor}` of an ansible-core branch Returns: Sorted (newest->oldest) dict of tag names that are part of `major_minor` mapped to parsed `packaging.version.Version`s """ tags = { tag: Version(stripped) for tag in tags if (stripped := tag.lstrip("v")).startswith(major_minor) } return dict(sorted(tags.items(), reverse=True, key=lambda x: x[1])) def get_tag_datetime(tag: git.TagReference) -> datetime.datetime: """ Args: tag: Lightweight tag reference Returns: A `datetime.datetime` of the tagged date or the committed date for a non-annotated tag """ if tag.tag: return git.objects.util.from_timestamp( tag.tag.tagged_date, tag.tag.tagger_tz_offset ) return tag.commit.committed_datetime def _get_last_commit_before( commits: Iterable[git.objects.Commit], before: datetime.datetime ) -> git.objects.Commit: for commit in commits: if commit.committed_datetime <= before: return commit raise ValueError("No commit found!") def get_last_hash( docs_repo: git.Repo, core_tag: git.TagReference, branch: str, remote: str ) -> str: """ Get the last commit before the datetime of ansible-core's release of TAG. Args: docs_repo: ansible-documentation `git.Repo` object core_tag: `git.TagReference` for the corresponding tag in ansible-core branch: Branch name in which to search for the properly timed commit Returns: Commit hash Raises: ValueError: No commit was found before the datetime of ansible-core's release of TAG """ return _get_last_commit_before( commits=docs_repo.iter_commits(f"{remote}/{branch}", first_parent=True), before=get_tag_datetime(core_tag), ) def get_branch(tag_name: str, /) -> str: """ Determine a `stable-XX.XX` branch name based on `tag_name` """ version = Version(tag_name.lstrip("v")) major_minor = f"{version.major}.{version.minor}" return "stable-" + major_minor def v_prefix_tag(name: str, /) -> str: """ Ensure a tag/version has a `v` prefix """ return "v" + name.lstrip("v") # START: typer CLI code app = typer.Typer() def fatal(__msg: object, /, *, returncode: int = 1) -> NoReturn: typer.secho(f"! {__msg}", err=True, fg="red") raise typer.Exit(returncode) def msg(__msg: object, not_on_quiet: bool = True, /, **kwargs: Any) -> None: if not_on_quiet: try: quiet = click.get_current_context().ensure_object(Args).quiet except Exception: quiet = False if quiet: return kwarg: dict[str, Any] = {"err": True, "fg": "blue"} | kwargs typer.secho(f"* {__msg}", **kwarg) @dataclass(kw_only=True) class Args: """ Context for global arguments """ docs_repo_path: Path docs_repo: git.Repo docs_remote: str core_repo_path: Path core_repo: git.Repo core_remote: str quiet: bool def ensure_tag(tag: git.TagReference) -> None: """ Ensure a `git.TagReference` actually object """ try: _ = tag.object except ValueError: name = tag.name.removeprefix("refs/tags/") fatal(f"Tag {name} does not exist in core!") def get_new_tags(args: Args, branch: str) -> dict[str, Version]: """ Returns: Sorted (newest->oldest) dict of new tag names mapped to parsed `packaging.version.Version`s """ core_tags, our_tags = get_tags(args.core_repo), get_tags(args.docs_repo) core_filtered_tags = filter_tags(core_tags, branch.removeprefix("stable-")) our_filtered_tags = filter_tags(our_tags, branch.removeprefix("stable-")) missing_tags: dict[str, Version] = {} for tag, version in core_filtered_tags.items(): if tag in our_filtered_tags: break missing_tags[tag] = version return missing_tags class BranchTagRef(NamedTuple): branch: str tag: str ref: str def branch_tag_ref( args: Args, branch: str | None, tag: str, ref: str | None ) -> BranchTagRef: tag = v_prefix_tag(tag) branch = branch or get_branch(tag) core_tag = args.core_repo.tag(tag) ensure_tag(core_tag) if not ref: ref = get_last_hash(args.docs_repo, core_tag, branch, args.docs_remote) return BranchTagRef(branch, tag, ref) def create_tag( args: Args, branch: str, tag: str, ref: str, *, push: bool ) -> git.TagReference: """ Create and push a tag with the proper message Args: args: CLI context `Args` object branch: Branch name tag: Tag name ref: Reference to tag """ message = MESSAGE.substitute(version_str=tag.lstrip("v"), branch=branch) msg(f"Tagging {ref} as {tag}") tag_ref = git.TagReference.create(args.docs_repo, tag, ref, message) if push: print(f"Pushing {tag} to {args.docs_remote}") args.docs_repo.remote(args.docs_remote).push(tag) return tag_ref PARAMS = SimpleNamespace( branches=typer.Option( None, "-b", "--branch", help="Branches in which to search for tags." " Can be specified multiple times." f" Defaults to {DEFAULT_ACTIVE_BRANCHES}", ), branch=typer.Option( None, "-b", "--branch", help="Branch name. Autodetect based on --tag by deafult.", ), tag_required=typer.Option( ..., "-t", "--tag", help="Tag name", ), ref=typer.Option( ..., "-r", "--ref", help="Tag reference", ), ) @app.callback(help=__doc__) def callback( ctx: typer.Context, docs_repo_path: Path = typer.Option( ROOT, "--docs", help="Path to ansible-documentation checkout", dir_okay=True, file_okay=False, exists=True, ), core_repo_path: Path = typer.Option( DEFAULT_ANSIBLE_CORE_CHECKOUT, "--core", help="Path to core checkout", dir_okay=True, file_okay=False, exists=True, ), remote: Optional[str] = typer.Option( None, help="Git Remote name for ansible-core and ansible-documentation checkouts." f" Default: {DEFAULT_REMOTE}", ), core_remote: Optional[str] = typer.Option( None, help="Override remote name for core checkout" ), docs_remote: Optional[str] = typer.Option( None, help="Override remote name for docs checkout" ), fetch: bool = typer.Option(True, help="Whether to fetch repos"), quiet: bool = typer.Option(False, help="Silence logging"), ): """ Process global CLI arguments and create a context object to store them """ core_remote = core_remote or remote or DEFAULT_REMOTE docs_remote = docs_remote or remote or DEFAULT_REMOTE docs_repo = git.Repo(docs_repo_path) core_repo = git.Repo(core_repo_path) args = Args( docs_repo_path=docs_repo_path, docs_repo=docs_repo, docs_remote=docs_remote, core_repo_path=core_repo_path, core_repo=core_repo, core_remote=core_remote, quiet=quiet, ) ctx.obj = args if fetch: fetch_all(args) def fetch_all(args: Args) -> None: remotes = { "docs": (args.docs_repo, args.docs_remote), "core": (args.core_repo, args.core_remote), } for name, (repo, cur_remote) in remotes.items(): msg(f"Fetching {cur_remote} from {name} repo...") repo.remote(cur_remote).fetch() @app.command(name="new-tags") def new_tags_command( ctx: typer.Context, branches: Optional[List[str]] = PARAMS.branches ) -> None: """ List new tags in ansible-core that are not tagged here """ args = ctx.ensure_object(Args) branches = branches or list(DEFAULT_ACTIVE_BRANCHES) missing_tags = [tag for branch in branches for tag in get_new_tags(args, branch)] if missing_tags: print("\n".join(missing_tags)) ctx.exit(0 if missing_tags else 1) @app.command(name="hash") def hash_command( ctx: typer.Context, tag: str = PARAMS.tag_required, branch: Optional[str] = PARAMS.branch, ) -> None: """ Get the last commit hash before the datetime of ansible-core's release of TAG. """ args = ctx.ensure_object(Args) _, _, ref = branch_tag_ref(args, branch, tag, None) print(ref) @app.command(name="mantag") def mantag_command( ctx: typer.Context, tag: str = PARAMS.tag_required, ref: str = PARAMS.ref, branch: Optional[str] = PARAMS.branch, push: bool = True, ) -> None: """ Manually tag a release """ args = ctx.ensure_object(Args) triplet = branch_tag_ref(args, branch, tag, ref) create_tag(args, *triplet, push=push) @app.command(name="tag") def tag_command( ctx: typer.Context, branches: Optional[List[str]] = PARAMS.branches, push: bool = True, ): """ Determine the missing ansible-core releases from `--branch`, create corresponding tags for each release in the ansible-documentation repo, and push them. """ args = ctx.ensure_object(Args) branches = branches or list(DEFAULT_ACTIVE_BRANCHES) triplets: list[BranchTagRef] = [ branch_tag_ref(args, branch, tag, None) for branch in branches for tag in get_new_tags(args, branch) ] for triplet in triplets: create_tag(args, *triplet, push=push) if __name__ == "__main__": app()