Example #1
0
    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"]
Example #2
0
    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)
Example #3
0
    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
Example #4
0
    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
Example #6
0
    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)
Example #7
0
    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)
Example #8
0
    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)
Example #10
0
    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
Example #11
0
    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).")
Example #12
0
    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
Example #13
0
    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)
Example #14
0
    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)
Example #15
0
    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)