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