configureIntegration.tsx 16 KB

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