diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 5d61ffa62a250e..5e152b9f46643b 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -243,6 +243,9 @@ from sentry.integrations.api.endpoints.organization_config_integrations import ( OrganizationConfigIntegrationsEndpoint, ) +from sentry.integrations.api.endpoints.organization_integration_channels import ( + OrganizationIntegrationChannelsEndpoint, +) from sentry.integrations.api.endpoints.organization_integration_details import ( OrganizationIntegrationDetailsEndpoint, ) @@ -1894,6 +1897,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: OrganizationIntegrationReposEndpoint.as_view(), name="sentry-api-0-organization-integration-repos", ), + re_path( + r"^(?P[^/]+)/integrations/(?P[^/]+)/channels/$", + OrganizationIntegrationChannelsEndpoint.as_view(), + name="sentry-api-0-organization-integration-channels", + ), re_path( r"^(?P[^/]+)/integrations/(?P[^/]+)/issues/$", OrganizationIntegrationIssuesEndpoint.as_view(), diff --git a/src/sentry/integrations/api/endpoints/organization_integration_channels.py b/src/sentry/integrations/api/endpoints/organization_integration_channels.py new file mode 100644 index 00000000000000..616b9b5caf952e --- /dev/null +++ b/src/sentry/integrations/api/endpoints/organization_integration_channels.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import logging +from typing import Any + +from rest_framework.request import Request +from rest_framework.response import Response + +from sentry.api.api_owners import ApiOwner +from sentry.api.api_publish_status import ApiPublishStatus +from sentry.api.base import control_silo_endpoint +from sentry.integrations.api.bases.organization_integrations import ( + OrganizationIntegrationBaseEndpoint, +) +from sentry.integrations.discord.client import DiscordClient +from sentry.integrations.models import Integration +from sentry.integrations.msteams.client import MsTeamsClient +from sentry.integrations.services.integration.model import RpcIntegration +from sentry.integrations.types import IntegrationProviderSlug +from sentry.organizations.services.organization import RpcUserOrganizationContext +from sentry.shared_integrations.exceptions import ApiError + +logger = logging.getLogger(__name__) + + +def _slack_list_channels(*, integration_id: int) -> list[dict[str, Any]]: + """ + List Slack channels for a given integration. + + Fetches up to the Slack API limit (1000 channels). + Handles authentication via integration context and validates responses. + """ + + from sentry.integrations.slack.sdk_client import SlackSdkClient + + client = SlackSdkClient(integration_id=integration_id) + + try: + response = client.conversations_list( + exclude_archived=True, + types="public_channel,private_channel", + limit=1000, # Max allowed by Slack API + ) + resp_data: dict[str, Any] = response.data if isinstance(response.data, dict) else {} + except Exception as e: + logger.warning("Slack API request failed for integration_id=%s: %s", integration_id, e) + return [] + + # Validate structure + raw_channels = resp_data.get("channels") + if not isinstance(raw_channels, list): + logger.warning( + "Unexpected Slack API response structure for integration_id=%s: %r", + integration_id, + resp_data, + ) + return [] + + results: list[dict[str, Any]] = [] + for ch in raw_channels: + if not isinstance(ch, dict): + continue + + ch_id = ch.get("id") + ch_name = ch.get("name") + if not ch_id or not ch_name: + continue + + is_private = bool(ch.get("is_private")) + name_str = str(ch_name) + + results.append( + { + "id": str(ch_id), + "name": name_str, + "display": f"#{name_str}", + "type": "private" if is_private else "public", + } + ) + + return results + + +def _discord_list_channels(*, guild_id: str) -> list[dict[str, Any]]: + """ + List Discord channels for a given guild that can receive messages. + + The Discord API returns all guild channels in a single call. + This function filters for messageable channels only. + """ + + DISCORD_CHANNEL_TYPES = { + 0: "text", + 5: "announcement", + 15: "forum", + } + + client = DiscordClient() + + try: + raw_resp = client.get( + f"/guilds/{guild_id}/channels", + headers=client.prepare_auth_header(), + ) + except Exception as e: + logger.warning( + "Discord API request failed for guild_id=%s: %s", + guild_id, + e, + ) + return [] + + if not isinstance(raw_resp, list): + logger.warning( + "Unexpected Discord API response for guild_id=%s: %r", + guild_id, + raw_resp, + ) + return [] + + selectable_types = set(DISCORD_CHANNEL_TYPES.keys()) + results: list[dict[str, Any]] = [] + + for item in raw_resp: + if not isinstance(item, dict): + continue + + ch_type = item.get("type") + if not isinstance(ch_type, int) or ch_type not in selectable_types: + continue + + ch_id = item.get("id") + ch_name = item.get("name") + if not ch_id or not ch_name: + continue + + results.append( + { + "id": str(ch_id), + "name": str(ch_name), + "display": f"#{ch_name}", + "type": DISCORD_CHANNEL_TYPES.get(ch_type, "unknown"), + } + ) + + return results + + +def _msteams_list_channels( + *, integration: Integration | RpcIntegration, team_id: str +) -> list[dict[str, Any]]: + """ + List Microsoft Teams channels for a given team. + + The Teams API returns all channels at once. + Only standard and private channels are included. + """ + + client = MsTeamsClient(integration) + + try: + raw_resp = client.get(client.CHANNEL_URL % team_id) + except Exception as e: + logger.warning( + "Microsoft Teams API request failed for integration_id=%s, team_id=%s: %s", + integration.id, + team_id, + e, + ) + return [] + + if not isinstance(raw_resp, dict): + logger.warning( + "Unexpected Microsoft Teams API response for integration_id=%s, team_id=%s: %r", + integration.id, + team_id, + raw_resp, + ) + return [] + + raw_channels = raw_resp.get("conversations") + if not isinstance(raw_channels, list): + logger.warning( + "Missing or invalid 'conversations' in Teams API response for integration_id=%s, team_id=%s: %r", + integration.id, + team_id, + raw_resp, + ) + return [] + + results: list[dict[str, Any]] = [] + for item in raw_channels: + if not isinstance(item, dict): + continue + + ch_id = item.get("id") + display_name = item.get("displayName") + if not ch_id or not display_name: + continue + + ch_type = str(item.get("membershipType") or "standard") + + results.append( + { + "id": str(ch_id), + "name": str(display_name), + "display": str(display_name), + "type": ch_type, # "standard" or "private" + } + ) + + return results + + +@control_silo_endpoint +class OrganizationIntegrationChannelsEndpoint(OrganizationIntegrationBaseEndpoint): + publish_status = { + "GET": ApiPublishStatus.PRIVATE, + } + owner = ApiOwner.TELEMETRY_EXPERIENCE + + def get( + self, + request: Request, + organization_context: RpcUserOrganizationContext, + integration_id: int, + **kwargs: Any, + ) -> Response: + """ + List all messaging channels for an integration. + """ + + integration = self.get_integration(organization_context.organization.id, integration_id) + + try: + match integration.provider: + case IntegrationProviderSlug.SLACK.value: + results = _slack_list_channels(integration_id=integration.id) + case IntegrationProviderSlug.DISCORD.value: + results = _discord_list_channels(guild_id=str(integration.external_id)) + case IntegrationProviderSlug.MSTEAMS.value: + results = _msteams_list_channels( + integration=integration, + team_id=str(integration.external_id), + ) + case _: + return self.respond( + { + "results": [], + "warning": f"Channel listing not supported for provider '{integration.provider}'.", + } + ) + except ApiError as e: + return self.respond({"detail": str(e)}, status=400) + + return self.respond({"results": results}) diff --git a/static/app/data/controlsiloUrlPatterns.ts b/static/app/data/controlsiloUrlPatterns.ts index 6b3a90f149ec92..5d85049ce3f38b 100644 --- a/static/app/data/controlsiloUrlPatterns.ts +++ b/static/app/data/controlsiloUrlPatterns.ts @@ -62,6 +62,7 @@ const patterns: RegExp[] = [ new RegExp('^api/0/organizations/[^/]+/audit-logs/$'), new RegExp('^api/0/organizations/[^/]+/integrations/$'), new RegExp('^api/0/organizations/[^/]+/integrations/[^/]+/$'), + new RegExp('^api/0/organizations/[^/]+/integrations/[^/]+/channels/$'), new RegExp('^api/0/organizations/[^/]+/sentry-app-installations/$'), new RegExp('^api/0/organizations/[^/]+/sentry-apps/$'), new RegExp('^api/0/organizations/[^/]+/sentry-app-components/$'), diff --git a/tests/sentry/integrations/api/endpoints/test_organization_integration_channels.py b/tests/sentry/integrations/api/endpoints/test_organization_integration_channels.py new file mode 100644 index 00000000000000..0e9bd9085bd3be --- /dev/null +++ b/tests/sentry/integrations/api/endpoints/test_organization_integration_channels.py @@ -0,0 +1,176 @@ +from typing import cast +from unittest.mock import patch + +from sentry.testutils.cases import APITestCase +from sentry.testutils.silo import control_silo_test + + +class OrganizationIntegrationChannelsTest(APITestCase): + endpoint = "sentry-api-0-organization-integration-channels" + method = "get" + + def setUp(self) -> None: + super().setUp() + self.login_as(user=self.user) + + +@control_silo_test +class OrganizationIntegrationChannelsSlackTest(OrganizationIntegrationChannelsTest): + def setUp(self) -> None: + super().setUp() + self.integration = self.create_integration( + organization=self.organization, + provider="slack", + name="Slack Workspace", + external_id="TXXXXXXX1", + metadata={ + "access_token": "xoxb-token", + "installation_type": "born_as_bot", + }, + ) + + @patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_list") + def test_slack_channels_list(self, mock_conversations_list): + channels = [ + {"id": "C123", "name": "general", "is_private": False}, + {"id": "C124", "name": "random", "is_private": True}, + {"id": "C125", "name": "alerts", "is_private": False}, + ] + mock_conversations_list.return_value.data = { + "ok": True, + "channels": channels, + } + resp = self.get_success_response(self.organization.slug, self.integration.id) + results = resp.data["results"] + assert len(results) == 3 + for i, ch in enumerate(channels): + expected_type = "private" if ch.get("is_private") else "public" + assert results[i] == { + "id": ch["id"], + "name": ch["name"], + "display": f"#{ch['name']}", + "type": expected_type, + } + + @patch("sentry.integrations.slack.sdk_client.SlackSdkClient.conversations_list") + def test_large_slack_channel_list(self, mock_conversations_list): + mock_channels = [ + {"id": f"C{i:04}", "name": f"channel-{i}", "is_private": i % 2 == 0} + for i in range(1, 1001) + ] + mock_conversations_list.return_value.data = { + "ok": True, + "channels": mock_channels, + } + response = self.get_success_response(self.organization.slug, self.integration.id) + results = response.data["results"] + assert len(results) == 1000 + assert results[0]["id"] == "C0001" + assert results[0]["type"] == "public" + assert results[-1]["id"] == "C1000" + assert results[-1]["type"] == "private" + + +@control_silo_test +class OrganizationIntegrationChannelsDiscordTest(OrganizationIntegrationChannelsTest): + def setUp(self) -> None: + super().setUp() + self.integration = self.create_integration( + organization=self.organization, + provider="discord", + name="Discord Server", + external_id="1234567890", + ) + + @patch("sentry.integrations.discord.client.DiscordClient.get") + def test_discord_channels_list(self, mock_get): + mock_channels = [ + {"id": "123456", "name": "general", "type": 0}, + {"id": "789012", "name": "announcements", "type": 5}, + {"id": "345678", "name": "off-topic", "type": 0}, + ] + mock_get.return_value = mock_channels + response = self.get_success_response(self.organization.slug, self.integration.id) + results = response.data["results"] + DISCORD_CHANNEL_TYPES = { + 0: "text", + 5: "announcement", + 15: "forum", + } + expected = [] + for ch in mock_channels: + channel_type = cast(int, ch["type"]) # mypy: ensure int key for map lookup + expected.append( + { + "id": ch["id"], + "name": ch["name"], + "display": f"#{ch['name']}", + "type": DISCORD_CHANNEL_TYPES.get(channel_type, "unknown"), + } + ) + assert results == expected + mock_get.assert_called_once() + + +@control_silo_test +class OrganizationIntegrationChannelsMsTeamsTest(OrganizationIntegrationChannelsTest): + def setUp(self) -> None: + super().setUp() + self.integration = self.create_integration( + organization=self.organization, + provider="msteams", + name="MS Teams", + external_id="19:team-id@thread.tacv2", + metadata={ + "access_token": "token", + "service_url": "https://smba.trafficmanager.net/amer/", + "expires_at": 9999999999, + }, + ) + + @patch("sentry.integrations.msteams.client.MsTeamsClient.get") + def test_msteams_channels_list(self, mock_get): + mock_channels = { + "conversations": [ + {"id": "19:channel1@thread.tacv2", "displayName": "Development"}, + {"id": "19:channel2@thread.tacv2", "displayName": "General"}, + { + "id": "19:channel3@thread.tacv2", + "displayName": "Testing", + "membershipType": "private", + }, + ] + } + mock_get.return_value = mock_channels + response = self.get_success_response(self.organization.slug, self.integration.id) + results = response.data["results"] + expected = [] + for ch in mock_channels["conversations"]: + expected.append( + { + "id": ch["id"], + "name": ch["displayName"], + "display": ch["displayName"], + "type": ch.get("membershipType", "standard"), + } + ) + assert results == expected + mock_get.assert_called_once() + + +@control_silo_test +class OrganizationIntegrationChannelsErrorTest(OrganizationIntegrationChannelsTest): + def test_integration_not_found(self): + response = self.get_error_response(self.organization.slug, 9999, status_code=404) + assert response.status_code == 404 + + def test_unsupported_provider(self): + integration = self.create_integration( + organization=self.organization, + provider="github", + name="GitHub", + external_id="github:1", + ) + response = self.get_success_response(self.organization.slug, integration.id) + assert response.data["results"] == [] + assert "not supported" in response.data["warning"]