Repository: getsentry/sentry

Issue:
Jira integration support sprint field.
As the title.


Discussion:
+1 to this. According to this a few fields are broken (?):
https://github.com/getsentry/sentry/blob/master/src/sentry/integrations/jira/integration.py#L86
@saifelse was on a roll regarding Jira so shamelessly pinging him in case he is interested in a PR 🙂 

Resolving patch:
diff --git a/src/sentry/integrations/jira/client.py b/src/sentry/integrations/jira/client.py
--- a/src/sentry/integrations/jira/client.py
+++ b/src/sentry/integrations/jira/client.py
@@ -16,6 +16,7 @@
 
 JIRA_KEY = "%s.jira" % (urlparse(absolute_uri()).hostname,)
 ISSUE_KEY_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]*-\d+$")
+CUSTOMFIELD_PREFIX = "customfield_"
 
 
 class JiraCloud(object):
@@ -89,6 +90,7 @@ class JiraApiClient(ApiClient):
     ASSIGN_URL = "/rest/api/2/issue/%s/assignee"
     TRANSITION_URL = "/rest/api/2/issue/%s/transitions"
     EMAIL_URL = "/rest/api/3/user/email"
+    AUTOCOMPLETE_URL = "/rest/api/2/jql/autocompletedata/suggestions"
 
     integration_name = "jira"
 
@@ -235,3 +237,14 @@ def assign_issue(self, key, name_or_account_id):
     def get_email(self, account_id):
         user = self.get_cached(self.EMAIL_URL, params={"accountId": account_id})
         return user.get("email")
+
+    def get_field_autocomplete(self, name, value):
+        if name.startswith(CUSTOMFIELD_PREFIX):
+            # Transform `customfield_0123` into `cf[0123]`
+            cf_id = name[len(CUSTOMFIELD_PREFIX) :]
+            jql_name = "cf[{}]".format(cf_id)
+        else:
+            jql_name = name
+        return self.get_cached(
+            self.AUTOCOMPLETE_URL, params={"fieldName": jql_name, "fieldValue": value}
+        )
diff --git a/src/sentry/integrations/jira/integration.py b/src/sentry/integrations/jira/integration.py
--- a/src/sentry/integrations/jira/integration.py
+++ b/src/sentry/integrations/jira/integration.py
@@ -86,9 +86,9 @@
     aspects={"externalInstall": external_install},
 )
 
-# hide sprint, epic link, parent and linked issues fields because they don't work
-# since sprint and epic link are "custom" we need to search for them by name
-HIDDEN_ISSUE_FIELDS = {"keys": ["parent", "issuelinks"], "names": ["Sprint", "Epic Link"]}
+# Hide linked issues fields because we don't have the necessary UI for fully specifying
+# a valid link (e.g. "is blocked by ISSUE-1").
+HIDDEN_ISSUE_FIELDS = ["issuelinks"]
 
 # A list of common builtin custom field types for Jira for easy reference.
 JIRA_CUSTOM_FIELD_TYPES = {
@@ -96,6 +96,8 @@
     "textarea": "com.atlassian.jira.plugin.system.customfieldtypes:textarea",
     "multiuserpicker": "com.atlassian.jira.plugin.system.customfieldtypes:multiuserpicker",
     "tempo_account": "com.tempoplugin.tempo-accounts:accounts.customfield",
+    "sprint": "com.pyxis.greenhopper.jira:gh-sprint",
+    "epic": "com.pyxis.greenhopper.jira:gh-epic-link",
 }
 
 
@@ -422,8 +424,15 @@ def build_dynamic_field(self, field_meta, group=None):
         ):
             fieldtype = "select"
             fkwargs["choices"] = self.make_choices(field_meta.get("allowedValues"))
-        elif field_meta.get("autoCompleteUrl") and (
-            schema.get("items") == "user" or schema["type"] == "user"
+        elif (
+            # Assignee and reporter fields
+            field_meta.get("autoCompleteUrl")
+            and (schema.get("items") == "user" or schema["type"] == "user")
+            # Sprint and "Epic Link" fields
+            or schema.get("custom")
+            in (JIRA_CUSTOM_FIELD_TYPES["sprint"], JIRA_CUSTOM_FIELD_TYPES["epic"])
+            # Parent field
+            or schema["type"] == "issuelink"
         ):
             fieldtype = "select"
             organization = (
@@ -629,12 +638,8 @@ def get_create_issue_config(self, group, user, **kwargs):
 
         # title is renamed to summary before sending to Jira
         standard_fields = [f["name"] for f in fields] + ["summary"]
-        ignored_fields = set(
-            k
-            for k, v in six.iteritems(issue_type_meta["fields"])
-            if v["name"] in HIDDEN_ISSUE_FIELDS["names"]
-        )
-        ignored_fields.update(HIDDEN_ISSUE_FIELDS["keys"])
+        ignored_fields = set()
+        ignored_fields.update(HIDDEN_ISSUE_FIELDS)
         ignored_fields.update(self.get_persisted_ignored_fields())
 
         # apply ordering to fields based on some known built-in Jira fields.
@@ -754,6 +759,15 @@ def create_issue(self, data, **kwargs):
                     elif schema.get("custom") == JIRA_CUSTOM_FIELD_TYPES.get("multiuserpicker"):
                         # custom multi-picker
                         v = [{user_id_field: v}]
+                    elif schema["type"] == "issuelink":  # used by Parent field
+                        v = {"key": v}
+                    elif schema.get("custom") == JIRA_CUSTOM_FIELD_TYPES["epic"]:
+                        v = v
+                    elif schema.get("custom") == JIRA_CUSTOM_FIELD_TYPES["sprint"]:
+                        try:
+                            v = int(v)
+                        except ValueError:
+                            raise IntegrationError("Invalid sprint ({}) specified".format(v))
                     elif schema["type"] == "array" and schema.get("items") == "option":
                         v = [{"value": vx} for vx in v]
                     elif schema["type"] == "array" and schema.get("items") == "string":
diff --git a/src/sentry/integrations/jira/search.py b/src/sentry/integrations/jira/search.py
--- a/src/sentry/integrations/jira/search.py
+++ b/src/sentry/integrations/jira/search.py
@@ -1,5 +1,6 @@
 from __future__ import absolute_import
 
+from bs4 import BeautifulSoup
 import six
 from rest_framework.response import Response
 
@@ -24,16 +25,18 @@ def get(self, request, organization, integration_id):
             integration = self._get_integration(organization, integration_id)
         except Integration.DoesNotExist:
             return Response(status=404)
+        installation = integration.get_installation(organization.id)
+        jira_client = installation.get_client()
 
         field = request.GET.get("field")
         query = request.GET.get("query")
+
         if field is None:
             return Response({"detail": "field is a required parameter"}, status=400)
         if not query:
             return Response({"detail": "query is a required parameter"}, status=400)
 
-        installation = integration.get_installation(organization.id)
-        if field == "externalIssue":
+        if field in ("externalIssue", "parent"):
             if not query:
                 return Response([])
             try:
@@ -48,7 +51,6 @@ def get(self, request, organization, integration_id):
             )
 
         if field in ("assignee", "reporter"):
-            jira_client = installation.get_client()
             try:
                 response = jira_client.search_users_for_project(
                     request.GET.get("project", ""), query
@@ -62,5 +64,19 @@ def get(self, request, organization, integration_id):
             users = [{"value": user_id, "label": display} for user_id, display in user_tuples]
             return Response(users)
 
-        # TODO(jess): handle other autocomplete urls
-        return Response(status=400)
+        try:
+            response = jira_client.get_field_autocomplete(name=field, value=query)
+        except (ApiUnauthorized, ApiError):
+            return Response(
+                {"detail": "Unable to fetch autocomplete for {} from Jira".format(field)},
+                status=400,
+            )
+        choices = [
+            {
+                "value": result["value"],
+                # Jira's response will highlight the matching substring in the name using HTML formatting.
+                "label": BeautifulSoup(result["displayName"], "html.parser").get_text(),
+            }
+            for result in response["results"]
+        ]
+        return Response(choices)


Test patch:
diff --git a/tests/fixtures/integrations/jira/stubs/createmeta_response.json b/tests/fixtures/integrations/jira/stubs/createmeta_response.json
--- a/tests/fixtures/integrations/jira/stubs/createmeta_response.json
+++ b/tests/fixtures/integrations/jira/stubs/createmeta_response.json
@@ -125,6 +125,48 @@
                 {"value": "Feature 2", "id": "10106"}
               ]
             },
+            "customfield_10400": {
+              "operations": [
+                "set"
+              ],
+              "name": "Epic Link",
+              "required": false,
+              "hasDefaultValue": false,
+              "key": "customfield_10400",
+              "schema": {
+                "customId": 10400,
+                "type": "any",
+                "custom": "com.pyxis.greenhopper.jira:gh-epic-link"
+              }
+            },
+            "customfield_10500": {
+              "operations": [
+                "set"
+              ],
+              "name": "Sprint",
+              "required": false,
+              "hasDefaultValue": false,
+              "key": "customfield_10500",
+              "schema": {
+                "items": "json",
+                "customId": 10500,
+                "type": "array",
+                "custom": "com.pyxis.greenhopper.jira:gh-sprint"
+              }
+            },
+            "parent": {
+              "operations": [
+                "set"
+              ],
+              "name": "Parent",
+              "required": false,
+              "hasDefaultValue": false,
+              "key": "parent",
+              "schema": {
+                "type": "issuelink",
+                "system": "parent"
+              }
+            },
             "reporter": {
               "operations": [
                 "set"
diff --git a/tests/sentry/integrations/jira/test_client.py b/tests/sentry/integrations/jira/test_client.py
new file mode 100644
--- /dev/null
+++ b/tests/sentry/integrations/jira/test_client.py
@@ -0,0 +1,62 @@
+from __future__ import absolute_import
+
+import responses
+
+from sentry.integrations.jira.client import JiraCloud
+from sentry.models import Integration
+from sentry.testutils import TestCase
+from sentry.utils import json
+from sentry.utils.compat import mock
+
+
+class StubJiraCloud(JiraCloud):
+    def request_hook(self, *args, **kwargs):
+        r = super(StubJiraCloud, self).request_hook(*args, **kwargs)
+        r["params"]["jwt"] = "my-jwt-token"
+        return r
+
+
+class JiraClientTest(TestCase):
+    @mock.patch("sentry.integrations.jira.integration.JiraCloud", new=StubJiraCloud)
+    def setUp(self):
+        integration = Integration.objects.create(
+            provider="jira",
+            name="Jira Cloud",
+            metadata={
+                "oauth_client_id": "oauth-client-id",
+                "shared_secret": "a-super-secret-key-from-atlassian",
+                "base_url": "https://example.atlassian.net",
+                "domain_name": "example.atlassian.net",
+            },
+        )
+        integration.add_organization(self.organization, self.user)
+        install = integration.get_installation(self.organization.id)
+        self.client = install.get_client()
+
+    @responses.activate
+    def test_get_field_autocomplete_for_non_customfield(self):
+        body = {"results": [{"value": "ISSUE-1", "displayName": "My Issue (ISSUE-1)"}]}
+        responses.add(
+            method=responses.GET,
+            url="https://example.atlassian.net/rest/api/2/jql/autocompletedata/suggestions?fieldName=my_field&fieldValue=abc&jwt=my-jwt-token",
+            body=json.dumps(body),
+            status=200,
+            content_type="application/json",
+            match_querystring=True,
+        )
+        res = self.client.get_field_autocomplete("my_field", "abc")
+        assert res == body
+
+    @responses.activate
+    def test_get_field_autocomplete_for_customfield(self):
+        body = {"results": [{"value": "ISSUE-1", "displayName": "My Issue (ISSUE-1)"}]}
+        responses.add(
+            method=responses.GET,
+            url="https://example.atlassian.net/rest/api/2/jql/autocompletedata/suggestions?fieldName=cf[0123]&fieldValue=abc&jwt=my-jwt-token",
+            body=json.dumps(body),
+            status=200,
+            content_type="application/json",
+            match_querystring=True,
+        )
+        res = self.client.get_field_autocomplete("customfield_0123", "abc")
+        assert res == body
diff --git a/tests/sentry/integrations/jira/test_integration.py b/tests/sentry/integrations/jira/test_integration.py
--- a/tests/sentry/integrations/jira/test_integration.py
+++ b/tests/sentry/integrations/jira/test_integration.py
@@ -64,9 +64,10 @@ def test_get_create_issue_config(self):
             project_id=self.project.id,
         )
         group = event.group
-
         installation = self.integration.get_installation(self.organization.id)
-
+        search_url = reverse(
+            "sentry-extensions-jira-search", args=[self.organization.slug, self.integration.id],
+        )
         with mock.patch.object(installation, "get_client", get_client):
             assert installation.get_create_issue_config(group, self.user) == [
                 {
@@ -130,18 +131,39 @@ def test_get_create_issue_config(self):
                     "type": "select",
                 },
                 {
+                    "name": "customfield_10400",
+                    "url": search_url,
+                    "choices": [],
+                    "label": "Epic Link",
+                    "required": False,
+                    "type": "select",
+                },
+                {
+                    "name": "customfield_10500",
+                    "url": search_url,
+                    "choices": [],
+                    "label": "Sprint",
+                    "required": False,
+                    "type": "select",
+                },
+                {
+                    "name": "labels",
                     "default": "",
                     "required": False,
                     "type": "text",
-                    "name": "labels",
                     "label": "Labels",
                 },
+                {
+                    "name": "parent",
+                    "url": search_url,
+                    "choices": [],
+                    "label": "Parent",
+                    "required": False,
+                    "type": "select",
+                },
                 {
                     "name": "reporter",
-                    "url": reverse(
-                        "sentry-extensions-jira-search",
-                        args=[self.organization.slug, self.integration.id],
-                    ),
+                    "url": search_url,
                     "required": True,
                     "choices": [],
                     "label": "Reporter",
@@ -223,7 +245,10 @@ def test_get_create_issue_config_with_ignored_fields(self):
                 "issuetype",
                 "customfield_10200",
                 "customfield_10300",
+                "customfield_10400",
+                "customfield_10500",
                 "labels",
+                "parent",
                 "reporter",
             ]
 
@@ -238,7 +263,10 @@ def test_get_create_issue_config_with_ignored_fields(self):
                 "description",
                 "issuetype",
                 "customfield_10300",
+                "customfield_10400",
+                "customfield_10500",
                 "labels",
+                "parent",
                 "reporter",
             ]
 
diff --git a/tests/sentry/integrations/jira/test_search_endpoint.py b/tests/sentry/integrations/jira/test_search_endpoint.py
--- a/tests/sentry/integrations/jira/test_search_endpoint.py
+++ b/tests/sentry/integrations/jira/test_search_endpoint.py
@@ -66,9 +66,12 @@ def responder(request):
         path = reverse("sentry-extensions-jira-search", args=[org.slug, self.integration.id])
 
         # queries come through from the front end lowercased, so HSP-1 -> hsp-1
-        resp = self.client.get("%s?field=externalIssue&query=hsp-1" % (path,))
-        assert resp.status_code == 200
-        assert resp.data == [{"label": "(HSP-1) this is a test issue summary", "value": "HSP-1"}]
+        for field in ("externalIssue", "parent"):
+            resp = self.client.get("%s?field=%s&query=hsp-1" % (path, field))
+            assert resp.status_code == 200
+            assert resp.data == [
+                {"label": "(HSP-1) this is a test issue summary", "value": "HSP-1"}
+            ]
 
     @responses.activate
     def test_issue_search_error(self):
@@ -84,9 +87,12 @@ def test_issue_search_error(self):
 
         path = reverse("sentry-extensions-jira-search", args=[org.slug, self.integration.id])
 
-        resp = self.client.get("%s?field=externalIssue&query=test" % (path,))
-        assert resp.status_code == 400
-        assert resp.data == {"detail": "Error Communicating with Jira (HTTP 500): unknown error"}
+        for field in ("externalIssue", "parent"):
+            resp = self.client.get("%s?field=%s&query=test" % (path, field))
+            assert resp.status_code == 400
+            assert resp.data == {
+                "detail": "Error Communicating with Jira (HTTP 500): unknown error"
+            }
 
     @responses.activate
     def test_assignee_search(self):
@@ -142,3 +148,47 @@ def test_assignee_search_error(self):
 
         resp = self.client.get("%s?project=10000&field=assignee&query=bob" % (path,))
         assert resp.status_code == 400
+
+    @responses.activate
+    def test_customfield_search(self):
+        def responder(request):
+            query = parse_qs(urlparse(request.url).query)
+            assert "cf[0123]" == query["fieldName"][0]
+            assert "sp" == query["fieldValue"][0]
+            return 200, {}, '{"results": [{"displayName": "<b>Sp</b>rint 1 (1)", "value": "1"}]}'
+
+        responses.add_callback(
+            responses.GET,
+            "https://example.atlassian.net/rest/api/2/jql/autocompletedata/suggestions",
+            callback=responder,
+            content_type="application/json",
+            match_querystring=False,
+        )
+        org = self.organization
+        self.login_as(self.user)
+
+        path = reverse("sentry-extensions-jira-search", args=[org.slug, self.integration.id])
+
+        resp = self.client.get("%s?field=customfield_0123&query=sp" % (path,))
+        assert resp.status_code == 200
+        assert resp.data == [{"label": "Sprint 1 (1)", "value": "1"}]
+
+    @responses.activate
+    def test_customfield_search_error(self):
+        responses.add(
+            responses.GET,
+            "https://example.atlassian.net/rest/api/2/jql/autocompletedata/suggestions",
+            status=500,
+            body="Totally broken",
+            match_querystring=False,
+        )
+        org = self.organization
+        self.login_as(self.user)
+
+        path = reverse("sentry-extensions-jira-search", args=[org.slug, self.integration.id])
+
+        resp = self.client.get("%s?field=customfield_0123&query=sp" % (path,))
+        assert resp.status_code == 400
+        assert resp.data == {
+            "detail": "Unable to fetch autocomplete for customfield_0123 from Jira"
+        }