configureIntegration.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  1. import {Fragment, useEffect} from 'react';
  2. import type {RouteComponentProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  5. import Access from 'sentry/components/acl/access';
  6. import {Alert} from 'sentry/components/alert';
  7. import {Button, LinkButton} from 'sentry/components/button';
  8. import Confirm from 'sentry/components/confirm';
  9. import Form from 'sentry/components/forms/form';
  10. import JsonForm from 'sentry/components/forms/jsonForm';
  11. import List from 'sentry/components/list';
  12. import ListItem from 'sentry/components/list/listItem';
  13. import LoadingError from 'sentry/components/loadingError';
  14. import LoadingIndicator from 'sentry/components/loadingIndicator';
  15. import NavTabs from 'sentry/components/navTabs';
  16. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  17. import {IconAdd, IconArrow} from 'sentry/icons';
  18. import {t} from 'sentry/locale';
  19. import {space} from 'sentry/styles/space';
  20. import type {
  21. IntegrationProvider,
  22. IntegrationWithConfig,
  23. Organization,
  24. PluginWithProjectList,
  25. } from 'sentry/types';
  26. import {singleLineRenderer} from 'sentry/utils/marked';
  27. import type {ApiQueryKey} from 'sentry/utils/queryClient';
  28. import {setApiQueryData, useApiQuery, useQueryClient} from 'sentry/utils/queryClient';
  29. import useRouteAnalyticsEventNames from 'sentry/utils/routeAnalytics/useRouteAnalyticsEventNames';
  30. import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
  31. import useApi from 'sentry/utils/useApi';
  32. import useOrganization from 'sentry/utils/useOrganization';
  33. import useProjects from 'sentry/utils/useProjects';
  34. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  35. import BreadcrumbTitle from 'sentry/views/settings/components/settingsBreadcrumb/breadcrumbTitle';
  36. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  37. import AddIntegration from './addIntegration';
  38. import IntegrationAlertRules from './integrationAlertRules';
  39. import IntegrationCodeMappings from './integrationCodeMappings';
  40. import IntegrationExternalTeamMappings from './integrationExternalTeamMappings';
  41. import IntegrationExternalUserMappings from './integrationExternalUserMappings';
  42. import IntegrationItem from './integrationItem';
  43. import IntegrationMainSettings from './integrationMainSettings';
  44. import IntegrationRepos from './integrationRepos';
  45. import IntegrationServerlessFunctions from './integrationServerlessFunctions';
  46. type Props = RouteComponentProps<
  47. {
  48. integrationId: string;
  49. providerKey: string;
  50. },
  51. {}
  52. >;
  53. const TABS = [
  54. 'repos',
  55. 'codeMappings',
  56. 'userMappings',
  57. 'teamMappings',
  58. 'settings',
  59. ] as const;
  60. type Tab = (typeof TABS)[number];
  61. const makeIntegrationQuery = (
  62. organization: Organization,
  63. integrationId: string
  64. ): ApiQueryKey => {
  65. return [`/organizations/${organization.slug}/integrations/${integrationId}/`];
  66. };
  67. const makePluginQuery = (organization: Organization): ApiQueryKey => {
  68. return [`/organizations/${organization.slug}/plugins/configs/`];
  69. };
  70. function ConfigureIntegration({params, router, routes, location}: Props) {
  71. const api = useApi();
  72. const queryClient = useQueryClient();
  73. const organization = useOrganization();
  74. const tab: Tab = TABS.includes(location.query.tab) ? location.query.tab : 'repos';
  75. const {integrationId, providerKey} = params;
  76. const {
  77. data: config = {providers: []},
  78. isLoading: isLoadingConfig,
  79. isError: isErrorConfig,
  80. refetch: refetchConfig,
  81. remove: removeConfig,
  82. } = useApiQuery<{
  83. providers: IntegrationProvider[];
  84. }>([`/organizations/${organization.slug}/config/integrations/`], {staleTime: 0});
  85. const {
  86. data: integration,
  87. isLoading: isLoadingIntegration,
  88. isError: isErrorIntegration,
  89. refetch: refetchIntegration,
  90. remove: removeIntegration,
  91. } = useApiQuery<IntegrationWithConfig>(
  92. makeIntegrationQuery(organization, integrationId),
  93. {staleTime: 0}
  94. );
  95. const {
  96. data: plugins,
  97. isLoading: isLoadingPlugins,
  98. isError: isErrorPlugins,
  99. refetch: refetchPlugins,
  100. remove: removePlugins,
  101. } = useApiQuery<PluginWithProjectList[] | null>(makePluginQuery(organization), {
  102. staleTime: 0,
  103. });
  104. const provider = config.providers.find(p => p.key === integration?.provider.key);
  105. const {projects} = useProjects();
  106. useRouteAnalyticsEventNames(
  107. 'integrations.details_viewed',
  108. 'Integrations: Details Viewed'
  109. );
  110. useRouteAnalyticsParams(
  111. provider
  112. ? {
  113. integration: provider.key,
  114. integration_type: 'first_party',
  115. }
  116. : {}
  117. );
  118. useEffect(() => {
  119. refetchIntegration();
  120. }, [projects, refetchIntegration]);
  121. useEffect(() => {
  122. // This page should not be accessible by members (unless its github or gitlab)
  123. const allowMemberConfiguration = ['github', 'gitlab'].includes(providerKey);
  124. if (!allowMemberConfiguration && !organization.access.includes('org:integrations')) {
  125. router.push(
  126. normalizeUrl({
  127. pathname: `/settings/${organization.slug}/integrations/${providerKey}/`,
  128. })
  129. );
  130. }
  131. }, [router, organization, providerKey]);
  132. if (isLoadingConfig || isLoadingIntegration || isLoadingPlugins) {
  133. return <LoadingIndicator />;
  134. }
  135. if (isErrorConfig || isErrorIntegration || isErrorPlugins) {
  136. return <LoadingError />;
  137. }
  138. if (!provider || !integration) {
  139. return null;
  140. }
  141. const onTabChange = (value: Tab) => {
  142. router.push({
  143. pathname: location.pathname,
  144. query: {...location.query, tab: value},
  145. });
  146. };
  147. /**
  148. * Refetch everything, this could be improved to reload only the right thing
  149. */
  150. const onUpdateIntegration = () => {
  151. removePlugins();
  152. refetchPlugins();
  153. removeConfig();
  154. refetchConfig();
  155. removeIntegration();
  156. refetchIntegration();
  157. };
  158. const handleOpsgenieMigration = async () => {
  159. try {
  160. await api.requestPromise(
  161. `/organizations/${organization.slug}/integrations/${integrationId}/migrate-opsgenie/`,
  162. {
  163. method: 'PUT',
  164. }
  165. );
  166. setApiQueryData<PluginWithProjectList[] | null>(
  167. queryClient,
  168. makePluginQuery(organization),
  169. oldData => {
  170. return oldData?.filter(({id}) => id === 'opsgenie') ?? [];
  171. }
  172. );
  173. addSuccessMessage(t('Migration in progress.'));
  174. } catch (error) {
  175. addErrorMessage(t('Something went wrong! Please try again.'));
  176. }
  177. };
  178. const handleJiraMigration = async () => {
  179. try {
  180. await api.requestPromise(
  181. `/organizations/${organization.slug}/integrations/${integrationId}/issues/`,
  182. {
  183. method: 'PUT',
  184. data: {},
  185. }
  186. );
  187. setApiQueryData<PluginWithProjectList[] | null>(
  188. queryClient,
  189. makePluginQuery(organization),
  190. oldData => {
  191. return oldData?.filter(({id}) => id === 'jira') ?? [];
  192. }
  193. );
  194. addSuccessMessage(t('Migration in progress.'));
  195. } catch (error) {
  196. addErrorMessage(t('Something went wrong! Please try again.'));
  197. }
  198. };
  199. const isInstalledOpsgeniePlugin = (plugin: PluginWithProjectList) => {
  200. return (
  201. plugin.id === 'opsgenie' &&
  202. plugin.projectList.length >= 1 &&
  203. plugin.projectList.find(({enabled}) => enabled === true)
  204. );
  205. };
  206. const getAction = () => {
  207. if (provider.key === 'pagerduty') {
  208. return (
  209. <AddIntegration
  210. provider={provider}
  211. onInstall={onUpdateIntegration}
  212. account={integration.domainName}
  213. organization={organization}
  214. >
  215. {onClick => (
  216. <Button
  217. priority="primary"
  218. size="sm"
  219. icon={<IconAdd isCircled />}
  220. onClick={() => onClick()}
  221. >
  222. {t('Add Services')}
  223. </Button>
  224. )}
  225. </AddIntegration>
  226. );
  227. }
  228. if (provider.key === 'discord') {
  229. return (
  230. <LinkButton
  231. aria-label="Open this server in the Discord app"
  232. size="sm"
  233. // @ts-ignore - the type of integration here is weird.
  234. href={`discord://discord.com/channels/${integration.externalId}`}
  235. >
  236. {t('Open in Discord')}
  237. </LinkButton>
  238. );
  239. }
  240. const shouldMigrateJiraPlugin =
  241. ['jira', 'jira_server'].includes(provider.key) &&
  242. (plugins || []).find(({id}) => id === 'jira');
  243. if (shouldMigrateJiraPlugin) {
  244. return (
  245. <Access access={['org:integrations']}>
  246. {({hasAccess}) => (
  247. <Confirm
  248. disabled={!hasAccess}
  249. header="Migrate Linked Issues from Jira Plugins"
  250. renderMessage={() => (
  251. <Fragment>
  252. <p>
  253. {t(
  254. 'This will automatically associate all the Linked Issues of your Jira Plugins to this integration.'
  255. )}
  256. </p>
  257. <p>
  258. {t(
  259. '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.'
  260. )}
  261. </p>
  262. <p>
  263. {t(
  264. 'Once the migration is complete, your Jira Plugins will be disabled.'
  265. )}
  266. </p>
  267. </Fragment>
  268. )}
  269. onConfirm={() => {
  270. handleJiraMigration();
  271. }}
  272. >
  273. <Button priority="primary" disabled={!hasAccess}>
  274. {t('Migrate Plugin')}
  275. </Button>
  276. </Confirm>
  277. )}
  278. </Access>
  279. );
  280. }
  281. const shouldMigrateOpsgeniePlugin =
  282. provider.key === 'opsgenie' &&
  283. organization.features.includes('integrations-opsgenie-migration') &&
  284. (plugins || []).find(isInstalledOpsgeniePlugin);
  285. if (shouldMigrateOpsgeniePlugin) {
  286. return (
  287. <Access access={['org:integrations']}>
  288. {({hasAccess}) => (
  289. <Confirm
  290. disabled={!hasAccess}
  291. header="Migrate API Keys and Alert Rules from Opsgenie"
  292. renderMessage={() => (
  293. <Fragment>
  294. <p>
  295. {t(
  296. 'This will automatically associate all the API keys and Alert Rules of your Opsgenie Plugins to this integration.'
  297. )}
  298. </p>
  299. <p>
  300. {t(
  301. 'API keys will be automatically named after one of the projects with which they were associated.'
  302. )}
  303. </p>
  304. <p>
  305. {t(
  306. 'Once the migration is complete, your Opsgenie Plugins will be disabled.'
  307. )}
  308. </p>
  309. </Fragment>
  310. )}
  311. onConfirm={() => {
  312. handleOpsgenieMigration();
  313. }}
  314. >
  315. <Button priority="primary" disabled={!hasAccess}>
  316. {t('Migrate Plugin')}
  317. </Button>
  318. </Confirm>
  319. )}
  320. </Access>
  321. );
  322. }
  323. return null;
  324. };
  325. // TODO(Steve): Refactor components into separate tabs and use more generic tab logic
  326. function renderMainTab() {
  327. if (!provider || !integration) {
  328. return null;
  329. }
  330. const instructions =
  331. integration.dynamicDisplayInformation?.configure_integration?.instructions;
  332. return (
  333. <Fragment>
  334. {integration.configOrganization.length > 0 && (
  335. <Form
  336. hideFooter
  337. saveOnBlur
  338. allowUndo
  339. apiMethod="POST"
  340. initialData={integration.configData || {}}
  341. apiEndpoint={`/organizations/${organization.slug}/integrations/${integration.id}/`}
  342. >
  343. <JsonForm
  344. fields={integration.configOrganization}
  345. title={
  346. integration.provider.aspects.configure_integration?.title ||
  347. t('Organization Integration Settings')
  348. }
  349. />
  350. </Form>
  351. )}
  352. {instructions && instructions.length > 0 && (
  353. <Alert type="info">
  354. {instructions.length === 1 ? (
  355. <span
  356. dangerouslySetInnerHTML={{__html: singleLineRenderer(instructions[0])}}
  357. />
  358. ) : (
  359. <List symbol={<IconArrow size="xs" direction="right" />}>
  360. {instructions.map((instruction, i) => (
  361. <ListItem key={i}>
  362. <span
  363. dangerouslySetInnerHTML={{__html: singleLineRenderer(instruction)}}
  364. />
  365. </ListItem>
  366. )) ?? null}
  367. </List>
  368. )}
  369. </Alert>
  370. )}
  371. {provider.features.includes('alert-rule') && <IntegrationAlertRules />}
  372. {provider.features.includes('commits') && (
  373. <IntegrationRepos integration={integration} />
  374. )}
  375. {provider.features.includes('serverless') && (
  376. <IntegrationServerlessFunctions integration={integration} />
  377. )}
  378. </Fragment>
  379. );
  380. }
  381. function renderTabContent() {
  382. if (!integration) {
  383. return null;
  384. }
  385. switch (tab) {
  386. case 'codeMappings':
  387. return <IntegrationCodeMappings integration={integration} />;
  388. case 'repos':
  389. return renderMainTab();
  390. case 'userMappings':
  391. return <IntegrationExternalUserMappings integration={integration} />;
  392. case 'teamMappings':
  393. return <IntegrationExternalTeamMappings integration={integration} />;
  394. case 'settings':
  395. return (
  396. <IntegrationMainSettings
  397. onUpdate={onUpdateIntegration}
  398. organization={organization}
  399. integration={integration}
  400. />
  401. );
  402. default:
  403. return renderMainTab();
  404. }
  405. }
  406. // renders everything below header
  407. function renderMainContent() {
  408. const hasStacktraceLinking = provider!.features.includes('stacktrace-link');
  409. const hasCodeOwners =
  410. provider!.features.includes('codeowners') &&
  411. organization.features.includes('integrations-codeowners');
  412. // if no code mappings, render the single tab
  413. if (!hasStacktraceLinking) {
  414. return renderMainTab();
  415. }
  416. // otherwise render the tab view
  417. const tabs = [
  418. ['repos', t('Repositories')],
  419. ['codeMappings', t('Code Mappings')],
  420. ...(hasCodeOwners ? [['userMappings', t('User Mappings')]] : []),
  421. ...(hasCodeOwners ? [['teamMappings', t('Team Mappings')]] : []),
  422. ] as [id: Tab, label: string][];
  423. return (
  424. <Fragment>
  425. <NavTabs underlined>
  426. {tabs.map(tabTuple => (
  427. <li
  428. key={tabTuple[0]}
  429. className={tab === tabTuple[0] ? 'active' : ''}
  430. onClick={() => onTabChange(tabTuple[0])}
  431. >
  432. <CapitalizedLink>{tabTuple[1]}</CapitalizedLink>
  433. </li>
  434. ))}
  435. </NavTabs>
  436. {renderTabContent()}
  437. </Fragment>
  438. );
  439. }
  440. return (
  441. <Fragment>
  442. <SentryDocumentTitle
  443. title={integration ? integration.provider.name : 'Configure Integration'}
  444. />
  445. <BackButtonWrapper>
  446. <LinkButton
  447. icon={<IconArrow direction="left" size="sm" />}
  448. size="sm"
  449. to={`/settings/${organization.slug}/integrations/${provider.key}/`}
  450. >
  451. {t('Back')}
  452. </LinkButton>
  453. </BackButtonWrapper>
  454. <SettingsPageHeader
  455. noTitleStyles
  456. title={<IntegrationItem integration={integration} />}
  457. action={getAction()}
  458. />
  459. {renderMainContent()}
  460. <BreadcrumbTitle
  461. routes={routes}
  462. title={t('Configure %s', integration.provider.name)}
  463. />
  464. </Fragment>
  465. );
  466. }
  467. export default ConfigureIntegration;
  468. const BackButtonWrapper = styled('div')`
  469. margin-bottom: ${space(2)};
  470. width: 100%;
  471. `;
  472. const CapitalizedLink = styled('a')`
  473. text-transform: capitalize;
  474. `;