import {Fragment, useEffect} from 'react'; import {RouteComponentProps} from 'react-router'; import styled from '@emotion/styled'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import Access from 'sentry/components/acl/access'; import {Alert} from 'sentry/components/alert'; import {Button, LinkButton} from 'sentry/components/button'; import Confirm from 'sentry/components/confirm'; import Form from 'sentry/components/forms/form'; import JsonForm from 'sentry/components/forms/jsonForm'; import List from 'sentry/components/list'; import ListItem from 'sentry/components/list/listItem'; import LoadingError from 'sentry/components/loadingError'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import NavTabs from 'sentry/components/navTabs'; import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle'; import {IconAdd, IconArrow} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import { IntegrationProvider, IntegrationWithConfig, Organization, PluginWithProjectList, } from 'sentry/types'; import {singleLineRenderer} from 'sentry/utils/marked'; import { ApiQueryKey, setApiQueryData, useApiQuery, useQueryClient, } from 'sentry/utils/queryClient'; import useRouteAnalyticsEventNames from 'sentry/utils/routeAnalytics/useRouteAnalyticsEventNames'; import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams'; import useApi from 'sentry/utils/useApi'; import useOrganization from 'sentry/utils/useOrganization'; import {normalizeUrl} from 'sentry/utils/withDomainRequired'; import BreadcrumbTitle from 'sentry/views/settings/components/settingsBreadcrumb/breadcrumbTitle'; import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader'; import AddIntegration from './addIntegration'; import IntegrationAlertRules from './integrationAlertRules'; import IntegrationCodeMappings from './integrationCodeMappings'; import IntegrationExternalTeamMappings from './integrationExternalTeamMappings'; import IntegrationExternalUserMappings from './integrationExternalUserMappings'; import IntegrationItem from './integrationItem'; import IntegrationMainSettings from './integrationMainSettings'; import IntegrationRepos from './integrationRepos'; import IntegrationServerlessFunctions from './integrationServerlessFunctions'; type Props = RouteComponentProps< { integrationId: string; providerKey: string; }, {} >; const TABS = [ 'repos', 'codeMappings', 'userMappings', 'teamMappings', 'settings', ] as const; type Tab = (typeof TABS)[number]; const makeIntegrationQuery = ( organization: Organization, integrationId: string ): ApiQueryKey => { return [`/organizations/${organization.slug}/integrations/${integrationId}/`]; }; const makePluginQuery = (organization: Organization): ApiQueryKey => { return [`/organizations/${organization.slug}/plugins/configs/`]; }; function ConfigureIntegration({params, router, routes, location}: Props) { const api = useApi(); const queryClient = useQueryClient(); const organization = useOrganization(); const tab: Tab = TABS.includes(location.query.tab) ? location.query.tab : 'repos'; const {integrationId, providerKey} = params; const { data: config = {providers: []}, isLoading: isLoadingConfig, isError: isErrorConfig, refetch: refetchConfig, remove: removeConfig, } = useApiQuery<{ providers: IntegrationProvider[]; }>([`/organizations/${organization.slug}/config/integrations/`], {staleTime: 0}); const { data: integration, isLoading: isLoadingIntegration, isError: isErrorIntegration, refetch: refetchIntegration, remove: removeIntegration, } = useApiQuery( makeIntegrationQuery(organization, integrationId), {staleTime: 0} ); const { data: plugins, isLoading: isLoadingPlugins, isError: isErrorPlugins, refetch: refetchPlugins, remove: removePlugins, } = useApiQuery(makePluginQuery(organization), { staleTime: 0, }); const provider = config.providers.find(p => p.key === integration?.provider.key); useRouteAnalyticsEventNames( 'integrations.details_viewed', 'Integrations: Details Viewed' ); useRouteAnalyticsParams( provider ? { integration: provider.key, integration_type: 'first_party', } : {} ); useEffect(() => { // This page should not be accessible by members (unless its github or gitlab) const allowMemberConfiguration = ['github', 'gitlab'].includes(providerKey); if (!allowMemberConfiguration && !organization.access.includes('org:integrations')) { router.push( normalizeUrl({ pathname: `/settings/${organization.slug}/integrations/${providerKey}/`, }) ); } }, [router, organization, providerKey]); if (isLoadingConfig || isLoadingIntegration || isLoadingPlugins) { return ; } if (isErrorConfig || isErrorIntegration || isErrorPlugins) { return ; } if (!provider || !integration) { return null; } const onTabChange = (value: Tab) => { router.push({ pathname: location.pathname, query: {...location.query, tab: value}, }); }; /** * Refetch everything, this could be improved to reload only the right thing */ const onUpdateIntegration = () => { removePlugins(); refetchPlugins(); removeConfig(); refetchConfig(); removeIntegration(); refetchIntegration(); }; const handleOpsgenieMigration = async () => { try { await api.requestPromise( `/organizations/${organization.slug}/integrations/${integrationId}/migrate-opsgenie/`, { method: 'PUT', } ); setApiQueryData( queryClient, makePluginQuery(organization), oldData => { return oldData?.filter(({id}) => id === 'opsgenie') ?? []; } ); addSuccessMessage(t('Migration in progress.')); } catch (error) { addErrorMessage(t('Something went wrong! Please try again.')); } }; const handleJiraMigration = async () => { try { await api.requestPromise( `/organizations/${organization.slug}/integrations/${integrationId}/issues/`, { method: 'PUT', data: {}, } ); setApiQueryData( queryClient, makePluginQuery(organization), oldData => { return oldData?.filter(({id}) => id === 'jira') ?? []; } ); addSuccessMessage(t('Migration in progress.')); } catch (error) { addErrorMessage(t('Something went wrong! Please try again.')); } }; const isInstalledOpsgeniePlugin = (plugin: PluginWithProjectList) => { return ( plugin.id === 'opsgenie' && plugin.projectList.length >= 1 && plugin.projectList.find(({enabled}) => enabled === true) ); }; const getAction = () => { if (provider.key === 'pagerduty') { return ( {onClick => ( )} ); } if (provider.key === 'discord') { return ( {t('Open in Discord')} ); } const shouldMigrateJiraPlugin = ['jira', 'jira_server'].includes(provider.key) && (plugins || []).find(({id}) => id === 'jira'); if (shouldMigrateJiraPlugin) { return ( {({hasAccess}) => ( (

{t( 'This will automatically associate all the Linked Issues of your Jira Plugins to this integration.' )}

{t( 'If the Jira Plugins had the option checked to automatically create a Jira ticket for every new Sentry issue checked, you will need to create alert rules to recreate this behavior. Jira Server does not have this feature.' )}

{t( 'Once the migration is complete, your Jira Plugins will be disabled.' )}

)} onConfirm={() => { handleJiraMigration(); }} >
)}
); } const shouldMigrateOpsgeniePlugin = provider.key === 'opsgenie' && organization.features.includes('integrations-opsgenie-migration') && (plugins || []).find(isInstalledOpsgeniePlugin); if (shouldMigrateOpsgeniePlugin) { return ( {({hasAccess}) => ( (

{t( 'This will automatically associate all the API keys and Alert Rules of your Opsgenie Plugins to this integration.' )}

{t( 'API keys will be automatically named after one of the projects with which they were associated.' )}

{t( 'Once the migration is complete, your Opsgenie Plugins will be disabled.' )}

)} onConfirm={() => { handleOpsgenieMigration(); }} >
)}
); } return null; }; // TODO(Steve): Refactor components into separate tabs and use more generic tab logic function renderMainTab() { if (!provider || !integration) { return null; } const instructions = integration.dynamicDisplayInformation?.configure_integration?.instructions; return ( {integration.configOrganization.length > 0 && (
)} {instructions && instructions.length > 0 && ( {instructions.length === 1 ? ( ) : ( }> {instructions.map((instruction, i) => ( )) ?? null} )} )} {provider.features.includes('alert-rule') && } {provider.features.includes('commits') && ( )} {provider.features.includes('serverless') && ( )}
); } function renderTabContent() { if (!integration) { return null; } switch (tab) { case 'codeMappings': return ; case 'repos': return renderMainTab(); case 'userMappings': return ; case 'teamMappings': return ; case 'settings': return ( ); default: return renderMainTab(); } } // renders everything below header function renderMainContent() { const hasStacktraceLinking = provider!.features.includes('stacktrace-link'); const hasCodeOwners = provider!.features.includes('codeowners') && organization.features.includes('integrations-codeowners'); // if no code mappings, render the single tab if (!hasStacktraceLinking) { return renderMainTab(); } // otherwise render the tab view const tabs = [ ['repos', t('Repositories')], ['codeMappings', t('Code Mappings')], ...(hasCodeOwners ? [['userMappings', t('User Mappings')]] : []), ...(hasCodeOwners ? [['teamMappings', t('Team Mappings')]] : []), ] as [id: Tab, label: string][]; return ( {tabs.map(tabTuple => (
  • onTabChange(tabTuple[0])} > {tabTuple[1]}
  • ))}
    {renderTabContent()}
    ); } return ( } size="sm" to={`/settings/${organization.slug}/integrations/${provider.key}/`} > {t('Back')} } action={getAction()} /> {renderMainContent()} ); } export default ConfigureIntegration; const BackButtonWrapper = styled('div')` margin-bottom: ${space(2)}; width: 100%; `; const CapitalizedLink = styled('a')` text-transform: capitalize; `;