Skip to content

Commit 1cd6468

Browse files
author
Nikolas Philips
committed
Add azure devops support
1 parent 769830b commit 1cd6468

File tree

11 files changed

+931
-15
lines changed

11 files changed

+931
-15
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ docker run --rm -it baloise/gitopscli --help
3131
For detailed installation and usage instructions, visit [https://baloise.github.io/gitopscli/](https://baloise.github.io/gitopscli/).
3232

3333
## Git Provider Support
34-
Currently, we support BitBucket Server, GitHub and Gitlab.
34+
Currently, we support BitBucket Server, GitHub, GitLab, and Azure DevOps.
3535

3636
## Development
3737

docs/index.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,11 @@ A command line interface to perform operations on GitOps managed infrastructure
88
- Update YAML values in config repository to e.g. deploy an application
99
- Add pull request comments
1010
- Create and delete preview environments in the config repository for a pull request in an app repository
11-
- Update root config repository with all apps from child config repositories
11+
- Update root config repository with all apps from child config repositories
12+
13+
## Git Provider Support
14+
GitOps CLI supports the following Git providers:
15+
- **GitHub** - Full API integration
16+
- **GitLab** - Full API integration
17+
- **Bitbucket Server** - Full API integration
18+
- **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`)

gitopscli/cliparser.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,12 @@ def __parse_yaml(value: str) -> Any:
312312

313313

314314
def __parse_git_provider(value: str) -> GitProvider:
315-
mapping = {"github": GitProvider.GITHUB, "bitbucket-server": GitProvider.BITBUCKET, "gitlab": GitProvider.GITLAB}
315+
mapping = {
316+
"github": GitProvider.GITHUB,
317+
"bitbucket-server": GitProvider.BITBUCKET,
318+
"gitlab": GitProvider.GITLAB,
319+
"azure-devops": GitProvider.AZURE_DEVOPS,
320+
}
316321
assert set(mapping.values()) == set(GitProvider), "git provider mapping not exhaustive"
317322
lowercase_stripped_value = value.lower().strip()
318323
if lowercase_stripped_value not in mapping:
@@ -341,6 +346,8 @@ def __deduce_empty_git_provider_from_git_provider_url(
341346
updated_args["git_provider"] = GitProvider.BITBUCKET
342347
elif "gitlab" in git_provider_url.lower():
343348
updated_args["git_provider"] = GitProvider.GITLAB
349+
elif "dev.azure.com" in git_provider_url.lower():
350+
updated_args["git_provider"] = GitProvider.AZURE_DEVOPS
344351
else:
345352
error("Cannot deduce git provider from --git-provider-url. Please provide --git-provider")
346353
return updated_args
Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
from typing import Any, Literal
2+
3+
from azure.devops.connection import Connection
4+
from azure.devops.credentials import BasicAuthentication
5+
from azure.devops.v7_1.git import GitClient
6+
from azure.devops.v7_1.git.models import (
7+
GitPullRequest,
8+
GitPullRequestCommentThread,
9+
GitPullRequestCompletionOptions,
10+
GitRef,
11+
GitRefUpdate,
12+
GitRefUpdateResult,
13+
GitRepository,
14+
Comment,
15+
)
16+
from msrest.exceptions import ClientException
17+
18+
from gitopscli.gitops_exception import GitOpsException
19+
20+
from .git_repo_api import GitRepoApi
21+
22+
23+
class AzureDevOpsGitRepoApiAdapter(GitRepoApi):
24+
"""Azure DevOps SDK adapter for GitOps CLI operations."""
25+
26+
def __init__(
27+
self,
28+
git_provider_url: str,
29+
username: str | None,
30+
password: str | None,
31+
organisation: str,
32+
repository_name: str,
33+
) -> None:
34+
# In Azure DevOps:
35+
# git_provider_url = https://dev.azure.com/organization (e.g. https://dev.azure.com/org)
36+
# organisation = project name
37+
# repository_name = repo name
38+
self.__base_url = git_provider_url.rstrip("/")
39+
self.__username = username or ""
40+
self.__password = password
41+
self.__project_name = organisation # In Azure DevOps, "organisation" param is actually the project
42+
self.__repository_name = repository_name
43+
44+
if not password:
45+
raise GitOpsException("Password (Personal Access Token) is required for Azure DevOps")
46+
47+
# Create connection using Basic Authentication with PAT
48+
credentials = BasicAuthentication(self.__username, password)
49+
self.__connection = Connection(base_url=self.__base_url, creds=credentials)
50+
self.__git_client = self.__connection.clients.get_git_client()
51+
52+
def get_username(self) -> str | None:
53+
return self.__username
54+
55+
def get_password(self) -> str | None:
56+
return self.__password
57+
58+
def get_clone_url(self) -> str:
59+
# https://dev.azure.com/organization/project/_git/repository
60+
return f"{self.__base_url}/{self.__project_name}/_git/{self.__repository_name}"
61+
62+
def create_pull_request_to_default_branch(
63+
self,
64+
from_branch: str,
65+
title: str,
66+
description: str,
67+
) -> GitRepoApi.PullRequestIdAndUrl:
68+
to_branch = self.__get_default_branch()
69+
return self.create_pull_request(from_branch, to_branch, title, description)
70+
71+
def create_pull_request(
72+
self,
73+
from_branch: str,
74+
to_branch: str,
75+
title: str,
76+
description: str,
77+
) -> GitRepoApi.PullRequestIdAndUrl:
78+
try:
79+
# Ensure branch names have proper refs/ prefix
80+
source_ref = from_branch if from_branch.startswith("refs/") else f"refs/heads/{from_branch}"
81+
target_ref = to_branch if to_branch.startswith("refs/") else f"refs/heads/{to_branch}"
82+
83+
pull_request = GitPullRequest(
84+
source_ref_name=source_ref,
85+
target_ref_name=target_ref,
86+
title=title,
87+
description=description,
88+
)
89+
90+
created_pr = self.__git_client.create_pull_request(
91+
git_pull_request_to_create=pull_request,
92+
repository_id=self.__repository_name,
93+
project=self.__project_name,
94+
)
95+
96+
return GitRepoApi.PullRequestIdAndUrl(
97+
pr_id=created_pr.pull_request_id,
98+
url=created_pr.url
99+
)
100+
101+
except ClientException as ex:
102+
error_msg = str(ex)
103+
if "401" in error_msg:
104+
raise GitOpsException("Bad credentials") from ex
105+
elif "404" in error_msg:
106+
raise GitOpsException(
107+
f"Repository '{self.__project_name}/{self.__repository_name}' does not exist"
108+
) from ex
109+
else:
110+
raise GitOpsException(f"Error creating pull request: {error_msg}") from ex
111+
except Exception as ex:
112+
raise GitOpsException(f"Error connecting to '{self.__base_url}'") from ex
113+
114+
def merge_pull_request(
115+
self,
116+
pr_id: int,
117+
merge_method: Literal["squash", "rebase", "merge"] = "merge",
118+
merge_parameters: dict[str, Any] | None = None,
119+
) -> None:
120+
try:
121+
# Get the pull request to get the last merge source commit
122+
pr = self.__git_client.get_pull_request(
123+
repository_id=self.__repository_name,
124+
pull_request_id=pr_id,
125+
project=self.__project_name,
126+
)
127+
128+
# Map merge methods to Azure DevOps completion options
129+
completion_options = GitPullRequestCompletionOptions()
130+
if merge_method == "squash":
131+
completion_options.merge_strategy = "squash"
132+
elif merge_method == "rebase":
133+
completion_options.merge_strategy = "rebase"
134+
else: # merge
135+
completion_options.merge_strategy = "noFastForward"
136+
137+
# Apply any additional merge parameters
138+
if merge_parameters:
139+
for key, value in merge_parameters.items():
140+
setattr(completion_options, key, value)
141+
142+
# Update the pull request to complete it
143+
pr_update = GitPullRequest(
144+
status="completed",
145+
last_merge_source_commit=pr.last_merge_source_commit,
146+
completion_options=completion_options,
147+
)
148+
149+
self.__git_client.update_pull_request(
150+
git_pull_request_to_update=pr_update,
151+
repository_id=self.__repository_name,
152+
pull_request_id=pr_id,
153+
project=self.__project_name,
154+
)
155+
156+
except ClientException as ex:
157+
error_msg = str(ex)
158+
if "401" in error_msg:
159+
raise GitOpsException("Bad credentials") from ex
160+
elif "404" in error_msg:
161+
raise GitOpsException(f"Pull request with ID '{pr_id}' does not exist") from ex
162+
else:
163+
raise GitOpsException(f"Error merging pull request: {error_msg}") from ex
164+
except Exception as ex:
165+
raise GitOpsException(f"Error connecting to '{self.__base_url}'") from ex
166+
167+
def add_pull_request_comment(self, pr_id: int, text: str, parent_id: int | None = None) -> None:
168+
try:
169+
comment = Comment(content=text, comment_type="text")
170+
thread = GitPullRequestCommentThread(
171+
comments=[comment],
172+
status="active",
173+
)
174+
175+
# Azure DevOps doesn't support direct reply to comments in the same way as other platforms
176+
# parent_id is ignored for now
177+
178+
self.__git_client.create_thread(
179+
comment_thread=thread,
180+
repository_id=self.__repository_name,
181+
pull_request_id=pr_id,
182+
project=self.__project_name,
183+
)
184+
185+
except ClientException as ex:
186+
error_msg = str(ex)
187+
if "401" in error_msg:
188+
raise GitOpsException("Bad credentials") from ex
189+
elif "404" in error_msg:
190+
raise GitOpsException(f"Pull request with ID '{pr_id}' does not exist") from ex
191+
else:
192+
raise GitOpsException(f"Error adding comment: {error_msg}") from ex
193+
except Exception as ex:
194+
raise GitOpsException(f"Error connecting to '{self.__base_url}'") from ex
195+
196+
def delete_branch(self, branch: str) -> None:
197+
try:
198+
# Get the branch reference first
199+
refs = self.__git_client.get_refs(
200+
repository_id=self.__repository_name,
201+
project=self.__project_name,
202+
filter=f"heads/{branch}",
203+
)
204+
205+
if not refs:
206+
raise GitOpsException(f"Branch '{branch}' does not exist")
207+
208+
branch_ref = refs[0]
209+
210+
# Create ref update to delete the branch
211+
ref_update = GitRefUpdate(
212+
name=f"refs/heads/{branch}",
213+
old_object_id=branch_ref.object_id,
214+
new_object_id="0000000000000000000000000000000000000000",
215+
)
216+
217+
self.__git_client.update_refs(
218+
ref_updates=[ref_update],
219+
repository_id=self.__repository_name,
220+
project=self.__project_name,
221+
)
222+
223+
except GitOpsException:
224+
# Re-raise GitOpsException without modification
225+
raise
226+
except ClientException as ex:
227+
error_msg = str(ex)
228+
if "401" in error_msg:
229+
raise GitOpsException("Bad credentials") from ex
230+
elif "404" in error_msg:
231+
raise GitOpsException(f"Branch '{branch}' does not exist") from ex
232+
else:
233+
raise GitOpsException(f"Error deleting branch: {error_msg}") from ex
234+
except Exception as ex:
235+
raise GitOpsException(f"Error connecting to '{self.__base_url}'") from ex
236+
237+
def get_branch_head_hash(self, branch: str) -> str:
238+
try:
239+
refs = self.__git_client.get_refs(
240+
repository_id=self.__repository_name,
241+
project=self.__project_name,
242+
filter=f"heads/{branch}",
243+
)
244+
245+
if not refs:
246+
raise GitOpsException(f"Branch '{branch}' does not exist")
247+
248+
return refs[0].object_id
249+
250+
except GitOpsException:
251+
# Re-raise GitOpsException without modification
252+
raise
253+
except ClientException as ex:
254+
error_msg = str(ex)
255+
if "401" in error_msg:
256+
raise GitOpsException("Bad credentials") from ex
257+
elif "404" in error_msg:
258+
raise GitOpsException(f"Branch '{branch}' does not exist") from ex
259+
else:
260+
raise GitOpsException(f"Error getting branch hash: {error_msg}") from ex
261+
except Exception as ex:
262+
raise GitOpsException(f"Error connecting to '{self.__base_url}'") from ex
263+
264+
def get_pull_request_branch(self, pr_id: int) -> str:
265+
try:
266+
pr = self.__git_client.get_pull_request(
267+
repository_id=self.__repository_name,
268+
pull_request_id=pr_id,
269+
project=self.__project_name,
270+
)
271+
272+
# Extract branch name from sourceRefName (remove refs/heads/ prefix)
273+
source_ref = pr.source_ref_name
274+
if source_ref.startswith("refs/heads/"):
275+
return source_ref[11:] # Remove "refs/heads/" prefix
276+
return source_ref
277+
278+
except ClientException as ex:
279+
error_msg = str(ex)
280+
if "401" in error_msg:
281+
raise GitOpsException("Bad credentials") from ex
282+
elif "404" in error_msg:
283+
raise GitOpsException(f"Pull request with ID '{pr_id}' does not exist") from ex
284+
else:
285+
raise GitOpsException(f"Error getting pull request: {error_msg}") from ex
286+
except Exception as ex:
287+
raise GitOpsException(f"Error connecting to '{self.__base_url}'") from ex
288+
289+
def add_pull_request_label(self, pr_id: int, pr_labels: list[str]) -> None:
290+
# Azure DevOps uses labels differently than other platforms
291+
# The SDK doesn't have direct label support for pull requests
292+
# This operation is silently ignored as labels aren't critical for GitOps operations
293+
pass
294+
295+
def __get_default_branch(self) -> str:
296+
try:
297+
repo = self.__git_client.get_repository(
298+
repository_id=self.__repository_name,
299+
project=self.__project_name,
300+
)
301+
302+
default_branch = repo.default_branch or "refs/heads/main"
303+
# Remove refs/heads/ prefix if present
304+
if default_branch.startswith("refs/heads/"):
305+
return default_branch[11:]
306+
return default_branch
307+
308+
except ClientException as ex:
309+
error_msg = str(ex)
310+
if "401" in error_msg:
311+
raise GitOpsException("Bad credentials") from ex
312+
elif "404" in error_msg:
313+
raise GitOpsException(
314+
f"Repository '{self.__project_name}/{self.__repository_name}' does not exist"
315+
) from ex
316+
else:
317+
raise GitOpsException(f"Error getting repository info: {error_msg}") from ex
318+
except Exception as ex:
319+
raise GitOpsException(f"Error connecting to '{self.__base_url}'") from ex

gitopscli/git_api/git_provider.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ class GitProvider(Enum):
55
GITHUB = auto()
66
BITBUCKET = auto()
77
GITLAB = auto()
8+
AZURE_DEVOPS = auto()

gitopscli/git_api/git_repo_api_factory.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from gitopscli.gitops_exception import GitOpsException
22

3+
from .azure_devops_git_repo_api_adapter import AzureDevOpsGitRepoApiAdapter
34
from .bitbucket_git_repo_api_adapter import BitbucketGitRepoApiAdapter
45
from .git_api_config import GitApiConfig
56
from .git_provider import GitProvider
@@ -41,4 +42,14 @@ def create(config: GitApiConfig, organisation: str, repository_name: str) -> Git
4142
organisation=organisation,
4243
repository_name=repository_name,
4344
)
45+
elif config.git_provider is GitProvider.AZURE_DEVOPS:
46+
if not config.git_provider_url:
47+
raise GitOpsException("Please provide url for Azure DevOps!")
48+
git_repo_api = AzureDevOpsGitRepoApiAdapter(
49+
git_provider_url=config.git_provider_url,
50+
username=config.username,
51+
password=config.password,
52+
organisation=organisation,
53+
repository_name=repository_name,
54+
)
4455
return GitRepoApiLoggingProxy(git_repo_api)

0 commit comments

Comments
 (0)