From 3f82786e5ddfa2ff4175a5fcdf77140581826493 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonasz=20=C5=81asut-Balcerzak?= Date: Thu, 26 Feb 2026 14:51:37 +0100 Subject: [PATCH 1/2] Fix ADO integration and update unit tests to be compliant with python>3.13 --- .../azure_devops_git_repo_api_adapter.py | 65 +++++++------------ gitopscli/git_api/git_repo_api_factory.py | 3 + .../test_azure_devops_git_repo_api_adapter.py | 42 ++---------- tests/git_api/test_repo_api_factory.py | 2 + tests/test_cliparser.py | 14 ++-- 5 files changed, 41 insertions(+), 85 deletions(-) diff --git a/gitopscli/git_api/azure_devops_git_repo_api_adapter.py b/gitopscli/git_api/azure_devops_git_repo_api_adapter.py index da4fdcf..8c3356c 100644 --- a/gitopscli/git_api/azure_devops_git_repo_api_adapter.py +++ b/gitopscli/git_api/azure_devops_git_repo_api_adapter.py @@ -1,13 +1,13 @@ +from collections.abc import Callable 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 ( +from azure.devops.v7_0.git.models import ( Comment, GitPullRequest, GitPullRequestCommentThread, GitPullRequestCompletionOptions, - GitRefUpdate, ) from msrest.exceptions import ClientException @@ -26,6 +26,7 @@ def __init__( password: str | None, organisation: str, repository_name: str, + sleep_func: Callable[[int], None] | None, ) -> None: # In Azure DevOps: # git_provider_url = https://dev.azure.com/organization (e.g. https://dev.azure.com/org) @@ -37,6 +38,11 @@ def __init__( self.__project_name = organisation # In Azure DevOps, "organisation" param is actually the project self.__repository_name = repository_name + if not sleep_func: + raise GitOpsException("Sleep function is required for Azure DevOps") + + self.__sleep_func = sleep_func + if not password: raise GitOpsException("Password (Personal Access Token) is required for Azure DevOps") @@ -108,13 +114,23 @@ def merge_pull_request( merge_parameters: dict[str, Any] | None = None, ) -> None: try: + # Required because of race-condition before PullRequest is first properly created+queued + # and the PullRequest completion can be requested + self.__sleep_func(3) + pr = self.__git_client.get_pull_request( repository_id=self.__repository_name, pull_request_id=pr_id, project=self.__project_name, ) - completion_options = GitPullRequestCompletionOptions() + # Handle deletion of a branch with completion_options instead of delete_pull_request call + completion_options = GitPullRequestCompletionOptions( + bypass_policy=False, + delete_source_branch=True, + transition_work_items=False, + ) + if merge_method == "squash": completion_options.merge_strategy = "squash" elif merge_method == "rebase": @@ -177,46 +193,9 @@ def add_pull_request_comment(self, pr_id: int, text: str, parent_id: int | None except Exception as ex: 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: - raise GitOpsException(f"Error connecting to '{self.__base_url}'") from ex + def delete_branch(self, _: str) -> None: + # branch deletion is set in merge completion_options + return def get_branch_head_hash(self, branch: str) -> str: def _raise_branch_not_found() -> None: diff --git a/gitopscli/git_api/git_repo_api_factory.py b/gitopscli/git_api/git_repo_api_factory.py index 5a7c1d5..d08352d 100644 --- a/gitopscli/git_api/git_repo_api_factory.py +++ b/gitopscli/git_api/git_repo_api_factory.py @@ -1,3 +1,5 @@ +from time import sleep + from gitopscli.gitops_exception import GitOpsException from .azure_devops_git_repo_api_adapter import AzureDevOpsGitRepoApiAdapter @@ -51,5 +53,6 @@ def create(config: GitApiConfig, organisation: str, repository_name: str) -> Git password=config.password, organisation=organisation, repository_name=repository_name, + sleep_func=sleep, ) return GitRepoApiLoggingProxy(git_repo_api) 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 index 0609ad1..4133c98 100644 --- a/tests/git_api/test_azure_devops_git_repo_api_adapter.py +++ b/tests/git_api/test_azure_devops_git_repo_api_adapter.py @@ -8,6 +8,10 @@ from gitopscli.gitops_exception import GitOpsException +def mock_sleep_func(_: int) -> None: + return + + class AzureDevOpsGitRepoApiAdapterTest(unittest.TestCase): def setUp(self): with patch("gitopscli.git_api.azure_devops_git_repo_api_adapter.Connection"): @@ -17,6 +21,7 @@ def setUp(self): password="testtoken", organisation="testproject", repository_name="testrepo", + sleep_func=mock_sleep_func, ) @patch("gitopscli.git_api.azure_devops_git_repo_api_adapter.Connection") @@ -30,6 +35,7 @@ def test_init_success(self, mock_connection): password="token", organisation="project", repository_name="repo", + sleep_func=mock_sleep_func, ) self.assertEqual(adapter.get_username(), "user") @@ -44,6 +50,7 @@ def test_init_no_password_raises_exception(self): password=None, organisation="project", repository_name="repo", + sleep_func=mock_sleep_func, ) self.assertEqual(str(context.value), "Password (Personal Access Token) is required for Azure DevOps") @@ -239,41 +246,6 @@ def test_get_pull_request_branch_without_refs_prefix(self): 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: diff --git a/tests/git_api/test_repo_api_factory.py b/tests/git_api/test_repo_api_factory.py index 599cb0c..a7f71b0 100644 --- a/tests/git_api/test_repo_api_factory.py +++ b/tests/git_api/test_repo_api_factory.py @@ -1,4 +1,5 @@ import unittest +from time import sleep from unittest.mock import MagicMock, patch from gitopscli.git_api import GitApiConfig, GitProvider, GitRepoApiFactory @@ -155,6 +156,7 @@ def test_create_azure_devops(self, mock_azure_devops_adapter_constructor, mock_l password="PAT_TOKEN", organisation="ORG", repository_name="REPO", + sleep_func=sleep, ) mock_logging_proxy_constructor.assert_called_with(mock_azure_devops_adapter) diff --git a/tests/test_cliparser.py b/tests/test_cliparser.py index 26eae3d..cb056ab 100644 --- a/tests/test_cliparser.py +++ b/tests/test_cliparser.py @@ -78,7 +78,7 @@ --pr-id PR_ID the id of the pull request --parent-id PARENT_ID the id of the parent comment, in case of a reply - -v [VERBOSE], --verbose [VERBOSE] + -v, --verbose [VERBOSE] Verbose exception logging --text TEXT the text of the comment """ @@ -146,7 +146,7 @@ --git-hash GIT_HASH the git hash which should be deployed --preview-id PREVIEW_ID The user-defined preview ID - -v [VERBOSE], --verbose [VERBOSE] + -v, --verbose [VERBOSE] Verbose exception logging """ @@ -188,7 +188,7 @@ --pr-id PR_ID the id of the pull request --parent-id PARENT_ID the id of the parent comment, in case of a reply - -v [VERBOSE], --verbose [VERBOSE] + -v, --verbose [VERBOSE] Verbose exception logging """ @@ -260,7 +260,7 @@ The user-defined preview ID --expect-preview-exists [EXPECT_PREVIEW_EXISTS] Fail if preview does not exist - -v [VERBOSE], --verbose [VERBOSE] + -v, --verbose [VERBOSE] Verbose exception logging """ @@ -303,7 +303,7 @@ --branch BRANCH The branch for which the preview was created for --expect-preview-exists [EXPECT_PREVIEW_EXISTS] Fail if preview does not exist - -v [VERBOSE], --verbose [VERBOSE] + -v, --verbose [VERBOSE] Verbose exception logging """ @@ -383,7 +383,7 @@ JSON array pr labels (Gitlab, Github supported) --merge-parameters MERGE_PARAMETERS JSON object pr parameters (only Gitlab supported) - -v [VERBOSE], --verbose [VERBOSE] + -v, --verbose [VERBOSE] Verbose exception logging """ @@ -435,7 +435,7 @@ --git-provider-url GIT_PROVIDER_URL Git provider base API URL (e.g. https://bitbucket.example.tld) - -v [VERBOSE], --verbose [VERBOSE] + -v, --verbose [VERBOSE] Verbose exception logging --root-organisation ROOT_ORGANISATION Root config repository organisation From e6c3440c8d55c1895ecc93a7954dc58f9a1656ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonasz=20=C5=81asut-Balcerzak?= Date: Thu, 26 Feb 2026 14:57:22 +0100 Subject: [PATCH 2/2] Restore test_cliparser.py --- tests/test_cliparser.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_cliparser.py b/tests/test_cliparser.py index cb056ab..26eae3d 100644 --- a/tests/test_cliparser.py +++ b/tests/test_cliparser.py @@ -78,7 +78,7 @@ --pr-id PR_ID the id of the pull request --parent-id PARENT_ID the id of the parent comment, in case of a reply - -v, --verbose [VERBOSE] + -v [VERBOSE], --verbose [VERBOSE] Verbose exception logging --text TEXT the text of the comment """ @@ -146,7 +146,7 @@ --git-hash GIT_HASH the git hash which should be deployed --preview-id PREVIEW_ID The user-defined preview ID - -v, --verbose [VERBOSE] + -v [VERBOSE], --verbose [VERBOSE] Verbose exception logging """ @@ -188,7 +188,7 @@ --pr-id PR_ID the id of the pull request --parent-id PARENT_ID the id of the parent comment, in case of a reply - -v, --verbose [VERBOSE] + -v [VERBOSE], --verbose [VERBOSE] Verbose exception logging """ @@ -260,7 +260,7 @@ The user-defined preview ID --expect-preview-exists [EXPECT_PREVIEW_EXISTS] Fail if preview does not exist - -v, --verbose [VERBOSE] + -v [VERBOSE], --verbose [VERBOSE] Verbose exception logging """ @@ -303,7 +303,7 @@ --branch BRANCH The branch for which the preview was created for --expect-preview-exists [EXPECT_PREVIEW_EXISTS] Fail if preview does not exist - -v, --verbose [VERBOSE] + -v [VERBOSE], --verbose [VERBOSE] Verbose exception logging """ @@ -383,7 +383,7 @@ JSON array pr labels (Gitlab, Github supported) --merge-parameters MERGE_PARAMETERS JSON object pr parameters (only Gitlab supported) - -v, --verbose [VERBOSE] + -v [VERBOSE], --verbose [VERBOSE] Verbose exception logging """ @@ -435,7 +435,7 @@ --git-provider-url GIT_PROVIDER_URL Git provider base API URL (e.g. https://bitbucket.example.tld) - -v, --verbose [VERBOSE] + -v [VERBOSE], --verbose [VERBOSE] Verbose exception logging --root-organisation ROOT_ORGANISATION Root config repository organisation