configureIntegration.tsx 16 KB

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