Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
311 changes: 311 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,311 @@
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")

# Create connection using Basic Authentication with PAT
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:
# Ensure branch names have proper refs/ prefix
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:
# Get the pull request to get the last merge source commit
pr = self.__git_client.get_pull_request(
repository_id=self.__repository_name,
pull_request_id=pr_id,
project=self.__project_name,
)

# Map merge methods to Azure DevOps completion options
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"

# Apply any additional merge parameters
if merge_parameters:
for key, value in merge_parameters.items():
setattr(completion_options, key, value)

# Update the pull request to complete it
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:
# Get the branch reference first
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:
# Re-raise GitOpsException without modification
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:
# Re-raise GitOpsException without modification
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,
)

# Extract branch name from sourceRefName (remove refs/heads/ prefix)
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"
# Remove refs/heads/ prefix if present
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