diff --git a/src/sentry/apidocs/examples/integration_examples.py b/src/sentry/apidocs/examples/integration_examples.py index 04653f595f0e..1c71002f42c3 100644 --- a/src/sentry/apidocs/examples/integration_examples.py +++ b/src/sentry/apidocs/examples/integration_examples.py @@ -26,6 +26,7 @@ class IntegrationExamples: "team:read", "users:read", ], + "outOfDate": False, "status": "active", "provider": { "key": "slack", @@ -79,6 +80,7 @@ class IntegrationExamples: "team:read", "users:read", ], + "outOfDate": False, "status": "active", "provider": { "key": "slack", @@ -804,6 +806,7 @@ class IntegrationExamples: "domainName": "example.atlassian.net", "accountType": None, "scopes": None, + "outOfDate": None, "status": "active", "provider": { "key": "jira", @@ -844,6 +847,7 @@ class IntegrationExamples: "domainName": "example.atlassian.net", "accountType": None, "scopes": None, + "outOfDate": None, "status": "active", "provider": { "key": "jira", diff --git a/src/sentry/integrations/api/serializers/models/integration.py b/src/sentry/integrations/api/serializers/models/integration.py index efb82b1d3440..d8360d1c433c 100644 --- a/src/sentry/integrations/api/serializers/models/integration.py +++ b/src/sentry/integrations/api/serializers/models/integration.py @@ -9,6 +9,7 @@ from sentry.api.serializers import Serializer, register, serialize from sentry.constants import ObjectStatus from sentry.integrations.base import IntegrationProvider +from sentry.integrations.constants import SlackScope from sentry.integrations.models.integration import Integration from sentry.integrations.models.organization_integration import OrganizationIntegration from sentry.integrations.services.integration import ( @@ -16,6 +17,7 @@ RpcOrganizationIntegration, integration_service, ) +from sentry.integrations.utils.github_permissions import get_missing_github_app_permissions from sentry.shared_integrations.exceptions import ApiError from sentry.users.models.user import User from sentry.users.services.user import RpcUser @@ -30,6 +32,7 @@ class OrganizationIntegrationResponse(TypedDict): domainName: str | None accountType: str | None scopes: list[str] | None + outOfDate: bool | None status: str provider: Any configOrganization: Any @@ -70,6 +73,7 @@ class IntegrationSerializerResponse(TypedDict): domainName: str | None accountType: str | None scopes: list[str] | None + outOfDate: bool | None status: str provider: IntegrationProviderInfo @@ -84,6 +88,15 @@ def serialize( **kwargs: Any, ) -> IntegrationSerializerResponse: provider = obj.get_provider() + + out_of_date = None + + match provider.key: + case "github": + out_of_date = bool(get_missing_github_app_permissions(obj.metadata)) + case "slack": + out_of_date = SlackScope.APP_MENTIONS_READ not in (obj.metadata.get("scopes") or []) + return { "id": str(obj.id), "name": obj.name, @@ -91,6 +104,7 @@ def serialize( "domainName": obj.metadata.get("domain_name"), "accountType": obj.metadata.get("account_type"), "scopes": obj.metadata.get("scopes"), + "outOfDate": out_of_date, "status": obj.get_status_display(), "provider": serialize_provider(provider), } diff --git a/src/sentry/integrations/constants.py b/src/sentry/integrations/constants.py new file mode 100644 index 000000000000..9d7480b45a80 --- /dev/null +++ b/src/sentry/integrations/constants.py @@ -0,0 +1,21 @@ +from enum import StrEnum + + +class SlackScope(StrEnum): + """ + OAuth scopes for the Slack integration. + + Keep this outside ``sentry.integrations.slack`` so import-sensitive code + (like API serializers during app startup) can reference Slack protocol + constants without executing the heavy Slack package ``__init__`` and + triggering circular imports. + """ + + CHANNELS_HISTORY = "channels:history" + """Allows the bot to read message history in channels.""" + GROUPS_HISTORY = "groups:history" + """Allows the bot to read message history in private groups.""" + APP_MENTIONS_READ = "app_mentions:read" + """Allows the bot to read mentions in app messages.""" + ASSISTANT_WRITE = "assistant:write" + """Allows the bot to act as a Slack Agent.""" diff --git a/src/sentry/integrations/slack/utils/constants.py b/src/sentry/integrations/slack/utils/constants.py index 342f2580836f..663b6eacc6e9 100644 --- a/src/sentry/integrations/slack/utils/constants.py +++ b/src/sentry/integrations/slack/utils/constants.py @@ -1,16 +1,5 @@ -from enum import StrEnum - -SLACK_RATE_LIMITED_MESSAGE = "Requests to Slack exceeded the rate limit. Please try again later." +from sentry.integrations.constants import SlackScope +__all__ = ["SlackScope", "SLACK_RATE_LIMITED_MESSAGE"] -class SlackScope(StrEnum): - """OAuth scopes for the Slack integration.""" - - CHANNELS_HISTORY = "channels:history" - """Allows the bot to read message history in channels.""" - GROUPS_HISTORY = "groups:history" - """Allows the bot to read message history in private groups.""" - APP_MENTIONS_READ = "app_mentions:read" - """Allows the bot to read mentions in app messages.""" - ASSISTANT_WRITE = "assistant:write" - """Allows the bot to act as a Slack Agent.""" +SLACK_RATE_LIMITED_MESSAGE = "Requests to Slack exceeded the rate limit. Please try again later." diff --git a/tests/sentry/integrations/api/__init__.py b/tests/sentry/integrations/api/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/sentry/integrations/api/serializers/models/__init__.py b/tests/sentry/integrations/api/serializers/models/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/sentry/integrations/api/serializers/models/test_integration.py b/tests/sentry/integrations/api/serializers/models/test_integration.py new file mode 100644 index 000000000000..4adb32abd071 --- /dev/null +++ b/tests/sentry/integrations/api/serializers/models/test_integration.py @@ -0,0 +1,49 @@ +from sentry.api.serializers import serialize +from sentry.integrations.utils.github_permissions import GITHUB_APP_REQUIRED_PERMISSIONS_OPTION +from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.options import override_options + + +class IntegrationSerializerTest(TestCase): + def setUp(self) -> None: + super().setUp() + self.user = self.create_user() + self.organization = self.create_organization(owner=self.user) + + def test_other_provider_has_null_out_of_date(self) -> None: + integration = self.create_provider_integration( + provider="opsgenie", + external_id="opsgenie:1", + name="Team A", + metadata={"permissions": {"contents": "read"}}, + ) + + result = serialize(integration, self.user) + + assert result["outOfDate"] is None + + @override_options({GITHUB_APP_REQUIRED_PERMISSIONS_OPTION: {"contents": "write"}}) + def test_github_out_of_date_when_missing_permissions(self) -> None: + integration = self.create_provider_integration( + provider="github", + external_id="1", + name="octocat", + metadata={"permissions": {"contents": "read"}}, + ) + + result = serialize(integration, self.user) + + assert result["outOfDate"] is True + + @override_options({GITHUB_APP_REQUIRED_PERMISSIONS_OPTION: {"contents": "write"}}) + def test_github_not_out_of_date_when_permissions_satisfied(self) -> None: + integration = self.create_provider_integration( + provider="github", + external_id="2", + name="octocat", + metadata={"permissions": {"contents": "write"}}, + ) + + result = serialize(integration, self.user) + + assert result["outOfDate"] is False diff --git a/tests/sentry/issues/endpoints/test_group_integration_details.py b/tests/sentry/issues/endpoints/test_group_integration_details.py index cb2feef0630c..1ab4339c0baa 100644 --- a/tests/sentry/issues/endpoints/test_group_integration_details.py +++ b/tests/sentry/issues/endpoints/test_group_integration_details.py @@ -86,6 +86,7 @@ def test_simple_get_link(self) -> None: "domainName": integration.metadata.get("domain_name"), "accountType": integration.metadata.get("account_type"), "scopes": integration.metadata.get("scopes"), + "outOfDate": None, "status": integration.get_status_display(), "provider": { "key": provider.key, @@ -129,6 +130,7 @@ def test_simple_get_create(self) -> None: "domainName": integration.metadata.get("domain_name"), "accountType": integration.metadata.get("account_type"), "scopes": integration.metadata.get("scopes"), + "outOfDate": None, "status": integration.get_status_display(), "provider": { "key": provider.key, diff --git a/tests/sentry/issues/endpoints/test_group_integrations.py b/tests/sentry/issues/endpoints/test_group_integrations.py index 67b14d4af468..dce35fcf2227 100644 --- a/tests/sentry/issues/endpoints/test_group_integrations.py +++ b/tests/sentry/issues/endpoints/test_group_integrations.py @@ -33,6 +33,7 @@ def test_simple_get(self) -> None: "domainName": integration.metadata.get("domain_name"), "accountType": integration.metadata.get("account_type"), "scopes": integration.metadata.get("scopes"), + "outOfDate": None, "status": integration.get_status_display(), "provider": { "key": provider.key, diff --git a/tests/sentry/issues/endpoints/test_project_stacktrace_link.py b/tests/sentry/issues/endpoints/test_project_stacktrace_link.py index 3180c6c25d28..fd371c7c88a2 100644 --- a/tests/sentry/issues/endpoints/test_project_stacktrace_link.py +++ b/tests/sentry/issues/endpoints/test_project_stacktrace_link.py @@ -68,6 +68,7 @@ def serialized_integration(integration: Integration) -> Mapping[str, Any]: "icon": None, "id": str(integration.id), "name": "Example", + "outOfDate": None, "provider": serialized_provider(), "scopes": None, "status": "active",