jbi/jira/client.py (89 lines of code) (raw):
import logging
from typing import Any, Collection, Iterable, Optional, Union
import requests
from atlassian import Jira
from atlassian import errors as atlassian_errors
from atlassian.rest_client import log as atlassian_logger
from requests import exceptions as requests_exceptions
from jbi import environment
from jbi.common.instrument import instrument
settings = environment.get_settings()
logger = logging.getLogger(__name__)
def fatal_code(exc):
"""Do not retry 4XX errors, mark them as fatal."""
try:
return 400 <= exc.response.status_code < 500
except AttributeError:
# `ApiError` or `ConnectionError` won't have response attribute.
return False
instrumented_method = instrument(
prefix="jira",
exceptions=(
atlassian_errors.ApiError,
requests_exceptions.RequestException,
),
giveup=fatal_code,
)
class JiraCreateError(Exception):
"""Error raised on Jira issue creation."""
class JiraClient(Jira):
"""Adapted Atlassian Jira client that logs errors and wraps methods
in our instrumentation decorator.
"""
def raise_for_status(self, *args, **kwargs):
"""Catch and log HTTP errors responses of the Jira self.client.
Without this the actual requests and responses are not exposed when an error
occurs, which makes troubleshooting tedious.
"""
try:
return super().raise_for_status(*args, **kwargs)
except requests.HTTPError as exc:
request = exc.request
response = exc.response
assert response is not None, f"HTTPError {exc} has no attached response"
atlassian_logger.error(
"HTTP: %s %s -> %s %s",
request.method,
request.path_url,
response.status_code,
response.reason,
extra={"body": response.text},
)
# Set the exception message so that its str version contains details.
msg = f"{request.method} {request.path_url} -> HTTP {response.status_code}: {exc}"
exc.args = (msg,) + exc.args[1:]
raise
get_server_info = instrumented_method(Jira.get_server_info)
get_project_components = instrumented_method(Jira.get_project_components)
update_issue = instrumented_method(Jira.update_issue)
update_issue_field = instrumented_method(Jira.update_issue_field)
set_issue_status = instrumented_method(Jira.set_issue_status)
issue_add_comment = instrumented_method(Jira.issue_add_comment)
create_issue = instrumented_method(Jira.create_issue)
get_project = instrumented_method(Jira.get_project)
@instrumented_method
def paginated_projects(
self,
included_archived=None,
expand=None,
url=None,
keys: Optional[Collection[str]] = None,
) -> dict:
"""Returns a paginated list of projects visible to the user.
https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-projects/#api-rest-api-2-project-search-get
We've patched this method of the Jira client to accept the `keys` param.
"""
if not self.cloud:
raise ValueError(
"``projects_from_cloud`` method is only available for Jira Cloud platform"
)
params_dict: dict[str, Any] = {}
if keys is not None:
if len(keys) > 50:
raise ValueError("Up to 50 project keys can be provided.")
params_dict["keys"] = list(keys)
if included_archived:
params_dict["includeArchived"] = included_archived
if expand:
params_dict["expand"] = expand
page_url = url or self.resource_url("project/search")
is_url_absolute = bool(page_url.lower().startswith("http"))
projects: Union[dict, None] = self.get(
page_url, params=params_dict, absolute=is_url_absolute
)
return projects if projects else {"values": []}
@instrumented_method
def permitted_projects(self, permissions: Optional[Iterable] = None) -> list[dict]:
"""Fetches projects that the user has the required permissions for
https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-permissions/#api-rest-api-2-permissions-project-post
"""
if permissions is None:
permissions = []
response = self.post(
"/rest/api/2/permissions/project",
json={"permissions": list(permissions)},
)
projects: list[dict] = response["projects"] if response else []
return projects