diff --git a/.flake8 b/.flake8 index 0438b81d..46603647 100644 --- a/.flake8 +++ b/.flake8 @@ -1,2 +1,3 @@ [flake8] -max-line-length=105 \ No newline at end of file +max-line-length=105 +exclude=.venv* \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index d6aecc11..85dd0878 100644 --- a/poetry.lock +++ b/poetry.lock @@ -719,6 +719,21 @@ mccabe = ">=0.7.0,<0.8.0" pycodestyle = ">=2.10.0,<2.11.0" pyflakes = ">=3.0.0,<3.1.0" +[[package]] +name = "flake8-print" +version = "5.0.0" +description = "print statement checker plugin for flake8" +optional = false +python-versions = ">=3.7" +files = [ + {file = "flake8-print-5.0.0.tar.gz", hash = "sha256:76915a2a389cc1c0879636c219eb909c38501d3a43cc8dae542081c9ba48bdf9"}, + {file = "flake8_print-5.0.0-py3-none-any.whl", hash = "sha256:84a1a6ea10d7056b804221ac5e62b1cee1aefc897ce16f2e5c42d3046068f5d8"}, +] + +[package.dependencies] +flake8 = ">=3.0" +pycodestyle = "*" + [[package]] name = "furl" version = "2.1.3" @@ -1752,6 +1767,25 @@ PyYAML = "*" docs = ["sphinx"] test = ["pyaml", "pytest", "toml"] +[[package]] +name = "python-gitlab" +version = "3.15.0" +description = "Interact with GitLab API" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "python-gitlab-3.15.0.tar.gz", hash = "sha256:c9e65eb7612a9fbb8abf0339972eca7fd7a73d4da66c9b446ffe528930aff534"}, + {file = "python_gitlab-3.15.0-py3-none-any.whl", hash = "sha256:8f8d1c0d387f642eb1ac7bf5e8e0cd8b3dd49c6f34170cee3c7deb7d384611f3"}, +] + +[package.dependencies] +requests = ">=2.25.0" +requests-toolbelt = ">=0.10.1" + +[package.extras] +autocompletion = ["argcomplete (>=1.10.0,<3)"] +yaml = ["PyYaml (>=5.2)"] + [[package]] name = "pywin32" version = "306" @@ -1845,6 +1879,20 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +description = "A utility belt for advanced users of python-requests" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, + {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, +] + +[package.dependencies] +requests = ">=2.0.1,<3.0.0" + [[package]] name = "rfc3339-validator" version = "0.1.4" @@ -2110,4 +2158,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = "^3.8.1" -content-hash = "c9d160ad6ca95aea7a6bb7e3b6d877945c57407ba8d33590ffaaa417a785831b" +content-hash = "e4421783726e2c16fa65098b19709d0ae51e3887b99304be381c9fb15f446593" diff --git a/pyproject.toml b/pyproject.toml index 08244068..8073ba18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ python = '^3.8.1' gitpython = "^3.1.31" compliance-trestle = "^2.2.1" github3-py = "^4.0.1" +python-gitlab = "^3.15.0" [tool.poetry.group.dev.dependencies] flake8 = "^6.0.0" @@ -31,6 +32,7 @@ mypy = "^1.3.0" isort = "^5.12.0" safety = "^2.3.5" bandit = "^1.7.5" +flake8-print = "^5.0.0" [tool.poetry.group.tests.dependencies] pytest = "^7.3.2" diff --git a/tests/trestlebot/test_cli.py b/tests/trestlebot/test_cli.py index eb994756..be6f1776 100644 --- a/tests/trestlebot/test_cli.py +++ b/tests/trestlebot/test_cli.py @@ -98,6 +98,8 @@ def test_with_target_branch(monkeypatch, valid_args_dict, capsys): args_dict["target-branch"] = "main" monkeypatch.setattr(sys, "argv", ["trestlebot", *args_dict_to_list(args_dict)]) + # Patch is_github_actions since these tests will be running in + # GitHub Actions with patch("trestlebot.cli.is_github_actions") as mock_check: mock_check.return_value = False @@ -107,9 +109,10 @@ def test_with_target_branch(monkeypatch, valid_args_dict, capsys): captured = capsys.readouterr() expected_string = ( - "target-branch flag is set with an unsupported git provider. " - "If testing locally with the GitHub API, " - "set the GITHUB_ACTIONS environment variable to true." + "target-branch flag is set with an unset git provider. " + "To test locally, set the GITHUB_ACTIONS or GITLAB_CI environment variable." ) assert expected_string in captured.err + + mock_check.assert_called_once() diff --git a/tests/trestlebot/test_github.py b/tests/trestlebot/test_github.py index b0c523ab..8948cb11 100644 --- a/tests/trestlebot/test_github.py +++ b/tests/trestlebot/test_github.py @@ -17,6 +17,7 @@ """Test for GitHub provider logic""" from typing import Tuple +from unittest.mock import patch import pytest from git.repo import Repo @@ -70,3 +71,19 @@ def test_parse_repository_with_incorrect_name() -> None: match="https://notgithub.com/owner/repo.git is an invalid GitHub repo URL", ): gh.parse_repository("https://notgithub.com/owner/repo.git") + + +def test_create_pull_request_invalid_repo() -> None: + """Test triggering an error during pull request creation""" + gh = GitHub("fake") + with patch("github3.GitHub.repository") as mock_pull: + mock_pull.return_value = None + + with pytest.raises( + GitProviderException, + match="Repository for owner/repo cannot be None", + ): + gh.create_pull_request( + "owner", "repo", "main", "test", "My PR", "Has Changes" + ) + mock_pull.assert_called_once() diff --git a/tests/trestlebot/test_gitlab.py b/tests/trestlebot/test_gitlab.py new file mode 100644 index 00000000..d0034d37 --- /dev/null +++ b/tests/trestlebot/test_gitlab.py @@ -0,0 +1,129 @@ +#!/usr/bin/python + +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Test for GitLab provider logic""" + +from typing import Callable, Tuple +from unittest.mock import patch + +import pytest +from git.repo import Repo +from gitlab.exceptions import GitlabAuthenticationError, GitlabCreateError + +from tests.testutils import clean +from trestlebot.gitlab import GitLab +from trestlebot.provider import GitProviderException + + +@pytest.mark.parametrize( + "repo_url", + [ + "https://gitlab.com/owner/repo", + "https://gitlab.com/owner/repo.git", + "gitlab.com/owner/repo.git", + ], +) +def test_parse_repository(repo_url: str) -> None: + """Tests parsing valid GitLab repo urls""" + gl = GitLab("fake") + + owner, repo_name = gl.parse_repository(repo_url) + + assert owner == "owner" + assert repo_name == "repo" + + +@pytest.mark.parametrize( + "repo_url", + [ + "https://mygitlab.com/owner/repo", + "https://mygitlab.com/owner/repo.git", + "mygitlab.com/owner/repo.git", + ], +) +def test_parse_repository_with_server_url(repo_url: str) -> None: + """Test an invalid url input""" + gl = GitLab("fake", "https://mygitlab.com") + + owner, repo_name = gl.parse_repository(repo_url) + + assert owner == "owner" + assert repo_name == "repo" + + +def test_parse_repository_integration(tmp_repo: Tuple[str, Repo]) -> None: + """Tests integration with git remote get-url""" + repo_path, repo = tmp_repo + + repo.create_remote("origin", url="gitlab.com/test/repo.git") + + remote = repo.remote() + + gl = GitLab("fake") + + owner, repo_name = gl.parse_repository(remote.url) + + assert owner == "test" + assert repo_name == "repo" + + clean(repo_path, repo) + + +def test_parse_repository_with_incorrect_name() -> None: + """Test an invalid url input""" + gl = GitLab("fake") + with pytest.raises( + GitProviderException, + match="https://notgitlab.com/owner/repo.git is an invalid repo URL", + ): + gl.parse_repository("https://notgitlab.com/owner/repo.git") + + +def create_side_effect(name: str) -> None: + raise GitlabCreateError("example") + + +def auth_side_effect(name: str) -> None: + raise GitlabAuthenticationError("example") + + +@pytest.mark.parametrize( + "side_effect, msg", + [ + (create_side_effect, "Failed to create merge request in .*: example"), + ( + auth_side_effect, + "Authentication error during merge request creation in .*: example", + ), + ], +) +def test_create_pull_request_with_exceptions( + side_effect: Callable[[str], None], msg: str +) -> None: + """Test triggering an error during pull request creation""" + gl = GitLab("fake") + + with patch("gitlab.v4.objects.ProjectManager.get") as mock_get: + mock_get.side_effect = side_effect + + with pytest.raises( + GitProviderException, + match=msg, + ): + gl.create_pull_request( + "owner", "repo", "main", "test", "My PR", "Has Changes" + ) + mock_get.assert_called_once() diff --git a/trestlebot/cli.py b/trestlebot/cli.py index feaefd0d..327720d0 100644 --- a/trestlebot/cli.py +++ b/trestlebot/cli.py @@ -19,12 +19,12 @@ import argparse import logging -import os import sys from typing import List, Optional from trestlebot import bot, const, log -from trestlebot.github import GitHub +from trestlebot.github import GitHub, is_github_actions +from trestlebot.gitlab import GitLab, get_gitlab_root_url, is_gitlab_ci from trestlebot.provider import GitProvider from trestlebot.tasks.assemble_task import AssembleTask from trestlebot.tasks.authored import types @@ -229,19 +229,25 @@ def run() -> None: logger.info("Regeneration task skipped") if args.target_branch: - if not is_github_actions(): - logger.error( - "target-branch flag is set with an unsupported git provider. " - "If testing locally with the GitHub API, set " - "the GITHUB_ACTIONS environment variable to true." - ) - sys.exit(const.ERROR_EXIT_CODE) - if not args.with_token: logger.error("with-token value cannot be empty") sys.exit(const.ERROR_EXIT_CODE) - git_provider = GitHub(access_token=args.with_token.read().strip()) + if is_github_actions(): + git_provider = GitHub(access_token=args.with_token.read().strip()) + elif is_gitlab_ci(): + server_api_url = get_gitlab_root_url() + git_provider = GitLab( + api_token=args.with_token.read().strip(), server_url=server_api_url + ) + else: + logger.error( + ( + "target-branch flag is set with an unset git provider. " + "To test locally, set the GITHUB_ACTIONS or GITLAB_CI environment variable." + ) + ) + sys.exit(const.ERROR_EXIT_CODE) exit_code: int = const.SUCCESS_EXIT_CODE @@ -266,7 +272,7 @@ def run() -> None: # Print the full commit sha if commit_sha: - print(f"Commit Hash: {commit_sha}") + print(f"Commit Hash: {commit_sha}") # noqa except Exception as e: exit_code = handle_exception(e) @@ -278,12 +284,3 @@ def comma_sep_to_list(string: str) -> List[str]: """Convert comma-sep string to list of strings and strip.""" string = string.strip() if string else "" return list(map(str.strip, string.split(","))) if string else [] - - -# GitHub ref: -# https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables -def is_github_actions() -> bool: - var_value = os.getenv("GITHUB_ACTIONS") - if var_value and var_value.lower() in ["true", "1"]: - return True - return False diff --git a/trestlebot/github.py b/trestlebot/github.py index a0a7f741..03f06f93 100644 --- a/trestlebot/github.py +++ b/trestlebot/github.py @@ -16,10 +16,12 @@ """GitHub related functions for the Trestle Bot.""" +import os import re from typing import Optional, Tuple import github3 +from github3.exceptions import AuthenticationFailed from github3.repos.repo import Repository from trestlebot.provider import GitProvider, GitProviderException @@ -38,7 +40,7 @@ def __init__(self, access_token: str): session: github3.GitHub = github3.GitHub() session.login(token=access_token) - self.session = session + self._session = session self.pattern = r"^(?:https?://)?github\.com/([^/]+)/([^/.]+)" def parse_repository(self, repo_url: str) -> Tuple[str, str]: @@ -46,7 +48,7 @@ def parse_repository(self, repo_url: str) -> Tuple[str, str]: Parse repository url Args: - repo_url: Valid url for GitHub repo + repo_url: Valid url for a GitHub repo Returns: Owner and repo name in a tuple, respectively @@ -84,21 +86,39 @@ def create_pull_request( Returns: Pull request number """ - repository: Optional[Repository] = self.session.repository( - owner=ns, repository=repo_name - ) - if repository is None: - raise GitProviderException( - f"Repository for {ns}/{repo_name} cannot be None" + try: + repository: Optional[Repository] = self._session.repository( + owner=ns, repository=repo_name ) + if repository is None: + raise GitProviderException( + f"Repository for {ns}/{repo_name} cannot be None" + ) - pull_request = repository.create_pull( - title=title, body=body, base=base_branch, head=head_branch - ) + pull_request = repository.create_pull( + title=title, body=body, base=base_branch, head=head_branch + ) - if pull_request: - return pull_request.number - else: + if pull_request: + return pull_request.number + else: + raise GitProviderException( + ( + f"Failed to create pull request in {ns}/{repo_name}" + f"for {head_branch} to {base_branch}" + ) + ) + except AuthenticationFailed as e: raise GitProviderException( - "Failed to create pull request in {ns}/{repo_name} for {head_branch} to {base_branch}" + f"Authentication error during pull request creation in {ns}/{repo_name}: {e}" ) + + +# GitHub ref: +# https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables +def is_github_actions() -> bool: + """Determine in the environment is GitHub Actions""" + var_value = os.getenv("GITHUB_ACTIONS") + if var_value and var_value.lower() in ["true", "1"]: + return True + return False diff --git a/trestlebot/gitlab.py b/trestlebot/gitlab.py new file mode 100644 index 00000000..1b8b8717 --- /dev/null +++ b/trestlebot/gitlab.py @@ -0,0 +1,125 @@ +#!/usr/bin/python + +# Copyright 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""GitLab related functions for the Trestle Bot.""" + +import os +import re +from typing import Tuple + +import gitlab + +from trestlebot.provider import GitProvider, GitProviderException + + +class GitLab(GitProvider): + def __init__(self, api_token: str, server_url: str = "https://gitlab.com"): + """Create GitLab object to interact with the GitLab API""" + + self._gitlab_client = gitlab.Gitlab(server_url, private_token=api_token) + + stripped_url = re.sub(r"^(https?://)?", "", server_url) + self.pattern = r"^(?:https?://)?{0}/([^/]+)/([^/.]+)".format( + re.escape(stripped_url) + ) + + def parse_repository(self, repo_url: str) -> Tuple[str, str]: + """ + Parse repository url + + Args: + repo_url: Valid url for a GitLab repo + + Returns: + Owner and project name in a tuple, respectively + """ + match = re.match(self.pattern, repo_url) + + if not match: + raise GitProviderException(f"{repo_url} is an invalid repo URL") + + owner = match.group(1) + repo = match.group(2) + return (owner, repo) + + def create_pull_request( + self, + ns: str, + repo_name: str, + base_branch: str, + head_branch: str, + title: str, + body: str, + ) -> int: + """ + Create a pull (merge request in the GitLab) request in the repository + + Args: + ns: Namespace or owner of the repository + repo_name: Name of the repository + base_branch: Branch that changes need to be merged into + head_branch: Branch with changes + title: Text for the title of the pull_request + body: Text for the body of the pull request + + Returns: + Pull/Merge request number + """ + + try: + project = self._gitlab_client.projects.get(f"{ns}/{repo_name}") + merge_request = project.mergerequests.create( + { + "source_branch": head_branch, + "target_branch": base_branch, + "title": title, + "description": body, + } + ) + + return merge_request.id + + except gitlab.exceptions.GitlabCreateError as e: + raise GitProviderException( + f"Failed to create merge request in {ns}/{repo_name}: {e}" + ) + except gitlab.exceptions.GitlabAuthenticationError as e: + raise GitProviderException( + f"Authentication error during merge request creation in {ns}/{repo_name}: {e}" + ) + + +# GitLab ref: https://docs.gitlab.com/ee/ci/variables/predefined_variables.html + + +def is_gitlab_ci() -> bool: + """Determine if the environment is GitLab CI""" + var_value = os.getenv("GITLAB_CI") + if var_value and var_value.lower() in ["true", "1"]: + return True + return False + + +def get_gitlab_root_url() -> str: + """Get the GitLab URL""" + protocol = os.getenv("CI_SERVER_PROTOCOL") + host = os.getenv("CI_SERVER_HOST") + if protocol and host: + return f"{protocol}://{host}" + else: + raise GitProviderException( + "Set CI_SERVER_PROTOCOL and CI SERVER HOST environment variables" + )