Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 22 additions & 43 deletions gitopscli/git_api/azure_devops_git_repo_api_adapter.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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)
Expand All @@ -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")

Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions gitopscli/git_api/git_repo_api_factory.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from time import sleep

from gitopscli.gitops_exception import GitOpsException

from .azure_devops_git_repo_api_adapter import AzureDevOpsGitRepoApiAdapter
Expand Down Expand Up @@ -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)
42 changes: 7 additions & 35 deletions tests/git_api/test_azure_devops_git_repo_api_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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")

Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions tests/git_api/test_repo_api_factory.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import unittest
from time import sleep
from unittest.mock import MagicMock, patch

from gitopscli.git_api import GitApiConfig, GitProvider, GitRepoApiFactory
Expand Down Expand Up @@ -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)

Expand Down
Loading