import {Fragment} from 'react'; import {RouteComponentProps} from 'react-router'; import styled from '@emotion/styled'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import {Client} from 'sentry/api'; 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 NavTabs from 'sentry/components/navTabs'; 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 {trackIntegrationAnalytics} from 'sentry/utils/integrationUtil'; import {singleLineRenderer} from 'sentry/utils/marked'; import withApi from 'sentry/utils/withApi'; import {normalizeUrl} from 'sentry/utils/withDomainRequired'; import withOrganization from 'sentry/utils/withOrganization'; import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView'; 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 RouteParams = { integrationId: string; providerKey: string; }; type Props = RouteComponentProps & { api: Client; organization: Organization; }; type Tab = 'repos' | 'codeMappings' | 'userMappings' | 'teamMappings' | 'settings'; type State = DeprecatedAsyncView['state'] & { config: {providers: IntegrationProvider[]}; integration: IntegrationWithConfig; plugins: PluginWithProjectList[] | null; tab?: Tab; }; class ConfigureIntegration extends DeprecatedAsyncView { getEndpoints(): ReturnType { const {organization} = this.props; const {integrationId} = this.props.params; return [ ['config', `/organizations/${organization.slug}/config/integrations/`], [ 'integration', `/organizations/${organization.slug}/integrations/${integrationId}/`, ], ['plugins', `/organizations/${organization.slug}/plugins/configs/`], ]; } componentDidMount() { super.componentDidMount(); const { location, router, organization, params: {providerKey}, } = this.props; // 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}/`, }) ); } const value = (['codeMappings', 'userMappings', 'teamMappings'] as const).find( tab => tab === location.query.tab ) || 'repos'; // eslint-disable-next-line react/no-did-mount-set-state this.setState({tab: value}); } onRequestSuccess({stateKey, data}) { if (stateKey !== 'integration') { return; } trackIntegrationAnalytics('integrations.details_viewed', { integration: data.provider.key, integration_type: 'first_party', organization: this.props.organization, }); } getTitle() { return this.state.integration ? this.state.integration.provider.name : 'Configure Integration'; } hasStacktraceLinking(provider: IntegrationProvider) { // CodeOwners will only work if the provider has StackTrace Linking return ( provider.features.includes('stacktrace-link') && this.props.organization.features.includes('integrations-stacktrace-link') ); } hasCodeOwners(provider: IntegrationProvider) { return ( provider.features.includes('codeowners') && this.props.organization.features.includes('integrations-codeowners') ); } onTabChange = (value: Tab) => { this.setState({tab: value}); }; get tab() { return this.state.tab || 'repos'; } onUpdateIntegration = () => { this.setState(this.getDefaultState(), this.fetchData); }; handleJiraMigration = async () => { try { const { organization, params: {integrationId}, } = this.props; await this.api.requestPromise( `/organizations/${organization.slug}/integrations/${integrationId}/issues/`, { method: 'PUT', data: {}, } ); this.setState( { plugins: (this.state.plugins || []).filter(({id}) => id === 'jira'), }, () => addSuccessMessage(t('Migration in progress.')) ); } catch (error) { addErrorMessage(t('Something went wrong! Please try again.')); } }; handleOpsgenieMigration = async () => { const { organization, params: {integrationId}, } = this.props; try { await this.api.requestPromise( `/organizations/${organization.slug}/integrations/${integrationId}/migrate-opsgenie/`, { method: 'PUT', } ); this.setState( { plugins: (this.state.plugins || []).filter(({id}) => id === 'opsgenie'), }, () => addSuccessMessage(t('Migration in progress.')) ); } catch (error) { addErrorMessage(t('Something went wrong! Please try again.')); } }; isInstalledOpsgeniePlugin = (plugin: PluginWithProjectList) => { return ( plugin.id === 'opsgenie' && plugin.projectList.length >= 1 && plugin.projectList.find(({enabled}) => enabled === true) ); }; getAction = (provider: IntegrationProvider | undefined) => { const {integration, plugins} = this.state; const shouldMigrateJiraPlugin = provider && ['jira', 'jira_server'].includes(provider.key) && (plugins || []).find(({id}) => id === 'jira'); const shouldMigrateOpsgeniePlugin = this.props.organization.features.includes('integrations-opsgenie-migration') && provider && provider.key === 'opsgenie' && (plugins || []).find(this.isInstalledOpsgeniePlugin); const action = provider && provider.key === 'pagerduty' ? ( {onClick => ( )} ) : shouldMigrateJiraPlugin ? ( {({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={() => { this.handleJiraMigration(); }} >
)}
) : provider && provider.key === 'discord' ? ( Open in Discord ) : shouldMigrateOpsgeniePlugin ? ( {({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={() => { this.handleOpsgenieMigration(); }} >
)}
) : null; return action; }; // TODO(Steve): Refactor components into separate tabs and use more generic tab logic renderMainTab(provider: IntegrationProvider) { const {organization} = this.props; const {integration} = this.state; const instructions = integration.dynamicDisplayInformation?.configure_integration?.instructions; return ( {integration.configOrganization.length > 0 && (
)} {instructions && instructions.length > 0 && ( {instructions?.length === 1 ? ( ) : ( }> {instructions?.map((instruction, i) => ( )) ?? []} )} )} {provider.features.includes('alert-rule') && } {provider.features.includes('commits') && ( )} {provider.features.includes('serverless') && ( )}
); } renderBody() { const {integration} = this.state; const {organization, router} = this.props; const provider = this.state.config.providers.find( p => p.key === integration.provider.key ); if (!provider) { return null; } const title = ; const header = ( ); const backButton = ( ); return ( {backButton} {header} {this.renderMainContent(provider)} ); } // renders everything below header renderMainContent(provider: IntegrationProvider) { // if no code mappings, render the single tab if (!this.hasStacktraceLinking(provider)) { return this.renderMainTab(provider); } // otherwise render the tab view const tabs = [ ['repos', t('Repositories')], ['codeMappings', t('Code Mappings')], ...(this.hasCodeOwners(provider) ? [['userMappings', t('User Mappings')]] : []), ...(this.hasCodeOwners(provider) ? [['teamMappings', t('Team Mappings')]] : []), ] as [id: Tab, label: string][]; return ( {tabs.map(tabTuple => (
  • this.onTabChange(tabTuple[0])} > {tabTuple[1]}
  • ))}
    {this.renderTabContent(this.tab, provider)}
    ); } renderTabContent(tab: Tab, provider: IntegrationProvider) { const {integration} = this.state; const {organization} = this.props; switch (tab) { case 'codeMappings': return ; case 'repos': return this.renderMainTab(provider); case 'userMappings': return ; case 'teamMappings': return ; case 'settings': return ( ); default: return this.renderMainTab(provider); } } } export default withOrganization(withApi(ConfigureIntegration)); const BackButtonWrapper = styled('div')` margin-bottom: ${space(2)}; width: 100%; `; const CapitalizedLink = styled('a')` text-transform: capitalize; `;