client.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. from __future__ import annotations
  2. import logging
  3. import re
  4. from typing import Any
  5. from urllib.parse import parse_qsl, urlparse
  6. from django.urls import reverse
  7. from oauthlib.oauth1 import SIGNATURE_RSA
  8. from requests import PreparedRequest
  9. from requests_oauthlib import OAuth1
  10. from sentry.identity.services.identity.model import RpcIdentity
  11. from sentry.integrations.client import ApiClient
  12. from sentry.integrations.services.integration.model import RpcIntegration
  13. from sentry.models.integrations.integration import Integration
  14. from sentry.shared_integrations.exceptions import ApiError
  15. from sentry.silo.base import control_silo_function
  16. from sentry.utils import jwt
  17. from sentry.utils.http import absolute_uri
  18. logger = logging.getLogger(__name__)
  19. JIRA_KEY = f"{urlparse(absolute_uri()).hostname}.jira"
  20. ISSUE_KEY_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]*-\d+$")
  21. CUSTOMFIELD_PREFIX = "customfield_"
  22. class JiraServerClient(ApiClient):
  23. COMMENTS_URL = "/rest/api/2/issue/%s/comment"
  24. COMMENT_URL = "/rest/api/2/issue/%s/comment/%s"
  25. STATUS_URL = "/rest/api/2/status"
  26. CREATE_URL = "/rest/api/2/issue"
  27. ISSUE_URL = "/rest/api/2/issue/%s"
  28. ISSUE_FIELDS_URL = "/rest/api/2/issue/createmeta/%s/issuetypes/%s"
  29. ISSUE_TYPES_URL = "/rest/api/2/issue/createmeta/%s/issuetypes"
  30. PRIORITIES_URL = "/rest/api/2/priority"
  31. PROJECT_URL = "/rest/api/2/project"
  32. SEARCH_URL = "/rest/api/2/search/"
  33. VERSIONS_URL = "/rest/api/2/project/%s/versions"
  34. USERS_URL = "/rest/api/2/user/assignable/search"
  35. USER_URL = "/rest/api/2/user"
  36. SERVER_INFO_URL = "/rest/api/2/serverInfo"
  37. ASSIGN_URL = "/rest/api/2/issue/%s/assignee"
  38. TRANSITION_URL = "/rest/api/2/issue/%s/transitions"
  39. AUTOCOMPLETE_URL = "/rest/api/2/jql/autocompletedata/suggestions"
  40. PROPERTIES_URL = "/rest/api/3/issue/%s/properties/%s"
  41. integration_name = "jira_server"
  42. # This timeout is completely arbitrary. Jira doesn't give us any
  43. # caching headers to work with. Ideally we want a duration that
  44. # lets the user make their second jira issue with cached data.
  45. cache_time = 240
  46. def __init__(
  47. self,
  48. integration: RpcIntegration | Integration,
  49. identity: RpcIdentity,
  50. logging_context: Any | None = None,
  51. ):
  52. self.base_url = integration.metadata["base_url"]
  53. self.identity = identity
  54. super().__init__(
  55. integration_id=integration.id,
  56. verify_ssl=integration.metadata["verify_ssl"],
  57. logging_context=logging_context,
  58. )
  59. def get_cache_prefix(self):
  60. return "sentry-jira-server:"
  61. def finalize_request(self, prepared_request: PreparedRequest) -> PreparedRequest:
  62. return self.authorize_request(prepared_request=prepared_request)
  63. def authorize_request(self, prepared_request: PreparedRequest):
  64. """Jira Server authorizes with RSA-signed OAuth1 scheme"""
  65. if not self.identity:
  66. return prepared_request
  67. auth_scheme = OAuth1(
  68. client_key=self.identity.data["consumer_key"],
  69. rsa_key=self.identity.data["private_key"],
  70. resource_owner_key=self.identity.data["access_token"],
  71. resource_owner_secret=self.identity.data["access_token_secret"],
  72. signature_method=SIGNATURE_RSA,
  73. signature_type="auth_header",
  74. decoding=None,
  75. )
  76. prepared_request.prepare_auth(auth=auth_scheme)
  77. return prepared_request
  78. def user_id_get_param(self):
  79. return "username"
  80. def user_id_field(self):
  81. return "name"
  82. def user_query_param(self):
  83. return "username"
  84. def get_issue(self, issue_id):
  85. return self.get(self.ISSUE_URL % (issue_id,))
  86. def search_issues(self, query):
  87. q = query.replace('"', '\\"')
  88. # check if it looks like an issue id
  89. if ISSUE_KEY_RE.match(query):
  90. jql = f'id="{q}"'
  91. else:
  92. jql = f'text ~ "{q}"'
  93. return self.get(self.SEARCH_URL, params={"jql": jql})
  94. def create_comment(self, issue_key, comment):
  95. return self.post(self.COMMENTS_URL % issue_key, data={"body": comment})
  96. def update_comment(self, issue_key, comment_id, comment):
  97. return self.put(self.COMMENT_URL % (issue_key, comment_id), data={"body": comment})
  98. def get_projects_list(self, cached: bool = True):
  99. if not cached:
  100. return self.get(self.PROJECT_URL)
  101. return self.get_cached(self.PROJECT_URL)
  102. def get_issue_types(self, project_id):
  103. # Get a list of issue types for the given project
  104. return self.get_cached(self.ISSUE_TYPES_URL % (project_id))
  105. def get_issue_fields(self, project_id, issue_type_id):
  106. # Get a list of fields for the issue type and project
  107. return self.get_cached(self.ISSUE_FIELDS_URL % (project_id, issue_type_id))
  108. def get_project_key_for_id(self, project_id) -> str:
  109. if not project_id:
  110. return ""
  111. projects = self.get_projects_list()
  112. for project in projects:
  113. if project["id"] == project_id:
  114. return project["key"]
  115. return ""
  116. def get_versions(self, project):
  117. return self.get_cached(self.VERSIONS_URL % project)
  118. def get_priorities(self):
  119. """
  120. XXX(schew2381): There is an existing bug where we fetch and show all project priorities instead of scoping
  121. them to the selected project. This is fine when manually creating a Jira Server issue b/c we surface that
  122. the selected priority is not available. However for the alert rule action, you can save the action with an
  123. invalid priority for the chosen project. We surface this issue externally in our docs:
  124. https://docs.sentry.io/product/integrations/issue-tracking/jira/#issue-alert-not-creating-jira-issues
  125. We are limited by the Jira Server API b/c fetching priorities requires global/project admin permissions.
  126. There is currently no workaround for this!
  127. Please DO NOT attempt to use the following APIs:
  128. https://docs.atlassian.com/software/jira/docs/api/REST/9.11.0/#api/2/priorityschemes-getPrioritySchemes
  129. https://docs.atlassian.com/software/jira/docs/api/REST/9.11.0/#api/2/project/{projectKeyOrId}/priorityscheme-getAssignedPriorityScheme
  130. """
  131. return self.get_cached(self.PRIORITIES_URL)
  132. def get_users_for_project(self, project):
  133. # Jira Server wants a project key, while cloud is indifferent.
  134. project_key = self.get_project_key_for_id(project)
  135. return self.get_cached(self.USERS_URL, params={"project": project_key})
  136. def search_users_for_project(self, project, username):
  137. # Jira Server wants a project key, while cloud is indifferent.
  138. project_key = self.get_project_key_for_id(project)
  139. return self.get_cached(
  140. self.USERS_URL, params={"project": project_key, self.user_query_param(): username}
  141. )
  142. def search_users_for_issue(self, issue_key, email):
  143. return self.get_cached(
  144. self.USERS_URL, params={"issueKey": issue_key, self.user_query_param(): email}
  145. )
  146. def get_user(self, user_id):
  147. user_id_get_param = self.user_id_get_param()
  148. return self.get_cached(self.USER_URL, params={user_id_get_param: user_id})
  149. def create_issue(self, raw_form_data):
  150. data = {"fields": raw_form_data}
  151. return self.post(self.CREATE_URL, data=data)
  152. def get_server_info(self):
  153. return self.get(self.SERVER_INFO_URL)
  154. def get_valid_statuses(self):
  155. return self.get_cached(self.STATUS_URL)
  156. def get_transitions(self, issue_key):
  157. return self.get_cached(self.TRANSITION_URL % issue_key)["transitions"]
  158. def transition_issue(self, issue_key, transition_id):
  159. return self.post(
  160. self.TRANSITION_URL % issue_key, data={"transition": {"id": transition_id}}
  161. )
  162. def assign_issue(self, key, name_or_account_id):
  163. user_id_field = self.user_id_field()
  164. return self.put(self.ASSIGN_URL % key, data={user_id_field: name_or_account_id})
  165. def set_issue_property(self, issue_key, badge_num):
  166. module_key = "sentry-issues-glance"
  167. properties_key = f"com.atlassian.jira.issue:{JIRA_KEY}:{module_key}:status"
  168. data = {"type": "badge", "value": {"label": badge_num}}
  169. return self.put(self.PROPERTIES_URL % (issue_key, properties_key), data=data)
  170. def get_field_autocomplete(self, name, value):
  171. if name.startswith(CUSTOMFIELD_PREFIX):
  172. # Transform `customfield_0123` into `cf[0123]`
  173. cf_id = name[len(CUSTOMFIELD_PREFIX) :]
  174. jql_name = f"cf[{cf_id}]"
  175. else:
  176. jql_name = name
  177. return self.get_cached(
  178. self.AUTOCOMPLETE_URL, params={"fieldName": jql_name, "fieldValue": value}
  179. )
  180. class JiraServerSetupClient(ApiClient):
  181. """
  182. Client for making requests to JiraServer to follow OAuth1 flow.
  183. Jira OAuth1 docs: https://developer.atlassian.com/server/jira/platform/oauth/
  184. """
  185. request_token_url = "{}/plugins/servlet/oauth/request-token"
  186. access_token_url = "{}/plugins/servlet/oauth/access-token"
  187. authorize_url = "{}/plugins/servlet/oauth/authorize?oauth_token={}"
  188. integration_name = "jira_server_setup"
  189. @control_silo_function
  190. def __init__(self, base_url, consumer_key, private_key, verify_ssl=True):
  191. self.base_url = base_url
  192. self.consumer_key = consumer_key
  193. self.private_key = private_key
  194. self.verify_ssl = verify_ssl
  195. def get_request_token(self):
  196. """
  197. Step 1 of the oauth flow.
  198. Get a request token that we can have the user verify.
  199. """
  200. url = self.request_token_url.format(self.base_url)
  201. resp = self.post(url, allow_text=True)
  202. return dict(parse_qsl(resp.text))
  203. def get_authorize_url(self, request_token):
  204. """
  205. Step 2 of the oauth flow.
  206. Get a URL that the user can verify our request token at.
  207. """
  208. return self.authorize_url.format(self.base_url, request_token["oauth_token"])
  209. def get_access_token(self, request_token, verifier):
  210. """
  211. Step 3 of the oauth flow.
  212. Use the verifier and request token from step 1 to get an access token.
  213. """
  214. if not verifier:
  215. raise ApiError("Missing OAuth token verifier")
  216. auth = OAuth1(
  217. client_key=self.consumer_key,
  218. resource_owner_key=request_token["oauth_token"],
  219. resource_owner_secret=request_token["oauth_token_secret"],
  220. verifier=verifier,
  221. rsa_key=self.private_key,
  222. signature_method=SIGNATURE_RSA,
  223. signature_type="auth_header",
  224. decoding=None,
  225. )
  226. url = self.access_token_url.format(self.base_url)
  227. resp = self.post(url, auth=auth, allow_text=True)
  228. return dict(parse_qsl(resp.text))
  229. def create_issue_webhook(self, external_id, secret, credentials):
  230. auth = OAuth1(
  231. client_key=credentials["consumer_key"],
  232. rsa_key=credentials["private_key"],
  233. resource_owner_key=credentials["access_token"],
  234. resource_owner_secret=credentials["access_token_secret"],
  235. signature_method=SIGNATURE_RSA,
  236. signature_type="auth_header",
  237. decoding=None,
  238. )
  239. # Create a JWT token that we can add to the webhook URL
  240. # so we can locate the matching integration later.
  241. token = jwt.encode({"id": external_id}, secret)
  242. path = reverse("sentry-extensions-jiraserver-issue-updated", kwargs={"token": token})
  243. data = {
  244. "name": "Sentry Issue Sync",
  245. "url": absolute_uri(path),
  246. "events": ["jira:issue_created", "jira:issue_updated"],
  247. }
  248. return self.post("/rest/webhooks/1.0/webhook", auth=auth, data=data)
  249. def request(self, *args, **kwargs):
  250. """
  251. Add OAuth1 RSA signatures.
  252. """
  253. if "auth" not in kwargs:
  254. kwargs["auth"] = OAuth1(
  255. client_key=self.consumer_key,
  256. rsa_key=self.private_key,
  257. signature_method=SIGNATURE_RSA,
  258. signature_type="auth_header",
  259. decoding=None,
  260. )
  261. return self._request(*args, **kwargs)