mirror of
https://github.com/ansible/ansible-documentation.git
synced 2026-03-26 13:18:58 +07:00
* Add 2.16 back to the tagger script Even though 2.16 is EOL upstream there are still core releases happening so we should continue tagging docs to stay in sync.
420 lines
11 KiB
Python
Executable File
420 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
# Copyright (C) 2024 Maxwell G <maxwell@gtmx.me>
|
|
# 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", # Keep in sync with core even though EOL upstream
|
|
"stable-2.18",
|
|
"stable-2.19",
|
|
"stable-2.20",
|
|
)
|
|
|
|
|
|
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()
|