def get_team_foundation_id(self, identity: str) -> TeamFoundationId: """Fetch the unique Team Foundation GUID for a given identity. :param str identity: The identity to fetch for (should be email for users and display name for groups) :returns: The team foundation ID :raises ADOException: If we can't get the identity from the response """ # TODO this should be in an identities space in the next major release request_url = self.http_client.graph_endpoint() request_url += f"/identities?searchFilter=General&filterValue={identity}" request_url += f"&queryMembership=None&api-version=6.0" response = self.http_client.get(request_url) response_data = self.http_client.decode_response(response) extracted = self.http_client.extract_value(response_data) if len(extracted) == 0: raise ADOException("Could not resolve identity: " + identity) if len(extracted) > 1: raise ADOException( f"Found multiple identities matching '{identity}'") return extracted[0]["id"]
def set_status( self, *, sha: str, state: ADOGitStatusState, identifier: str, description: str, project_id: str, repository_id: str, context: str, target_url: Optional[str] = None, ) -> ADOResponse: """Set a status on a PR. :param str sha: The SHA of the commit to add the status to. :param ADOGitStatusState state: The state to set the status to. :param str identifier: A unique identifier for the status (so it can be changed later) :param str description: The text to show in the status :param str project_id: The ID of the project :param str repository_id: The ID for the repository :param str context: The context to use for build status notifications :param Optional[str] target_url: An optional URL to set which is opened when the description is clicked. :returns: The ADO response with the data in it :raises ADOException: If the SHA is not the full version, or the state is set to NOT_SET """ self.log.debug( f"Setting status ({state}) on sha ({sha}): {identifier} -> {description}" ) if len(sha) != 40: raise ADOException( "The SHA for a commit must be the full 40 character version") if state == ADOGitStatusState.NOT_SET: raise ADOException( "The NOT_SET state cannot be used for statuses on commits") request_url = (self.http_client.api_endpoint(project_id=project_id) + f"/git/repositories/{repository_id}/commits/{sha}/") request_url += "statuses?api-version=2.1" body = { "state": state.value, "description": description, "context": { "name": context, "genre": identifier }, } if target_url is not None: body["targetUrl"] = target_url response = self.http_client.post(request_url, json_data=body) return self.http_client.decode_response(response)
def _thread_matches_identifier(self, thread: ADOThread, identifier: str) -> bool: """Check if the ADO thread matches the user and identifier :param thread: The thread to check :param identifier: The identifier to check against :returns: True if the thread matches, False otherwise :raises ADOException: If we couldn't find the author or the properties """ try: # Deleted threads can stay around if they have other comments, so we # check if it was deleted before we check anything else. if thread["comments"][0]["isDeleted"]: return False except Exception: # If it's not there, it's not set pass try: # It wasn't one of the specified users comments if thread["comments"][0]["author"][ "uniqueName"] != self.context.username: return False except: raise ADOException( "Could not find comments.0.author.uniqueName in thread: " + str(thread)) try: properties = thread["properties"] except: raise ADOException("Could not find properties in thread: " + str(thread)) if properties is None: return False comment_identifier = properties.get( ADOCommentProperty.COMMENT_IDENTIFIER) if comment_identifier is None: return False value = comment_identifier.get("$value") if value == identifier: return True return False
def batch(self, operations: List[BatchRequest]) -> ADOResponse: """Run a batch operation. :param operations: The list of batch operations to run :returns: The ADO response with the data in it :raises ADOException: Raised if we try and run more than 200 batch operations at once """ if len(operations) >= 200: raise ADOException( "Cannot perform more than 200 batch operations at once") self.log.debug("Running batch operation") full_body = [] for operation in operations: full_body.append(operation.body()) request_url = f"{self.http_client.base_url(is_project=False)}/wit/$batch" response = self.http_client.post(request_url, full_body) return self.http_client.decode_response(response)
def decode_response(self, response: requests.models.Response) -> ADOResponse: """Decode the response from ADO, checking for errors. :param response: The response to check and parse :returns: The JSON data from the ADO response :raises ADOHTTPException: Raised if the request returned a non-200 status code :raises ADOException: Raise if the response was not JSON """ self.log.debug("Fetching response from ADO") if response.status_code < 200 or response.status_code >= 300: raise ADOHTTPException( f"ADO returned a non-200 status code, configuration={self}", response, ) try: content: ADOResponse = response.json() except: raise ADOException("The response did not contain JSON") return content
def download_zip(self, branch: str, output_path: str) -> None: """Download the zip of the branch specified. :param str branch: The name of the branch to download. :param str output_path: The path to write the output to. :raises ADOException: If the output path already exists :raises ADOHTTPException: If we fail to fetch the zip for any reason """ self.log.debug(f"Downloading branch: {branch}") request_url = ( f"{self.http_client.base_url()}/git/repositories/{self._context.repository_id}/Items?" ) parameters = { "path": "/", "versionDescriptor[versionOptions]": "0", "versionDescriptor[versionType]": "0", "versionDescriptor[version]": branch, "resolveLfs": "true", "$format": "zip", "api-version": "5.0-preview.1", } request_url += urllib.parse.urlencode(parameters) if os.path.exists(output_path): raise ADOException("The output path already exists") with self.http_client.get(request_url, stream=True) as response: download_from_response_stream(response=response, output_path=output_path, log=self.log)
def get_blobs(self, *, blob_ids: List[str], output_path: str) -> ADOResponse: """Get a git item. All non-specified options use the ADO default. :param List[str] blob_ids: The SHA1s of the blobs :param str output_path: The location to write out the zip to """ self.log.debug(f"Getting blobs") request_url = self.http_client.api_endpoint( is_default_collection=False) request_url += f"/git/repositories/{self.context.repository_id}/blobs?api-version=5.1" if os.path.exists(output_path): raise ADOException("The output path already exists") with self.http_client.post( request_url, additional_headers={"Accept": "application/zip"}, stream=True, json_data=blob_ids, ) as response: download_from_response_stream(response=response, output_path=output_path, log=self.log)
def get_status(self, *, sha: str, project_id: str, repository_id: str) -> ADOResponse: """Set a status on a PR. :param str sha: The SHA of the commit to add the status to. :param str project_id: The ID of the project :param str repository_id: The ID for the repository :returns: The ADO response with the data in it :raises ADOException: If the SHA is not the full version """ self.log.debug(f"Getting status for sha: {sha}") if len(sha) != 40: raise ADOException( "The SHA for a commit must be the full 40 character version") request_url = ( self.http_client.api_endpoint(project_id=project_id) + f"/git/repositories/{repository_id}/commits/{sha}/statuses?api-version=2.1" ) response = self.http_client.get(request_url) response_data = self.http_client.decode_response(response) return self.http_client.extract_value(response_data)
def get_team_foundation_id(self, identity: str) -> TeamFoundationId: """Fetch the unique Team Foundation GUID for a given identity. :param str identity: The identity to fetch for (should be email for users and display name for groups) :returns: The team foundation ID :raises ADOException: If we can't get the identity from the response """ request_url = self.http_client.base_url(is_default_collection=False, is_project=False) request_url += "/IdentityPicker/Identities?api-version=5.1-preview.1" body = { "query": identity, "identityTypes": ["user", "group"], "operationScopes": ["ims"], "properties": ["DisplayName", "Mail"], "filterByAncestorEntityIds": [], "filterByEntityIds": [], } response = self.http_client.post(request_url, json_data=body) response_data = self.http_client.decode_response(response) try: result = response_data["results"][0]["identities"][0] except: raise ADOException("Could not resolve identity: " + identity) if result["entityType"] == "User" and identity.lower( ) == result["mail"].lower(): return cast(TeamFoundationId, str(result["localId"])) if result["entityType"] == "Group" and identity.lower( ) == result["displayName"].lower(): return cast(TeamFoundationId, str(result["localId"])) raise ADOException("Could not resolve identity: " + identity)
def _get_descriptor_info( self, *, branch: str, team_foundation_id: TeamFoundationId, project_id: str, repository_id: str, ) -> Dict[str, str]: """Fetch the descriptor identity information for a given identity. :param str branch: The git branch of interest :param TeamFoundationId team_foundation_id: the unique Team Foundation GUID for the identity :param project_id: The identifier for the project :param str repository_id: The ID for the repository :returns: The raw descriptor info :raises ADOException: If we can't determine the descriptor info from the response """ request_url = self.http_client.api_endpoint(is_internal=True, project_id=project_id) request_url += "/_security/DisplayPermissions?" parameters = { "tfid": team_foundation_id, "permissionSetId": ADOSecurityClient.GIT_PERMISSIONS_NAMESPACE, "permissionSetToken": self._generate_permission_set_token( branch=branch, project_id=project_id, repository_id=repository_id ), "__v": "5", } request_url += urllib.parse.urlencode(parameters) response = self.http_client.get(request_url) response_data = self.http_client.decode_response(response) try: descriptor_info = { "type": response_data["descriptorIdentityType"], "id": response_data["descriptorIdentifier"], } except: raise ADOException( "Could not determine descriptor info for team_foundation_id: " + str(team_foundation_id) ) return descriptor_info
def extract_value(self, response_data: ADOResponse) -> ADOResponse: """Extract the "value" from the raw JSON data from an API response :param response_data: The raw JSON data from an API response :returns: The ADO response with the data in it :raises ADOException: If the response is invalid (does not support value extraction) """ self.log.debug("Extracting value") try: value: ADOResponse = response_data["value"] return value except: raise ADOException("The response was invalid (did not contain a value).")
def decode_response(self, response: requests.models.Response) -> ADOResponse: """Decode the response from ADO, checking for errors. :param response: The response to check and parse :returns: The JSON data from the ADO response :raises ADOHTTPException: Raised if the request returned a non-200 status code :raises ADOException: Raise if the response was not JSON """ self.validate_response(response) self.log.debug("Decoding response from ADO") try: content: ADOResponse = response.json() except: raise ADOException("The response did not contain JSON") return content
def create_thread_list( self, *, threads: List[ADOComment], comment_identifier: Optional[str] = None, ) -> None: """Create a list of threads :param List[ADOComment] threads: The threads to create :param Optional[str] comment_identifier: A unique identifier for the comments that can be used for identification at a later date :raises ADOException: If a thread is not an ADO comment """ self.log.debug(f"Setting threads on PR: {self.pull_request_id}") # Check the type of the input for thread in threads: if not isinstance(thread, ADOComment): raise ADOException("Thread was not an ADOComment: " + str(thread)) for thread in threads: self.log.debug("Adding thread") self.create_comment(thread, comment_identifier=comment_identifier)
def get_status(self, sha: str) -> ADOResponse: """Set a status on a PR. :param str sha: The SHA of the commit to add the status to. :returns: The ADO response with the data in it :raises ADOException: If the SHA is not the full version """ self.log.debug(f"Getting status for sha: {sha}") if len(sha) != 40: raise ADOException( "The SHA for a commit must be the full 40 character version") request_url = f"{self.http_client.base_url()}/git/repositories/{self._context.repository_id}/commits/{sha}/" request_url += "statuses?api-version=2.1" response = self.http_client.get(request_url) response_data = self.http_client.decode_response(response) return self.http_client.extract_value(response_data)
def add_attachment( self, identifier: str, path_to_attachment: str, *, filename: Optional[str] = None, bypass_rules: bool = False, supress_notifications: bool = False, ) -> ADOResponse: """Add an attachment to a work item. :param identifier: The identifier of the work item :param path_to_attachment: The path to the attachment on disk :param Optional[str] filename: The new file name of the attachment :param bool bypass_rules: Set to True if we should bypass validation rules, False otherwise :param bool supress_notifications: Set to True if notifications for this change should be supressed, False otherwise :returns: The ADO response with the data in it :raises ADOException: If we can't get the url from the response """ self.log.debug( f"Adding attachment to {identifier}: {path_to_attachment}") if filename is None: filename = os.path.basename(path_to_attachment) filename = filename.replace("#", "_") # Upload the file request_url = ( f"{self.http_client.base_url()}/wit/attachments?fileName={filename}&api-version=1.0" ) response = self.http_client.post_file(request_url, path_to_attachment) response_data = self.http_client.decode_response(response) url = response_data.get("url") if url is None: raise ADOException( f"Failed to get url from response: {response_data}") # Attach it to the ticket operation = WorkItemFieldOperationAdd( WorkItemField.relation, { "rel": "AttachedFile", "url": url, "attributes": { "comment": "" } }, ) request_url = f"{self.http_client.base_url()}/wit/workitems/{identifier}" request_url += f"?bypassRules={boolstr(bypass_rules)}" request_url += f"&suppressNotifications={boolstr(supress_notifications)}" request_url += f"&api-version=4.1" response = self.http_client.patch( request_url, [operation.raw()], additional_headers={"Content-Type": "application/json-patch+json"}, ) return self.http_client.decode_response(response)