From 7b5b2b31e3258fc0055c6142471eb49db568b4cf Mon Sep 17 00:00:00 2001 From: Abdullah Khan Date: Mon, 10 Aug 2026 14:07:47 -0400 Subject: [PATCH 1/4] chore(scm-first-messaging-integration): Fetching providers list and exposing status --- .../scm/useScmMessagingProviders.spec.ts | 169 ++++++++++++++++++ .../scm/useScmMessagingProviders.ts | 139 ++++++++++++++ 2 files changed, 308 insertions(+) create mode 100644 static/app/components/onboarding/scm/useScmMessagingProviders.spec.ts create mode 100644 static/app/components/onboarding/scm/useScmMessagingProviders.ts diff --git a/static/app/components/onboarding/scm/useScmMessagingProviders.spec.ts b/static/app/components/onboarding/scm/useScmMessagingProviders.spec.ts new file mode 100644 index 000000000000..d14bde89da2e --- /dev/null +++ b/static/app/components/onboarding/scm/useScmMessagingProviders.spec.ts @@ -0,0 +1,169 @@ +import {GitHubIntegrationProviderFixture} from 'sentry-fixture/githubIntegrationProvider'; +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {OrganizationIntegrationsFixture} from 'sentry-fixture/organizationIntegrations'; + +import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {useScmMessagingProviders} from 'sentry/components/onboarding/scm/useScmMessagingProviders'; + +const organization = OrganizationFixture(); + +function mockProviders() { + ['slack', 'discord', 'msteams'].forEach(key => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/config/integrations/`, + body: {providers: [GitHubIntegrationProviderFixture({key})]}, + match: [MockApiClient.matchQuery({provider_key: key})], + }); + }); +} + +function mockIntegrations(bodies: Array>) { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/`, + body: bodies, + match: [MockApiClient.matchQuery({integrationType: 'messaging'})], + }); +} + +function renderProviders() { + return renderHookWithProviders(() => useScmMessagingProviders(), {organization}); +} + +describe('useScmMessagingProviders', () => { + afterEach(() => MockApiClient.clearMockResponses()); + + it('returns one installable row per provider when no integrations are connected', async () => { + mockProviders(); + mockIntegrations([]); + + const {result} = renderProviders(); + + await waitFor(() => expect(result.current.isPending).toBe(false)); + + expect(result.current.isError).toBe(false); + expect(result.current.providers).toHaveLength(3); + expect(result.current.providers.map(p => p.providerKey)).toEqual([ + 'slack', + 'discord', + 'msteams', + ]); + result.current.providers.forEach(p => expect(p.status).toBe('installable')); + }); + + it('marks a provider connected when it has an active integration', async () => { + mockProviders(); + mockIntegrations([ + OrganizationIntegrationsFixture({ + id: '10', + provider: {key: 'slack'} as any, + status: 'active', + organizationIntegrationStatus: 'active', + }), + ]); + + const {result} = renderProviders(); + + await waitFor(() => expect(result.current.isPending).toBe(false)); + + const slack = result.current.providers.find(p => p.providerKey === 'slack'); + expect(slack?.status).toBe('connected'); + expect(slack?.integration?.id).toBe('10'); + + result.current.providers + .filter(p => p.providerKey !== 'slack') + .forEach(p => expect(p.status).toBe('installable')); + }); + + it('ignores inactive integrations and leaves the provider installable', async () => { + mockProviders(); + mockIntegrations([ + OrganizationIntegrationsFixture({ + id: '11', + provider: {key: 'discord'} as any, + status: 'disabled', + organizationIntegrationStatus: 'active', + }), + ]); + + const {result} = renderProviders(); + + await waitFor(() => expect(result.current.isPending).toBe(false)); + + const discord = result.current.providers.find(p => p.providerKey === 'discord'); + expect(discord?.status).toBe('installable'); + }); + + it('marks a tenant-type msteams integration as permission-limited', async () => { + mockProviders(); + mockIntegrations([ + OrganizationIntegrationsFixture({ + id: '12', + provider: {key: 'msteams'} as any, + status: 'active', + organizationIntegrationStatus: 'active', + configData: {installationType: 'tenant'}, + }), + ]); + + const {result} = renderProviders(); + + await waitFor(() => expect(result.current.isPending).toBe(false)); + + const msteams = result.current.providers.find(p => p.providerKey === 'msteams'); + expect(msteams?.status).toBe('permission-limited'); + // The integration is still exposed so the row can show the workspace name. + expect(msteams?.integration?.id).toBe('12'); + }); + + it('marks a team-type msteams integration as connected', async () => { + mockProviders(); + mockIntegrations([ + OrganizationIntegrationsFixture({ + id: '13', + provider: {key: 'msteams'} as any, + status: 'active', + organizationIntegrationStatus: 'active', + configData: {installationType: 'team'}, + }), + ]); + + const {result} = renderProviders(); + + await waitFor(() => expect(result.current.isPending).toBe(false)); + + const msteams = result.current.providers.find(p => p.providerKey === 'msteams'); + expect(msteams?.status).toBe('connected'); + }); + + it('returns isError when the integrations query fails', async () => { + mockProviders(); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/`, + statusCode: 500, + match: [MockApiClient.matchQuery({integrationType: 'messaging'})], + }); + + const {result} = renderProviders(); + + await waitFor(() => expect(result.current.isPending).toBe(false)); + + expect(result.current.isError).toBe(true); + expect(result.current.providers).toHaveLength(0); + }); + + it('preserves provider order matching SCM_MESSAGING_PROVIDER_KEYS', async () => { + mockProviders(); + mockIntegrations([]); + + const {result} = renderProviders(); + + await waitFor(() => expect(result.current.isPending).toBe(false)); + + expect(result.current.providers.map(p => p.providerKey)).toEqual([ + 'slack', + 'discord', + 'msteams', + ]); + }); +}); diff --git a/static/app/components/onboarding/scm/useScmMessagingProviders.ts b/static/app/components/onboarding/scm/useScmMessagingProviders.ts new file mode 100644 index 000000000000..19e1c6d80813 --- /dev/null +++ b/static/app/components/onboarding/scm/useScmMessagingProviders.ts @@ -0,0 +1,139 @@ +import {useMemo} from 'react'; +import {useQueries, useQuery} from '@tanstack/react-query'; + +import { + SCM_MESSAGING_PROVIDER_KEYS, + type ScmMessagingProviderKey, +} from 'sentry/components/onboarding/scm/messagingProviders'; +import {isIntegrationActive} from 'sentry/components/onboarding/scm/useScmMessagingSetupValidation'; +import type { + IntegrationProvider, + OrganizationIntegration, +} from 'sentry/types/integrations'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {useOrganization} from 'sentry/utils/useOrganization'; + +/** + * Settled fetch-state for a single curated messaging provider row. + * + * - `installable` No active integration; the install entry point is shown. + * - `permission-limited` An active integration exists but is ineligible for Issue + * Alert actions (tenant-type MS Teams). The row is shown with + * a disabled configure CTA and an explanation. + * - `connected` An active, eligible integration is present and ready to + * have a destination configured. + */ +export type ScmMessagingProviderStatus = + | 'installable' + | 'permission-limited' + | 'connected'; + +export type ScmMessagingProviderViewModel = { + provider: IntegrationProvider; + providerKey: ScmMessagingProviderKey; + status: ScmMessagingProviderStatus; + /** Defined when status is `connected` or `permission-limited`. */ + integration?: OrganizationIntegration; +}; + +/** + * Returns true when the integration can receive Issue Alert actions. + * MS Teams "tenant" installations route notifications differently and + * cannot be used as an issue-alert destination. + */ +function isEligibleForIssueAlerts(integration: OrganizationIntegration): boolean { + if (integration.provider.key !== 'msteams') { + return true; + } + return integration.configData?.installationType !== 'tenant'; +} + +function toStatus( + integration: OrganizationIntegration | undefined +): ScmMessagingProviderStatus { + if (!integration) { + return 'installable'; + } + return isEligibleForIssueAlerts(integration) ? 'connected' : 'permission-limited'; +} + +export function useScmMessagingProviders(): { + isError: boolean; + isPending: boolean; + providers: ScmMessagingProviderViewModel[]; + refetchIntegrations: () => void; +} { + const organization = useOrganization(); + + const integrationsQuery = useQuery( + apiOptions.as()( + '/organizations/$organizationIdOrSlug/integrations/', + { + path: {organizationIdOrSlug: organization.slug}, + query: {integrationType: 'messaging'}, + staleTime: Infinity, + } + ) + ); + + const providerQueries = useQueries({ + queries: SCM_MESSAGING_PROVIDER_KEYS.map(providerKey => + apiOptions.as<{providers: IntegrationProvider[]}>()( + '/organizations/$organizationIdOrSlug/config/integrations/', + { + path: {organizationIdOrSlug: organization.slug}, + query: {provider_key: providerKey}, + staleTime: Infinity, + } + ) + ), + // Preserve index → key association so order always matches SCM_MESSAGING_PROVIDER_KEYS. + combine: results => ({ + byKey: Object.fromEntries( + results.map((r, i) => [SCM_MESSAGING_PROVIDER_KEYS[i], r.data?.providers[0]]) + ) as Partial>, + isPending: results.some(r => r.isPending), + isError: results.some(r => r.isError), + }), + }); + + const isPending = integrationsQuery.isPending || providerQueries.isPending; + const isError = integrationsQuery.isError || providerQueries.isError; + + const providers = useMemo(() => { + if (isPending || isError) { + return []; + } + + const integrations = integrationsQuery.data ?? []; + + return SCM_MESSAGING_PROVIDER_KEYS.flatMap(providerKey => { + const provider = providerQueries.byKey[providerKey]; + if (!provider) { + return []; + } + + // Find the first active integration for this provider. Inactive + // integrations are treated the same as no integration. + const integration = integrations.find( + i => i.provider.key === providerKey && isIntegrationActive(i) + ); + + return [ + { + providerKey, + provider, + status: toStatus(integration), + integration, + }, + ]; + }); + }, [isPending, isError, integrationsQuery.data, providerQueries.byKey]); + + return { + providers, + isPending, + isError, + refetchIntegrations: () => integrationsQuery.refetch(), + }; +} From 44c467b41450fad40c12f33c665f81d191a4daa0 Mon Sep 17 00:00:00 2001 From: Abdullah Khan Date: Mon, 10 Aug 2026 17:28:17 -0400 Subject: [PATCH 2/4] chore(scm-first-messaging-integration): Fetching providers list and exposition status --- .../components/onboarding/scm/useScmMessagingProviders.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/static/app/components/onboarding/scm/useScmMessagingProviders.spec.ts b/static/app/components/onboarding/scm/useScmMessagingProviders.spec.ts index d14bde89da2e..8494f32d71d3 100644 --- a/static/app/components/onboarding/scm/useScmMessagingProviders.spec.ts +++ b/static/app/components/onboarding/scm/useScmMessagingProviders.spec.ts @@ -5,6 +5,7 @@ import {OrganizationIntegrationsFixture} from 'sentry-fixture/organizationIntegr import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; import {useScmMessagingProviders} from 'sentry/components/onboarding/scm/useScmMessagingProviders'; +import type {OrganizationIntegration} from 'sentry/types/integrations'; const organization = OrganizationFixture(); @@ -18,7 +19,7 @@ function mockProviders() { }); } -function mockIntegrations(bodies: Array>) { +function mockIntegrations(bodies: OrganizationIntegration[]) { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/integrations/`, body: bodies, From d756fbbd074e1c5ed2ad2926c771d66091e5e1ce Mon Sep 17 00:00:00 2001 From: Abdullah Khan Date: Mon, 10 Aug 2026 17:38:26 -0400 Subject: [PATCH 3/4] chore(scm-first-messaging-integration): Cleaning up --- .../app/components/onboarding/scm/useScmMessagingProviders.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/app/components/onboarding/scm/useScmMessagingProviders.ts b/static/app/components/onboarding/scm/useScmMessagingProviders.ts index 19e1c6d80813..25736bd03a47 100644 --- a/static/app/components/onboarding/scm/useScmMessagingProviders.ts +++ b/static/app/components/onboarding/scm/useScmMessagingProviders.ts @@ -29,11 +29,11 @@ export type ScmMessagingProviderStatus = | 'connected'; export type ScmMessagingProviderViewModel = { + /** Defined when status is `connected` or `permission-limited`. */ + integration: OrganizationIntegration | undefined; provider: IntegrationProvider; providerKey: ScmMessagingProviderKey; status: ScmMessagingProviderStatus; - /** Defined when status is `connected` or `permission-limited`. */ - integration?: OrganizationIntegration; }; /** From b91d5ab081cfdca1141fe2cb0f1c98c33f750e81 Mon Sep 17 00:00:00 2001 From: Abdullah Khan Date: Fri, 14 Aug 2026 10:59:09 -0400 Subject: [PATCH 4/4] chore(scm-messaging-integration-ui): Addressing pr suggestions --- .../onboarding/scm/useScmMessagingProviders.spec.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/static/app/components/onboarding/scm/useScmMessagingProviders.spec.ts b/static/app/components/onboarding/scm/useScmMessagingProviders.spec.ts index 8494f32d71d3..831db4fc93fa 100644 --- a/static/app/components/onboarding/scm/useScmMessagingProviders.spec.ts +++ b/static/app/components/onboarding/scm/useScmMessagingProviders.spec.ts @@ -57,7 +57,6 @@ describe('useScmMessagingProviders', () => { mockIntegrations([ OrganizationIntegrationsFixture({ id: '10', - provider: {key: 'slack'} as any, status: 'active', organizationIntegrationStatus: 'active', }), @@ -81,7 +80,7 @@ describe('useScmMessagingProviders', () => { mockIntegrations([ OrganizationIntegrationsFixture({ id: '11', - provider: {key: 'discord'} as any, + provider: {...OrganizationIntegrationsFixture().provider, key: 'discord'}, status: 'disabled', organizationIntegrationStatus: 'active', }), @@ -100,7 +99,7 @@ describe('useScmMessagingProviders', () => { mockIntegrations([ OrganizationIntegrationsFixture({ id: '12', - provider: {key: 'msteams'} as any, + provider: {...OrganizationIntegrationsFixture().provider, key: 'msteams'}, status: 'active', organizationIntegrationStatus: 'active', configData: {installationType: 'tenant'}, @@ -122,7 +121,7 @@ describe('useScmMessagingProviders', () => { mockIntegrations([ OrganizationIntegrationsFixture({ id: '13', - provider: {key: 'msteams'} as any, + provider: {...OrganizationIntegrationsFixture().provider, key: 'msteams'}, status: 'active', organizationIntegrationStatus: 'active', configData: {installationType: 'team'},