|
| 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 |
0 commit comments