From ea16738dfdb5e5ee3ba0d172c3c5d15be2a12323 Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Tue, 15 Apr 2025 16:36:04 -0700 Subject: [PATCH 1/9] switch to paginated project endpoint for jira issue config --- src/sentry/features/temporary.py | 2 + src/sentry/integrations/jira/client.py | 6 ++ .../integrations/jira/endpoints/search.py | 12 +++ src/sentry/integrations/jira/integration.py | 27 +++++- .../integrations/jira/test_integration.py | 87 ++++++++++++++++++- 5 files changed, 126 insertions(+), 8 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index b3ac73b444f799..80c748229020c6 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -514,6 +514,8 @@ def register_temporary_features(manager: FeatureManager): manager.add("organizations:streamlined-publishing-flow", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable per-project selection for Jira integration manager.add("organizations:jira-per-project-statuses", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) + # Enable using paginated projects endpoint for Jira integration + manager.add("organizations:jira-paginated-projects", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable Relay extracting logs from breadcrumbs for a project. manager.add("projects:ourlogs-breadcrumb-extraction", ProjectFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) diff --git a/src/sentry/integrations/jira/client.py b/src/sentry/integrations/jira/client.py index 7b82b60a09f7ba..6a4652d32aafa2 100644 --- a/src/sentry/integrations/jira/client.py +++ b/src/sentry/integrations/jira/client.py @@ -29,6 +29,7 @@ class JiraCloudClient(ApiClient): ISSUE_URL = "/rest/api/2/issue/%s" META_URL = "/rest/api/2/issue/createmeta" PRIORITIES_URL = "/rest/api/2/priority" + PROJECTS_PAGINATED_URL = "/rest/api/2/project/search" PROJECT_URL = "/rest/api/2/project" SEARCH_URL = "/rest/api/2/search/" VERSIONS_URL = "/rest/api/2/project/%s/versions" @@ -122,6 +123,11 @@ def create_comment(self, issue_key, 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_paginated(self, params: dict[str, str | Any] | None = None): + response = self.get(self.PROJECTS_PAGINATED_URL, params=params) + return response + + # deprecated - please use paginated one above def get_projects_list(self): return self.get_cached(self.PROJECT_URL) diff --git a/src/sentry/integrations/jira/endpoints/search.py b/src/sentry/integrations/jira/endpoints/search.py index fb2da8b0d477cc..6207a3cc0d6677 100644 --- a/src/sentry/integrations/jira/endpoints/search.py +++ b/src/sentry/integrations/jira/endpoints/search.py @@ -5,6 +5,7 @@ from rest_framework.request import Request from rest_framework.response import Response +from sentry import features from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import control_silo_endpoint @@ -83,6 +84,17 @@ def get( ) users = [{"value": user_id, "label": display} for user_id, display in user_tuples] return Response(users) + if features.has("organizations:jira-paginated-projects", organization, actor=request.user): + if field == "project": + try: + response = jira_client.get_projects_paginated(params={"query": query}) + projects = [ + {"label": f"{p["key"]} - {p["name"]}", "value": p["id"]} + for p in response.get("values", []) + ] + except (ApiUnauthorized, ApiError): + return Response({"detail": "Unable to fetch projects from Jira"}, status=400) + return Response(projects) try: response = jira_client.get_field_autocomplete(name=field, value=query) diff --git a/src/sentry/integrations/jira/integration.py b/src/sentry/integrations/jira/integration.py index f5280fe6ca7bef..43cc5a44b6bec7 100644 --- a/src/sentry/integrations/jira/integration.py +++ b/src/sentry/integrations/jira/integration.py @@ -789,7 +789,14 @@ def get_create_issue_config(self, group: Group | None, user: RpcUser, **kwargs): project_id = params.get("project", defaults.get("project")) client = self.get_client() try: - jira_projects = client.get_projects_list() + if features.has( + "organizations:jira-paginated-projects", group.organization, actor=user + ): + jira_projects = client.get_projects_paginated( + {"maxResults": MAX_PER_PROJECT_QUERIES} + )["values"] + else: + jira_projects = client.get_projects_list() except ApiError as e: logger.info( "jira.get-create-issue-config.no-projects", @@ -822,15 +829,27 @@ def get_create_issue_config(self, group: Group | None, user: RpcUser, **kwargs): if not any(c for c in issue_type_choices if c[0] == issue_type): issue_type = issue_type_meta["id"] - fields = [ + projects_form_field = {} + if features.has("organizations:jira-paginated-projects", group.organization, actor=user): + paginated_projects_url = reverse( + "sentry-extensions-jira-search", args=[self.organization.slug, self.model.id] + ) + projects_form_field["url"] = paginated_projects_url + + projects_form_field.update( { "name": "project", "label": "Jira Project", - "choices": [(p["id"], p["key"]) for p in jira_projects], + "choices": [(p["id"], f"{p["key"]} - {p["name"]}") for p in jira_projects], "default": meta["id"], "type": "select", "updatesForm": True, - }, + "required": True, + } + ) + + fields = [ + projects_form_field, *fields, { "name": "issuetype", diff --git a/tests/sentry/integrations/jira/test_integration.py b/tests/sentry/integrations/jira/test_integration.py index 9a8462b02c5a70..f022a7ec53c7ca 100644 --- a/tests/sentry/integrations/jira/test_integration.py +++ b/tests/sentry/integrations/jira/test_integration.py @@ -112,9 +112,10 @@ def test_get_create_issue_config(self): "name": "project", "default": "10000", "updatesForm": True, - "choices": [("10000", "EX"), ("10001", "ABC")], + "choices": [("10000", "EX - Example"), ("10001", "ABC - Alphabetical")], "label": "Jira Project", "type": "select", + "required": True, }, { "default": "message", @@ -208,6 +209,81 @@ def test_get_create_issue_config(self): }, ] + @responses.activate + @with_feature("organizations:jira-paginated-projects") + def test_get_create_issue_config_paginated_projects(self): + """Test that projects are fetched using pagination when the feature flag is enabled""" + event = self.store_event( + data={ + "event_id": "a" * 32, + "message": "message", + "timestamp": self.min_ago, + }, + project_id=self.project.id, + default_event_type=EventType.DEFAULT, + ) + group = event.group + assert group is not None + + # Mock the paginated projects response + responses.add( + responses.GET, + "https://example.atlassian.net/rest/api/2/project/search", + json={ + "values": [ + {"id": "10000", "key": "PROJ1", "name": "Project 1"}, + {"id": "10001", "key": "PROJ2", "name": "Project 2"}, + ], + "total": 2, + }, + ) + + # Mock the create issue metadata endpoint + responses.add( + responses.GET, + "https://example.atlassian.net/rest/api/2/issue/createmeta", + json={ + "projects": [ + { + "id": "10000", + "key": "PROJ1", + "name": "Project 1", + "issuetypes": [ + { + "description": "An error in the code", + "fields": { + "issuetype": { + "key": "issuetype", + "name": "Issue Type", + "required": True, + } + }, + "id": "bug1", + "name": "Bug", + } + ], + } + ] + }, + ) + + installation = self.integration.get_installation(self.organization.id) + fields = installation.get_create_issue_config(group, self.user) + + # Find the project field in the config + project_field = next(field for field in fields if field["name"] == "project") + + # Verify the project field is configured correctly + assert ( + project_field["url"] + == f"/extensions/jira/search/{self.organization.slug}/{self.integration.id}/" + ) + assert project_field["choices"] == [ + ("10000", "PROJ1 - Project 1"), + ("10001", "PROJ2 - Project 2"), + ] + assert project_field["type"] == "select" + def test_get_create_issue_config_customer_domain(self): event = self.store_event( data={ @@ -365,11 +441,12 @@ def test_get_create_issue_config_with_default_and_param(self): assert project_field == { "default": "10000", - "choices": [("10000", "EX"), ("10001", "ABC")], + "choices": [("10000", "EX - Example"), ("10001", "ABC - Alphabetical")], "type": "select", "name": "project", "label": "Jira Project", "updatesForm": True, + "required": True, } def test_get_create_issue_config_with_default(self): @@ -398,11 +475,12 @@ def test_get_create_issue_config_with_default(self): assert project_field == { "default": "10001", - "choices": [("10000", "EX"), ("10001", "ABC")], + "choices": [("10000", "EX - Example"), ("10001", "ABC - Alphabetical")], "type": "select", "name": "project", "label": "Jira Project", "updatesForm": True, + "required": True, } @patch("sentry.integrations.jira.integration.JiraIntegration.fetch_issue_create_meta") @@ -447,11 +525,12 @@ def test_get_create_issue_config_with_default_project_deleted( assert project_field == { "default": "10001", - "choices": [("10000", "EX"), ("10001", "ABC")], + "choices": [("10000", "EX - Example"), ("10001", "ABC - Alphabetical")], "type": "select", "name": "project", "label": "Jira Project", "updatesForm": True, + "required": True, } def test_get_create_issue_config_with_label_default(self): From 84d46be25382f1ed711f8dd9257acc99e6abf6a5 Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Tue, 15 Apr 2025 16:40:06 -0700 Subject: [PATCH 2/9] remove ff --- src/sentry/features/temporary.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 80c748229020c6..b3ac73b444f799 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -514,8 +514,6 @@ def register_temporary_features(manager: FeatureManager): manager.add("organizations:streamlined-publishing-flow", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable per-project selection for Jira integration manager.add("organizations:jira-per-project-statuses", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) - # Enable using paginated projects endpoint for Jira integration - manager.add("organizations:jira-paginated-projects", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable Relay extracting logs from breadcrumbs for a project. manager.add("projects:ourlogs-breadcrumb-extraction", ProjectFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) From 870c67712d2545a15a0b9a67c971060520ef2cb1 Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Wed, 16 Apr 2025 09:13:31 -0700 Subject: [PATCH 3/9] fix typing --- src/sentry/integrations/jira/integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/integrations/jira/integration.py b/src/sentry/integrations/jira/integration.py index 43cc5a44b6bec7..402978bd845c39 100644 --- a/src/sentry/integrations/jira/integration.py +++ b/src/sentry/integrations/jira/integration.py @@ -829,7 +829,7 @@ def get_create_issue_config(self, group: Group | None, user: RpcUser, **kwargs): if not any(c for c in issue_type_choices if c[0] == issue_type): issue_type = issue_type_meta["id"] - projects_form_field = {} + projects_form_field: dict[str, Any] = {} if features.has("organizations:jira-paginated-projects", group.organization, actor=user): paginated_projects_url = reverse( "sentry-extensions-jira-search", args=[self.organization.slug, self.model.id] From 51f8318e3718ef166e7359acd95aa0d799ace1d9 Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Wed, 16 Apr 2025 09:20:33 -0700 Subject: [PATCH 4/9] add test for search endpoint --- src/sentry/features/temporary.py | 2 + .../integrations/jira/test_search_endpoint.py | 47 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index b3ac73b444f799..80c748229020c6 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -514,6 +514,8 @@ def register_temporary_features(manager: FeatureManager): manager.add("organizations:streamlined-publishing-flow", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable per-project selection for Jira integration manager.add("organizations:jira-per-project-statuses", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) + # Enable using paginated projects endpoint for Jira integration + manager.add("organizations:jira-paginated-projects", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable Relay extracting logs from breadcrumbs for a project. manager.add("projects:ourlogs-breadcrumb-extraction", ProjectFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) diff --git a/tests/sentry/integrations/jira/test_search_endpoint.py b/tests/sentry/integrations/jira/test_search_endpoint.py index 2d40d6b03cb0cc..95a6f83bb66254 100644 --- a/tests/sentry/integrations/jira/test_search_endpoint.py +++ b/tests/sentry/integrations/jira/test_search_endpoint.py @@ -6,6 +6,7 @@ from fixtures.integrations.stub_service import StubService from sentry.testutils.cases import APITestCase +from sentry.testutils.helpers.features import with_feature from sentry.testutils.silo import control_silo_test @@ -181,3 +182,49 @@ def test_customfield_search_error(self): assert resp.data == { "detail": "Unable to fetch autocomplete for customfield_0123 from Jira" } + + @responses.activate + @with_feature("organizations:jira-paginated-projects") + def test_project_search_with_pagination(self): + responses.add( + responses.GET, + "https://example.atlassian.net/rest/api/2/project/search", + json={ + "values": [ + {"id": "10000", "key": "EX", "name": "Example"}, + ], + "total": 2, + }, + ) + + self.login_as(self.user) + + path = reverse( + "sentry-extensions-jira-search", args=[self.organization.slug, self.integration.id] + ) + + resp = self.client.get(f"{path}?field=project&query=example") + assert resp.status_code == 200 + assert resp.data == [ + {"label": "EX - Example", "value": "10000"}, + ] + + @responses.activate + @with_feature("organizations:jira-paginated-projects") + def test_project_search_error_with_pagination(self): + responses.add( + responses.GET, + "https://example.atlassian.net/rest/api/2/project/search", + status=500, + body="susge", + ) + + self.login_as(self.user) + + path = reverse( + "sentry-extensions-jira-search", args=[self.organization.slug, self.integration.id] + ) + + resp = self.client.get(f"{path}?field=project&query=example") + assert resp.status_code == 400 + assert resp.data == {"detail": "Unable to fetch projects from Jira"} From d45b0902079d436695659115b44dc8d519c3071c Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Thu, 17 Apr 2025 09:44:52 -0700 Subject: [PATCH 5/9] pr fixes --- src/sentry/integrations/jira/client.py | 4 +- .../integrations/jira/endpoints/search.py | 24 ++++++------ src/sentry/integrations/jira/integration.py | 37 ++++++++----------- 3 files changed, 31 insertions(+), 34 deletions(-) diff --git a/src/sentry/integrations/jira/client.py b/src/sentry/integrations/jira/client.py index 6a4652d32aafa2..6bec97a17d4c70 100644 --- a/src/sentry/integrations/jira/client.py +++ b/src/sentry/integrations/jira/client.py @@ -123,12 +123,12 @@ def create_comment(self, issue_key, 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_paginated(self, params: dict[str, str | Any] | None = None): + def get_projects_paginated(self, params: dict[str, Any] | None = None): response = self.get(self.PROJECTS_PAGINATED_URL, params=params) return response - # deprecated - please use paginated one above def get_projects_list(self): + """deprecated - please use paginated projects endpoint""" return self.get_cached(self.PROJECT_URL) def get_project_key_for_id(self, project_id) -> str: diff --git a/src/sentry/integrations/jira/endpoints/search.py b/src/sentry/integrations/jira/endpoints/search.py index 6207a3cc0d6677..fe3b322a50a346 100644 --- a/src/sentry/integrations/jira/endpoints/search.py +++ b/src/sentry/integrations/jira/endpoints/search.py @@ -84,17 +84,19 @@ def get( ) users = [{"value": user_id, "label": display} for user_id, display in user_tuples] return Response(users) - if features.has("organizations:jira-paginated-projects", organization, actor=request.user): - if field == "project": - try: - response = jira_client.get_projects_paginated(params={"query": query}) - projects = [ - {"label": f"{p["key"]} - {p["name"]}", "value": p["id"]} - for p in response.get("values", []) - ] - except (ApiUnauthorized, ApiError): - return Response({"detail": "Unable to fetch projects from Jira"}, status=400) - return Response(projects) + + if field == "project" and features.has( + "organizations:jira-paginated-projects", organization, actor=request.user + ): + try: + response = jira_client.get_projects_paginated(params={"query": query}) + projects = [ + {"label": f"{p["key"]} - {p["name"]}", "value": p["id"]} + for p in response.get("values", []) + ] + except (ApiUnauthorized, ApiError): + return Response({"detail": "Unable to fetch projects from Jira"}, status=400) + return Response(projects) try: response = jira_client.get_field_autocomplete(name=field, value=query) diff --git a/src/sentry/integrations/jira/integration.py b/src/sentry/integrations/jira/integration.py index 402978bd845c39..de1646f19fa6e5 100644 --- a/src/sentry/integrations/jira/integration.py +++ b/src/sentry/integrations/jira/integration.py @@ -789,14 +789,13 @@ def get_create_issue_config(self, group: Group | None, user: RpcUser, **kwargs): project_id = params.get("project", defaults.get("project")) client = self.get_client() try: - if features.has( - "organizations:jira-paginated-projects", group.organization, actor=user - ): - jira_projects = client.get_projects_paginated( - {"maxResults": MAX_PER_PROJECT_QUERIES} - )["values"] - else: - jira_projects = client.get_projects_list() + jira_projects = ( + client.get_projects_paginated({"maxResults": MAX_PER_PROJECT_QUERIES})["values"] + if features.has( + "organizations:jira-paginated-projects", group.organization, actor=user + ) + else client.get_projects_list() + ) except ApiError as e: logger.info( "jira.get-create-issue-config.no-projects", @@ -829,25 +828,21 @@ def get_create_issue_config(self, group: Group | None, user: RpcUser, **kwargs): if not any(c for c in issue_type_choices if c[0] == issue_type): issue_type = issue_type_meta["id"] - projects_form_field: dict[str, Any] = {} + projects_form_field = { + "name": "project", + "label": "Jira Project", + "choices": [(p["id"], f"{p["key"]} - {p["name"]}") for p in jira_projects], + "default": meta["id"], + "type": "select", + "updatesForm": True, + "required": True, + } if features.has("organizations:jira-paginated-projects", group.organization, actor=user): paginated_projects_url = reverse( "sentry-extensions-jira-search", args=[self.organization.slug, self.model.id] ) projects_form_field["url"] = paginated_projects_url - projects_form_field.update( - { - "name": "project", - "label": "Jira Project", - "choices": [(p["id"], f"{p["key"]} - {p["name"]}") for p in jira_projects], - "default": meta["id"], - "type": "select", - "updatesForm": True, - "required": True, - } - ) - fields = [ projects_form_field, *fields, From be86d5b4f3031a2d39fd9760f3f28c6a0a13d65f Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Fri, 18 Apr 2025 09:06:23 -0700 Subject: [PATCH 6/9] pull projects out of try block --- src/sentry/integrations/jira/endpoints/search.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/sentry/integrations/jira/endpoints/search.py b/src/sentry/integrations/jira/endpoints/search.py index fe3b322a50a346..524763971c81bc 100644 --- a/src/sentry/integrations/jira/endpoints/search.py +++ b/src/sentry/integrations/jira/endpoints/search.py @@ -90,12 +90,14 @@ def get( ): try: response = jira_client.get_projects_paginated(params={"query": query}) - projects = [ - {"label": f"{p["key"]} - {p["name"]}", "value": p["id"]} - for p in response.get("values", []) - ] except (ApiUnauthorized, ApiError): return Response({"detail": "Unable to fetch projects from Jira"}, status=400) + + projects = [ + {"label": f"{p["key"]} - {p["name"]}", "value": p["id"]} + for p in response.get("values", []) + ] + return Response(projects) try: From 8ef48560735b20e367332b378daa480adf2a3a04 Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Fri, 18 Apr 2025 10:02:50 -0700 Subject: [PATCH 7/9] type a load of formfield configs --- .../integrations/jira/endpoints/search.py | 3 +- src/sentry/integrations/jira/integration.py | 66 ++++++++++++------- src/sentry/integrations/mixins/issues.py | 48 +++++++++----- 3 files changed, 77 insertions(+), 40 deletions(-) diff --git a/src/sentry/integrations/jira/endpoints/search.py b/src/sentry/integrations/jira/endpoints/search.py index 524763971c81bc..6e45b92584b217 100644 --- a/src/sentry/integrations/jira/endpoints/search.py +++ b/src/sentry/integrations/jira/endpoints/search.py @@ -10,6 +10,7 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import control_silo_endpoint from sentry.integrations.api.bases.integration import IntegrationEndpoint +from sentry.integrations.jira.integration import JiraProjectMapping from sentry.integrations.models.integration import Integration from sentry.organizations.services.organization import RpcOrganization from sentry.shared_integrations.exceptions import ApiError, ApiUnauthorized, IntegrationError @@ -94,7 +95,7 @@ def get( return Response({"detail": "Unable to fetch projects from Jira"}, status=400) projects = [ - {"label": f"{p["key"]} - {p["name"]}", "value": p["id"]} + JiraProjectMapping(label=f"{p["key"]} - {p["name"]}", value=p["id"]) for p in response.get("values", []) ] diff --git a/src/sentry/integrations/jira/integration.py b/src/sentry/integrations/jira/integration.py index de1646f19fa6e5..edf1b0b8972d88 100644 --- a/src/sentry/integrations/jira/integration.py +++ b/src/sentry/integrations/jira/integration.py @@ -23,7 +23,12 @@ ) 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.mixins.issues import ( + MAX_CHAR, + BaseFormFieldConfig, + IssueSyncIntegration, + ResolveSyncAction, +) from sentry.integrations.models.external_issue import ExternalIssue from sentry.integrations.models.integration_external_project import IntegrationExternalProject from sentry.integrations.services.integration import integration_service @@ -128,6 +133,15 @@ class JiraProjectMapping(TypedDict): label: str +class SelectFormFieldConfig(BaseFormFieldConfig, total=False): + choices: list[tuple[str, str]] + updatesForm: bool + + +class AsyncSelectFormFieldConfig(SelectFormFieldConfig): + url: str + + class JiraIntegration(IssueSyncIntegration): outbound_status_key = "sync_status_forward" inbound_status_key = "sync_status_reverse" @@ -828,33 +842,41 @@ def get_create_issue_config(self, group: Group | None, user: RpcUser, **kwargs): if not any(c for c in issue_type_choices if c[0] == issue_type): issue_type = issue_type_meta["id"] - projects_form_field = { - "name": "project", - "label": "Jira Project", - "choices": [(p["id"], f"{p["key"]} - {p["name"]}") for p in jira_projects], - "default": meta["id"], - "type": "select", - "updatesForm": True, - "required": True, - } + projects_form_field = SelectFormFieldConfig( + name="project", + label="Jira Project", + choices=[(p["id"], f"{p["key"]} - {p["name"]}") for p in jira_projects], + default=meta["id"], + type="select", + updatesForm=True, + required=True, + ) + if features.has("organizations:jira-paginated-projects", group.organization, actor=user): - paginated_projects_url = reverse( - "sentry-extensions-jira-search", args=[self.organization.slug, self.model.id] + paginated_projects_url = self.search_url(group.organization.slug) + projects_form_field = AsyncSelectFormFieldConfig( + name="project", + label="Jira Project", + choices=[(p["id"], f"{p["key"]} - {p["name"]}") for p in jira_projects], + default=meta["id"], + type="select", + updatesForm=True, + required=True, + url=paginated_projects_url, ) - projects_form_field["url"] = paginated_projects_url fields = [ projects_form_field, *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 - }, + SelectFormFieldConfig( + name="issuetype", + label="Issue Type", + choices=issue_type_choices, + default=issue_type or issue_type_meta["id"], + type="select", + updatesForm=True, + required=bool(issue_type_choices), + ), ] # title is renamed to summary before sending to Jira diff --git a/src/sentry/integrations/mixins/issues.py b/src/sentry/integrations/mixins/issues.py index d3e4a908bae325..44ec31b2745c2c 100644 --- a/src/sentry/integrations/mixins/issues.py +++ b/src/sentry/integrations/mixins/issues.py @@ -7,7 +7,7 @@ from collections.abc import Mapping, Sequence from copy import deepcopy from operator import attrgetter -from typing import Any, ClassVar +from typing import Any, ClassVar, TypedDict from sentry.eventstore.models import GroupEvent from sentry.integrations.base import IntegrationInstallation @@ -35,6 +35,19 @@ MAX_CHAR = 50 +class BaseFormFieldConfig(TypedDict): + name: str + label: str + default: str + type: str + required: bool + + +class TextAreaFormFieldConfig(BaseFormFieldConfig, total=False): + autosize: bool + maxRows: int + + class ResolveSyncAction(enum.Enum): """ When an issue's state changes, we may have to sync the state based on the @@ -144,7 +157,7 @@ def get_group_description(self, group, event, **kwargs): @all_silo_function def get_create_issue_config( self, group: Group | None, user: User | RpcUser, **kwargs - ) -> list[dict[str, Any]]: + ) -> list[BaseFormFieldConfig]: """ These fields are used to render a form for the user, and are then passed in the format of: @@ -160,21 +173,22 @@ def get_create_issue_config( event = group.get_latest_event() return [ - { - "name": "title", - "label": "Title", - "default": self.get_group_title(group, event, **kwargs), - "type": "string", - "required": True, - }, - { - "name": "description", - "label": "Description", - "default": self.get_group_description(group, event, **kwargs), - "type": "textarea", - "autosize": True, - "maxRows": 10, - }, + BaseFormFieldConfig( + name="title", + label="Title", + default=self.get_group_title(group, event, **kwargs), + type="string", + required=True, + ), + TextAreaFormFieldConfig( + name="description", + label="Description", + default=self.get_group_description(group, event, **kwargs), + type="textarea", + autosize=True, + maxRows=10, + required=False, + ), ] def get_link_issue_config(self, group, **kwargs): From 08e5d1a4a0c889412df551c0c85d17edc85f6059 Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Fri, 18 Apr 2025 10:18:09 -0700 Subject: [PATCH 8/9] Revert "type a load of formfield configs" This reverts commit 8ef48560735b20e367332b378daa480adf2a3a04. --- .../integrations/jira/endpoints/search.py | 3 +- src/sentry/integrations/jira/integration.py | 66 +++++++------------ src/sentry/integrations/mixins/issues.py | 48 +++++--------- 3 files changed, 40 insertions(+), 77 deletions(-) diff --git a/src/sentry/integrations/jira/endpoints/search.py b/src/sentry/integrations/jira/endpoints/search.py index 6e45b92584b217..524763971c81bc 100644 --- a/src/sentry/integrations/jira/endpoints/search.py +++ b/src/sentry/integrations/jira/endpoints/search.py @@ -10,7 +10,6 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import control_silo_endpoint from sentry.integrations.api.bases.integration import IntegrationEndpoint -from sentry.integrations.jira.integration import JiraProjectMapping from sentry.integrations.models.integration import Integration from sentry.organizations.services.organization import RpcOrganization from sentry.shared_integrations.exceptions import ApiError, ApiUnauthorized, IntegrationError @@ -95,7 +94,7 @@ def get( return Response({"detail": "Unable to fetch projects from Jira"}, status=400) projects = [ - JiraProjectMapping(label=f"{p["key"]} - {p["name"]}", value=p["id"]) + {"label": f"{p["key"]} - {p["name"]}", "value": p["id"]} for p in response.get("values", []) ] diff --git a/src/sentry/integrations/jira/integration.py b/src/sentry/integrations/jira/integration.py index edf1b0b8972d88..de1646f19fa6e5 100644 --- a/src/sentry/integrations/jira/integration.py +++ b/src/sentry/integrations/jira/integration.py @@ -23,12 +23,7 @@ ) 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, - BaseFormFieldConfig, - IssueSyncIntegration, - ResolveSyncAction, -) +from sentry.integrations.mixins.issues import MAX_CHAR, IssueSyncIntegration, ResolveSyncAction from sentry.integrations.models.external_issue import ExternalIssue from sentry.integrations.models.integration_external_project import IntegrationExternalProject from sentry.integrations.services.integration import integration_service @@ -133,15 +128,6 @@ class JiraProjectMapping(TypedDict): label: str -class SelectFormFieldConfig(BaseFormFieldConfig, total=False): - choices: list[tuple[str, str]] - updatesForm: bool - - -class AsyncSelectFormFieldConfig(SelectFormFieldConfig): - url: str - - class JiraIntegration(IssueSyncIntegration): outbound_status_key = "sync_status_forward" inbound_status_key = "sync_status_reverse" @@ -842,41 +828,33 @@ def get_create_issue_config(self, group: Group | None, user: RpcUser, **kwargs): if not any(c for c in issue_type_choices if c[0] == issue_type): issue_type = issue_type_meta["id"] - projects_form_field = SelectFormFieldConfig( - name="project", - label="Jira Project", - choices=[(p["id"], f"{p["key"]} - {p["name"]}") for p in jira_projects], - default=meta["id"], - type="select", - updatesForm=True, - required=True, - ) - + projects_form_field = { + "name": "project", + "label": "Jira Project", + "choices": [(p["id"], f"{p["key"]} - {p["name"]}") for p in jira_projects], + "default": meta["id"], + "type": "select", + "updatesForm": True, + "required": True, + } if features.has("organizations:jira-paginated-projects", group.organization, actor=user): - paginated_projects_url = self.search_url(group.organization.slug) - projects_form_field = AsyncSelectFormFieldConfig( - name="project", - label="Jira Project", - choices=[(p["id"], f"{p["key"]} - {p["name"]}") for p in jira_projects], - default=meta["id"], - type="select", - updatesForm=True, - required=True, - url=paginated_projects_url, + paginated_projects_url = reverse( + "sentry-extensions-jira-search", args=[self.organization.slug, self.model.id] ) + projects_form_field["url"] = paginated_projects_url fields = [ projects_form_field, *fields, - SelectFormFieldConfig( - name="issuetype", - label="Issue Type", - choices=issue_type_choices, - default=issue_type or issue_type_meta["id"], - type="select", - updatesForm=True, - required=bool(issue_type_choices), - ), + { + "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 diff --git a/src/sentry/integrations/mixins/issues.py b/src/sentry/integrations/mixins/issues.py index 44ec31b2745c2c..d3e4a908bae325 100644 --- a/src/sentry/integrations/mixins/issues.py +++ b/src/sentry/integrations/mixins/issues.py @@ -7,7 +7,7 @@ from collections.abc import Mapping, Sequence from copy import deepcopy from operator import attrgetter -from typing import Any, ClassVar, TypedDict +from typing import Any, ClassVar from sentry.eventstore.models import GroupEvent from sentry.integrations.base import IntegrationInstallation @@ -35,19 +35,6 @@ MAX_CHAR = 50 -class BaseFormFieldConfig(TypedDict): - name: str - label: str - default: str - type: str - required: bool - - -class TextAreaFormFieldConfig(BaseFormFieldConfig, total=False): - autosize: bool - maxRows: int - - class ResolveSyncAction(enum.Enum): """ When an issue's state changes, we may have to sync the state based on the @@ -157,7 +144,7 @@ def get_group_description(self, group, event, **kwargs): @all_silo_function def get_create_issue_config( self, group: Group | None, user: User | RpcUser, **kwargs - ) -> list[BaseFormFieldConfig]: + ) -> list[dict[str, Any]]: """ These fields are used to render a form for the user, and are then passed in the format of: @@ -173,22 +160,21 @@ def get_create_issue_config( event = group.get_latest_event() return [ - BaseFormFieldConfig( - name="title", - label="Title", - default=self.get_group_title(group, event, **kwargs), - type="string", - required=True, - ), - TextAreaFormFieldConfig( - name="description", - label="Description", - default=self.get_group_description(group, event, **kwargs), - type="textarea", - autosize=True, - maxRows=10, - required=False, - ), + { + "name": "title", + "label": "Title", + "default": self.get_group_title(group, event, **kwargs), + "type": "string", + "required": True, + }, + { + "name": "description", + "label": "Description", + "default": self.get_group_description(group, event, **kwargs), + "type": "textarea", + "autosize": True, + "maxRows": 10, + }, ] def get_link_issue_config(self, group, **kwargs): From 720019d238ba761ad30bd4f9312c4d4435e6b5b8 Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Fri, 18 Apr 2025 10:23:23 -0700 Subject: [PATCH 9/9] reapply the JiraProjectMapping typeddict --- src/sentry/integrations/jira/endpoints/search.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/sentry/integrations/jira/endpoints/search.py b/src/sentry/integrations/jira/endpoints/search.py index 524763971c81bc..6e45b92584b217 100644 --- a/src/sentry/integrations/jira/endpoints/search.py +++ b/src/sentry/integrations/jira/endpoints/search.py @@ -10,6 +10,7 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import control_silo_endpoint from sentry.integrations.api.bases.integration import IntegrationEndpoint +from sentry.integrations.jira.integration import JiraProjectMapping from sentry.integrations.models.integration import Integration from sentry.organizations.services.organization import RpcOrganization from sentry.shared_integrations.exceptions import ApiError, ApiUnauthorized, IntegrationError @@ -94,7 +95,7 @@ def get( return Response({"detail": "Unable to fetch projects from Jira"}, status=400) projects = [ - {"label": f"{p["key"]} - {p["name"]}", "value": p["id"]} + JiraProjectMapping(label=f"{p["key"]} - {p["name"]}", value=p["id"]) for p in response.get("values", []) ]