Repository: getsentry/sentry

Issue:
Create grouptype dataclass
~~Create a migration for a new table to store the [group type data](https://github.com/getsentry/sentry/blob/master/src/sentry/types/issues.py) currently using enums.~~

~~This migration should also backfill the existing group types.~~

~~See https://github.com/getsentry/sentry/issues/43484 for more context and suggested columns.~~


Create a grouptype dataclass in place of the existing enums / dicts to store new issue type data. 


Discussion:

Resolving patch:
diff --git a/src/sentry/grouptype/grouptype.py b/src/sentry/grouptype/grouptype.py
new file mode 100644
--- /dev/null
+++ b/src/sentry/grouptype/grouptype.py
@@ -0,0 +1,122 @@
+from dataclasses import dataclass
+
+from sentry.event_manager import DEFAULT_GROUPHASH_IGNORE_LIMIT
+from sentry.types.issues import GroupCategory
+
+_group_type_registry = {}
+
+
+@dataclass(frozen=True)
+class GroupType:
+    type_id: int
+    slug: str
+    description: str
+    category: int
+    ignore_limit: int = DEFAULT_GROUPHASH_IGNORE_LIMIT
+
+    def __init_subclass__(cls, **kwargs):
+        super().__init_subclass__(**kwargs)
+        if _group_type_registry.get(cls.type_id):
+            raise ValueError(
+                f"A group type with the type_id {cls.type_id} has already been registered."
+            )
+        _group_type_registry[cls.type_id] = cls
+
+    def __post_init__(self):
+        valid_categories = [category.value for category in GroupCategory]
+        if self.category not in valid_categories:
+            raise ValueError(f"Category must be one of {valid_categories} from GroupCategory.")
+
+
+def get_group_types_by_category(category):
+    return [child.type_id for child in GroupType.__subclasses__() if child.category == category]
+
+
+def get_group_type_by_slug(slug):
+    for group_type in _group_type_registry.values():
+        if group_type.slug == slug:
+            return group_type
+    raise ValueError(f"No group type with the slug {slug} is registered.")
+
+
+@dataclass(frozen=True)
+class ErrorGroupType(GroupType):
+    type_id = 1
+    slug = "error"
+    description = "Error"
+    category = GroupCategory.ERROR.value
+    ignore_limit = 0
+
+
+@dataclass(frozen=True)
+class SlowDBQueryGroupType(GroupType):
+    type_id = 1001
+    slug = "performance_slow_db_query"
+    description = "Slow DB Query"
+    category = GroupCategory.PERFORMANCE.value
+    ignore_limit = 100
+
+
+@dataclass(frozen=True)
+class PerformanceRenderBlockingAssetSpanGroupType(GroupType):
+    type_id = 1004
+    slug = "performance_render_blocking_asset_span"
+    description = "Large Render Blocking Asset"
+    category = GroupCategory.PERFORMANCE.value
+
+
+@dataclass(frozen=True)
+class PerformanceNPlusOneGroupType(GroupType):
+    type_id = 1006
+    slug = "performance_n_plus_one_db_queries"
+    description = "N+1 Query"
+    category = GroupCategory.PERFORMANCE.value
+
+
+@dataclass(frozen=True)
+class PerformanceConsecutiveDBQueriesGroupType(GroupType):
+    type_id = 1007
+    slug = "performance_consecutive_db_queries"
+    description = "Consecutive DB Queries"
+    category = GroupCategory.PERFORMANCE.value
+    ignore_limit = 15
+
+
+@dataclass(frozen=True)
+class PerformanceFileIOMainThreadGroupType(GroupType):
+    type_id = 1008
+    slug = "performance_file_io_main_thread"
+    description = "File IO on Main Thread"
+    category = GroupCategory.PERFORMANCE.value
+
+
+@dataclass(frozen=True)
+class PerformanceNPlusOneAPICallsGroupType(GroupType):
+    type_id = 1010
+    slug = "performance_n_plus_one_api_calls"
+    description = "N+1 API Call"
+    category = GroupCategory.PERFORMANCE.value
+
+
+@dataclass(frozen=True)
+class PerformanceMNPlusOneDBQueriesGroupType(GroupType):
+    type_id = 1011
+    slug = "performance_m_n_plus_one_db_queries"
+    description = "MN+1 Query"
+    category = GroupCategory.PERFORMANCE.value
+
+
+@dataclass(frozen=True)
+class PerformanceUncompressedAssetsGroupType(GroupType):
+    type_id = 1012
+    slug = "performance_uncompressed_assets"
+    description = "Uncompressed Asset"
+    category = GroupCategory.PERFORMANCE.value
+
+
+@dataclass(frozen=True)
+class ProfileBlockedThreadGroupType(GroupType):
+    type_id = 2000
+    slug = "profile_blocked_thread"
+    description = "Blocked Thread"
+    category = GroupCategory.PROFILE.value


Test patch:
diff --git a/tests/grouptype/test_grouptype.py b/tests/grouptype/test_grouptype.py
new file mode 100644
--- /dev/null
+++ b/tests/grouptype/test_grouptype.py
@@ -0,0 +1,82 @@
+from dataclasses import dataclass
+from unittest.mock import patch
+
+from sentry.grouptype.grouptype import (
+    GroupType,
+    _group_type_registry,
+    get_group_type_by_slug,
+    get_group_types_by_category,
+)
+from sentry.testutils import TestCase
+from sentry.testutils.silo import region_silo_test
+from sentry.types.issues import GroupCategory
+
+
+@region_silo_test
+class GroupTypeTest(TestCase):
+    def test_get_types_by_category(self):
+        with patch.dict(_group_type_registry, {}, clear=True):
+
+            @dataclass(frozen=True)
+            class TestGroupType(GroupType):
+                type_id = 1
+                slug = "test"
+                description = "Test"
+                category = GroupCategory.ERROR.value
+                ignore_limit = 0
+
+            @dataclass(frozen=True)
+            class TestGroupType2(GroupType):
+                type_id = 2
+                slug = "hellboy"
+                description = "Hellboy"
+                category = GroupCategory.PERFORMANCE.value
+
+            @dataclass(frozen=True)
+            class TestGroupType3(GroupType):
+                type_id = 3
+                slug = "angelgirl"
+                description = "AngelGirl"
+                category = GroupCategory.PERFORMANCE.value
+
+        with patch(
+            "sentry.grouptype.grouptype.GroupType.__subclasses__",
+            return_value=[TestGroupType, TestGroupType2, TestGroupType3],
+        ):
+            assert get_group_types_by_category(GroupCategory.PERFORMANCE.value) == [2, 3]
+            assert get_group_types_by_category(GroupCategory.ERROR.value) == [1]
+
+    def test_get_group_type_by_slug(self):
+        with patch.dict(_group_type_registry, {}, clear=True):
+
+            @dataclass(frozen=True)
+            class TestGroupType(GroupType):
+                type_id = 1
+                slug = "test"
+                description = "Test"
+                category = GroupCategory.ERROR.value
+                ignore_limit = 0
+
+            assert get_group_type_by_slug(TestGroupType.slug) == TestGroupType
+
+            nonexistent_slug = "meow"
+            with self.assertRaisesMessage(
+                ValueError, f"No group type with the slug {nonexistent_slug} is registered."
+            ):
+                get_group_type_by_slug(nonexistent_slug)
+
+    def test_category_validation(self):
+        with patch.dict(_group_type_registry, {}, clear=True):
+
+            @dataclass(frozen=True)
+            class TestGroupType(GroupType):
+                type_id = 1
+                slug = "error"
+                description = "Error"
+                category = 22
+
+        with self.assertRaisesMessage(
+            ValueError,
+            f"Category must be one of {[category.value for category in GroupCategory]} from GroupCategory",
+        ):
+            TestGroupType(1, "error", "Error", 22)