diff --git a/README.md b/README.md index 44f9835d..8cb6d65c 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ docker run --rm -it baloise/gitopscli --help For detailed installation and usage instructions, visit [https://baloise.github.io/gitopscli/](https://baloise.github.io/gitopscli/). ## Git Provider Support -Currently, we support BitBucket Server, GitHub and Gitlab. +Currently, we support BitBucket Server, GitHub, GitLab, and Azure DevOps. ## Development diff --git a/docs/index.md b/docs/index.md index fa540954..bb46ae2a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,4 +8,11 @@ A command line interface to perform operations on GitOps managed infrastructure - Update YAML values in config repository to e.g. deploy an application - Add pull request comments - Create and delete preview environments in the config repository for a pull request in an app repository -- Update root config repository with all apps from child config repositories \ No newline at end of file +- Update root config repository with all apps from child config repositories + +## Git Provider Support +GitOps CLI supports the following Git providers: +- **GitHub** - Full API integration +- **GitLab** - Full API integration +- **Bitbucket Server** - Full API integration +- **Azure DevOps** - Full API integration (Note: the git provider URL must be with org name, e.g. `https://dev.azure.com/organisation` and the --organisation parameter must be the project name, e.g. `my-project`) \ No newline at end of file diff --git a/gitopscli/cliparser.py b/gitopscli/cliparser.py index 2c8ac715..5b28e051 100644 --- a/gitopscli/cliparser.py +++ b/gitopscli/cliparser.py @@ -312,7 +312,12 @@ def __parse_yaml(value: str) -> Any: def __parse_git_provider(value: str) -> GitProvider: - mapping = {"github": GitProvider.GITHUB, "bitbucket-server": GitProvider.BITBUCKET, "gitlab": GitProvider.GITLAB} + mapping = { + "github": GitProvider.GITHUB, + "bitbucket-server": GitProvider.BITBUCKET, + "gitlab": GitProvider.GITLAB, + "azure-devops": GitProvider.AZURE_DEVOPS, + } assert set(mapping.values()) == set(GitProvider), "git provider mapping not exhaustive" lowercase_stripped_value = value.lower().strip() if lowercase_stripped_value not in mapping: @@ -341,6 +346,8 @@ def __deduce_empty_git_provider_from_git_provider_url( updated_args["git_provider"] = GitProvider.BITBUCKET elif "gitlab" in git_provider_url.lower(): updated_args["git_provider"] = GitProvider.GITLAB + elif "dev.azure.com" in git_provider_url.lower(): + updated_args["git_provider"] = GitProvider.AZURE_DEVOPS else: error("Cannot deduce git provider from --git-provider-url. Please provide --git-provider") return updated_args diff --git a/gitopscli/git_api/azure_devops_git_repo_api_adapter.py b/gitopscli/git_api/azure_devops_git_repo_api_adapter.py new file mode 100644 index 00000000..b51657fb --- /dev/null +++ b/gitopscli/git_api/azure_devops_git_repo_api_adapter.py @@ -0,0 +1,300 @@ +from typing import Any, Literal + +from azure.devops.connection import Connection +from azure.devops.credentials import BasicAuthentication +from azure.devops.v7_1.git.models import ( + Comment, + GitPullRequest, + GitPullRequestCommentThread, + GitPullRequestCompletionOptions, + GitRefUpdate, +) +from msrest.exceptions import ClientException + +from gitopscli.gitops_exception import GitOpsException + +from .git_repo_api import GitRepoApi + + +class AzureDevOpsGitRepoApiAdapter(GitRepoApi): + """Azure DevOps SDK adapter for GitOps CLI operations.""" + + def __init__( + self, + git_provider_url: str, + username: str | None, + password: str | None, + organisation: str, + repository_name: str, + ) -> None: + # In Azure DevOps: + # git_provider_url = https://dev.azure.com/organization (e.g. https://dev.azure.com/org) + # organisation = project name + # repository_name = repo name + self.__base_url = git_provider_url.rstrip("/") + self.__username = username or "" + self.__password = password + self.__project_name = organisation # In Azure DevOps, "organisation" param is actually the project + self.__repository_name = repository_name + + if not password: + raise GitOpsException("Password (Personal Access Token) is required for Azure DevOps") + + credentials = BasicAuthentication(self.__username, password) + self.__connection = Connection(base_url=self.__base_url, creds=credentials) + self.__git_client = self.__connection.clients.get_git_client() + + def get_username(self) -> str | None: + return self.__username + + def get_password(self) -> str | None: + return self.__password + + def get_clone_url(self) -> str: + # https://dev.azure.com/organization/project/_git/repository + return f"{self.__base_url}/{self.__project_name}/_git/{self.__repository_name}" + + def create_pull_request_to_default_branch( + self, + from_branch: str, + title: str, + description: str, + ) -> GitRepoApi.PullRequestIdAndUrl: + to_branch = self.__get_default_branch() + return self.create_pull_request(from_branch, to_branch, title, description) + + def create_pull_request( + self, + from_branch: str, + to_branch: str, + title: str, + description: str, + ) -> GitRepoApi.PullRequestIdAndUrl: + try: + source_ref = from_branch if from_branch.startswith("refs/") else f"refs/heads/{from_branch}" + target_ref = to_branch if to_branch.startswith("refs/") else f"refs/heads/{to_branch}" + + pull_request = GitPullRequest( + source_ref_name=source_ref, + target_ref_name=target_ref, + title=title, + description=description, + ) + + created_pr = self.__git_client.create_pull_request( + git_pull_request_to_create=pull_request, + repository_id=self.__repository_name, + project=self.__project_name, + ) + + return GitRepoApi.PullRequestIdAndUrl(pr_id=created_pr.pull_request_id, url=created_pr.url) + + except ClientException as ex: + error_msg = str(ex) + if "401" in error_msg: + raise GitOpsException("Bad credentials") from ex + if "404" in error_msg: + raise GitOpsException( + f"Repository '{self.__project_name}/{self.__repository_name}' does not exist" + ) from ex + raise GitOpsException(f"Error creating pull request: {error_msg}") from ex + except Exception as ex: # noqa: BLE001 + raise GitOpsException(f"Error connecting to '{self.__base_url}'") from ex + + def merge_pull_request( + self, + pr_id: int, + merge_method: Literal["squash", "rebase", "merge"] = "merge", + merge_parameters: dict[str, Any] | None = None, + ) -> None: + try: + pr = self.__git_client.get_pull_request( + repository_id=self.__repository_name, + pull_request_id=pr_id, + project=self.__project_name, + ) + + completion_options = GitPullRequestCompletionOptions() + if merge_method == "squash": + completion_options.merge_strategy = "squash" + elif merge_method == "rebase": + completion_options.merge_strategy = "rebase" + else: # merge + completion_options.merge_strategy = "noFastForward" + + if merge_parameters: + for key, value in merge_parameters.items(): + setattr(completion_options, key, value) + + pr_update = GitPullRequest( + status="completed", + last_merge_source_commit=pr.last_merge_source_commit, + completion_options=completion_options, + ) + + self.__git_client.update_pull_request( + git_pull_request_to_update=pr_update, + repository_id=self.__repository_name, + pull_request_id=pr_id, + project=self.__project_name, + ) + + except ClientException as ex: + error_msg = str(ex) + if "401" in error_msg: + raise GitOpsException("Bad credentials") from ex + if "404" in error_msg: + raise GitOpsException(f"Pull request with ID '{pr_id}' does not exist") from ex + raise GitOpsException(f"Error merging pull request: {error_msg}") from ex + except Exception as ex: # noqa: BLE001 + raise GitOpsException(f"Error connecting to '{self.__base_url}'") from ex + + def add_pull_request_comment(self, pr_id: int, text: str, parent_id: int | None = None) -> None: # noqa: ARG002 + try: + comment = Comment(content=text, comment_type="text") + thread = GitPullRequestCommentThread( + comments=[comment], + status="active", + ) + + # Azure DevOps doesn't support direct reply to comments in the same way as other platforms + # parent_id is ignored for now + + self.__git_client.create_thread( + comment_thread=thread, + repository_id=self.__repository_name, + pull_request_id=pr_id, + project=self.__project_name, + ) + + except ClientException as ex: + error_msg = str(ex) + if "401" in error_msg: + raise GitOpsException("Bad credentials") from ex + if "404" in error_msg: + raise GitOpsException(f"Pull request with ID '{pr_id}' does not exist") from ex + raise GitOpsException(f"Error adding comment: {error_msg}") from ex + except Exception as ex: # noqa: BLE001 + raise GitOpsException(f"Error connecting to '{self.__base_url}'") from ex + + def delete_branch(self, branch: str) -> None: + def _raise_branch_not_found() -> None: + raise GitOpsException(f"Branch '{branch}' does not exist") + + try: + refs = self.__git_client.get_refs( + repository_id=self.__repository_name, + project=self.__project_name, + filter=f"heads/{branch}", + ) + + if not refs: + _raise_branch_not_found() + + branch_ref = refs[0] + + # Create ref update to delete the branch + ref_update = GitRefUpdate( + name=f"refs/heads/{branch}", + old_object_id=branch_ref.object_id, + new_object_id="0000000000000000000000000000000000000000", + ) + + self.__git_client.update_refs( + ref_updates=[ref_update], + repository_id=self.__repository_name, + project=self.__project_name, + ) + + except GitOpsException: + raise + except ClientException as ex: + error_msg = str(ex) + if "401" in error_msg: + raise GitOpsException("Bad credentials") from ex + if "404" in error_msg: + raise GitOpsException(f"Branch '{branch}' does not exist") from ex + raise GitOpsException(f"Error deleting branch: {error_msg}") from ex + except Exception as ex: # noqa: BLE001 + raise GitOpsException(f"Error connecting to '{self.__base_url}'") from ex + + def get_branch_head_hash(self, branch: str) -> str: + def _raise_branch_not_found() -> None: + raise GitOpsException(f"Branch '{branch}' does not exist") + + try: + refs = self.__git_client.get_refs( + repository_id=self.__repository_name, + project=self.__project_name, + filter=f"heads/{branch}", + ) + + if not refs: + _raise_branch_not_found() + + return str(refs[0].object_id) + + except GitOpsException: + raise + except ClientException as ex: + error_msg = str(ex) + if "401" in error_msg: + raise GitOpsException("Bad credentials") from ex + if "404" in error_msg: + raise GitOpsException(f"Branch '{branch}' does not exist") from ex + raise GitOpsException(f"Error getting branch hash: {error_msg}") from ex + except Exception as ex: # noqa: BLE001 + raise GitOpsException(f"Error connecting to '{self.__base_url}'") from ex + + def get_pull_request_branch(self, pr_id: int) -> str: + try: + pr = self.__git_client.get_pull_request( + repository_id=self.__repository_name, + pull_request_id=pr_id, + project=self.__project_name, + ) + + source_ref = str(pr.source_ref_name) + if source_ref.startswith("refs/heads/"): + return source_ref[11:] # Remove "refs/heads/" prefix + return source_ref + + except ClientException as ex: + error_msg = str(ex) + if "401" in error_msg: + raise GitOpsException("Bad credentials") from ex + if "404" in error_msg: + raise GitOpsException(f"Pull request with ID '{pr_id}' does not exist") from ex + raise GitOpsException(f"Error getting pull request: {error_msg}") from ex + except Exception as ex: # noqa: BLE001 + raise GitOpsException(f"Error connecting to '{self.__base_url}'") from ex + + def add_pull_request_label(self, pr_id: int, pr_labels: list[str]) -> None: + # Azure DevOps uses labels differently than other platforms + # The SDK doesn't have direct label support for pull requests + # This operation is silently ignored as labels aren't critical for GitOps operations + pass + + def __get_default_branch(self) -> str: + try: + repo = self.__git_client.get_repository( + repository_id=self.__repository_name, + project=self.__project_name, + ) + + default_branch = repo.default_branch or "refs/heads/main" + if default_branch.startswith("refs/heads/"): + return default_branch[11:] + return default_branch + + except ClientException as ex: + error_msg = str(ex) + if "401" in error_msg: + raise GitOpsException("Bad credentials") from ex + if "404" in error_msg: + raise GitOpsException( + f"Repository '{self.__project_name}/{self.__repository_name}' does not exist" + ) from ex + raise GitOpsException(f"Error getting repository info: {error_msg}") from ex + except Exception as ex: # noqa: BLE001 + raise GitOpsException(f"Error connecting to '{self.__base_url}'") from ex diff --git a/gitopscli/git_api/git_provider.py b/gitopscli/git_api/git_provider.py index f9df9e29..753c8d93 100644 --- a/gitopscli/git_api/git_provider.py +++ b/gitopscli/git_api/git_provider.py @@ -5,3 +5,4 @@ class GitProvider(Enum): GITHUB = auto() BITBUCKET = auto() GITLAB = auto() + AZURE_DEVOPS = auto() diff --git a/gitopscli/git_api/git_repo_api_factory.py b/gitopscli/git_api/git_repo_api_factory.py index 7226945d..5a7c1d55 100644 --- a/gitopscli/git_api/git_repo_api_factory.py +++ b/gitopscli/git_api/git_repo_api_factory.py @@ -1,5 +1,6 @@ from gitopscli.gitops_exception import GitOpsException +from .azure_devops_git_repo_api_adapter import AzureDevOpsGitRepoApiAdapter from .bitbucket_git_repo_api_adapter import BitbucketGitRepoApiAdapter from .git_api_config import GitApiConfig from .git_provider import GitProvider @@ -41,4 +42,14 @@ def create(config: GitApiConfig, organisation: str, repository_name: str) -> Git organisation=organisation, repository_name=repository_name, ) + elif config.git_provider is GitProvider.AZURE_DEVOPS: + if not config.git_provider_url: + raise GitOpsException("Please provide url for Azure DevOps!") + git_repo_api = AzureDevOpsGitRepoApiAdapter( + git_provider_url=config.git_provider_url, + username=config.username, + password=config.password, + organisation=organisation, + repository_name=repository_name, + ) return GitRepoApiLoggingProxy(git_repo_api) diff --git a/poetry.lock b/poetry.lock index cb77f3b9..b7b549e8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "atlassian-python-api" @@ -6,6 +6,7 @@ version = "3.41.3" description = "Python Atlassian REST API Wrapper" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "atlassian-python-api-3.41.3.tar.gz", hash = "sha256:a29aae8f456babe125e3371a0355018e9c1d37190333efc312bd81163bd96ffd"}, {file = "atlassian_python_api-3.41.3-py3-none-any.whl", hash = "sha256:7661d3ce3c80e887a7e5ec1c61c1e37d3eaacb4857e377b38ef4084d0f067757"}, @@ -21,12 +22,49 @@ six = "*" [package.extras] kerberos = ["requests-kerberos"] +[[package]] +name = "azure-core" +version = "1.35.0" +description = "Microsoft Azure Core Library for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "azure_core-1.35.0-py3-none-any.whl", hash = "sha256:8db78c72868a58f3de8991eb4d22c4d368fae226dac1002998d6c50437e7dad1"}, + {file = "azure_core-1.35.0.tar.gz", hash = "sha256:c0be528489485e9ede59b6971eb63c1eaacf83ef53001bfe3904e475e972be5c"}, +] + +[package.dependencies] +requests = ">=2.21.0" +six = ">=1.11.0" +typing-extensions = ">=4.6.0" + +[package.extras] +aio = ["aiohttp (>=3.0)"] +tracing = ["opentelemetry-api (>=1.26,<2.0)"] + +[[package]] +name = "azure-devops" +version = "7.1.0b4" +description = "Python wrapper around the Azure DevOps 7.x APIs" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "azure-devops-7.1.0b4.tar.gz", hash = "sha256:f04ba939112579f3d530cfecc044a74ef9e9339ba23c9ee1ece248241f07ff85"}, + {file = "azure_devops-7.1.0b4-py3-none-any.whl", hash = "sha256:f827e9fbc7c77bc6f2aaee46e5717514e9fe7d676c87624eccd0ca640b54f122"}, +] + +[package.dependencies] +msrest = ">=0.7.1,<0.8.0" + [[package]] name = "babel" version = "2.13.1" description = "Internationalization utilities" optional = false python-versions = ">=3.7" +groups = ["docs"] files = [ {file = "Babel-2.13.1-py3-none-any.whl", hash = "sha256:7077a4984b02b6727ac10f1f7294484f737443d7e2e66c5e4380e41a3ae0b4ed"}, {file = "Babel-2.13.1.tar.gz", hash = "sha256:33e0952d7dd6374af8dbf6768cc4ddf3ccfefc244f9986d4074704f2fbd18900"}, @@ -44,6 +82,7 @@ version = "2024.7.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" +groups = ["main", "docs"] files = [ {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, @@ -55,6 +94,7 @@ version = "1.16.0" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"}, {file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"}, @@ -119,6 +159,7 @@ version = "3.4.0" description = "Validate configuration and produce human readable error messages." optional = false python-versions = ">=3.8" +groups = ["test"] files = [ {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, @@ -130,6 +171,7 @@ version = "3.3.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" +groups = ["main", "docs"] files = [ {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, @@ -229,6 +271,7 @@ version = "8.1.7" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" +groups = ["docs"] files = [ {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, @@ -243,10 +286,12 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["docs", "test"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {test = "sys_platform == \"win32\""} [[package]] name = "coverage" @@ -254,6 +299,7 @@ version = "7.3.2" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" +groups = ["test"] files = [ {file = "coverage-7.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d872145f3a3231a5f20fd48500274d7df222e291d90baa2026cc5152b7ce86bf"}, {file = "coverage-7.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:310b3bb9c91ea66d59c53fa4989f57d2436e08f18fb2f421a1b0b6b8cc7fffda"}, @@ -310,7 +356,7 @@ files = [ ] [package.extras] -toml = ["tomli"] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" @@ -318,6 +364,7 @@ version = "42.0.4" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "cryptography-42.0.4-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:ffc73996c4fca3d2b6c1c8c12bfd3ad00def8621da24f547626bf06441400449"}, {file = "cryptography-42.0.4-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:db4b65b02f59035037fde0998974d84244a64c3265bdef32a827ab9b63d61b18"}, @@ -372,6 +419,7 @@ version = "1.2.14" description = "Python @deprecated decorator to deprecate old python classes, functions or methods." optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main"] files = [ {file = "Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c"}, {file = "Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3"}, @@ -389,6 +437,7 @@ version = "0.3.7" description = "Distribution utilities" optional = false python-versions = "*" +groups = ["test"] files = [ {file = "distlib-0.3.7-py2.py3-none-any.whl", hash = "sha256:2e24928bc811348f0feb63014e97aaae3037f2cf48712d51ae61df7fd6075057"}, {file = "distlib-0.3.7.tar.gz", hash = "sha256:9dafe54b34a028eafd95039d5e5d4851a13734540f1331060d31c9916e7147a8"}, @@ -400,6 +449,8 @@ version = "1.1.3" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" +groups = ["test"] +markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, @@ -414,6 +465,7 @@ version = "3.13.1" description = "A platform independent file lock." optional = false python-versions = ">=3.8" +groups = ["test"] files = [ {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"}, {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"}, @@ -422,7 +474,7 @@ files = [ [package.extras] docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] -typing = ["typing-extensions (>=4.8)"] +typing = ["typing-extensions (>=4.8) ; python_version < \"3.11\""] [[package]] name = "ghp-import" @@ -430,6 +482,7 @@ version = "2.1.0" description = "Copy your docs directly to the gh-pages branch." optional = false python-versions = "*" +groups = ["docs"] files = [ {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, @@ -447,6 +500,7 @@ version = "4.0.11" description = "Git Object Database" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "gitdb-4.0.11-py3-none-any.whl", hash = "sha256:81a3407ddd2ee8df444cbacea00e2d038e40150acfa3001696fe0dcf1d3adfa4"}, {file = "gitdb-4.0.11.tar.gz", hash = "sha256:bf5421126136d6d0af55bc1e7c1af1c397a34f5b7bd79e776cd3e89785c2b04b"}, @@ -461,6 +515,7 @@ version = "3.1.41" description = "GitPython is a Python library used to interact with Git repositories" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "GitPython-3.1.41-py3-none-any.whl", hash = "sha256:c36b6634d069b3f719610175020a9aed919421c87552185b085e04fbbdb10b7c"}, {file = "GitPython-3.1.41.tar.gz", hash = "sha256:ed66e624884f76df22c8e16066d567aaa5a37d5b5fa19db2c6df6f7156db9048"}, @@ -470,7 +525,7 @@ files = [ gitdb = ">=4.0.1,<5" [package.extras] -test = ["black", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "sumtypes"] +test = ["black", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "sumtypes"] [[package]] name = "identify" @@ -478,6 +533,7 @@ version = "2.5.31" description = "File identification library for Python" optional = false python-versions = ">=3.8" +groups = ["test"] files = [ {file = "identify-2.5.31-py2.py3-none-any.whl", hash = "sha256:90199cb9e7bd3c5407a9b7e81b4abec4bb9d249991c79439ec8af740afc6293d"}, {file = "identify-2.5.31.tar.gz", hash = "sha256:7736b3c7a28233637e3c36550646fc6389bedd74ae84cb788200cc8e2dd60b75"}, @@ -492,6 +548,7 @@ version = "3.7" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" +groups = ["main", "docs"] files = [ {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, @@ -503,17 +560,31 @@ version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" +groups = ["test"] files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "isodate" +version = "0.7.2" +description = "An ISO 8601 date/time/duration parser and formatter" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15"}, + {file = "isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6"}, +] + [[package]] name = "jinja2" version = "3.1.4" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" +groups = ["docs"] files = [ {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, @@ -531,6 +602,7 @@ version = "1.6.0" description = "A final implementation of JSONPath for Python that aims to be standard compliant, including arithmetic and binary comparison operators and providing clear AST for metaprogramming." optional = false python-versions = "*" +groups = ["main"] files = [ {file = "jsonpath-ng-1.6.0.tar.gz", hash = "sha256:5483f8e9d74c39c9abfab554c070ae783c1c8cbadf5df60d561bc705ac68a07e"}, {file = "jsonpath_ng-1.6.0-py3-none-any.whl", hash = "sha256:6fd04833412c4b3d9299edf369542f5e67095ca84efa17cbb7f06a34958adc9f"}, @@ -545,6 +617,7 @@ version = "3.5.1" description = "Python implementation of John Gruber's Markdown." optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "Markdown-3.5.1-py3-none-any.whl", hash = "sha256:5874b47d4ee3f0b14d764324d2c94c03ea66bee56f2d929da9f2508d65e722dc"}, {file = "Markdown-3.5.1.tar.gz", hash = "sha256:b65d7beb248dc22f2e8a31fb706d93798093c308dc1aba295aedeb9d41a813bd"}, @@ -560,6 +633,7 @@ version = "0.8.1" description = "A Python-Markdown extension which provides an 'include' function" optional = false python-versions = ">=3.7" +groups = ["docs"] files = [ {file = "markdown-include-0.8.1.tar.gz", hash = "sha256:1d0623e0fc2757c38d35df53752768356162284259d259c486b4ab6285cdbbe3"}, {file = "markdown_include-0.8.1-py3-none-any.whl", hash = "sha256:32f0635b9cfef46997b307e2430022852529f7a5b87c0075c504283e7cc7db53"}, @@ -577,6 +651,7 @@ version = "2.1.3" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.7" +groups = ["docs"] files = [ {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, @@ -646,6 +721,7 @@ version = "1.3.4" description = "A deep merge function for 🐍." optional = false python-versions = ">=3.6" +groups = ["docs"] files = [ {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, @@ -657,6 +733,7 @@ version = "1.5.3" description = "Project documentation with Markdown." optional = false python-versions = ">=3.7" +groups = ["docs"] files = [ {file = "mkdocs-1.5.3-py3-none-any.whl", hash = "sha256:3b3a78e736b31158d64dbb2f8ba29bd46a379d0c6e324c2246c3bc3d2189cfc1"}, {file = "mkdocs-1.5.3.tar.gz", hash = "sha256:eb7c99214dcb945313ba30426c2451b735992c73c2e10838f76d09e39ff4d0e2"}, @@ -679,7 +756,7 @@ watchdog = ">=2.0" [package.extras] i18n = ["babel (>=2.9.0)"] -min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-import (==1.0)", "importlib-metadata (==4.3)", "jinja2 (==2.11.1)", "markdown (==3.2.1)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "packaging (==20.5)", "pathspec (==0.11.1)", "platformdirs (==2.2.0)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "typing-extensions (==3.10)", "watchdog (==2.0)"] +min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4) ; platform_system == \"Windows\"", "ghp-import (==1.0)", "importlib-metadata (==4.3) ; python_version < \"3.10\"", "jinja2 (==2.11.1)", "markdown (==3.2.1)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "packaging (==20.5)", "pathspec (==0.11.1)", "platformdirs (==2.2.0)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "typing-extensions (==3.10) ; python_version < \"3.8\"", "watchdog (==2.0)"] [[package]] name = "mkdocs-material" @@ -687,6 +764,7 @@ version = "9.4.7" description = "Documentation that simply works" optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "mkdocs_material-9.4.7-py3-none-any.whl", hash = "sha256:4d698d52bb6a6a3c452ab854481c4cdb68453a0420956a6aee2de55fe15fe610"}, {file = "mkdocs_material-9.4.7.tar.gz", hash = "sha256:e704e001c9ef17291e1d3462c202425217601653e18f68f85d28eff4690e662b"}, @@ -716,17 +794,41 @@ version = "1.3" description = "Extension pack for Python Markdown and MkDocs Material." optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "mkdocs_material_extensions-1.3-py3-none-any.whl", hash = "sha256:0297cc48ba68a9fdd1ef3780a3b41b534b0d0df1d1181a44676fda5f464eeadc"}, {file = "mkdocs_material_extensions-1.3.tar.gz", hash = "sha256:f0446091503acb110a7cab9349cbc90eeac51b58d1caa92a704a81ca1e24ddbd"}, ] +[[package]] +name = "msrest" +version = "0.7.1" +description = "AutoRest swagger generator Python client runtime." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "msrest-0.7.1-py3-none-any.whl", hash = "sha256:21120a810e1233e5e6cc7fe40b474eeb4ec6f757a15d7cf86702c369f9567c32"}, + {file = "msrest-0.7.1.zip", hash = "sha256:6e7661f46f3afd88b75667b7187a92829924446c7ea1d169be8c4bb7eeb788b9"}, +] + +[package.dependencies] +azure-core = ">=1.24.0" +certifi = ">=2017.4.17" +isodate = ">=0.6.0" +requests = ">=2.16,<3.0" +requests-oauthlib = ">=0.5.0" + +[package.extras] +async = ["aiodns ; python_version >= \"3.5\"", "aiohttp (>=3.0) ; python_version >= \"3.5\""] + [[package]] name = "mypy" version = "1.6.1" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" +groups = ["test"] files = [ {file = "mypy-1.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e5012e5cc2ac628177eaac0e83d622b2dd499e28253d4107a08ecc59ede3fc2c"}, {file = "mypy-1.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d8fbb68711905f8912e5af474ca8b78d077447d8f3918997fecbf26943ff3cbb"}, @@ -773,6 +875,7 @@ version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.5" +groups = ["test"] files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, @@ -784,6 +887,7 @@ version = "1.8.0" description = "Node.js virtual environment builder" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +groups = ["test"] files = [ {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, @@ -798,6 +902,7 @@ version = "3.2.2" description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca"}, {file = "oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"}, @@ -814,6 +919,7 @@ version = "23.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.7" +groups = ["docs", "test"] files = [ {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, @@ -825,6 +931,7 @@ version = "0.5.6" description = "Divides large result sets into pages for easier browsing" optional = false python-versions = "*" +groups = ["docs"] files = [ {file = "paginate-0.5.6.tar.gz", hash = "sha256:5e6007b6a9398177a7e1648d04fdd9f8c9766a1a945bceac82f1929e8c78af2d"}, ] @@ -835,6 +942,7 @@ version = "0.11.2" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.7" +groups = ["docs"] files = [ {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, @@ -846,6 +954,7 @@ version = "3.11.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false python-versions = ">=3.7" +groups = ["docs", "test"] files = [ {file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"}, {file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"}, @@ -861,6 +970,7 @@ version = "1.3.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" +groups = ["test"] files = [ {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, @@ -876,6 +986,7 @@ version = "3.11" description = "Python Lex & Yacc" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce"}, {file = "ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3"}, @@ -887,6 +998,7 @@ version = "3.5.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.8" +groups = ["test"] files = [ {file = "pre_commit-3.5.0-py2.py3-none-any.whl", hash = "sha256:841dc9aef25daba9a0238cd27984041fa0467b4199fc4852e27950664919f660"}, {file = "pre_commit-3.5.0.tar.gz", hash = "sha256:5804465c675b659b0862f07907f96295d490822a450c4c40e747d0b1c6ebcb32"}, @@ -905,6 +1017,7 @@ version = "2.21" description = "C parser in Python" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main"] files = [ {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, @@ -916,6 +1029,7 @@ version = "2.1.1" description = "Use the full Github API v3" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "PyGithub-2.1.1-py3-none-any.whl", hash = "sha256:4b528d5d6f35e991ea5fd3f942f58748f24938805cb7fcf24486546637917337"}, {file = "PyGithub-2.1.1.tar.gz", hash = "sha256:ecf12c2809c44147bce63b047b3d2e9dac8a41b63e90fcb263c703f64936b97c"}, @@ -936,13 +1050,14 @@ version = "2.16.1" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.7" +groups = ["docs"] files = [ {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"}, {file = "Pygments-2.16.1.tar.gz", hash = "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"}, ] [package.extras] -plugins = ["importlib-metadata"] +plugins = ["importlib-metadata ; python_version < \"3.8\""] [[package]] name = "pyjwt" @@ -950,6 +1065,7 @@ version = "2.8.0" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "PyJWT-2.8.0-py3-none-any.whl", hash = "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"}, {file = "PyJWT-2.8.0.tar.gz", hash = "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de"}, @@ -970,6 +1086,7 @@ version = "10.3.1" description = "Extension pack for Python Markdown." optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "pymdown_extensions-10.3.1-py3-none-any.whl", hash = "sha256:8cba67beb2a1318cdaf742d09dff7c0fc4cafcc290147ade0f8fb7b71522711a"}, {file = "pymdown_extensions-10.3.1.tar.gz", hash = "sha256:f6c79941498a458852853872e379e7bab63888361ba20992fc8b4f8a9b61735e"}, @@ -988,6 +1105,7 @@ version = "1.5.0" description = "Python binding to the Networking and Cryptography (NaCl) library" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1"}, {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92"}, @@ -1014,6 +1132,7 @@ version = "7.4.3" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" +groups = ["test"] files = [ {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, @@ -1036,6 +1155,7 @@ version = "2.8.2" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main", "docs"] files = [ {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, @@ -1050,6 +1170,7 @@ version = "2.10.1" description = "Interact with GitLab API" optional = false python-versions = ">=3.6.0" +groups = ["main"] files = [ {file = "python-gitlab-2.10.1.tar.gz", hash = "sha256:7afa7d7c062fa62c173190452265a30feefb844428efc58ea5244f3b9fc0d40f"}, {file = "python_gitlab-2.10.1-py3-none-any.whl", hash = "sha256:581a219759515513ea9399e936ed7137437cfb681f52d2641626685c492c999d"}, @@ -1069,6 +1190,7 @@ version = "6.0.1" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.6" +groups = ["docs", "test"] files = [ {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, @@ -1129,6 +1251,7 @@ version = "0.1" description = "A custom YAML tag for referencing environment variables in YAML files. " optional = false python-versions = ">=3.6" +groups = ["docs"] files = [ {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, @@ -1143,6 +1266,7 @@ version = "2023.10.3" description = "Alternative regular expression module, to replace re." optional = false python-versions = ">=3.7" +groups = ["docs"] files = [ {file = "regex-2023.10.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4c34d4f73ea738223a094d8e0ffd6d2c1a1b4c175da34d6b0de3d8d69bee6bcc"}, {file = "regex-2023.10.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a8f4e49fc3ce020f65411432183e6775f24e02dff617281094ba6ab079ef0915"}, @@ -1240,6 +1364,7 @@ version = "2.32.0" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" +groups = ["main", "docs"] files = [ {file = "requests-2.32.0-py3-none-any.whl", hash = "sha256:f2c3881dddb70d056c5bd7600a4fae312b2a300e39be6a118d30b90bd27262b5"}, {file = "requests-2.32.0.tar.gz", hash = "sha256:fa5490319474c82ef1d2c9bc459d3652e3ae4ef4c4ebdd18a21145a47ca4b6b8"}, @@ -1261,6 +1386,7 @@ version = "1.3.1" description = "OAuthlib authentication support for Requests." optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main"] files = [ {file = "requests-oauthlib-1.3.1.tar.gz", hash = "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a"}, {file = "requests_oauthlib-1.3.1-py2.py3-none-any.whl", hash = "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5"}, @@ -1279,6 +1405,7 @@ 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.*" +groups = ["main"] 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"}, @@ -1293,6 +1420,7 @@ version = "0.18.5" description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "ruamel.yaml-0.18.5-py3-none-any.whl", hash = "sha256:a013ac02f99a69cdd6277d9664689eb1acba07069f912823177c5eced21a6ada"}, {file = "ruamel.yaml-0.18.5.tar.gz", hash = "sha256:61917e3a35a569c1133a8f772e1226961bf5a1198bea7e23f06a0841dea1ab0e"}, @@ -1311,6 +1439,8 @@ version = "0.2.8" description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" optional = false python-versions = ">=3.6" +groups = ["main"] +markers = "platform_python_implementation == \"CPython\" and python_version < \"3.13\"" files = [ {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b42169467c42b692c19cf539c38d4602069d8c1505e97b86387fcf7afb766e1d"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:07238db9cbdf8fc1e9de2489a4f68474e70dffcb32232db7c08fa61ca0c7c462"}, @@ -1370,6 +1500,7 @@ version = "0.1.4" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" +groups = ["test"] files = [ {file = "ruff-0.1.4-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:864958706b669cce31d629902175138ad8a069d99ca53514611521f532d91495"}, {file = "ruff-0.1.4-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:9fdd61883bb34317c788af87f4cd75dfee3a73f5ded714b77ba928e418d6e39e"}, @@ -1396,14 +1527,16 @@ version = "70.0.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" +groups = ["docs", "test"] files = [ {file = "setuptools-70.0.0-py3-none-any.whl", hash = "sha256:54faa7f2e8d2d11bcd2c07bed282eef1046b5c080d1c32add737d7b5817b1ad4"}, {file = "setuptools-70.0.0.tar.gz", hash = "sha256:f211a66637b8fa059bb28183da127d4e86396c991a942b028c6650d4319c3fd0"}, ] +markers = {docs = "python_version >= \"3.12\""} [package.extras] docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov ; platform_python_implementation != \"PyPy\"", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] [[package]] name = "six" @@ -1411,6 +1544,7 @@ version = "1.16.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["main", "docs"] files = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, @@ -1422,6 +1556,7 @@ version = "5.0.1" description = "A pure Python implementation of a sliding window memory map manager" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "smmap-5.0.1-py3-none-any.whl", hash = "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da"}, {file = "smmap-5.0.1.tar.gz", hash = "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62"}, @@ -1433,6 +1568,8 @@ version = "2.0.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.7" +groups = ["test"] +markers = "python_version == \"3.10\"" files = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, @@ -1444,6 +1581,7 @@ version = "2.13.3" description = "Run-time type checker for Python" optional = false python-versions = ">=3.5.3" +groups = ["test"] files = [ {file = "typeguard-2.13.3-py3-none-any.whl", hash = "sha256:5e3e3be01e887e7eafae5af63d1f36c849aaa94e3a0112097312aabfa16284f1"}, {file = "typeguard-2.13.3.tar.gz", hash = "sha256:00edaa8da3a133674796cf5ea87d9f4b4c367d77476e185e80251cc13dfbb8c4"}, @@ -1451,7 +1589,7 @@ files = [ [package.extras] doc = ["sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["mypy", "pytest", "typing-extensions"] +test = ["mypy ; platform_python_implementation != \"PyPy\"", "pytest", "typing-extensions"] [[package]] name = "typing-extensions" @@ -1459,6 +1597,7 @@ version = "4.8.0" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" +groups = ["main", "test"] files = [ {file = "typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0"}, {file = "typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"}, @@ -1470,13 +1609,14 @@ version = "2.2.2" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" +groups = ["main", "docs"] files = [ {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -1487,6 +1627,7 @@ version = "20.24.6" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" +groups = ["test"] files = [ {file = "virtualenv-20.24.6-py3-none-any.whl", hash = "sha256:520d056652454c5098a00c0f073611ccbea4c79089331f60bf9d7ba247bb7381"}, {file = "virtualenv-20.24.6.tar.gz", hash = "sha256:02ece4f56fbf939dbbc33c0715159951d6bf14aaf5457b092e4548e1382455af"}, @@ -1499,7 +1640,7 @@ platformdirs = ">=3.9.1,<4" [package.extras] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] [[package]] name = "watchdog" @@ -1507,6 +1648,7 @@ version = "3.0.0" description = "Filesystem events monitoring" optional = false python-versions = ">=3.7" +groups = ["docs"] files = [ {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:336adfc6f5cc4e037d52db31194f7581ff744b67382eb6021c868322e32eef41"}, {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a70a8dcde91be523c35b2bf96196edc5730edb347e374c7de7cd20c43ed95397"}, @@ -1546,6 +1688,7 @@ version = "1.15.0" description = "Module for decorators, wrappers and monkey patching." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +groups = ["main"] files = [ {file = "wrapt-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:ca1cccf838cd28d5a0883b342474c630ac48cac5df0ee6eacc9c7290f76b11c1"}, {file = "wrapt-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e826aadda3cae59295b95343db8f3d965fb31059da7de01ee8d1c40a60398b29"}, @@ -1625,6 +1768,6 @@ files = [ ] [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = "^3.10" -content-hash = "2c921617c0e33c8061a14bae143877020bde00cefd268c0be4f0b53af530e968" +content-hash = "ed0a5649308eb1db05ed4fdb7563bcb645c01f52ce4e9a965d8ca2a56e855541" diff --git a/pyproject.toml b/pyproject.toml index c11c772a..a9d37eec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ jsonpath-ng = "*" atlassian-python-api = "*" pygithub = "*" python-gitlab = "^2.6.0" +azure-devops = "^7.1.0b4" [tool.poetry.group.test.dependencies] ruff = "*" @@ -53,6 +54,7 @@ ignore = [ "PLR0913", # https://docs.astral.sh/ruff/rules/too-many-arguments/ "D", # https://docs.astral.sh/ruff/rules/#pydocstyle-d "COM812", # https://docs.astral.sh/ruff/rules/missing-trailing-comma/ (clashes with formatter) + "ISC001", # https://docs.astral.sh/ruff/rules/single-line-implicit-string-concatenation/ (clashes with formatter) "EM101", # https://docs.astral.sh/ruff/rules/raw-string-in-exception/ "EM102", # https://docs.astral.sh/ruff/rules/f-string-in-exception/ "S101", # https://docs.astral.sh/ruff/rules/assert/ @@ -65,5 +67,9 @@ ignore = [ "S106", # https://docs.astral.sh/ruff/rules/hardcoded-password-func-arg/ "S108", # https://docs.astral.sh/ruff/rules/hardcoded-temp-file/ "ANN", # https://docs.astral.sh/ruff/rules/#flake8-annotations-ann - "PT009" # https://docs.astral.sh/ruff/rules/pytest-unittest-assertion/ + "PT009", # https://docs.astral.sh/ruff/rules/pytest-unittest-assertion/ + "SLF001", # https://docs.astral.sh/ruff/rules/private-member-access/ (needed for testing private methods) +] +"gitopscli/git_api/azure_devops_git_repo_api_adapter.py" = [ + "TRY300", # https://docs.astral.sh/ruff/rules/try-consider-else/ (conflicts with RET505) ] diff --git a/tests/git_api/test_azure_devops_git_repo_api_adapter.py b/tests/git_api/test_azure_devops_git_repo_api_adapter.py new file mode 100644 index 00000000..0609ad11 --- /dev/null +++ b/tests/git_api/test_azure_devops_git_repo_api_adapter.py @@ -0,0 +1,321 @@ +import unittest +from unittest.mock import MagicMock, patch + +import pytest +from msrest.exceptions import ClientException + +from gitopscli.git_api.azure_devops_git_repo_api_adapter import AzureDevOpsGitRepoApiAdapter +from gitopscli.gitops_exception import GitOpsException + + +class AzureDevOpsGitRepoApiAdapterTest(unittest.TestCase): + def setUp(self): + with patch("gitopscli.git_api.azure_devops_git_repo_api_adapter.Connection"): + self.adapter = AzureDevOpsGitRepoApiAdapter( + git_provider_url="https://dev.azure.com/testorg", + username="testuser", + password="testtoken", + organisation="testproject", + repository_name="testrepo", + ) + + @patch("gitopscli.git_api.azure_devops_git_repo_api_adapter.Connection") + def test_init_success(self, mock_connection): + mock_git_client = MagicMock() + mock_connection.return_value.clients.get_git_client.return_value = mock_git_client + + adapter = AzureDevOpsGitRepoApiAdapter( + git_provider_url="https://dev.azure.com/myorg", + username="user", + password="token", + organisation="project", + repository_name="repo", + ) + + self.assertEqual(adapter.get_username(), "user") + self.assertEqual(adapter.get_password(), "token") + self.assertEqual(adapter.get_clone_url(), "https://dev.azure.com/myorg/project/_git/repo") + + def test_init_no_password_raises_exception(self): + with pytest.raises(GitOpsException) as context: + AzureDevOpsGitRepoApiAdapter( + git_provider_url="https://dev.azure.com/org", + username="user", + password=None, + organisation="project", + repository_name="repo", + ) + self.assertEqual(str(context.value), "Password (Personal Access Token) is required for Azure DevOps") + + def test_get_clone_url(self): + expected_url = "https://dev.azure.com/testorg/testproject/_git/testrepo" + self.assertEqual(self.adapter.get_clone_url(), expected_url) + + def test_create_pull_request_success(self): + mock_pr = MagicMock() + mock_pr.pull_request_id = 123 + mock_pr.url = "https://dev.azure.com/testorg/testproject/_git/testrepo/pullrequest/123" + + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.create_pull_request.return_value = mock_pr + + result = self.adapter.create_pull_request( + from_branch="feature-branch", to_branch="main", title="Test PR", description="Test description" + ) + + self.assertEqual(result.pr_id, 123) + self.assertEqual(result.url, "https://dev.azure.com/testorg/testproject/_git/testrepo/pullrequest/123") + + # Verify the git client was called correctly + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.create_pull_request.assert_called_once() + call_args = self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.create_pull_request.call_args + self.assertEqual(call_args.kwargs["repository_id"], "testrepo") + self.assertEqual(call_args.kwargs["project"], "testproject") + + pr_request = call_args.kwargs["git_pull_request_to_create"] + self.assertEqual(pr_request.source_ref_name, "refs/heads/feature-branch") + self.assertEqual(pr_request.target_ref_name, "refs/heads/main") + self.assertEqual(pr_request.title, "Test PR") + self.assertEqual(pr_request.description, "Test description") + + def test_create_pull_request_with_refs_prefix(self): + mock_pr = MagicMock() + mock_pr.pull_request_id = 123 + mock_pr.url = "test-url" + + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.create_pull_request.return_value = mock_pr + + self.adapter.create_pull_request( + from_branch="refs/heads/feature-branch", + to_branch="refs/heads/main", + title="Test PR", + description="Test description", + ) + + # Verify the git client was called correctly with refs already included + call_args = self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.create_pull_request.call_args + pr_request = call_args.kwargs["git_pull_request_to_create"] + self.assertEqual(pr_request.source_ref_name, "refs/heads/feature-branch") + self.assertEqual(pr_request.target_ref_name, "refs/heads/main") + + def test_create_pull_request_unauthorized(self): + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.create_pull_request.side_effect = ClientException("401") + + with pytest.raises(GitOpsException) as context: + self.adapter.create_pull_request("from", "to", "title", "desc") + + self.assertEqual(str(context.value), "Bad credentials") + + def test_create_pull_request_not_found(self): + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.create_pull_request.side_effect = ClientException("404") + + with pytest.raises(GitOpsException) as context: + self.adapter.create_pull_request("from", "to", "title", "desc") + + self.assertEqual(str(context.value), "Repository 'testproject/testrepo' does not exist") + + def test_create_pull_request_to_default_branch(self): + # Mock get repository for default branch + mock_repo = MagicMock() + mock_repo.default_branch = "refs/heads/develop" + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.get_repository.return_value = mock_repo + + # Mock create pull request + mock_pr = MagicMock() + mock_pr.pull_request_id = 456 + mock_pr.url = "test-url" + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.create_pull_request.return_value = mock_pr + + result = self.adapter.create_pull_request_to_default_branch( + from_branch="feature", title="Test PR", description="Test desc" + ) + + self.assertEqual(result.pr_id, 456) + + # Verify create PR was called with default branch + call_args = self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.create_pull_request.call_args + pr_request = call_args.kwargs["git_pull_request_to_create"] + self.assertEqual(pr_request.source_ref_name, "refs/heads/feature") + self.assertEqual(pr_request.target_ref_name, "refs/heads/develop") + + def test_merge_pull_request_success(self): + # Mock get pull request + mock_pr = MagicMock() + mock_pr.last_merge_source_commit = MagicMock() + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.get_pull_request.return_value = mock_pr + + # Mock update pull request + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.update_pull_request.return_value = None + + self.adapter.merge_pull_request(123, "squash") + + # Verify get_pull_request was called + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.get_pull_request.assert_called_once_with( + repository_id="testrepo", pull_request_id=123, project="testproject" + ) + + # Verify update_pull_request was called + call_args = self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.update_pull_request.call_args + self.assertEqual(call_args.kwargs["repository_id"], "testrepo") + self.assertEqual(call_args.kwargs["pull_request_id"], 123) + self.assertEqual(call_args.kwargs["project"], "testproject") + + pr_update = call_args.kwargs["git_pull_request_to_update"] + self.assertEqual(pr_update.status, "completed") + self.assertEqual(pr_update.completion_options.merge_strategy, "squash") + + def test_merge_pull_request_different_strategies(self): + # Mock get pull request + mock_pr = MagicMock() + mock_pr.last_merge_source_commit = MagicMock() + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.get_pull_request.return_value = mock_pr + + # Test merge strategy + self.adapter.merge_pull_request(123, "merge") + call_args = self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.update_pull_request.call_args + pr_update = call_args.kwargs["git_pull_request_to_update"] + self.assertEqual(pr_update.completion_options.merge_strategy, "noFastForward") + + # Test rebase strategy + self.adapter.merge_pull_request(456, "rebase") + call_args = self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.update_pull_request.call_args + pr_update = call_args.kwargs["git_pull_request_to_update"] + self.assertEqual(pr_update.completion_options.merge_strategy, "rebase") + + def test_add_pull_request_comment_success(self): + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.create_thread.return_value = None + + self.adapter.add_pull_request_comment(123, "Test comment") + + # Verify create_thread was called + call_args = self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.create_thread.call_args + self.assertEqual(call_args.kwargs["repository_id"], "testrepo") + self.assertEqual(call_args.kwargs["pull_request_id"], 123) + self.assertEqual(call_args.kwargs["project"], "testproject") + + thread = call_args.kwargs["comment_thread"] + self.assertEqual(len(thread.comments), 1) + self.assertEqual(thread.comments[0].content, "Test comment") + self.assertEqual(thread.comments[0].comment_type, "text") + self.assertEqual(thread.status, "active") + + def test_get_branch_head_hash_success(self): + mock_ref = MagicMock() + mock_ref.object_id = "abc123def456" + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.get_refs.return_value = [mock_ref] + + result = self.adapter.get_branch_head_hash("main") + + self.assertEqual(result, "abc123def456") + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.get_refs.assert_called_once_with( + repository_id="testrepo", project="testproject", filter="heads/main" + ) + + def test_get_branch_head_hash_not_found(self): + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.get_refs.return_value = [] + + with pytest.raises(GitOpsException) as context: + self.adapter.get_branch_head_hash("nonexistent") + + self.assertEqual(str(context.value), "Branch 'nonexistent' does not exist") + + def test_get_pull_request_branch_success(self): + mock_pr = MagicMock() + mock_pr.source_ref_name = "refs/heads/feature-branch" + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.get_pull_request.return_value = mock_pr + + result = self.adapter.get_pull_request_branch(123) + + self.assertEqual(result, "feature-branch") + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.get_pull_request.assert_called_once_with( + repository_id="testrepo", pull_request_id=123, project="testproject" + ) + + def test_get_pull_request_branch_without_refs_prefix(self): + mock_pr = MagicMock() + mock_pr.source_ref_name = "feature-branch" + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.get_pull_request.return_value = mock_pr + + result = self.adapter.get_pull_request_branch(123) + + self.assertEqual(result, "feature-branch") + + def test_delete_branch_success(self): + # Mock get refs + mock_ref = MagicMock() + mock_ref.object_id = "abc123def456" + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.get_refs.return_value = [mock_ref] + + # Mock update refs + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.update_refs.return_value = None + + self.adapter.delete_branch("feature-branch") + + # Verify get_refs was called + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.get_refs.assert_called_once_with( + repository_id="testrepo", project="testproject", filter="heads/feature-branch" + ) + + # Verify update_refs was called + call_args = self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.update_refs.call_args + self.assertEqual(call_args.kwargs["repository_id"], "testrepo") + self.assertEqual(call_args.kwargs["project"], "testproject") + + ref_updates = call_args.kwargs["ref_updates"] + self.assertEqual(len(ref_updates), 1) + self.assertEqual(ref_updates[0].name, "refs/heads/feature-branch") + self.assertEqual(ref_updates[0].old_object_id, "abc123def456") + self.assertEqual(ref_updates[0].new_object_id, "0000000000000000000000000000000000000000") + + def test_delete_branch_not_found(self): + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.get_refs.return_value = [] + + with pytest.raises(GitOpsException) as context: + self.adapter.delete_branch("nonexistent") + + self.assertEqual(str(context.value), "Branch 'nonexistent' does not exist") + + def test_add_pull_request_label_does_nothing(self): + # Labels aren't supported in the SDK implementation, should not raise exception + try: + self.adapter.add_pull_request_label(123, ["bug", "enhancement"]) + except Exception as ex: # noqa: BLE001 + self.fail(f"add_pull_request_label should not raise exception: {ex}") + + def test_username_and_password_getters(self): + self.assertEqual(self.adapter.get_username(), "testuser") + self.assertEqual(self.adapter.get_password(), "testtoken") + + def test_connection_error_handling(self): + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.create_pull_request.side_effect = Exception( + "Connection failed" + ) + + with pytest.raises(GitOpsException) as context: + self.adapter.create_pull_request("from", "to", "title", "desc") + + self.assertIn("Error connecting to 'https://dev.azure.com/testorg'", str(context.value)) + + def test_get_default_branch_success(self): + mock_repo = MagicMock() + mock_repo.default_branch = "refs/heads/main" + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.get_repository.return_value = mock_repo + + result = self.adapter._AzureDevOpsGitRepoApiAdapter__get_default_branch() + + self.assertEqual(result, "main") + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.get_repository.assert_called_once_with( + repository_id="testrepo", project="testproject" + ) + + def test_get_default_branch_fallback(self): + mock_repo = MagicMock() + mock_repo.default_branch = None + self.adapter._AzureDevOpsGitRepoApiAdapter__git_client.get_repository.return_value = mock_repo + + result = self.adapter._AzureDevOpsGitRepoApiAdapter__get_default_branch() + + self.assertEqual(result, "main") # Should fallback to "main" + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/git_api/test_repo_api_factory.py b/tests/git_api/test_repo_api_factory.py index 29b5d7c2..599cb0c4 100644 --- a/tests/git_api/test_repo_api_factory.py +++ b/tests/git_api/test_repo_api_factory.py @@ -126,3 +126,47 @@ def test_create_gitlab_default_provider_url(self, mock_gitlab_adapter_constructo repository_name="REPO", ) mock_logging_proxy_constructor.assert_called_with(mock_gitlab_adapter) + + @patch("gitopscli.git_api.git_repo_api_factory.GitRepoApiLoggingProxy") + @patch("gitopscli.git_api.git_repo_api_factory.AzureDevOpsGitRepoApiAdapter") + def test_create_azure_devops(self, mock_azure_devops_adapter_constructor, mock_logging_proxy_constructor): + mock_azure_devops_adapter = MagicMock() + mock_azure_devops_adapter_constructor.return_value = mock_azure_devops_adapter + + mock_logging_proxy = MagicMock() + mock_logging_proxy_constructor.return_value = mock_logging_proxy + + git_repo_api = GitRepoApiFactory.create( + config=GitApiConfig( + username="USER", + password="PAT_TOKEN", + git_provider=GitProvider.AZURE_DEVOPS, + git_provider_url="https://dev.azure.com", + ), + organisation="ORG", + repository_name="REPO", + ) + + self.assertEqual(git_repo_api, mock_logging_proxy) + + mock_azure_devops_adapter_constructor.assert_called_with( + git_provider_url="https://dev.azure.com", + username="USER", + password="PAT_TOKEN", + organisation="ORG", + repository_name="REPO", + ) + mock_logging_proxy_constructor.assert_called_with(mock_azure_devops_adapter) + + def test_create_azure_devops_missing_url(self): + try: + GitRepoApiFactory.create( + config=GitApiConfig( + username="USER", password="PAT_TOKEN", git_provider=GitProvider.AZURE_DEVOPS, git_provider_url=None + ), + organisation="ORG", + repository_name="REPO", + ) + self.fail("Expected a GitOpsException") + except GitOpsException as ex: + self.assertEqual("Please provide url for Azure DevOps!", str(ex)) diff --git a/tests/test_cliparser.py b/tests/test_cliparser.py index 4cd4c5b0..4a683203 100644 --- a/tests/test_cliparser.py +++ b/tests/test_cliparser.py @@ -1458,3 +1458,53 @@ def test_failed_git_provider_deduction_from_url(self): "gitopscli: error: Cannot deduce git provider from --git-provider-url. Please provide --git-provider", last_stderr_line, ) + + def test_git_provider_azure_devops_explicit(self): + verbose, args = parse_args( + [ + "add-pr-comment", + "--git-provider", + "azure-devops", + "--git-provider-url", + "https://dev.azure.com", + "--username", + "user", + "--password", + "token", + "--organisation", + "org", + "--repository-name", + "repo", + "--pr-id", + "123", + "--text", + "comment", + ] + ) + self.assert_type(args, AddPrCommentCommand.Args) + self.assertEqual(args.git_provider, GitProvider.AZURE_DEVOPS) + self.assertEqual(args.git_provider_url, "https://dev.azure.com") + + def test_git_provider_azure_devops_deduced_from_dev_azure_com_url(self): + verbose, args = parse_args( + [ + "add-pr-comment", + "--git-provider-url", + "https://dev.azure.com/myorg", + "--username", + "user", + "--password", + "token", + "--organisation", + "org", + "--repository-name", + "repo", + "--pr-id", + "123", + "--text", + "comment", + ] + ) + self.assert_type(args, AddPrCommentCommand.Args) + self.assertEqual(args.git_provider, GitProvider.AZURE_DEVOPS) + self.assertEqual(args.git_provider_url, "https://dev.azure.com/myorg")