diff --git a/setup.cfg b/setup.cfg index 952951b..0903918 100644 --- a/setup.cfg +++ b/setup.cfg @@ -4,6 +4,9 @@ disallow_untyped_defs = True warn_redundant_casts = True strict_equality = True +[mypy-pygit2] +ignore_missing_imports = True + [mypy-semver] ignore_missing_imports = True diff --git a/src/wakemebot/bot.py b/src/wakemebot/bot.py new file mode 100644 index 0000000..4e13273 --- /dev/null +++ b/src/wakemebot/bot.py @@ -0,0 +1,106 @@ +import sys +from pathlib import Path +from typing import Any, Dict, Generator, List, Union + +import pygit2 +import ruamel.yaml +from ops2deb.parser import validate +from ops2deb.updater import FixIndentEmitter, LatestRelease, find_latest_releases +from ruamel.yaml import YAML, YAMLError + +GIT_AUTHOR = pygit2.Signature("wakemebot", "wakemebot@users.noreply.github.com") +GIT_COMMITTER = pygit2.Signature("wakemebot", "wakemebot@protonmail.com") + +BRANCH_NAME_TEMPLATE = ( + "chore/wakemebot-update-{release.blueprint.name}-" + "from-{release.blueprint.version}-to-{release.version}" +) + +COMMIT_MESSAGE_TEMPLATE = ( + "chore(bot): update {release.blueprint.name} " + "from {release.blueprint.version} to {release.version}" +) + + +def load( + configuration_content: str, yaml: YAML = YAML() +) -> Union[List[Dict[str, Any]], Dict[str, Any]]: + try: + return yaml.load(configuration_content) + except YAMLError as e: + print(f"Invalid YAML file.\n{e}") + sys.exit(1) + + +def yaml_factory() -> ruamel.yaml.YAML: + yaml = ruamel.yaml.YAML(typ="rt") + yaml.Emitter = FixIndentEmitter + return yaml + + +def create_branch_and_commit_configuration( + repo: pygit2.Repository, + configuration_path: Path, + branch_name: str, + commit_message: str, +) -> pygit2.Branch: + commit = repo[repo.head.target] + branch = repo.branches.create(branch_name, commit) + repository_path = Path(pygit2.discover_repository(configuration_path.parent)).parent + repo.index.add(configuration_path.relative_to(repository_path)) + repo.index.write() + tree = repo.index.write_tree() + parent, ref = repo.resolve_refish(refish=branch.name) + repo.create_commit( + branch.name, GIT_AUTHOR, GIT_COMMITTER, commit_message, tree, [parent.oid] + ) + return branch + + +def push_branch(repo: pygit2.Repository, branch_name: str, access_token: str) -> None: + remote = repo.remotes["origin"] + credentials = pygit2.UserPass("wakemebot", access_token) + callbacks = pygit2.RemoteCallbacks(credentials) + remote.push([branch_name], callbacks) + + +def find_configuration_files(repository_path: Path) -> List[Path]: + paths = list(repository_path.glob("**/*ops2deb*.yml")) + print(f"Found {len(paths)} ops2deb configuration files") + return paths + + +def update_configuration_file( + configuration_path: Path, + skip_names: List[str] = None, +) -> Generator[LatestRelease, None, None]: + original_configuration = configuration_path.read_text() + blueprints = validate(load(original_configuration)) + + print(f"Looking for new releases in {configuration_path}") + releases, _ = find_latest_releases(blueprints, skip_names) + releases or print("Did not find any updates") + + for release in releases: + yaml = yaml_factory() + configuration_dict = yaml.load(original_configuration) + release.update_configuration(configuration_dict) + with configuration_path.open("w") as output: + yaml.dump(configuration_dict, output) + yield release + + +def update(repository_path: Path, access_token: str) -> None: + configuration_paths = find_configuration_files(repository_path) + repo = pygit2.Repository(repository_path) + for configuration_path in configuration_paths: + for release in update_configuration_file(configuration_path): + branch_name = BRANCH_NAME_TEMPLATE.format(release=release) + commit_message = COMMIT_MESSAGE_TEMPLATE.format(release=release) + try: + branch = create_branch_and_commit_configuration( + repo, configuration_path, branch_name, commit_message + ) + push_branch(repo, branch.name, access_token) + except pygit2.GitError as e: + print(f"Something went wrong with branch {branch_name}: {e}") diff --git a/src/wakemebot/cli.py b/src/wakemebot/cli.py index 990d34a..e77ec07 100644 --- a/src/wakemebot/cli.py +++ b/src/wakemebot/cli.py @@ -1,9 +1,11 @@ from pathlib import Path +from typing import Optional +import pygit2 import typer from pydantic import BaseModel, Field, HttpUrl -from wakemebot import aptly +from wakemebot import aptly, bot from . import __version__ @@ -16,6 +18,36 @@ class DebianRepository(BaseModel): distribution: str = Field(..., regex=r"[a-zA-Z0-9]+") +def check_access_token(value: Optional[str]) -> str: + if value is None: + raise typer.BadParameter("Missing Github Access Token") + return value + + +def get_git_repo_path(path: Path) -> Path: + repo_path = pygit2.discover_repository(path) + if not repo_path: + raise typer.BadParameter(f"No repository found at '{path}'") + return Path(repo_path).parent + + +@app.command(help="Look for updates and create pull requests.") +def update( + repository_path: Path = typer.Option( + Path("."), + help="Path to WakeMeOps repository", + callback=get_git_repo_path, + ), + access_token: str = typer.Option( + None, + help="Github Personal Access Token", + envvar="WAKEMEBOT_GITHUB_ACCESS_TOKEN", + callback=check_access_token, + ), +) -> None: + bot.update(repository_path, access_token) + + @app.command(help="Output wakemebot version") def version() -> None: typer.secho(__version__) diff --git a/tests/test_bot.py b/tests/test_bot.py new file mode 100644 index 0000000..750d318 --- /dev/null +++ b/tests/test_bot.py @@ -0,0 +1,108 @@ +import base64 +from textwrap import dedent +from unittest.mock import Mock, patch + +import httpx +import pygit2 +import pytest +from ops2deb.parser import parse +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import Response + +from wakemebot.bot import GIT_AUTHOR, GIT_COMMITTER, update, update_configuration_file + + +@pytest.fixture +def ops2deb_configuration() -> str: + configuration = """\ + - name: great-app + version: 1.0.0 + summary: Great package + description: A detailed description of the great package. + fetch: + url: http://testserver/{{version}}/great-app.tar.gz + sha256: f1be6dd36b503641d633765655e81cdae1ff8f7f73a2582b7468adceb5e212a9 + script: + - mv great-app {{src}}/usr/bin/great-app + + - name: super-app + version: 1.0.0 + summary: Super package + description: A detailed description of the great package. + fetch: + url: http://testserver/{{version}}/great-app.tar.gz + sha256: f1be6dd36b503641d633765655e81cdae1ff8f7f73a2582b7468adceb5e212a9 + script: + - mv super-app {{src}}/usr/bin/super-app + """ + return dedent(configuration) + + +@pytest.fixture +def server() -> Starlette: + starlette_app = Starlette(debug=True) + + @starlette_app.route("/1.1.0/great-app.tar.gz") + @starlette_app.route("/1.1.1/great-app.tar.gz") + async def server_great_app_tar_gz(request: Request): + response = b"""H4sIAAAAAAAAA+3OMQ7CMBAEQD/FH0CyjSy/xwVCFJAoCf/HFCAqqEI1U9yudF + fceTn17dDnOewnDa3VZ+ZW02e+hHxsrYxRagkp59FDTDv+9HZft77EGNbLdbp9uf + u1BwAAAAAAAAAAgD96AGPmdYsAKAAA""" + return Response( + base64.b64decode(response), + status_code=200, + media_type="application/x-gzip", + ) + + return starlette_app + + +@pytest.fixture(scope="function") +def client(server): + real_async_client = httpx.AsyncClient + + def async_client_mock(**kwargs): + kwargs.pop("transport", None) + return real_async_client(app=server, **kwargs) + + httpx.AsyncClient = async_client_mock + yield + httpx.AsyncClient = real_async_client + + +@pytest.fixture +def repo(tmp_path, ops2deb_configuration): + repo = pygit2.init_repository(tmp_path, False) + configuration_path = tmp_path / "ops2deb.yml" + configuration_path.write_text(ops2deb_configuration) + repo.index.add(configuration_path.name) + repo.index.write() + tree = repo.index.write_tree() + repo.create_commit("HEAD", GIT_AUTHOR, GIT_COMMITTER, "initial commit", tree, []) + return repo + + +def test_update_configuration_file_should_update_one_blueprint_by_iteration( + client, ops2deb_configuration, tmp_path +): + configuration_path = tmp_path / "ops2deb.yml" + configuration_path.write_text(ops2deb_configuration) + configuration_file_iterator = update_configuration_file(configuration_path) + next(configuration_file_iterator) + blueprints = parse(configuration_path) + assert blueprints[0].version == "1.1.1" + assert blueprints[1].version == "1.0.0" + next(configuration_file_iterator) + blueprints = parse(configuration_path) + assert blueprints[0].version == "1.0.0" + assert blueprints[1].version == "1.1.1" + + +@patch("wakemebot.bot.push_branch", Mock()) +def test_update_should_use_proper_commit_titles_and_branch_names( + client, ops2deb_configuration, tmp_path, repo +): + update(tmp_path, "") + assert repo.branches.get("chore/wakemebot-update-great-app-from-1.0.0-to-1.1.1") + assert repo.branches.get("chore/wakemebot-update-super-app-from-1.0.0-to-1.1.1")