Skip to content

Commit b182fb5

Browse files
niikuNikolas Philips
andauthored
Add support for Azure DevOps (#236)
* Add azure devops support --------- Co-authored-by: Nikolas Philips <[email protected]>
1 parent 769830b commit b182fb5

File tree

11 files changed

+906
-16
lines changed

11 files changed

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