Browse Source

ref(Jira): Split Jira Cloud and Jira Server (#37034)

* Split Jira Cloud and Jira Server
Colleen O'Rourke 2 years ago
parent
commit
2fbf550ec0

+ 2 - 2
src/sentry/integrations/jira/__init__.py

@@ -1,12 +1,12 @@
 from sentry.rules import rules
 
 from .actions import JiraCreateTicketAction
-from .client import JIRA_KEY, JiraApiClient
+from .client import JIRA_KEY, JiraCloudClient
 from .integration import JiraIntegration, JiraIntegrationProvider
 
 __all__ = (
     "JIRA_KEY",
-    "JiraApiClient",
+    "JiraCloudClient",
     "JiraCreateTicketAction",
     "JiraIntegration",
     "JiraIntegrationProvider",

+ 38 - 64
src/sentry/integrations/jira/client.py

@@ -16,60 +16,7 @@ ISSUE_KEY_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]*-\d+$")
 CUSTOMFIELD_PREFIX = "customfield_"
 
 
-class JiraCloud:
-    """
-    Contains the jira-cloud specifics that a JiraClient needs
-    in order to communicate with jira
-    """
-
-    def __init__(self, shared_secret):
-        self.shared_secret = shared_secret
-
-    @property
-    def cache_prefix(self):
-        return "sentry-jira-2:"
-
-    def request_hook(self, method, path, data, params, **kwargs):
-        """
-        Used by Jira Client to apply the jira-cloud authentication
-        """
-        # handle params that are already part of the path
-        url_params = dict(parse_qs(urlsplit(path).query))
-        url_params.update(params or {})
-        path = path.split("?")[0]
-
-        jwt_payload = {
-            "iss": JIRA_KEY,
-            "iat": datetime.datetime.utcnow(),
-            "exp": datetime.datetime.utcnow() + datetime.timedelta(seconds=5 * 60),
-            "qsh": get_query_hash(path, method.upper(), url_params),
-        }
-        encoded_jwt = jwt.encode(jwt_payload, self.shared_secret)
-        params = dict(jwt=encoded_jwt, **(url_params or {}))
-        request_spec = kwargs.copy()
-        request_spec.update(dict(method=method, path=path, data=data, params=params))
-        return request_spec
-
-    def user_id_field(self):
-        """
-        Jira-Cloud requires GDPR compliant API usage so we have to use accountId
-        """
-        return "accountId"
-
-    def user_query_param(self):
-        """
-        Jira-Cloud requires GDPR compliant API usage so we have to use query
-        """
-        return "query"
-
-    def user_id_get_param(self):
-        """
-        Jira-Cloud requires GDPR compliant API usage so we have to use accountId
-        """
-        return "accountId"
-
-
-class JiraApiClient(ApiClient):
+class JiraCloudClient(ApiClient):
     # TODO: Update to v3 endpoints
     COMMENTS_URL = "/rest/api/2/issue/%s/comment"
     COMMENT_URL = "/rest/api/2/issue/%s/comment/%s"
@@ -97,23 +44,41 @@ class JiraApiClient(ApiClient):
     # lets the user make their second jira issue with cached data.
     cache_time = 240
 
-    def __init__(self, base_url, jira_style, verify_ssl, logging_context=None):
+    def __init__(self, base_url, shared_secret, verify_ssl, logging_context=None):
         self.base_url = base_url
-        # `jira_style` encapsulates differences between jira server & jira cloud.
-        # We only support one API version for Jira, but server/cloud require different
-        # authentication mechanisms and caching.
-        self.jira_style = jira_style
+        self.shared_secret = shared_secret
         super().__init__(verify_ssl, logging_context)
 
     def get_cache_prefix(self):
-        return self.jira_style.cache_prefix
+        return "sentry-jira-2:"
+
+    def request_hook(self, method, path, data, params, **kwargs):
+        """
+        Used by Jira Client to apply the jira-cloud authentication
+        """
+        # handle params that are already part of the path
+        url_params = dict(parse_qs(urlsplit(path).query))
+        url_params.update(params or {})
+        path = path.split("?")[0]
+
+        jwt_payload = {
+            "iss": JIRA_KEY,
+            "iat": datetime.datetime.utcnow(),
+            "exp": datetime.datetime.utcnow() + datetime.timedelta(seconds=5 * 60),
+            "qsh": get_query_hash(path, method.upper(), url_params),
+        }
+        encoded_jwt = jwt.encode(jwt_payload, self.shared_secret)
+        params = dict(jwt=encoded_jwt, **(url_params or {}))
+        request_spec = kwargs.copy()
+        request_spec.update(dict(method=method, path=path, data=data, params=params))
+        return request_spec
 
     def request(self, method, path, data=None, params=None, **kwargs):
         """
         Use the request_hook method for our specific style of Jira to
         add authentication data and transform parameters.
         """
-        request_spec = self.jira_style.request_hook(method, path, data, params, **kwargs)
+        request_spec = self.request_hook(method, path, data, params, **kwargs)
         if "headers" not in request_spec:
             request_spec["headers"] = {}
 
@@ -124,13 +89,22 @@ class JiraApiClient(ApiClient):
         return self._request(**request_spec)
 
     def user_id_get_param(self):
-        return self.jira_style.user_id_get_param()
+        """
+        Jira-Cloud requires GDPR compliant API usage so we have to use accountId
+        """
+        return "accountId"
 
     def user_id_field(self):
-        return self.jira_style.user_id_field()
+        """
+        Jira-Cloud requires GDPR compliant API usage so we have to use accountId
+        """
+        return "accountId"
 
     def user_query_param(self):
-        return self.jira_style.user_query_param()
+        """
+        Jira-Cloud requires GDPR compliant API usage so we have to use query
+        """
+        return "query"
 
     def get_issue(self, issue_id):
         return self.get(self.ISSUE_URL % (issue_id,))

+ 3 - 3
src/sentry/integrations/jira/integration.py

@@ -35,7 +35,7 @@ from sentry.shared_integrations.exceptions import (
 from sentry.utils.decorators import classproperty
 from sentry.utils.http import absolute_uri
 
-from .client import JiraApiClient, JiraCloud
+from .client import JiraCloudClient
 from .utils import build_user_choice
 
 logger = logging.getLogger("sentry.integrations.jira")
@@ -342,9 +342,9 @@ class JiraIntegration(IntegrationInstallation, IssueSyncMixin):
             logging_context["integration_id"] = attrgetter("org_integration.integration.id")(self)
             logging_context["org_integration_id"] = attrgetter("org_integration.id")(self)
 
-        return JiraApiClient(
+        return JiraCloudClient(
             self.model.metadata["base_url"],
-            JiraCloud(self.model.metadata["shared_secret"]),
+            self.model.metadata["shared_secret"],
             verify_ssl=True,
             logging_context=logging_context,
         )

+ 4 - 4
src/sentry/integrations/jira/utils/api.py

@@ -9,7 +9,7 @@ from rest_framework.response import Response
 from sentry.integrations.utils import sync_group_assignee_inbound
 from sentry.shared_integrations.exceptions import ApiError
 
-from ..client import JiraApiClient, JiraCloud
+from ..client import JiraCloudClient
 
 if TYPE_CHECKING:
     from sentry.models import Integration
@@ -18,10 +18,10 @@ if TYPE_CHECKING:
 logger = logging.getLogger(__name__)
 
 
-def _get_client(integration: Integration) -> JiraApiClient:
-    return JiraApiClient(
+def _get_client(integration: Integration) -> JiraCloudClient:
+    return JiraCloudClient(
         integration.metadata["base_url"],
-        JiraCloud(integration.metadata["shared_secret"]),
+        integration.metadata["shared_secret"],
         verify_ssl=True,
     )
 

+ 0 - 2
src/sentry/integrations/jira/utils/choice.py

@@ -6,8 +6,6 @@ def build_user_choice(user_response, user_id_field):
     if user_id_field not in user_response:
         return None
 
-    # The name field can be blank in jira-cloud, and the id_field varies by
-    # jira-cloud and jira-server
     name = user_response.get("name", "")
     email = user_response.get("emailAddress")
 

+ 207 - 58
src/sentry/integrations/jira_server/client.py

@@ -1,19 +1,223 @@
-from urllib.parse import parse_qsl
+import logging
+import re
+from urllib.parse import parse_qsl, urlparse
 
 from django.urls import reverse
 from oauthlib.oauth1 import SIGNATURE_RSA
 from requests_oauthlib import OAuth1
 
 from sentry.integrations.client import ApiClient
-from sentry.integrations.jira.client import JiraApiClient
 from sentry.shared_integrations.exceptions import ApiError
 from sentry.utils import jwt
 from sentry.utils.http import absolute_uri
 
+logger = logging.getLogger("sentry.integrations.jira_server")
+
+JIRA_KEY = f"{urlparse(absolute_uri()).hostname}.jira"
+ISSUE_KEY_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]*-\d+$")
+CUSTOMFIELD_PREFIX = "customfield_"
+
+
+class JiraServerClient(ApiClient):
+    COMMENTS_URL = "/rest/api/2/issue/%s/comment"
+    COMMENT_URL = "/rest/api/2/issue/%s/comment/%s"
+    STATUS_URL = "/rest/api/2/status"
+    CREATE_URL = "/rest/api/2/issue"
+    ISSUE_URL = "/rest/api/2/issue/%s"
+    META_URL = "/rest/api/2/issue/createmeta"  # TODO replace, this is deprecated
+    PRIORITIES_URL = "/rest/api/2/priority"
+    PROJECT_URL = "/rest/api/2/project"
+    SEARCH_URL = "/rest/api/2/search/"
+    VERSIONS_URL = "/rest/api/2/project/%s/versions"
+    USERS_URL = "/rest/api/2/user/assignable/search"
+    USER_URL = "/rest/api/2/user"
+    SERVER_INFO_URL = "/rest/api/2/serverInfo"
+    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"
+    PROPERTIES_URL = "/rest/api/3/issue/%s/properties/%s"
 
-class JiraServerClient(JiraApiClient):
     integration_name = "jira_server"
 
+    # This timeout is completely arbitrary. Jira doesn't give us any
+    # caching headers to work with. Ideally we want a duration that
+    # lets the user make their second jira issue with cached data.
+    cache_time = 240
+
+    def __init__(self, base_url, credentials, verify_ssl, logging_context=None):
+        self.base_url = base_url
+        self.credentials = credentials
+        super().__init__(verify_ssl, logging_context)
+
+    def get_cache_prefix(self):
+        return "sentry-jira-server:"
+
+    def request_hook(self, method, path, data, params, **kwargs):
+        """
+        Used by Jira Client to apply the jira-server authentication
+        Which is RSA signed OAuth1
+        """
+        if "auth" not in kwargs:
+            kwargs["auth"] = OAuth1(
+                client_key=self.credentials["consumer_key"],
+                rsa_key=self.credentials["private_key"],
+                resource_owner_key=self.credentials["access_token"],
+                resource_owner_secret=self.credentials["access_token_secret"],
+                signature_method=SIGNATURE_RSA,
+                signature_type="auth_header",
+            )
+
+        request_spec = kwargs.copy()
+        request_spec.update(dict(method=method, path=path, data=data, params=params))
+        return request_spec
+
+    def request(self, method, path, data=None, params=None, **kwargs):
+        """
+        Use the request_hook method for our specific style of Jira to
+        add authentication data and transform parameters.
+        """
+        request_spec = self.request_hook(method, path, data, params, **kwargs)
+        if "headers" not in request_spec:
+            request_spec["headers"] = {}
+
+        # Force adherence to the GDPR compliant API conventions.
+        # See
+        # https://developer.atlassian.com/cloud/jira/platform/deprecation-notice-user-privacy-api-migration-guide
+        request_spec["headers"]["x-atlassian-force-account-id"] = "true"
+        return self._request(**request_spec)
+
+    def user_id_get_param(self):
+        return "username"
+
+    def user_id_field(self):
+        return "name"
+
+    def user_query_param(self):
+        return "username"
+
+    def get_issue(self, issue_id):
+        return self.get(self.ISSUE_URL % (issue_id,))
+
+    def search_issues(self, query):
+        q = query.replace('"', '\\"')
+        # check if it looks like an issue id
+        if ISSUE_KEY_RE.match(query):
+            jql = f'id="{q}"'
+        else:
+            jql = f'text ~ "{q}"'
+        return self.get(self.SEARCH_URL, params={"jql": jql})
+
+    def create_comment(self, issue_key, comment):
+        return self.post(self.COMMENTS_URL % issue_key, data={"body": comment})
+
+    def update_comment(self, issue_key, comment_id, comment):
+        return self.put(self.COMMENT_URL % (issue_key, comment_id), data={"body": comment})
+
+    def get_projects_list(self):
+        return self.get_cached(self.PROJECT_URL)
+
+    def get_project_key_for_id(self, project_id):
+        if not project_id:
+            return ""
+        projects = self.get_projects_list()
+        for project in projects:
+            if project["id"] == project_id:
+                return project["key"].encode("utf-8")
+        return ""
+
+    def get_create_meta_for_project(self, project):
+        params = {"expand": "projects.issuetypes.fields", "projectIds": project}
+        metas = self.get_cached(self.META_URL, params=params)
+        # We saw an empty JSON response come back from the API :(
+        if not metas:
+            logger.info(
+                "jira.get-create-meta.empty-response",
+                extra={"base_url": self.base_url, "project": project},
+            )
+            return None
+
+        # XXX(dcramer): document how this is possible, if it even is
+        if len(metas["projects"]) > 1:
+            raise ApiError(f"More than one project found matching {project}.")
+
+        try:
+            return metas["projects"][0]
+        except IndexError:
+            logger.info(
+                "jira.get-create-meta.key-error",
+                extra={"base_url": self.base_url, "project": project},
+            )
+            return None
+
+    def get_versions(self, project):
+        return self.get_cached(self.VERSIONS_URL % project)
+
+    def get_priorities(self):
+        return self.get_cached(self.PRIORITIES_URL)
+
+    def get_users_for_project(self, project):
+        # Jira Server wants a project key, while cloud is indifferent.
+        project_key = self.get_project_key_for_id(project)
+        return self.get_cached(self.USERS_URL, params={"project": project_key})
+
+    def search_users_for_project(self, project, username):
+        # Jira Server wants a project key, while cloud is indifferent.
+        project_key = self.get_project_key_for_id(project)
+        return self.get_cached(
+            self.USERS_URL, params={"project": project_key, self.user_query_param(): username}
+        )
+
+    def search_users_for_issue(self, issue_key, email):
+        return self.get_cached(
+            self.USERS_URL, params={"issueKey": issue_key, self.user_query_param(): email}
+        )
+
+    def get_user(self, user_id):
+        user_id_get_param = self.user_id_get_param()
+        return self.get_cached(self.USER_URL, params={user_id_get_param: user_id})
+
+    def create_issue(self, raw_form_data):
+        data = {"fields": raw_form_data}
+        return self.post(self.CREATE_URL, data=data)
+
+    def get_server_info(self):
+        return self.get(self.SERVER_INFO_URL)
+
+    def get_valid_statuses(self):
+        return self.get_cached(self.STATUS_URL)
+
+    def get_transitions(self, issue_key):
+        return self.get_cached(self.TRANSITION_URL % issue_key)["transitions"]
+
+    def transition_issue(self, issue_key, transition_id):
+        return self.post(self.TRANSITION_URL % issue_key, {"transition": {"id": transition_id}})
+
+    def assign_issue(self, key, name_or_account_id):
+        user_id_field = self.user_id_field()
+        return self.put(self.ASSIGN_URL % key, data={user_id_field: name_or_account_id})
+
+    def set_issue_property(self, issue_key, badge_num):
+        module_key = "sentry-issues-glance"
+        properties_key = f"com.atlassian.jira.issue:{JIRA_KEY}:{module_key}:status"
+        data = {"type": "badge", "value": {"label": badge_num}}
+        return self.put(self.PROPERTIES_URL % (issue_key, properties_key), data=data)
+
+    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 = f"cf[{cf_id}]"
+        else:
+            jql_name = name
+        return self.get_cached(
+            self.AUTOCOMPLETE_URL, params={"fieldName": jql_name, "fieldValue": value}
+        )
+
 
 class JiraServerSetupClient(ApiClient):
     """
@@ -102,58 +306,3 @@ class JiraServerSetupClient(ApiClient):
                 signature_type="auth_header",
             )
         return self._request(*args, **kwargs)
-
-
-class JiraServer:
-    """
-    Contains the jira-server specifics that a JiraClient needs
-    in order to communicate with jira
-
-    You can find JIRA REST API docs here:
-
-    https://developer.atlassian.com/server/jira/platform/rest-apis/
-    """
-
-    def __init__(self, credentials):
-        self.credentials = credentials
-
-    @property
-    def cache_prefix(self):
-        return "sentry-jira-server:"
-
-    def request_hook(self, method, path, data, params, **kwargs):
-        """
-        Used by Jira Client to apply the jira-server authentication
-        Which is RSA signed OAuth1
-        """
-        if "auth" not in kwargs:
-            kwargs["auth"] = OAuth1(
-                client_key=self.credentials["consumer_key"],
-                rsa_key=self.credentials["private_key"],
-                resource_owner_key=self.credentials["access_token"],
-                resource_owner_secret=self.credentials["access_token_secret"],
-                signature_method=SIGNATURE_RSA,
-                signature_type="auth_header",
-            )
-
-        request_spec = kwargs.copy()
-        request_spec.update(dict(method=method, path=path, data=data, params=params))
-        return request_spec
-
-    def user_id_field(self):
-        """
-        Jira-Server doesn't require GDPR compliant API usage so we can use `name`
-        """
-        return "name"
-
-    def user_query_param(self):
-        """
-        Jira-Server doesn't require GDPR compliant API usage so we can use `username`
-        """
-        return "username"
-
-    def user_id_get_param(self):
-        """
-        Jira-Server doesn't require compliant API usage so we can use `username
-        """
-        return "username"

+ 851 - 7
src/sentry/integrations/jira_server/integration.py

@@ -1,4 +1,8 @@
+from __future__ import annotations
+
 import logging
+import re
+from typing import Any, Mapping, Optional, Sequence
 from urllib.parse import urlparse
 
 from cryptography.hazmat.backends import default_backend
@@ -11,22 +15,38 @@ from django.views.decorators.csrf import csrf_exempt
 from rest_framework.request import Request
 from rest_framework.response import Response
 
+from sentry import features
 from sentry.integrations import (
     FeatureDescription,
     IntegrationFeatures,
+    IntegrationInstallation,
     IntegrationMetadata,
     IntegrationProvider,
 )
-from sentry.integrations.jira import JiraIntegration
-from sentry.models import Identity
+from sentry.integrations.jira_server.utils.choice import build_user_choice
+from sentry.integrations.mixins import IssueSyncMixin, ResolveSyncAction
+from sentry.models import (
+    ExternalIssue,
+    Identity,
+    IntegrationExternalProject,
+    Organization,
+    OrganizationIntegration,
+    User,
+)
 from sentry.pipeline import PipelineView
-from sentry.shared_integrations.exceptions import ApiError, IntegrationError
+from sentry.shared_integrations.exceptions import (
+    ApiError,
+    ApiHostError,
+    ApiUnauthorized,
+    IntegrationError,
+    IntegrationFormError,
+)
 from sentry.utils.decorators import classproperty
 from sentry.utils.hashlib import sha1_text
 from sentry.utils.http import absolute_uri
 from sentry.web.helpers import render_to_response
 
-from .client import JiraServer, JiraServerClient, JiraServerSetupClient
+from .client import JiraServerClient, JiraServerSetupClient
 
 logger = logging.getLogger("sentry.integrations.jira_server")
 
@@ -223,11 +243,33 @@ class OAuthCallbackView(PipelineView):
             return pipeline.error("Could not fetch an access token from Jira")
 
 
-class JiraServerIntegration(JiraIntegration):
+# 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 = {
+    "select": "com.atlassian.jira.plugin.system.customfieldtypes:select",
+    "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",
+}
+
+
+class JiraServerIntegration(IntegrationInstallation, IssueSyncMixin):
     """
     IntegrationInstallation implementation for Jira-Server
     """
 
+    comment_key = "sync_comments"
+    outbound_status_key = "sync_status_forward"
+    inbound_status_key = "sync_status_reverse"
+    outbound_assignee_key = "sync_forward_assignment"
+    inbound_assignee_key = "sync_reverse_assignment"
+    issues_ignored_fields_key = "issues_ignored_fields"
+
     default_identity = None
 
     @classproperty
@@ -244,13 +286,188 @@ class JiraServerIntegration(JiraIntegration):
 
         return JiraServerClient(
             self.model.metadata["base_url"],
-            JiraServer(self.default_identity.data),
+            self.default_identity.data,
             self.model.metadata["verify_ssl"],
         )
 
+    def get_organization_config(self):
+        configuration = [
+            {
+                "name": self.outbound_status_key,
+                "type": "choice_mapper",
+                "label": _("Sync Sentry Status to Jira Server"),
+                "help": _(
+                    "When a Sentry issue changes status, change the status of the linked ticket in Jira Server."
+                ),
+                "addButtonText": _("Add Jira Server Project"),
+                "addDropdown": {
+                    "emptyMessage": _("All projects configured"),
+                    "noResultsMessage": _("Could not find Jira Server project"),
+                    "items": [],  # Populated with projects
+                },
+                "mappedSelectors": {
+                    "on_resolve": {"choices": [], "placeholder": _("Select a status")},
+                    "on_unresolve": {"choices": [], "placeholder": _("Select a status")},
+                },
+                "columnLabels": {
+                    "on_resolve": _("When resolved"),
+                    "on_unresolve": _("When unresolved"),
+                },
+                "mappedColumnLabel": _("Jira Server Project"),
+                "formatMessageValue": False,
+            },
+            {
+                "name": self.outbound_assignee_key,
+                "type": "boolean",
+                "label": _("Sync Sentry Assignment to Jira Server"),
+                "help": _(
+                    "When an issue is assigned in Sentry, assign its linked Jira Server ticket to the same user."
+                ),
+            },
+            {
+                "name": self.comment_key,
+                "type": "boolean",
+                "label": _("Sync Sentry Comments to Jira Server"),
+                "help": _("Post comments from Sentry issues to linked Jira Server tickets"),
+            },
+            {
+                "name": self.inbound_status_key,
+                "type": "boolean",
+                "label": _("Sync Jira Server Status to Sentry"),
+                "help": _(
+                    "When a Jira Server ticket is marked done, resolve its linked issue in Sentry. "
+                    "When a Jira Server ticket is removed from being done, unresolve its linked Sentry issue."
+                ),
+            },
+            {
+                "name": self.inbound_assignee_key,
+                "type": "boolean",
+                "label": _("Sync Jira Server Assignment to Sentry"),
+                "help": _(
+                    "When a ticket is assigned in Jira Server, assign its linked Sentry issue to the same user."
+                ),
+            },
+            {
+                "name": self.issues_ignored_fields_key,
+                "label": "Ignored Fields",
+                "type": "textarea",
+                "placeholder": _('e.g. "components, security, customfield_10006"'),
+                "help": _(
+                    "Comma-separated list of Jira Server fields that you don't want to show in issue creation form"
+                ),
+            },
+        ]
+
+        client = self.get_client()
+
+        try:
+            statuses = [(c["id"], c["name"]) for c in client.get_valid_statuses()]
+            configuration[0]["mappedSelectors"]["on_resolve"]["choices"] = statuses
+            configuration[0]["mappedSelectors"]["on_unresolve"]["choices"] = statuses
+
+            projects = [{"value": p["id"], "label": p["name"]} for p in client.get_projects_list()]
+            configuration[0]["addDropdown"]["items"] = projects
+        except ApiError:
+            configuration[0]["disabled"] = True
+            configuration[0]["disabledReason"] = _(
+                "Unable to communicate with the Jira instance. You may need to reinstall the addon."
+            )
+
+        organization = Organization.objects.get(id=self.organization_id)
+        has_issue_sync = features.has("organizations:integrations-issue-sync", organization)
+        if not has_issue_sync:
+            for field in configuration:
+                field["disabled"] = True
+                field["disabledReason"] = _(
+                    "Your organization does not have access to this feature"
+                )
+
+        return configuration
+
+    def update_organization_config(self, data):
+        """
+        Update the configuration field for an organization integration.
+        """
+        config = self.org_integration.config
+
+        if "sync_status_forward" in data:
+            project_mappings = data.pop("sync_status_forward")
+
+            if any(
+                not mapping["on_unresolve"] or not mapping["on_resolve"]
+                for mapping in project_mappings.values()
+            ):
+                raise IntegrationError("Resolve and unresolve status are required.")
+
+            data["sync_status_forward"] = bool(project_mappings)
+
+            IntegrationExternalProject.objects.filter(
+                organization_integration_id=self.org_integration.id
+            ).delete()
+
+            for project_id, statuses in project_mappings.items():
+                IntegrationExternalProject.objects.create(
+                    organization_integration_id=self.org_integration.id,
+                    external_id=project_id,
+                    resolved_status=statuses["on_resolve"],
+                    unresolved_status=statuses["on_unresolve"],
+                )
+
+        if self.issues_ignored_fields_key in data:
+            ignored_fields_text = data.pop(self.issues_ignored_fields_key)
+            # While we describe the config as a "comma-separated list", users are likely to
+            # accidentally use newlines, so we explicitly handle that case. On page
+            # refresh, they will see how it got interpreted as `get_config_data` will
+            # re-serialize the config as a comma-separated list.
+            ignored_fields_list = list(
+                filter(
+                    None, [field.strip() for field in re.split(r"[,\n\r]+", ignored_fields_text)]
+                )
+            )
+            data[self.issues_ignored_fields_key] = ignored_fields_list
+
+        config.update(data)
+        self.org_integration.update(config=config)
+
+    def get_config_data(self):
+        config = self.org_integration.config
+        project_mappings = IntegrationExternalProject.objects.filter(
+            organization_integration_id=self.org_integration.id
+        )
+        sync_status_forward = {}
+        for pm in project_mappings:
+            sync_status_forward[pm.external_id] = {
+                "on_unresolve": pm.unresolved_status,
+                "on_resolve": pm.resolved_status,
+            }
+        config["sync_status_forward"] = sync_status_forward
+        config[self.issues_ignored_fields_key] = ", ".join(
+            config.get(self.issues_ignored_fields_key, "")
+        )
+        return config
+
+    def sync_metadata(self):
+        client = self.get_client()
+
+        try:
+            server_info = client.get_server_info()
+            projects = client.get_projects_list()
+        except ApiError as e:
+            raise IntegrationError(self.message_from_error(e))
+
+        self.model.name = server_info["serverTitle"]
+
+        # There is no Jira instance icon (there is a favicon, but it doesn't seem
+        # possible to query that with the API). So instead we just use the first
+        # project Icon.
+        if len(projects) > 0:
+            avatar = (projects[0]["avatarUrls"]["48x48"],)
+            self.model.metadata.update({"icon": avatar})
+
+        self.model.save()
+
     def get_link_issue_config(self, group, **kwargs):
         fields = super().get_link_issue_config(group, **kwargs)
-
         org = group.organization
         autocomplete_url = reverse(
             "sentry-extensions-jiraserver-search", args=[org.slug, self.model.id]
@@ -277,9 +494,636 @@ class JiraServerIntegration(JiraIntegration):
 
         return fields
 
+    def get_issue_url(self, key, **kwargs):
+        return "{}/browse/{}".format(self.model.metadata["base_url"], key)
+
+    def get_persisted_default_config_fields(self) -> Sequence[str]:
+        return ["project", "issuetype", "priority", "labels"]
+
+    def get_persisted_user_default_config_fields(self):
+        return ["reporter"]
+
+    def get_persisted_ignored_fields(self):
+        return self.org_integration.config.get(self.issues_ignored_fields_key, [])
+
+    def get_group_description(self, group, event, **kwargs):
+        output = [
+            "Sentry Issue: [{}|{}]".format(
+                group.qualified_short_id,
+                absolute_uri(group.get_absolute_url(params={"referrer": "jira_integration"})),
+            )
+        ]
+        body = self.get_group_body(group, event)
+        if body:
+            output.extend(["", "{code}", body, "{code}"])
+        return "\n".join(output)
+
+    def get_issue(self, issue_id, **kwargs):
+        """
+        Jira installation's implementation of IssueSyncMixin's `get_issue`.
+        """
+        client = self.get_client()
+        issue = client.get_issue(issue_id)
+        fields = issue.get("fields", {})
+        return {
+            "key": issue_id,
+            "title": fields.get("summary"),
+            "description": fields.get("description"),
+        }
+
+    def create_comment(self, issue_id, user_id, group_note):
+        # https://jira.atlassian.com/secure/WikiRendererHelpAction.jspa?section=texteffects
+        comment = group_note.data["text"]
+        quoted_comment = self.create_comment_attribution(user_id, comment)
+        return self.get_client().create_comment(issue_id, quoted_comment)
+
+    def create_comment_attribution(self, user_id, comment_text):
+        user = User.objects.get(id=user_id)
+        attribution = f"{user.name} wrote:\n\n"
+        return f"{attribution}{{quote}}{comment_text}{{quote}}"
+
+    def update_comment(self, issue_id, user_id, group_note):
+        quoted_comment = self.create_comment_attribution(user_id, group_note.data["text"])
+        return self.get_client().update_comment(
+            issue_id, group_note.data["external_id"], quoted_comment
+        )
+
+    def search_issues(self, query):
+        try:
+            return self.get_client().search_issues(query)
+        except ApiError as e:
+            raise self.raise_error(e)
+
+    def make_choices(self, values):
+        if not values:
+            return []
+        results = []
+        for item in values:
+            key = item.get("id", None)
+            if "name" in item:
+                value = item["name"]
+            elif "value" in item:
+                # Value based options prefer the value on submit.
+                key = item["value"]
+                value = item["value"]
+            elif "label" in item:
+                # Label based options prefer the value on submit.
+                key = item["label"]
+                value = item["label"]
+            else:
+                continue
+            results.append((key, value))
+        return results
+
+    def error_message_from_json(self, data):
+        message = ""
+        if data.get("errorMessages"):
+            message = " ".join(data["errorMessages"])
+        if data.get("errors"):
+            if message:
+                message += " "
+            message += " ".join(f"{k}: {v}" for k, v in data.get("errors").items())
+        return message
+
+    def error_fields_from_json(self, data):
+        errors = data.get("errors")
+        if not errors:
+            return None
+
+        return {key: [error] for key, error in data.get("errors").items()}
+
     def search_url(self, org_slug):
         return reverse("sentry-extensions-jiraserver-search", args=[org_slug, self.model.id])
 
+    def build_dynamic_field(self, field_meta, group=None):
+        """
+        Builds a field based on Jira's meta field information
+        """
+        schema = field_meta["schema"]
+
+        # set up some defaults for form fields
+        fieldtype = "text"
+        fkwargs = {"label": field_meta["name"], "required": field_meta["required"]}
+        # override defaults based on field configuration
+        if (
+            schema["type"] in ["securitylevel", "priority"]
+            or schema.get("custom") == JIRA_CUSTOM_FIELD_TYPES["select"]
+        ):
+            fieldtype = "select"
+            fkwargs["choices"] = self.make_choices(field_meta.get("allowedValues"))
+        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 = (
+                group.organization
+                if group
+                else Organization.objects.get_from_cache(id=self.organization_id)
+            )
+            fkwargs["url"] = self.search_url(organization.slug)
+            fkwargs["choices"] = []
+        elif schema["type"] in ["timetracking"]:
+            # TODO: Implement timetracking (currently unsupported altogether)
+            return None
+        elif schema.get("items") in ["worklog", "attachment"]:
+            # TODO: Implement worklogs and attachments someday
+            return None
+        elif schema["type"] == "array" and schema["items"] != "string":
+            fieldtype = "select"
+            fkwargs.update(
+                {
+                    "multiple": True,
+                    "choices": self.make_choices(field_meta.get("allowedValues")),
+                    "default": "",
+                }
+            )
+        elif schema["type"] == "option" and len(field_meta.get("allowedValues", [])):
+            fieldtype = "select"
+            fkwargs.update(
+                {"choices": self.make_choices(field_meta.get("allowedValues")), "default": ""}
+            )
+
+        # break this out, since multiple field types could additionally
+        # be configured to use a custom property instead of a default.
+        if schema.get("custom"):
+            if schema["custom"] == JIRA_CUSTOM_FIELD_TYPES["textarea"]:
+                fieldtype = "textarea"
+
+        fkwargs["type"] = fieldtype
+        return fkwargs
+
+    def get_issue_type_meta(self, issue_type, meta):
+        issue_types = meta["issuetypes"]
+        issue_type_meta = None
+        if issue_type:
+            matching_type = [t for t in issue_types if t["id"] == issue_type]
+            issue_type_meta = matching_type[0] if len(matching_type) > 0 else None
+
+        # still no issue type? just use the first one.
+        if not issue_type_meta:
+            issue_type_meta = issue_types[0]
+
+        return issue_type_meta
+
+    def get_issue_create_meta(self, client, project_id, jira_projects):
+        meta = None
+        if project_id:
+            meta = self.fetch_issue_create_meta(client, project_id)
+        if meta is not None:
+            return meta
+
+        # If we don't have a jira projectid (or we couldn't fetch the metadata from the given project_id),
+        # iterate all projects and find the first project that has metadata.
+        # We only want one project as getting all project metadata is expensive and wasteful.
+        # In the first run experience, the user won't have a 'last used' project id
+        # so we need to iterate available projects until we find one that we can get metadata for.
+        attempts = 0
+        if len(jira_projects):
+            for fallback in jira_projects:
+                attempts += 1
+                meta = self.fetch_issue_create_meta(client, fallback["id"])
+                if meta:
+                    logger.info(
+                        "jira.get-issue-create-meta.attempts",
+                        extra={"organization_id": self.organization_id, "attempts": attempts},
+                    )
+                    return meta
+
+        jira_project_ids = "no projects"
+        if len(jira_projects):
+            jira_project_ids = ",".join(project["key"] for project in jira_projects)
+
+        logger.info(
+            "jira.get-issue-create-meta.no-metadata",
+            extra={
+                "organization_id": self.organization_id,
+                "attempts": attempts,
+                "jira_projects": jira_project_ids,
+            },
+        )
+        raise IntegrationError(
+            "Could not get issue create metadata for any Jira projects. "
+            "Ensure that your project permissions are correct."
+        )
+
+    def fetch_issue_create_meta(self, client, project_id):
+        try:
+            meta = client.get_create_meta_for_project(project_id)
+        except ApiUnauthorized:
+            logger.info(
+                "jira.fetch-issue-create-meta.unauthorized",
+                extra={"organization_id": self.organization_id, "jira_project": project_id},
+            )
+            raise IntegrationError(
+                "Jira returned: Unauthorized. " "Please check your configuration settings."
+            )
+        except ApiError as e:
+            logger.info(
+                "jira.fetch-issue-create-meta.error",
+                extra={
+                    "integration_id": self.model.id,
+                    "organization_id": self.organization_id,
+                    "jira_project": project_id,
+                    "error": str(e),
+                },
+            )
+            raise IntegrationError(
+                "There was an error communicating with the Jira API. "
+                "Please try again or contact support."
+            )
+        return meta
+
+    def get_create_issue_config(self, group, user, **kwargs):
+        """
+        We use the `group` to get three things: organization_slug, project
+        defaults, and default title and description. In the case where we're
+        getting `createIssueConfig` from Jira for Ticket Rules, we don't know
+        the issue group beforehand.
+
+        :param group: (Optional) Group model.
+        :param user: User model. TODO Make this the first parameter.
+        :param kwargs: (Optional) Object
+            * params: (Optional) Object
+            * params.project: (Optional) Sentry Project object
+            * params.issuetype: (Optional) String. The Jira issue type. For
+                example: "Bug", "Epic", "Story".
+        :return:
+        """
+        kwargs = kwargs or {}
+        kwargs["link_referrer"] = "jira_integration"
+        params = kwargs.get("params", {})
+        fields = []
+        defaults = {}
+        if group:
+            fields = super().get_create_issue_config(group, user, **kwargs)
+            defaults = self.get_defaults(group.project, user)
+
+        project_id = params.get("project", defaults.get("project"))
+        client = self.get_client()
+        try:
+            jira_projects = client.get_projects_list()
+        except ApiError as e:
+            logger.info(
+                "jira.get-create-issue-config.no-projects",
+                extra={
+                    "integration_id": self.model.id,
+                    "organization_id": self.organization_id,
+                    "error": str(e),
+                },
+            )
+            raise IntegrationError(
+                "Could not fetch project list from Jira. Ensure that Jira is"
+                " available and your account is still active."
+            )
+
+        meta = self.get_issue_create_meta(client, project_id, jira_projects)
+        if not meta:
+            raise IntegrationError(
+                "Could not fetch issue create metadata from Jira. Ensure that"
+                " the integration user has access to the requested project."
+            )
+
+        # check if the issuetype was passed as a parameter
+        issue_type = params.get("issuetype", defaults.get("issuetype"))
+        issue_type_meta = self.get_issue_type_meta(issue_type, meta)
+        issue_type_choices = self.make_choices(meta["issuetypes"])
+
+        # make sure default issue type is actually
+        # one that is allowed for project
+        if issue_type:
+            if not any(c for c in issue_type_choices if c[0] == issue_type):
+                issue_type = issue_type_meta["id"]
+
+        fields = [
+            {
+                "name": "project",
+                "label": "Jira Project",
+                "choices": [(p["id"], p["key"]) for p in jira_projects],
+                "default": meta["id"],
+                "type": "select",
+                "updatesForm": True,
+            },
+            *fields,
+            {
+                "name": "issuetype",
+                "label": "Issue Type",
+                "default": issue_type or issue_type_meta["id"],
+                "type": "select",
+                "choices": issue_type_choices,
+                "updatesForm": True,
+                "required": bool(issue_type_choices),  # required if we have any type choices
+            },
+        ]
+
+        # title is renamed to summary before sending to Jira
+        standard_fields = [f["name"] for f in fields] + ["summary"]
+        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.
+        # otherwise weird ordering occurs.
+        anti_gravity = {
+            "priority": (-150, ""),
+            "fixVersions": (-125, ""),
+            "components": (-100, ""),
+            "security": (-50, ""),
+        }
+
+        dynamic_fields = list(issue_type_meta["fields"].keys())
+        # Sort based on priority, then field name
+        dynamic_fields.sort(key=lambda f: anti_gravity.get(f, (0, f)))
+
+        # Build up some dynamic fields based on what is required.
+        for field in dynamic_fields:
+            if field in standard_fields or field in [x.strip() for x in ignored_fields]:
+                # don't overwrite the fixed fields for the form.
+                continue
+
+            mb_field = self.build_dynamic_field(issue_type_meta["fields"][field], group)
+            if mb_field:
+                if mb_field["label"] in params.get("ignored", []):
+                    continue
+                mb_field["name"] = field
+                fields.append(mb_field)
+
+        for field in fields:
+            if field["name"] == "priority":
+                # whenever priorities are available, put the available ones in the list.
+                # allowedValues for some reason doesn't pass enough info.
+                field["choices"] = self.make_choices(client.get_priorities())
+                field["default"] = defaults.get("priority", "")
+            elif field["name"] == "fixVersions":
+                field["choices"] = self.make_choices(client.get_versions(meta["key"]))
+            elif field["name"] == "labels":
+                field["default"] = defaults.get("labels", "")
+            elif field["name"] == "reporter":
+                reporter_id = defaults.get("reporter", "")
+                if not reporter_id:
+                    continue
+                try:
+                    reporter_info = client.get_user(reporter_id)
+                except ApiError as e:
+                    logger.info(
+                        "jira.get-create-issue-config.no-matching-reporter",
+                        extra={
+                            "integration_id": self.model.id,
+                            "organization_id": self.organization_id,
+                            "persisted_reporter_id": reporter_id,
+                            "error": str(e),
+                        },
+                    )
+                    continue
+                reporter_tuple = build_user_choice(reporter_info, client.user_id_field())
+                if not reporter_tuple:
+                    continue
+                reporter_id, reporter_label = reporter_tuple
+                field["default"] = reporter_id
+                field["choices"] = [(reporter_id, reporter_label)]
+
+        return fields
+
+    def create_issue(self, data, **kwargs):
+        """
+        Get the (cached) "createmeta" from Jira to use as a "schema". Clean up
+        the Jira issue by removing all fields that aren't enumerated by this
+        schema. Send this cleaned data to Jira. Finally, make another API call
+        to Jira to make sure the issue was created and return basic issue details.
+
+        :param data: JiraCreateTicketAction object
+        :param kwargs: not used
+        :return: simple object with basic Jira issue details
+        """
+        client = self.get_client()
+        cleaned_data = {}
+        # protect against mis-configured integration submitting a form without an
+        # issuetype assigned.
+        if not data.get("issuetype"):
+            raise IntegrationFormError({"issuetype": ["Issue type is required."]})
+
+        jira_project = data.get("project")
+        if not jira_project:
+            raise IntegrationFormError({"project": ["Jira project is required"]})
+
+        meta = client.get_create_meta_for_project(jira_project)
+        if not meta:
+            raise IntegrationError("Could not fetch issue create configuration from Jira.")
+
+        issue_type_meta = self.get_issue_type_meta(data["issuetype"], meta)
+        user_id_field = client.user_id_field()
+
+        fs = issue_type_meta["fields"]
+        for field in fs.keys():
+            f = fs[field]
+            if field == "description":
+                cleaned_data[field] = data[field]
+                continue
+            elif field == "summary":
+                cleaned_data["summary"] = data["title"]
+                continue
+            elif field == "labels" and "labels" in data:
+                labels = [label.strip() for label in data["labels"].split(",") if label.strip()]
+                cleaned_data["labels"] = labels
+                continue
+            if field in data.keys():
+                v = data.get(field)
+                if not v:
+                    continue
+
+                schema = f.get("schema")
+                if schema:
+                    if schema.get("type") == "string" and not schema.get("custom"):
+                        cleaned_data[field] = v
+                        continue
+                    if schema["type"] == "user" or schema.get("items") == "user":
+                        if schema.get("custom") == JIRA_CUSTOM_FIELD_TYPES.get("multiuserpicker"):
+                            # custom multi-picker
+                            v = [{user_id_field: user_id} for user_id in v]
+                        else:
+                            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(f"Invalid sprint ({v}) specified")
+                    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":
+                        v = [v]
+                    elif schema["type"] == "array" and schema.get("items") != "string":
+                        v = [{"id": vx} for vx in v]
+                    elif schema["type"] == "option":
+                        v = {"value": v}
+                    elif schema.get("custom") == JIRA_CUSTOM_FIELD_TYPES.get("textarea"):
+                        v = v
+                    elif (
+                        schema["type"] == "number"
+                        or schema.get("custom") == JIRA_CUSTOM_FIELD_TYPES["tempo_account"]
+                    ):
+                        try:
+                            if "." in v:
+                                v = float(v)
+                            else:
+                                v = int(v)
+                        except ValueError:
+                            pass
+                    elif (
+                        schema.get("type") != "string"
+                        or (schema.get("items") and schema.get("items") != "string")
+                        or schema.get("custom") == JIRA_CUSTOM_FIELD_TYPES.get("select")
+                    ):
+                        v = {"id": v}
+                cleaned_data[field] = v
+
+        if not (isinstance(cleaned_data["issuetype"], dict) and "id" in cleaned_data["issuetype"]):
+            # something fishy is going on with this field, working on some Jira
+            # instances, and some not.
+            # testing against 5.1.5 and 5.1.4 does not convert (perhaps is no longer included
+            # in the projectmeta API call, and would normally be converted in the
+            # above clean method.)
+            cleaned_data["issuetype"] = {"id": cleaned_data["issuetype"]}
+
+        try:
+            response = client.create_issue(cleaned_data)
+        except Exception as e:
+            raise self.raise_error(e)
+
+        issue_key = response.get("key")
+        if not issue_key:
+            raise IntegrationError("There was an error creating the issue.")
+
+        # Immediately fetch and return the created issue.
+        return self.get_issue(issue_key)
+
+    def sync_assignee_outbound(
+        self,
+        external_issue: ExternalIssue,
+        user: Optional[User],
+        assign: bool = True,
+        **kwargs: Any,
+    ) -> None:
+        """
+        Propagate a sentry issue's assignee to a jira issue's assignee
+        """
+        client = self.get_client()
+
+        jira_user = None
+        if user and assign:
+            for ue in user.emails.filter(is_verified=True):
+                try:
+                    possible_users = client.search_users_for_issue(external_issue.key, ue.email)
+                except (ApiUnauthorized, ApiError):
+                    continue
+                for possible_user in possible_users:
+                    email = possible_user.get("emailAddress")
+                    # pull email from API if we can use it
+                    if not email and self.use_email_scope:
+                        account_id = possible_user.get("accountId")
+                        email = client.get_email(account_id)
+                    # match on lowercase email
+                    # TODO(steve): add check against display name when JIRA_USE_EMAIL_SCOPE is false
+                    if email and email.lower() == ue.email.lower():
+                        jira_user = possible_user
+                        break
+            if jira_user is None:
+                # TODO(jess): do we want to email people about these types of failures?
+                logger.info(
+                    "jira.assignee-not-found",
+                    extra={
+                        "integration_id": external_issue.integration_id,
+                        "user_id": user.id,
+                        "issue_key": external_issue.key,
+                    },
+                )
+                return
+
+        try:
+            id_field = client.user_id_field()
+            client.assign_issue(external_issue.key, jira_user and jira_user.get(id_field))
+        except (ApiUnauthorized, ApiError):
+            # TODO(jess): do we want to email people about these types of failures?
+            logger.info(
+                "jira.failed-to-assign",
+                extra={
+                    "organization_id": external_issue.organization_id,
+                    "integration_id": external_issue.integration_id,
+                    "user_id": user.id if user else None,
+                    "issue_key": external_issue.key,
+                },
+            )
+
+    def sync_status_outbound(self, external_issue, is_resolved, project_id, **kwargs):
+        """
+        Propagate a sentry issue's status to a linked issue's status.
+        """
+        client = self.get_client()
+        jira_issue = client.get_issue(external_issue.key)
+        jira_project = jira_issue["fields"]["project"]
+
+        try:
+            external_project = IntegrationExternalProject.objects.get(
+                external_id=jira_project["id"],
+                organization_integration_id__in=OrganizationIntegration.objects.filter(
+                    organization_id=external_issue.organization_id,
+                    integration_id=external_issue.integration_id,
+                ),
+            )
+        except IntegrationExternalProject.DoesNotExist:
+            return
+
+        jira_status = (
+            external_project.resolved_status if is_resolved else external_project.unresolved_status
+        )
+
+        # don't bother updating if it's already the status we'd change it to
+        if jira_issue["fields"]["status"]["id"] == jira_status:
+            return
+        try:
+            transitions = client.get_transitions(external_issue.key)
+        except ApiHostError:
+            raise IntegrationError("Could not reach host to get transitions.")
+
+        try:
+            transition = [t for t in transitions if t.get("to", {}).get("id") == jira_status][0]
+        except IndexError:
+            # TODO(jess): Email for failure
+            logger.warning(
+                "jira.status-sync-fail",
+                extra={
+                    "organization_id": external_issue.organization_id,
+                    "integration_id": external_issue.integration_id,
+                    "issue_key": external_issue.key,
+                },
+            )
+            return
+
+        client.transition_issue(external_issue.key, transition["id"])
+
+    def _get_done_statuses(self):
+        client = self.get_client()
+        statuses = client.get_valid_statuses()
+        return {s["id"] for s in statuses if s["statusCategory"]["key"] == "done"}
+
+    def get_resolve_sync_action(self, data: Mapping[str, Any]) -> ResolveSyncAction:
+        done_statuses = self._get_done_statuses()
+        c_from = data["changelog"]["from"]
+        c_to = data["changelog"]["to"]
+        return ResolveSyncAction.from_resolve_unresolve(
+            should_resolve=c_to in done_statuses and c_from not in done_statuses,
+            should_unresolve=c_from in done_statuses and c_to not in done_statuses,
+        )
+
     def after_link_issue(self, external_issue, data=None, **kwargs):
         super().after_link_issue(external_issue, **kwargs)
 

+ 76 - 2
src/sentry/integrations/jira_server/search.py

@@ -1,5 +1,79 @@
-from sentry.integrations.jira.webhooks import JiraSearchEndpoint
+from bs4 import BeautifulSoup
+from rest_framework.request import Request
+from rest_framework.response import Response
 
+from sentry.api.bases.integration import IntegrationEndpoint
+from sentry.models import Integration
+from sentry.shared_integrations.exceptions import ApiError, ApiUnauthorized, IntegrationError
 
-class JiraServerSearchEndpoint(JiraSearchEndpoint):
+from .utils import build_user_choice
+
+
+class JiraServerSearchEndpoint(IntegrationEndpoint):
     provider = "jira_server"
+
+    def _get_integration(self, organization, integration_id):
+        return Integration.objects.get(
+            organizations=organization, id=integration_id, provider=self.provider
+        )
+
+    def get(self, request: Request, organization, integration_id) -> Response:
+        try:
+            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)
+
+        if field in ("externalIssue", "parent"):
+            if not query:
+                return Response([])
+            try:
+                resp = installation.search_issues(query)
+            except IntegrationError as e:
+                return Response({"detail": str(e)}, status=400)
+            return Response(
+                [
+                    {"label": "({}) {}".format(i["key"], i["fields"]["summary"]), "value": i["key"]}
+                    for i in resp.get("issues", [])
+                ]
+            )
+
+        if field in ("assignee", "reporter"):
+            try:
+                response = jira_client.search_users_for_project(
+                    request.GET.get("project", ""), query
+                )
+            except (ApiUnauthorized, ApiError):
+                return Response({"detail": "Unable to fetch users from Jira"}, status=400)
+
+            user_tuples = filter(
+                None, [build_user_choice(user, jira_client.user_id_field()) for user in response]
+            )
+            users = [{"value": user_id, "label": display} for user_id, display in user_tuples]
+            return Response(users)
+
+        try:
+            response = jira_client.get_field_autocomplete(name=field, value=query)
+        except (ApiUnauthorized, ApiError):
+            return Response(
+                {"detail": f"Unable to fetch autocomplete for {field} from Jira"},
+                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)

+ 9 - 0
src/sentry/integrations/jira_server/utils/__init__.py

@@ -0,0 +1,9 @@
+from .api import get_assignee_email, handle_assignee_change, handle_status_change
+from .choice import build_user_choice
+
+__all__ = (
+    "build_user_choice",
+    "get_assignee_email",
+    "handle_assignee_change",
+    "handle_status_change",
+)

+ 99 - 0
src/sentry/integrations/jira_server/utils/api.py

@@ -0,0 +1,99 @@
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, Any, Mapping
+
+from sentry.integrations.utils import sync_group_assignee_inbound
+from sentry.shared_integrations.exceptions import IntegrationError
+
+from ..client import JiraServerClient
+
+if TYPE_CHECKING:
+    from sentry.models import Identity, Integration, OrganizationIntegration
+
+logger = logging.getLogger(__name__)
+
+
+def _get_client(integration: Integration) -> JiraServerClient:
+    oi = OrganizationIntegration.objects.get(integration_id=integration.id)
+    try:
+        default_identity = Identity.objects.get(id=oi.default_auth_id)
+    except Identity.DoesNotExist:
+        raise IntegrationError("Identity not found.")
+
+    return JiraServerClient(
+        integration.metadata["base_url"],
+        default_identity.data,
+        verify_ssl=True,
+    )
+
+
+def get_assignee_email(
+    integration: Integration,
+    assignee: Mapping[str, str],
+    use_email_scope: bool = False,
+) -> str | None:
+    """Get email from `assignee` or pull it from API (if we have the scope for it.)"""
+    email = assignee.get("emailAddress")
+    if not email and use_email_scope:
+        account_id = assignee.get("accountId")
+        client = _get_client(integration)
+        email = client.get_email(account_id)
+    return email
+
+
+def handle_assignee_change(
+    integration: Integration,
+    data: Mapping[str, Any],
+    use_email_scope: bool = False,
+) -> None:
+    assignee_changed = any(
+        item for item in data["changelog"]["items"] if item["field"] == "assignee"
+    )
+    if not assignee_changed:
+        return
+
+    fields = data["issue"]["fields"]
+
+    # If there is no assignee, assume it was unassigned.
+    assignee = fields.get("assignee")
+    issue_key = data["issue"]["key"]
+
+    if assignee is None:
+        sync_group_assignee_inbound(integration, None, issue_key, assign=False)
+        return
+
+    email = get_assignee_email(integration, assignee, use_email_scope)
+    # TODO(steve) check display name
+    if not email:
+        logger.info(
+            "missing-assignee-email",
+            extra={"issue_key": issue_key, "integration_id": integration.id},
+        )
+        return
+
+    sync_group_assignee_inbound(integration, email, issue_key, assign=True)
+
+
+def handle_status_change(integration, data):
+    status_changed = any(item for item in data["changelog"]["items"] if item["field"] == "status")
+    if not status_changed:
+        return
+
+    issue_key = data["issue"]["key"]
+
+    try:
+        changelog = next(item for item in data["changelog"]["items"] if item["field"] == "status")
+    except StopIteration:
+        logger.info(
+            "missing-changelog-status",
+            extra={"issue_key": issue_key, "integration_id": integration.id},
+        )
+        return
+
+    for org_id in integration.organizations.values_list("id", flat=True):
+        installation = integration.get_installation(org_id)
+
+        installation.sync_status_inbound(
+            issue_key, {"changelog": changelog, "issue": data["issue"]}
+        )

Some files were not shown because too many files changed in this diff