diff --git a/hacking/tagger/requirements.txt b/hacking/tagger/requirements.txt new file mode 100644 index 00000000000..220933af86f --- /dev/null +++ b/hacking/tagger/requirements.txt @@ -0,0 +1,3 @@ +gitpython +packaging +typer diff --git a/hacking/tagger/tag.py b/hacking/tagger/tag.py new file mode 100755 index 00000000000..a3e556cb7ec --- /dev/null +++ b/hacking/tagger/tag.py @@ -0,0 +1,416 @@ +#!/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.14", "stable-2.15", "stable-2.16") + + +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() diff --git a/noxfile.py b/noxfile.py index 50f4034d38a..fbb0beb757d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -10,6 +10,7 @@ LINT_FILES: tuple[str, ...] = ( "hacking/pr_labeler/label.py", + "hacking/tagger/tag.py", "noxfile.py", *iglob("docs/bin/*.py"), ) diff --git a/tests/typing.in b/tests/typing.in index 63112a0d90a..5eaee63794a 100644 --- a/tests/typing.in +++ b/tests/typing.in @@ -1,3 +1,4 @@ -r ../hacking/pr_labeler/requirements.txt +-r ../hacking/tagger/requirements.txt mypy nox diff --git a/tests/typing.txt b/tests/typing.txt index 345d3fc9295..c6cce4e8805 100644 --- a/tests/typing.txt +++ b/tests/typing.txt @@ -28,6 +28,10 @@ distlib==0.3.8 # via virtualenv filelock==3.13.1 # via virtualenv +gitdb==4.0.11 + # via gitpython +gitpython==3.1.42 + # via -r tests/../hacking/tagger/requirements.txt idna==3.6 # via requests jinja2==3.1.3 @@ -41,7 +45,9 @@ mypy-extensions==1.0.0 nox==2024.3.2 # via -r tests/typing.in packaging==24.0 - # via nox + # via + # -r tests/../hacking/tagger/requirements.txt + # nox platformdirs==4.2.0 # via virtualenv pycparser==2.21 @@ -54,10 +60,14 @@ pynacl==1.5.0 # via pygithub requests==2.31.0 # via pygithub +smmap==5.0.1 + # via gitdb tomli==2.0.1 # via mypy typer==0.9.0 - # via -r tests/../hacking/pr_labeler/requirements.txt + # via + # -r tests/../hacking/pr_labeler/requirements.txt + # -r tests/../hacking/tagger/requirements.txt typing-extensions==4.10.0 # via # codeowners