Skip to content

Commit

Permalink
feat: add cli to create github pull requests
Browse files Browse the repository at this point in the history
  • Loading branch information
fyhertz committed Mar 14, 2022
1 parent 413e48d commit a607fb8
Show file tree
Hide file tree
Showing 4 changed files with 250 additions and 1 deletion.
3 changes: 3 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
106 changes: 106 additions & 0 deletions src/wakemebot/bot.py
Original file line number Diff line number Diff line change
@@ -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", "[email protected]")
GIT_COMMITTER = pygit2.Signature("wakemebot", "[email protected]")

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}")
34 changes: 33 additions & 1 deletion src/wakemebot/cli.py
Original file line number Diff line number Diff line change
@@ -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__

Expand All @@ -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__)
Expand Down
108 changes: 108 additions & 0 deletions tests/test_bot.py
Original file line number Diff line number Diff line change
@@ -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")

0 comments on commit a607fb8

Please sign in to comment.