From 824f18e61e04f15c6fbc89d9073a917198f2343b Mon Sep 17 00:00:00 2001 From: Lyn Nagara Date: Thu, 26 Mar 2026 15:55:40 -0700 Subject: [PATCH 1/8] feat(cells): Support multi-cell jira integration note: this is behind a feature flag for safety, the default is unchanged (no multi-cell) and merging the code should be a no-op --- src/sentry/integrations/jira/endpoints/descriptor.py | 2 +- src/sentry/integrations/jira/integration.py | 10 ++++++---- src/sentry/middleware/integrations/parsers/jira.py | 6 +----- src/sentry/options/defaults.py | 7 +++++++ .../integrations/jira/test_sentry_issue_details.py | 6 +----- .../middleware/integrations/parsers/test_jira.py | 7 +++---- 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/sentry/integrations/jira/endpoints/descriptor.py b/src/sentry/integrations/jira/endpoints/descriptor.py index 01b384985695d1..42747bfc5af191 100644 --- a/src/sentry/integrations/jira/endpoints/descriptor.py +++ b/src/sentry/integrations/jira/endpoints/descriptor.py @@ -68,7 +68,7 @@ def get(self, request: Request) -> Response: "content": {"type": "label", "label": {"value": "Linked Issues"}}, "target": { "type": "web_panel", - "url": "/extensions/jira/issue/{issue.key}/", + "url": "/extensions/jira/issue-details/{issue.key}/", }, "name": {"value": "Sentry "}, "key": "sentry-issues-glance", diff --git a/src/sentry/integrations/jira/integration.py b/src/sentry/integrations/jira/integration.py index 4742d9a738586d..4df5b34c188daf 100644 --- a/src/sentry/integrations/jira/integration.py +++ b/src/sentry/integrations/jira/integration.py @@ -1200,10 +1200,12 @@ class JiraIntegrationProvider(IntegrationProvider): metadata = metadata integration_cls = JiraIntegration - # Jira is cell-restricted because the JiraSentryIssueDetailsView view does not currently - # contain organization-identifying information aside from the ExternalIssue. Multiple cells - # may contain a matching ExternalIssue and we could leak data across the organizations. - is_cell_restricted = True + @property + def is_cell_restricted(self) -> bool: + # TODO(cells): Remove this option and property once multi-cell rollout is complete. + from sentry import options + + return not options.get("integrations.jira.multi-cell-enabled") features = frozenset( [ diff --git a/src/sentry/middleware/integrations/parsers/jira.py b/src/sentry/middleware/integrations/parsers/jira.py index be00aea968212f..70a52bd026ed11 100644 --- a/src/sentry/middleware/integrations/parsers/jira.py +++ b/src/sentry/middleware/integrations/parsers/jira.py @@ -67,11 +67,7 @@ def get_response(self) -> HttpResponseBase: if len(cells) == 0: return self.get_default_missing_integration_response() - if len(cells) > 1: - # This shouldn't happen because we block multi cell install at the install time. - raise ValueError("Jira integration is installed in multiple cells") - - if self.view_class in self.immediate_response_cell_classes: + if self.view_class in self.immediate_response_cell_classes and len(cells) == 1: try: return self.get_response_from_cell_silo(cell=cells[0]) except ApiError as err: diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index c079014870a65f..ed49c2b45eb6da 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -4191,3 +4191,10 @@ type=Bool, flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, ) + +register( + "integrations.jira.multi-cell-enabled", + default=False, + type=Bool, + flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, +) diff --git a/tests/sentry/integrations/jira/test_sentry_issue_details.py b/tests/sentry/integrations/jira/test_sentry_issue_details.py index 0db8dcddb54b95..c11f275427da95 100644 --- a/tests/sentry/integrations/jira/test_sentry_issue_details.py +++ b/tests/sentry/integrations/jira/test_sentry_issue_details.py @@ -258,26 +258,22 @@ def test_expired_invalid_installation_error( assert response.status_code == 200 assert UNABLE_TO_VERIFY_INSTALLATION.encode() in response.content - @patch.object(ExternalIssue.objects, "get") @patch("sentry.integrations.jira.views.sentry_issue_details.get_integration_from_request") @responses.activate def test_simple_get( self, mock_get_integration_from_request: MagicMock, - mock_get_external_issue: MagicMock, ) -> None: responses.add( responses.PUT, self.properties_url % (self.issue_key, self.properties_key), json={} ) - - mock_get_external_issue.side_effect = [self.de_external_issue, self.us_external_issue] - mock_get_integration_from_request.return_value = self.integration response = self.client.get(self.path) assert response.status_code == 200 resp_content = response.content.decode() for group in [self.us_group, self.de_group]: + assert group.title in resp_content assert group.get_absolute_url() in resp_content @patch("sentry.integrations.jira.views.sentry_issue_details.get_integration_from_request") diff --git a/tests/sentry/middleware/integrations/parsers/test_jira.py b/tests/sentry/middleware/integrations/parsers/test_jira.py index f0a7a38f9c8b31..0088d3a001deea 100644 --- a/tests/sentry/middleware/integrations/parsers/test_jira.py +++ b/tests/sentry/middleware/integrations/parsers/test_jira.py @@ -2,7 +2,6 @@ from unittest.mock import patch -import pytest import responses from django.http import HttpRequest, HttpResponse from django.test import RequestFactory, override_settings @@ -204,8 +203,8 @@ def test_get_response_multiple_regions(self) -> None: with patch.object(parser, "get_integration_from_request") as method: method.return_value = integration - # assert ValueError is raised if the integration is not valid - with pytest.raises(ValueError): - parser.get_response() + # Multi-cell installs fall back to control silo rather than raising + response = parser.get_response() + assert response.status_code == 200 assert_no_webhook_payloads() From 441ce0f072f5aee58baa7fc3446642d9b1223582 Mon Sep 17 00:00:00 2001 From: Lyn Nagara Date: Thu, 26 Mar 2026 15:55:40 -0700 Subject: [PATCH 2/8] add todo --- src/sentry/integrations/jira/views/sentry_issue_details.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/sentry/integrations/jira/views/sentry_issue_details.py b/src/sentry/integrations/jira/views/sentry_issue_details.py index f89d7ecd916309..19177f0c9fcf95 100644 --- a/src/sentry/integrations/jira/views/sentry_issue_details.py +++ b/src/sentry/integrations/jira/views/sentry_issue_details.py @@ -102,6 +102,8 @@ class JiraSentryIssueDetailsView(JiraSentryUIBaseView): """ Handles requests (from the Sentry integration in Jira) for HTML to display when you click on "Sentry -> Linked Issues" in the RH sidebar of an issue in the Jira UI. + + TODO(cells): Remove once all installs have migrated to JiraSentryIssueDetailsControlView. """ html_file = "sentry/integrations/jira-issue.html" From e87367ad4b46c6cd15bff0b8a3b023538d595a60 Mon Sep 17 00:00:00 2001 From: Lyn Nagara Date: Thu, 26 Mar 2026 16:36:14 -0700 Subject: [PATCH 3/8] add backward compat -- delegate to issue-details if multicell called on old url --- .../middleware/integrations/parsers/jira.py | 15 ++++++++------ .../integrations/parsers/test_jira.py | 20 +++++++------------ 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/sentry/middleware/integrations/parsers/jira.py b/src/sentry/middleware/integrations/parsers/jira.py index 70a52bd026ed11..a8e501d2222182 100644 --- a/src/sentry/middleware/integrations/parsers/jira.py +++ b/src/sentry/middleware/integrations/parsers/jira.py @@ -67,12 +67,15 @@ def get_response(self) -> HttpResponseBase: if len(cells) == 0: return self.get_default_missing_integration_response() - if self.view_class in self.immediate_response_cell_classes and len(cells) == 1: - try: - return self.get_response_from_cell_silo(cell=cells[0]) - except ApiError as err: - sentry_sdk.capture_exception(err) - return self.get_response_from_control_silo() + if self.view_class in self.immediate_response_cell_classes: + if len(cells) == 1: + try: + return self.get_response_from_cell_silo(cell=cells[0]) + except ApiError as err: + sentry_sdk.capture_exception(err) + return self.get_response_from_control_silo() + # TODO(cells): Remove once all installs have migrated to JiraSentryIssueDetailsControlView. + return JiraSentryIssueDetailsControlView.as_view()(self.request, **self.match.kwargs) if self.view_class in self.outbox_response_cell_classes: return self.get_response_from_webhookpayload( diff --git a/tests/sentry/middleware/integrations/parsers/test_jira.py b/tests/sentry/middleware/integrations/parsers/test_jira.py index 0088d3a001deea..7caf2d098cf160 100644 --- a/tests/sentry/middleware/integrations/parsers/test_jira.py +++ b/tests/sentry/middleware/integrations/parsers/test_jira.py @@ -185,26 +185,20 @@ def test_get_response_invalid_path(self) -> None: @override_cells(region_config) @override_settings(SILO_MODE=SiloMode.CONTROL) - @responses.activate def test_get_response_multiple_regions(self) -> None: - responses.add( - responses.POST, - eu_locality.to_url("/extensions/jira/issue/LR-123/"), - body="region response", - status=200, - ) - request = self.factory.post(path=f"{self.path_base}/issue/LR-123/") + # Use GET — the view only handles GET, and Jira sends GET for issue hooks. + request = self.factory.get(path=f"{self.path_base}/issue/LR-123/") parser = JiraRequestParser(request, self.get_response) - # Add a second organization. Jira only supports single regions. other_org = self.create_organization(owner=self.user, region="eu") integration = self.get_integration() integration.add_organization(other_org.id) - with patch.object(parser, "get_integration_from_request") as method: - method.return_value = integration - # Multi-cell installs fall back to control silo rather than raising + with patch.object(parser, "get_integration_from_request") as mock_integration: + mock_integration.return_value = integration response = parser.get_response() - assert response.status_code == 200 + # Must not 404 — multi-cell falls back to JiraSentryIssueDetailsControlView, not + # get_response_from_control_silo() which would 404 via the @cell_silo_view restriction. + assert response.status_code == 200 assert_no_webhook_payloads() From 69f2bdd6f226dcf99febc30447676fb7a2236a01 Mon Sep 17 00:00:00 2001 From: Lyn Nagara Date: Thu, 26 Mar 2026 16:39:19 -0700 Subject: [PATCH 4/8] fix type issue --- src/sentry/integrations/base.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/sentry/integrations/base.py b/src/sentry/integrations/base.py index 1d27bb59e7d644..6acbcee6476277 100644 --- a/src/sentry/integrations/base.py +++ b/src/sentry/integrations/base.py @@ -240,11 +240,15 @@ class is just a descriptor for how that object functions, and what behavior the installer's identity to the organization integration """ - is_cell_restricted: bool = False - """ - Returns True if each integration installation can only be connected on one cell of Sentry at a - time. It will raise an error if any organization from another cell attempts to install it. - """ + # TODO(cells): Remove once jira integration is updated and works for multi-cell. + # No integrations should be cell restricted. + @property + def is_cell_restricted(self) -> bool: + """ + Returns True if each integration installation can only be connected on one cell of Sentry at a + time. It will raise an error if any organization from another cell attempts to install it. + """ + return False features: frozenset[IntegrationFeatures] = frozenset() """can be any number of IntegrationFeatures""" From 2e299ccdb420aa62d4b743941138272881b93cf3 Mon Sep 17 00:00:00 2001 From: Lyn Nagara Date: Fri, 27 Mar 2026 07:16:36 -0700 Subject: [PATCH 5/8] . --- tests/sentry/integrations/test_pipeline.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/sentry/integrations/test_pipeline.py b/tests/sentry/integrations/test_pipeline.py index c16331c9b8ff38..6b69fc9dcdb86b 100644 --- a/tests/sentry/integrations/test_pipeline.py +++ b/tests/sentry/integrations/test_pipeline.py @@ -75,7 +75,7 @@ def _modify_provider(self): yield def _setup_cell_restriction(self): - self.provider.is_cell_restricted = True + self.provider.is_cell_restricted = True # type: ignore[misc] na_orgs = [ self.create_organization(name="na_org"), self.create_organization(name="na_org_2"), @@ -144,7 +144,7 @@ def test_with_customer_domain(self, *args) -> None: @patch("sentry.signals.integration_added.send_robust") def test_provider_should_check_cell_violation(self, *args) -> None: """Ensures we validate cells if `provider.is_cell_restricted` is set to True""" - self.provider.is_cell_restricted = True + self.provider.is_cell_restricted = True # type: ignore[misc] self.pipeline.state.data = {"external_id": self.external_id} with patch( "sentry.integrations.pipeline.is_violating_cell_restriction" @@ -727,7 +727,7 @@ def _modify_provider(self): yield def _setup_cell_restriction(self): - self.provider.is_cell_restricted = True + self.provider.is_cell_restricted = True # type: ignore[misc] na_orgs = [ self.create_organization(name="na_org"), self.create_organization(name="na_org_2"), From 5f7c37b801167600b90b91500d034689589979dc Mon Sep 17 00:00:00 2001 From: Lyn Nagara Date: Fri, 27 Mar 2026 08:39:22 -0700 Subject: [PATCH 6/8] fix typing for real --- src/sentry/integrations/example/integration.py | 5 +++++ tests/sentry/integrations/test_pipeline.py | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/sentry/integrations/example/integration.py b/src/sentry/integrations/example/integration.py index cbe2e508b42848..19b062c1f9fe97 100644 --- a/src/sentry/integrations/example/integration.py +++ b/src/sentry/integrations/example/integration.py @@ -220,6 +220,11 @@ class ExampleIntegrationProvider(IntegrationProvider): IntegrationFeatures.STACKTRACE_LINK, ] ) + cell_restricted = False + + @property + def is_cell_restricted(self) -> bool: + return self.cell_restricted def get_pipeline_views(self) -> Sequence[PipelineView[IntegrationPipeline]]: return [ExampleSetupView()] diff --git a/tests/sentry/integrations/test_pipeline.py b/tests/sentry/integrations/test_pipeline.py index 6b69fc9dcdb86b..a6269b4574447a 100644 --- a/tests/sentry/integrations/test_pipeline.py +++ b/tests/sentry/integrations/test_pipeline.py @@ -75,7 +75,7 @@ def _modify_provider(self): yield def _setup_cell_restriction(self): - self.provider.is_cell_restricted = True # type: ignore[misc] + self.provider.cell_restricted = True na_orgs = [ self.create_organization(name="na_org"), self.create_organization(name="na_org_2"), @@ -144,7 +144,7 @@ def test_with_customer_domain(self, *args) -> None: @patch("sentry.signals.integration_added.send_robust") def test_provider_should_check_cell_violation(self, *args) -> None: """Ensures we validate cells if `provider.is_cell_restricted` is set to True""" - self.provider.is_cell_restricted = True # type: ignore[misc] + self.provider.cell_restricted = True self.pipeline.state.data = {"external_id": self.external_id} with patch( "sentry.integrations.pipeline.is_violating_cell_restriction" @@ -727,7 +727,7 @@ def _modify_provider(self): yield def _setup_cell_restriction(self): - self.provider.is_cell_restricted = True # type: ignore[misc] + self.provider.cell_restricted = True na_orgs = [ self.create_organization(name="na_org"), self.create_organization(name="na_org_2"), From 3fc8f9f5a13d17b5dda4864c96847940b304f366 Mon Sep 17 00:00:00 2001 From: Lyn Nagara Date: Fri, 27 Mar 2026 08:58:21 -0700 Subject: [PATCH 7/8] doesn't test anything useful, remove tests --- tests/sentry/integrations/test_pipeline.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/tests/sentry/integrations/test_pipeline.py b/tests/sentry/integrations/test_pipeline.py index a6269b4574447a..680107a9a5af85 100644 --- a/tests/sentry/integrations/test_pipeline.py +++ b/tests/sentry/integrations/test_pipeline.py @@ -141,27 +141,6 @@ def test_with_customer_domain(self, *args) -> None: organization_id=self.organization.id, integration_id=integration.id ).exists() - @patch("sentry.signals.integration_added.send_robust") - def test_provider_should_check_cell_violation(self, *args) -> None: - """Ensures we validate cells if `provider.is_cell_restricted` is set to True""" - self.provider.cell_restricted = True - self.pipeline.state.data = {"external_id": self.external_id} - with patch( - "sentry.integrations.pipeline.is_violating_cell_restriction" - ) as mock_check_violation: - self.pipeline.finish_pipeline() - assert mock_check_violation.called - - @patch("sentry.signals.integration_added.send_robust") - def test_provider_should_not_check_cell_violation(self, *args) -> None: - """Ensures we don't reject cells if `provider.is_cell_restricted` is set to False""" - self.pipeline.state.data = {"external_id": self.external_id} - with patch( - "sentry.integrations.pipeline.is_violating_cell_restriction" - ) as mock_check_violation: - self.pipeline.finish_pipeline() - assert not mock_check_violation.called - @patch("sentry.signals.integration_added.send_robust") def test_is_violating_cell_restriction_success(self, *args) -> None: """Ensures pipeline can complete if all integration organizations reside in one cell.""" From a5b912deec2f96421e7d75a45e5cb43c6675879d Mon Sep 17 00:00:00 2001 From: Lyn Nagara Date: Fri, 27 Mar 2026 12:05:58 -0700 Subject: [PATCH 8/8] adjust tests --- src/sentry/integrations/example/integration.py | 5 ----- tests/sentry/integrations/test_pipeline.py | 8 +++----- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/sentry/integrations/example/integration.py b/src/sentry/integrations/example/integration.py index 19b062c1f9fe97..cbe2e508b42848 100644 --- a/src/sentry/integrations/example/integration.py +++ b/src/sentry/integrations/example/integration.py @@ -220,11 +220,6 @@ class ExampleIntegrationProvider(IntegrationProvider): IntegrationFeatures.STACKTRACE_LINK, ] ) - cell_restricted = False - - @property - def is_cell_restricted(self) -> bool: - return self.cell_restricted def get_pipeline_views(self) -> Sequence[PipelineView[IntegrationPipeline]]: return [ExampleSetupView()] diff --git a/tests/sentry/integrations/test_pipeline.py b/tests/sentry/integrations/test_pipeline.py index 680107a9a5af85..aa30c50406b832 100644 --- a/tests/sentry/integrations/test_pipeline.py +++ b/tests/sentry/integrations/test_pipeline.py @@ -75,7 +75,7 @@ def _modify_provider(self): yield def _setup_cell_restriction(self): - self.provider.cell_restricted = True + setattr(self.provider, "is_cell_restricted", True) na_orgs = [ self.create_organization(name="na_org"), self.create_organization(name="na_org_2"), @@ -177,11 +177,9 @@ def test_is_violating_cell_restriction_failure(self, *args) -> None: response = self.pipeline.finish_pipeline() assert isinstance(response, HttpResponse) error_message = "This integration has already been installed on another Sentry organization which resides in a different cell. Installation could not be completed." - assert error_message in response.content.decode() - if SiloMode.get_current_mode() == SiloMode.MONOLITH: assert error_message not in response.content.decode() - if SiloMode.get_current_mode() == SiloMode.CONTROL: + else: assert error_message in response.content.decode() def test_aliased_integration_key(self, *args) -> None: @@ -706,7 +704,7 @@ def _modify_provider(self): yield def _setup_cell_restriction(self): - self.provider.cell_restricted = True + setattr(self.provider, "is_cell_restricted", True) na_orgs = [ self.create_organization(name="na_org"), self.create_organization(name="na_org_2"),