diff --git a/src/sentry/features/permanent.py b/src/sentry/features/permanent.py index b8c9029f1cec84..8128ee1f1b3598 100644 --- a/src/sentry/features/permanent.py +++ b/src/sentry/features/permanent.py @@ -124,6 +124,8 @@ def register_permanent_features(manager: FeatureManager): "organizations:seer-based-priority": False, # Enable Vercel integration - there is a custom handler in getsentry "organizations:integrations-vercel": True, + # Enable GitHub multi-org for users to connect many Sentry orgs to a single GitHub org. + "organizations:integrations-scm-multi-org": True, # Enable issue view endpoints and UI "organizations:issue-views": False, } diff --git a/src/sentry/integrations/github/integration.py b/src/sentry/integrations/github/integration.py index 050775f76c6e1a..be75db50aa178f 100644 --- a/src/sentry/integrations/github/integration.py +++ b/src/sentry/integrations/github/integration.py @@ -755,7 +755,8 @@ class GitHubInstallationError(StrEnum): INSTALLATION_EXISTS = "Github installed on another Sentry organization." USER_MISMATCH = "Authenticated user is not the same as who installed the app." MISSING_INTEGRATION = "Integration does not exist." - INVALID_INSTALLATION = "User does not have access to given installation" + INVALID_INSTALLATION = "User does not have access to given installation." + FEATURE_NOT_AVAILABLE = "Your organization does not have access to this feature." def record_event(event: IntegrationPipelineViewType): @@ -834,7 +835,6 @@ def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase if self.active_user_organization is not None and features.has( "organizations:github-multi-org", organization=self.active_user_organization.organization, - actor=request.user, ): owner_orgs = self._get_owner_github_organizations() @@ -887,11 +887,18 @@ def _get_eligible_multi_org_installations( class GithubOrganizationSelection(PipelineView): def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase: self.active_user_organization = determine_active_organization(request) + has_scm_multi_org = ( + features.has( + "organizations:integrations-scm-multi-org", + organization=self.active_user_organization.organization, + ) + if self.active_user_organization is not None + else False + ) if self.active_user_organization is None or not features.has( "organizations:github-multi-org", organization=self.active_user_organization.organization, - actor=request.user, ): return pipeline.next_step() @@ -915,6 +922,14 @@ def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase if chosen_installation_id == "-1": return pipeline.next_step() + if not has_scm_multi_org: + lifecycle.record_failure(GitHubInstallationError.FEATURE_NOT_AVAILABLE) + return error( + request, + self.active_user_organization, + error_short=GitHubInstallationError.FEATURE_NOT_AVAILABLE, + ) + # Verify that the given GH installation belongs to the person installing the pipeline installation_ids = [ installation["installation_id"] for installation in installation_info @@ -936,7 +951,10 @@ def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase return self.render_react_view( request=request, pipeline_name="githubInstallationSelect", - props={"installation_info": installation_info}, + props={ + "installation_info": installation_info, + "has_scm_multi_org": has_scm_multi_org, + }, ) @@ -979,7 +997,6 @@ def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase if features.has( "organizations:github-multi-org", organization=self.active_user_organization.organization, - actor=request.user, ): try: integration = Integration.objects.get( diff --git a/tests/sentry/api/serializers/test_organization.py b/tests/sentry/api/serializers/test_organization.py index 2e900aab3d709a..19bd37fd664d43 100644 --- a/tests/sentry/api/serializers/test_organization.py +++ b/tests/sentry/api/serializers/test_organization.py @@ -82,6 +82,7 @@ def test_simple(self): "integrations-incident-management", "integrations-issue-basic", "integrations-issue-sync", + "integrations-scm-multi-org", "integrations-stacktrace-link", "integrations-ticket-rules", "integrations-vercel", diff --git a/tests/sentry/integrations/github/test_integration.py b/tests/sentry/integrations/github/test_integration.py index 078d821de49bad..7b2887144333e7 100644 --- a/tests/sentry/integrations/github/test_integration.py +++ b/tests/sentry/integrations/github/test_integration.py @@ -1272,6 +1272,7 @@ def test_get_account_id_backfill_missing(self): integration = Integration.objects.get(id=integration_id) assert integration.metadata["account_id"] == 60591805 + @with_feature("organizations:integrations-scm-multi-org") @with_feature("organizations:github-multi-org") @responses.activate @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @@ -1315,12 +1316,13 @@ def test_github_installation_calls_ui(self, mock_render, mock_record): mock_render.assert_called_with( request=ANY, pipeline_name="githubInstallationSelect", - props={"installation_info": installations}, + props={"installation_info": installations, "has_scm_multi_org": True}, ) # SLO assertions assert_success_metric(mock_record) + @with_feature("organizations:integrations-scm-multi-org") @with_feature("organizations:github-multi-org") @responses.activate @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @@ -1405,6 +1407,7 @@ def test_github_installation_stores_chosen_installation(self, mock_record): # SLO assertions assert_success_metric(mock_record) + @with_feature("organizations:integrations-scm-multi-org") @with_feature("organizations:github-multi-org") @responses.activate @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @@ -1449,7 +1452,7 @@ def test_github_installation_fails_on_invalid_installation(self, mock_record): self.assertTemplateUsed(resp, "sentry/integrations/github-integration-failed.html") assert ( - b'{"success":false,"data":{"error":"User does not have access to given installation"}' + b'{"success":false,"data":{"error":"User does not have access to given installation."}' in resp.content ) assert ( @@ -1476,6 +1479,128 @@ def test_github_installation_fails_on_invalid_installation(self, mock_record): assert_failure_metric(mock_record, GitHubInstallationError.INVALID_INSTALLATION) + @with_feature( + {"organizations:github-multi-org": True, "organizations:integrations-scm-multi-org": False} + ) + @responses.activate + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @patch.object(PipelineView, "render_react_view", return_value=HttpResponse()) + def test_github_installation_calls_ui_no_biz_plan(self, mock_render, mock_record): + self._setup_with_existing_installations() + installations = [ + { + "installation_id": "1", + "github_account": "santry", + "avatar_url": "https://github.com/knobiknows/all-the-bufo/raw/main/all-the-bufo/bufo-pitchforks.png", + }, + { + "installation_id": "2", + "github_account": "bufo-bot", + "avatar_url": "https://github.com/knobiknows/all-the-bufo/raw/main/all-the-bufo/bufo-pog.png", + }, + { + "installation_id": "-1", + "github_account": "Integrate with a new GitHub organization", + "avatar_url": "", + }, + ] + + resp = self.client.get(self.init_path) + assert resp.status_code == 302 + redirect = urlparse(resp["Location"]) + assert redirect.scheme == "https" + assert redirect.netloc == "github.com" + assert redirect.path == "/login/oauth/authorize" + assert ( + redirect.query + == f"client_id=github-client-id&state={self.pipeline.signature}&redirect_uri=http://testserver/extensions/github/setup/" + ) + resp = self.client.get( + "{}?{}".format( + self.setup_path, + urlencode({"code": "12345678901234567890", "state": self.pipeline.signature}), + ) + ) + mock_render.assert_called_with( + request=ANY, + pipeline_name="githubInstallationSelect", + props={"installation_info": installations, "has_scm_multi_org": False}, + ) + + # SLO assertions + assert_success_metric(mock_record) + + @with_feature( + {"organizations:github-multi-org": True, "organizations:integrations-scm-multi-org": False} + ) + @responses.activate + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @patch.object(PipelineView, "render_react_view", return_value=HttpResponse()) + def test_errors_when_invalid_access_to_multi_org(self, mock_render, mock_record): + self._setup_with_existing_installations() + installations = [ + { + "installation_id": "1", + "github_account": "santry", + "avatar_url": "https://github.com/knobiknows/all-the-bufo/raw/main/all-the-bufo/bufo-pitchforks.png", + }, + { + "installation_id": "2", + "github_account": "bufo-bot", + "avatar_url": "https://github.com/knobiknows/all-the-bufo/raw/main/all-the-bufo/bufo-pog.png", + }, + { + "installation_id": "-1", + "github_account": "Integrate with a new GitHub organization", + "avatar_url": "", + }, + ] + + resp = self.client.get(self.init_path) + assert resp.status_code == 302 + redirect = urlparse(resp["Location"]) + assert redirect.scheme == "https" + assert redirect.netloc == "github.com" + assert redirect.path == "/login/oauth/authorize" + assert ( + redirect.query + == f"client_id=github-client-id&state={self.pipeline.signature}&redirect_uri=http://testserver/extensions/github/setup/" + ) + resp = self.client.get( + "{}?{}".format( + self.setup_path, + urlencode({"code": "12345678901234567890", "state": self.pipeline.signature}), + ) + ) + mock_render.assert_called_with( + request=ANY, + pipeline_name="githubInstallationSelect", + props={"installation_info": installations, "has_scm_multi_org": False}, + ) + + # We rendered the GithubOrganizationSelection UI and the user chose to skip + resp = self.client.get( + "{}?{}".format( + self.setup_path, + urlencode( + { + "code": "12345678901234567890", + "state": self.pipeline.signature, + "chosen_installation_id": "12345", + } + ), + ) + ) + + self.assertTemplateUsed(resp, "sentry/integrations/github-integration-failed.html") + assert ( + b'{"success":false,"data":{"error":"Your organization does not have access to this feature."}}' + in resp.content + ) + assert b'window.opener.postMessage({"success":false' in resp.content + assert_failure_metric(mock_record, GitHubInstallationError.FEATURE_NOT_AVAILABLE) + + @with_feature("organizations:integrations-scm-multi-org") @with_feature("organizations:github-multi-org") @responses.activate @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @@ -1543,6 +1668,7 @@ def test_github_installation_skips_chosen_installation(self, mock_record): # SLO assertions assert_success_metric(mock_record) + @with_feature("organizations:integrations-scm-multi-org") @with_feature("organizations:github-multi-org") @responses.activate def test_github_installation_gets_owner_orgs(self): @@ -1554,6 +1680,7 @@ def test_github_installation_gets_owner_orgs(self): assert owner_orgs == ["santry"] + @with_feature("organizations:integrations-scm-multi-org") @with_feature("organizations:github-multi-org") @responses.activate def test_github_installation_filters_valid_installations(self):