Browse Source

ref(jira): Add data model for Jira create issue metadata (#76740)

Gabe Villalobos 6 months ago
parent
commit
d8850232d5

+ 5 - 1
fixtures/integrations/jira/stubs/createmeta_response.json

@@ -47,10 +47,12 @@
               "name": "Issue Type",
               "key": "issuetype",
               "hasDefaultValue": false,
-              "operations": ["set"]
+              "operations": ["set"],
+              "schema": { "type": "issuetype", "system": "issuetype" }
             },
             "labels": {
               "required": false,
+              "operations": ["add", "set", "remove"],
               "schema": {
                 "type": "array",
                 "items": "string",
@@ -89,6 +91,7 @@
             "customfield_10200": {
               "operations": ["set"],
               "required": false,
+              "key": "customfield_10200",
               "schema": {
                 "type": "option",
                 "custom": "com.codebarrel.jira.iconselectlist:icon-select-cf",
@@ -125,6 +128,7 @@
                 "customId": 10202
               },
               "name": "Feature",
+              "key": "customfield_10300",
               "hasDefaultValue": false,
               "operations": ["add", "set", "remove"],
               "allowedValues": [

+ 10 - 0
src/sentry/integrations/jira/integration.py

@@ -6,6 +6,7 @@ from collections.abc import Mapping, Sequence
 from operator import attrgetter
 from typing import Any
 
+import sentry_sdk
 from django.conf import settings
 from django.urls import reverse
 from django.utils.functional import classproperty
@@ -19,6 +20,7 @@ from sentry.integrations.base import (
     IntegrationMetadata,
     IntegrationProvider,
 )
+from sentry.integrations.jira.models.create_issue_metadata import JiraIssueTypeMetadata
 from sentry.integrations.jira.tasks import migrate_issues
 from sentry.integrations.mixins.issues import MAX_CHAR, IssueSyncIntegration, ResolveSyncAction
 from sentry.integrations.models.external_issue import ExternalIssue
@@ -570,6 +572,7 @@ class JiraIntegration(IssueSyncIntegration):
         return fkwargs
 
     def get_issue_type_meta(self, issue_type, meta):
+        self.parse_jira_issue_metadata(meta)
         issue_types = meta["issuetypes"]
         issue_type_meta = None
         if issue_type:
@@ -1039,6 +1042,13 @@ class JiraIntegration(IssueSyncIntegration):
             }
         )
 
+    def parse_jira_issue_metadata(self, meta: dict[str, Any]) -> list[JiraIssueTypeMetadata] | None:
+        try:
+            return JiraIssueTypeMetadata.from_jira_meta_config(meta)
+        except Exception as e:
+            sentry_sdk.capture_exception(e)
+            return None
+
 
 class JiraIntegrationProvider(IntegrationProvider):
     key = "jira"

+ 163 - 0
src/sentry/integrations/jira/models/create_issue_metadata.py

@@ -0,0 +1,163 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum
+from typing import Any
+
+
+class JiraSchemaTypes(str, Enum):
+    string = "string"
+    option = "option"
+    array = "array"
+    user = "user"
+    issue_type = "issuetype"
+    issue_link = "issuelink"
+    project = "project"
+    date = "date"
+    team = "team"
+    number = "number"
+    any = "any"
+
+
+@dataclass(frozen=True)
+class JiraSchema:
+    schema_type: str
+    """
+    The Field type. Possible types include:
+    - string
+    - array (has a corresponding `items` field with its subtype)
+    - user
+    - issuetype
+    - issuelink
+    - project (and PROJECT)
+    - date
+    - team
+    - any
+    """
+    custom: str | None = None
+    """
+    The very long custom field name corresponding to some namespace, plugin,
+    and custom field name.
+    """
+    custom_id: str | None = None
+    """
+    A unique identifier for a field on an issue, in the form of 'customfield_<int>'
+    """
+    system: str | None = None
+    """
+    TODO(Gabe): Figure out what this is used for
+    """
+    items: str | None = None
+    """
+    Specifies the subtype for aggregate type, such as `array`. For any
+    non-aggregate types, this will be None
+    """
+
+    @classmethod
+    def from_dict(cls, data: dict[str, Any]) -> JiraSchema:
+        schema_type = data["type"]
+        custom = data.get("custom")
+        custom_id = data.get("custom_id")
+        system = data.get("system")
+        items = data.get("items")
+
+        return cls(
+            schema_type=schema_type, custom=custom, custom_id=custom_id, system=system, items=items
+        )
+
+    @classmethod
+    def from_dict_list(cls, data: list[dict[str, Any]]) -> list[JiraSchema]:
+        return [cls.from_dict(item) for item in data]
+
+
+@dataclass(frozen=True)
+class JiraField:
+    """
+    Represents a Jira Issue Field, which is queried directly via Jira's issue
+    creation metadata API:
+    https://developer.atlassian.com/server/jira/platform/rest/v10000/api-group-issue/#api-api-2-issue-createmeta-projectidorkey-issuetypes-issuetypeid-get
+    """
+
+    required: bool
+    schema: JiraSchema
+    name: str
+    key: str
+    operations: list[str]
+    has_default_value: bool
+
+    def is_custom_field(self) -> bool:
+        return self.schema.custom is not None
+
+    @classmethod
+    def from_dict(cls, data: dict[str, Any]) -> JiraField:
+        required = data["required"]
+        name = data["name"]
+        key = data["key"]
+        operations = data["operations"]
+        has_default_value = data.get("hasDefaultValue") or False
+        raw_schema = data["schema"]
+
+        schema = JiraSchema.from_dict(raw_schema)
+
+        return cls(
+            required=required,
+            name=name,
+            key=key,
+            schema=schema,
+            operations=operations,
+            has_default_value=has_default_value,
+        )
+
+    @classmethod
+    def from_dict_list(cls, data: dict[str, dict[str, Any]]) -> dict[str, JiraField]:
+        return {key: cls.from_dict(val) for key, val in data.items()}
+
+    def get_field_type(self) -> JiraSchemaTypes | None:
+        type: str | None = self.schema.schema_type
+        if type == JiraSchemaTypes.array:
+            type = self.schema.items
+
+        if type not in JiraSchemaTypes:
+            return None
+
+        return JiraSchemaTypes(type)
+
+
+@dataclass(frozen=True)
+class JiraIssueTypeMetadata:
+    id: str
+    description: str
+    name: str
+    subtask: bool
+    icon_url: str
+    url: str
+    fields: dict[str, JiraField]
+
+    @classmethod
+    def from_dict(cls, data: dict[str, Any]) -> JiraIssueTypeMetadata:
+        jira_id = data["id"]
+        description = data["description"]
+        name = data["name"]
+        subtask = data["subtask"]
+        icon_url = data["iconUrl"]
+        url = data["self"]
+        raw_fields = data["fields"]
+
+        fields = JiraField.from_dict_list(raw_fields)
+
+        return cls(
+            id=jira_id,
+            name=name,
+            description=description,
+            subtask=subtask,
+            icon_url=icon_url,
+            url=url,
+            fields=fields,
+        )
+
+    @classmethod
+    def from_jira_meta_config(cls, meta_config: dict[str, Any]) -> list[JiraIssueTypeMetadata]:
+        issue_types_list = meta_config.get("issuetypes", {})
+        issue_configs = [cls.from_dict(it) for it in issue_types_list]
+
+        return issue_configs

+ 13 - 0
tests/sentry/integrations/jira/models/test_jira_schema.py

@@ -0,0 +1,13 @@
+from fixtures.integrations.jira.stub_client import StubJiraApiClient
+from sentry.integrations.jira.models.create_issue_metadata import JiraIssueTypeMetadata
+from sentry.testutils.cases import TestCase
+
+
+class TestJiraSchema(TestCase):
+    def test_schema_parsing(self):
+        create_meta = StubJiraApiClient().get_create_meta_for_project("proj-1")
+        issue_configs = JiraIssueTypeMetadata.from_jira_meta_config(create_meta)
+        assert len(issue_configs) == 1
+        assert issue_configs[0].name == "Bug"
+        assert issue_configs[0].id == "1"
+        assert len(issue_configs[0].fields) > 1