Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 8 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- 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`)
9 changes: 8 additions & 1 deletion gitopscli/cliparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
300 changes: 300 additions & 0 deletions gitopscli/git_api/azure_devops_git_repo_api_adapter.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions gitopscli/git_api/git_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ class GitProvider(Enum):
GITHUB = auto()
BITBUCKET = auto()
GITLAB = auto()
AZURE_DEVOPS = auto()
11 changes: 11 additions & 0 deletions gitopscli/git_api/git_repo_api_factory.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Loading