Skip to content

Commit 7b4c21a

Browse files
chore(github): Add business+ plan check for multi org (#91905)
1 parent 4f2f6ac commit 7b4c21a

File tree

4 files changed

+154
-7
lines changed

4 files changed

+154
-7
lines changed

src/sentry/features/permanent.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ def register_permanent_features(manager: FeatureManager):
124124
"organizations:seer-based-priority": False,
125125
# Enable Vercel integration - there is a custom handler in getsentry
126126
"organizations:integrations-vercel": True,
127+
# Enable GitHub multi-org for users to connect many Sentry orgs to a single GitHub org.
128+
"organizations:integrations-scm-multi-org": True,
127129
# Enable issue view endpoints and UI
128130
"organizations:issue-views": False,
129131
}

src/sentry/integrations/github/integration.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -755,7 +755,8 @@ class GitHubInstallationError(StrEnum):
755755
INSTALLATION_EXISTS = "Github installed on another Sentry organization."
756756
USER_MISMATCH = "Authenticated user is not the same as who installed the app."
757757
MISSING_INTEGRATION = "Integration does not exist."
758-
INVALID_INSTALLATION = "User does not have access to given installation"
758+
INVALID_INSTALLATION = "User does not have access to given installation."
759+
FEATURE_NOT_AVAILABLE = "Your organization does not have access to this feature."
759760

760761

761762
def record_event(event: IntegrationPipelineViewType):
@@ -834,7 +835,6 @@ def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase
834835
if self.active_user_organization is not None and features.has(
835836
"organizations:github-multi-org",
836837
organization=self.active_user_organization.organization,
837-
actor=request.user,
838838
):
839839
owner_orgs = self._get_owner_github_organizations()
840840

@@ -887,11 +887,18 @@ def _get_eligible_multi_org_installations(
887887
class GithubOrganizationSelection(PipelineView):
888888
def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase:
889889
self.active_user_organization = determine_active_organization(request)
890+
has_scm_multi_org = (
891+
features.has(
892+
"organizations:integrations-scm-multi-org",
893+
organization=self.active_user_organization.organization,
894+
)
895+
if self.active_user_organization is not None
896+
else False
897+
)
890898

891899
if self.active_user_organization is None or not features.has(
892900
"organizations:github-multi-org",
893901
organization=self.active_user_organization.organization,
894-
actor=request.user,
895902
):
896903
return pipeline.next_step()
897904

@@ -915,6 +922,14 @@ def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase
915922
if chosen_installation_id == "-1":
916923
return pipeline.next_step()
917924

925+
if not has_scm_multi_org:
926+
lifecycle.record_failure(GitHubInstallationError.FEATURE_NOT_AVAILABLE)
927+
return error(
928+
request,
929+
self.active_user_organization,
930+
error_short=GitHubInstallationError.FEATURE_NOT_AVAILABLE,
931+
)
932+
918933
# Verify that the given GH installation belongs to the person installing the pipeline
919934
installation_ids = [
920935
installation["installation_id"] for installation in installation_info
@@ -936,7 +951,10 @@ def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase
936951
return self.render_react_view(
937952
request=request,
938953
pipeline_name="githubInstallationSelect",
939-
props={"installation_info": installation_info},
954+
props={
955+
"installation_info": installation_info,
956+
"has_scm_multi_org": has_scm_multi_org,
957+
},
940958
)
941959

942960

@@ -979,7 +997,6 @@ def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase
979997
if features.has(
980998
"organizations:github-multi-org",
981999
organization=self.active_user_organization.organization,
982-
actor=request.user,
9831000
):
9841001
try:
9851002
integration = Integration.objects.get(

tests/sentry/api/serializers/test_organization.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ def test_simple(self):
8282
"integrations-incident-management",
8383
"integrations-issue-basic",
8484
"integrations-issue-sync",
85+
"integrations-scm-multi-org",
8586
"integrations-stacktrace-link",
8687
"integrations-ticket-rules",
8788
"integrations-vercel",

tests/sentry/integrations/github/test_integration.py

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1272,6 +1272,7 @@ def test_get_account_id_backfill_missing(self):
12721272
integration = Integration.objects.get(id=integration_id)
12731273
assert integration.metadata["account_id"] == 60591805
12741274

1275+
@with_feature("organizations:integrations-scm-multi-org")
12751276
@with_feature("organizations:github-multi-org")
12761277
@responses.activate
12771278
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
@@ -1315,12 +1316,13 @@ def test_github_installation_calls_ui(self, mock_render, mock_record):
13151316
mock_render.assert_called_with(
13161317
request=ANY,
13171318
pipeline_name="githubInstallationSelect",
1318-
props={"installation_info": installations},
1319+
props={"installation_info": installations, "has_scm_multi_org": True},
13191320
)
13201321

13211322
# SLO assertions
13221323
assert_success_metric(mock_record)
13231324

1325+
@with_feature("organizations:integrations-scm-multi-org")
13241326
@with_feature("organizations:github-multi-org")
13251327
@responses.activate
13261328
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
@@ -1405,6 +1407,7 @@ def test_github_installation_stores_chosen_installation(self, mock_record):
14051407
# SLO assertions
14061408
assert_success_metric(mock_record)
14071409

1410+
@with_feature("organizations:integrations-scm-multi-org")
14081411
@with_feature("organizations:github-multi-org")
14091412
@responses.activate
14101413
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
@@ -1449,7 +1452,7 @@ def test_github_installation_fails_on_invalid_installation(self, mock_record):
14491452

14501453
self.assertTemplateUsed(resp, "sentry/integrations/github-integration-failed.html")
14511454
assert (
1452-
b'{"success":false,"data":{"error":"User does not have access to given installation"}'
1455+
b'{"success":false,"data":{"error":"User does not have access to given installation."}'
14531456
in resp.content
14541457
)
14551458
assert (
@@ -1476,6 +1479,128 @@ def test_github_installation_fails_on_invalid_installation(self, mock_record):
14761479

14771480
assert_failure_metric(mock_record, GitHubInstallationError.INVALID_INSTALLATION)
14781481

1482+
@with_feature(
1483+
{"organizations:github-multi-org": True, "organizations:integrations-scm-multi-org": False}
1484+
)
1485+
@responses.activate
1486+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
1487+
@patch.object(PipelineView, "render_react_view", return_value=HttpResponse())
1488+
def test_github_installation_calls_ui_no_biz_plan(self, mock_render, mock_record):
1489+
self._setup_with_existing_installations()
1490+
installations = [
1491+
{
1492+
"installation_id": "1",
1493+
"github_account": "santry",
1494+
"avatar_url": "https://github.com/knobiknows/all-the-bufo/raw/main/all-the-bufo/bufo-pitchforks.png",
1495+
},
1496+
{
1497+
"installation_id": "2",
1498+
"github_account": "bufo-bot",
1499+
"avatar_url": "https://github.com/knobiknows/all-the-bufo/raw/main/all-the-bufo/bufo-pog.png",
1500+
},
1501+
{
1502+
"installation_id": "-1",
1503+
"github_account": "Integrate with a new GitHub organization",
1504+
"avatar_url": "",
1505+
},
1506+
]
1507+
1508+
resp = self.client.get(self.init_path)
1509+
assert resp.status_code == 302
1510+
redirect = urlparse(resp["Location"])
1511+
assert redirect.scheme == "https"
1512+
assert redirect.netloc == "github.com"
1513+
assert redirect.path == "/login/oauth/authorize"
1514+
assert (
1515+
redirect.query
1516+
== f"client_id=github-client-id&state={self.pipeline.signature}&redirect_uri=http://testserver/extensions/github/setup/"
1517+
)
1518+
resp = self.client.get(
1519+
"{}?{}".format(
1520+
self.setup_path,
1521+
urlencode({"code": "12345678901234567890", "state": self.pipeline.signature}),
1522+
)
1523+
)
1524+
mock_render.assert_called_with(
1525+
request=ANY,
1526+
pipeline_name="githubInstallationSelect",
1527+
props={"installation_info": installations, "has_scm_multi_org": False},
1528+
)
1529+
1530+
# SLO assertions
1531+
assert_success_metric(mock_record)
1532+
1533+
@with_feature(
1534+
{"organizations:github-multi-org": True, "organizations:integrations-scm-multi-org": False}
1535+
)
1536+
@responses.activate
1537+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
1538+
@patch.object(PipelineView, "render_react_view", return_value=HttpResponse())
1539+
def test_errors_when_invalid_access_to_multi_org(self, mock_render, mock_record):
1540+
self._setup_with_existing_installations()
1541+
installations = [
1542+
{
1543+
"installation_id": "1",
1544+
"github_account": "santry",
1545+
"avatar_url": "https://github.com/knobiknows/all-the-bufo/raw/main/all-the-bufo/bufo-pitchforks.png",
1546+
},
1547+
{
1548+
"installation_id": "2",
1549+
"github_account": "bufo-bot",
1550+
"avatar_url": "https://github.com/knobiknows/all-the-bufo/raw/main/all-the-bufo/bufo-pog.png",
1551+
},
1552+
{
1553+
"installation_id": "-1",
1554+
"github_account": "Integrate with a new GitHub organization",
1555+
"avatar_url": "",
1556+
},
1557+
]
1558+
1559+
resp = self.client.get(self.init_path)
1560+
assert resp.status_code == 302
1561+
redirect = urlparse(resp["Location"])
1562+
assert redirect.scheme == "https"
1563+
assert redirect.netloc == "github.com"
1564+
assert redirect.path == "/login/oauth/authorize"
1565+
assert (
1566+
redirect.query
1567+
== f"client_id=github-client-id&state={self.pipeline.signature}&redirect_uri=http://testserver/extensions/github/setup/"
1568+
)
1569+
resp = self.client.get(
1570+
"{}?{}".format(
1571+
self.setup_path,
1572+
urlencode({"code": "12345678901234567890", "state": self.pipeline.signature}),
1573+
)
1574+
)
1575+
mock_render.assert_called_with(
1576+
request=ANY,
1577+
pipeline_name="githubInstallationSelect",
1578+
props={"installation_info": installations, "has_scm_multi_org": False},
1579+
)
1580+
1581+
# We rendered the GithubOrganizationSelection UI and the user chose to skip
1582+
resp = self.client.get(
1583+
"{}?{}".format(
1584+
self.setup_path,
1585+
urlencode(
1586+
{
1587+
"code": "12345678901234567890",
1588+
"state": self.pipeline.signature,
1589+
"chosen_installation_id": "12345",
1590+
}
1591+
),
1592+
)
1593+
)
1594+
1595+
self.assertTemplateUsed(resp, "sentry/integrations/github-integration-failed.html")
1596+
assert (
1597+
b'{"success":false,"data":{"error":"Your organization does not have access to this feature."}}'
1598+
in resp.content
1599+
)
1600+
assert b'window.opener.postMessage({"success":false' in resp.content
1601+
assert_failure_metric(mock_record, GitHubInstallationError.FEATURE_NOT_AVAILABLE)
1602+
1603+
@with_feature("organizations:integrations-scm-multi-org")
14791604
@with_feature("organizations:github-multi-org")
14801605
@responses.activate
14811606
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
@@ -1543,6 +1668,7 @@ def test_github_installation_skips_chosen_installation(self, mock_record):
15431668
# SLO assertions
15441669
assert_success_metric(mock_record)
15451670

1671+
@with_feature("organizations:integrations-scm-multi-org")
15461672
@with_feature("organizations:github-multi-org")
15471673
@responses.activate
15481674
def test_github_installation_gets_owner_orgs(self):
@@ -1554,6 +1680,7 @@ def test_github_installation_gets_owner_orgs(self):
15541680

15551681
assert owner_orgs == ["santry"]
15561682

1683+
@with_feature("organizations:integrations-scm-multi-org")
15571684
@with_feature("organizations:github-multi-org")
15581685
@responses.activate
15591686
def test_github_installation_filters_valid_installations(self):

0 commit comments

Comments
 (0)