index.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490
  1. import {Fragment} from 'react';
  2. import {RouteComponentProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import * as Sentry from '@sentry/react';
  5. import {
  6. addErrorMessage,
  7. addLoadingMessage,
  8. addSuccessMessage,
  9. } from 'sentry/actionCreators/indicator';
  10. import {openEditOwnershipRules, openModal} from 'sentry/actionCreators/modal';
  11. import Access from 'sentry/components/acl/access';
  12. import Feature from 'sentry/components/acl/feature';
  13. import Alert from 'sentry/components/alert';
  14. import Button from 'sentry/components/button';
  15. import FeatureBadge from 'sentry/components/featureBadge';
  16. import Form from 'sentry/components/forms/form';
  17. import JsonForm from 'sentry/components/forms/jsonForm';
  18. import HookOrDefault from 'sentry/components/hookOrDefault';
  19. import ExternalLink from 'sentry/components/links/externalLink';
  20. import {IconWarning} from 'sentry/icons';
  21. import {t, tct} from 'sentry/locale';
  22. import space from 'sentry/styles/space';
  23. import {
  24. CodeOwner,
  25. Integration,
  26. Organization,
  27. Project,
  28. RepositoryProjectPathConfig,
  29. } from 'sentry/types';
  30. import routeTitleGen from 'sentry/utils/routeTitle';
  31. import AsyncView from 'sentry/views/asyncView';
  32. import FeedbackAlert from 'sentry/views/settings/account/notifications/feedbackAlert';
  33. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  34. import PermissionAlert from 'sentry/views/settings/project/permissionAlert';
  35. import AddCodeOwnerModal from 'sentry/views/settings/project/projectOwnership/addCodeOwnerModal';
  36. import CodeOwnersPanel from 'sentry/views/settings/project/projectOwnership/codeowners';
  37. import RulesPanel from 'sentry/views/settings/project/projectOwnership/rulesPanel';
  38. type Props = {
  39. organization: Organization;
  40. project: Project;
  41. } & RouteComponentProps<{orgId: string; projectId: string}, {}>;
  42. type State = {
  43. codeMappings: RepositoryProjectPathConfig[];
  44. integrations: Integration[];
  45. ownership: null | any;
  46. codeowners?: CodeOwner[];
  47. } & AsyncView['state'];
  48. const CodeOwnersHeader = HookOrDefault({
  49. hookName: 'component:codeowners-header',
  50. defaultComponent: () => <Fragment />,
  51. });
  52. class ProjectOwnership extends AsyncView<Props, State> {
  53. getTitle() {
  54. const {project} = this.props;
  55. return routeTitleGen(t('Issue Owners'), project.slug, false);
  56. }
  57. getEndpoints(): ReturnType<AsyncView['getEndpoints']> {
  58. const {organization, project} = this.props;
  59. const endpoints: ReturnType<AsyncView['getEndpoints']> = [
  60. ['ownership', `/projects/${organization.slug}/${project.slug}/ownership/`],
  61. [
  62. 'codeMappings',
  63. `/organizations/${organization.slug}/code-mappings/`,
  64. {query: {projectId: project.id}},
  65. ],
  66. [
  67. 'integrations',
  68. `/organizations/${organization.slug}/integrations/`,
  69. {query: {features: ['codeowners']}},
  70. ],
  71. ];
  72. if (organization.features.includes('integrations-codeowners')) {
  73. endpoints.push([
  74. 'codeowners',
  75. `/projects/${organization.slug}/${project.slug}/codeowners/`,
  76. {query: {expand: ['codeMapping', 'ownershipSyntax']}},
  77. ]);
  78. }
  79. return endpoints;
  80. }
  81. handleAddCodeOwner = () => {
  82. const {codeMappings, integrations} = this.state;
  83. openModal(modalProps => (
  84. <AddCodeOwnerModal
  85. {...modalProps}
  86. organization={this.props.organization}
  87. project={this.props.project}
  88. codeMappings={codeMappings}
  89. integrations={integrations}
  90. onSave={this.handleCodeOwnerAdded}
  91. />
  92. ));
  93. };
  94. getPlaceholder() {
  95. return `#example usage
  96. path:src/example/pipeline/* person@sentry.io #infra
  97. url:http://example.com/settings/* #product
  98. tags.sku_class:enterprise #enterprise`;
  99. }
  100. getDetail() {
  101. return tct(
  102. `Automatically assign issues and send alerts to the right people based on issue properties. [link:Learn more].`,
  103. {
  104. link: (
  105. <ExternalLink href="https://docs.sentry.io/product/error-monitoring/issue-owners/" />
  106. ),
  107. }
  108. );
  109. }
  110. handleOwnershipSave = (text: string | null) => {
  111. this.setState(prevState => ({
  112. ownership: {
  113. ...prevState.ownership,
  114. raw: text,
  115. },
  116. }));
  117. };
  118. handleCodeOwnerAdded = (data: CodeOwner) => {
  119. const {codeowners} = this.state;
  120. const newCodeowners = [data, ...(codeowners || [])];
  121. this.setState({codeowners: newCodeowners});
  122. };
  123. handleCodeOwnerDeleted = (data: CodeOwner) => {
  124. const {codeowners} = this.state;
  125. const newCodeowners = (codeowners || []).filter(
  126. codeowner => codeowner.id !== data.id
  127. );
  128. this.setState({codeowners: newCodeowners});
  129. };
  130. handleCodeOwnerUpdated = (data: CodeOwner) => {
  131. const codeowners = this.state.codeowners || [];
  132. const index = codeowners.findIndex(item => item.id === data.id);
  133. this.setState({
  134. codeowners: [...codeowners.slice(0, index), data, ...codeowners.slice(index + 1)],
  135. });
  136. };
  137. handleAddCodeOwnerRequest = async () => {
  138. const {organization, project} = this.props;
  139. try {
  140. addLoadingMessage(t('Requesting\u2026'));
  141. await this.api.requestPromise(
  142. `/projects/${organization.slug}/${project.slug}/codeowners-request/`,
  143. {
  144. method: 'POST',
  145. data: {},
  146. }
  147. );
  148. addSuccessMessage(t('Request Sent'));
  149. } catch (err) {
  150. addErrorMessage(t('Unable to send request'));
  151. Sentry.captureException(err);
  152. }
  153. };
  154. renderCodeOwnerErrors = () => {
  155. const {project, organization} = this.props;
  156. const {codeowners} = this.state;
  157. const errMessageComponent = (message, values, link, linkValue) => (
  158. <Fragment>
  159. <ErrorMessageContainer>
  160. <span>{message}</span>
  161. <b>{values.join(', ')}</b>
  162. </ErrorMessageContainer>
  163. <ErrorCtaContainer>
  164. <ExternalLink href={link}>{linkValue}</ExternalLink>
  165. </ErrorCtaContainer>
  166. </Fragment>
  167. );
  168. const errMessageListComponent = (
  169. message: string,
  170. values: string[],
  171. linkFunction: (s: string) => string,
  172. linkValueFunction: (s: string) => string
  173. ) => {
  174. return (
  175. <Fragment>
  176. <ErrorMessageContainer>
  177. <span>{message}</span>
  178. </ErrorMessageContainer>
  179. <ErrorMessageListContainer>
  180. {values.map((value, index) => (
  181. <ErrorInlineContainer key={index}>
  182. <b>{value}</b>
  183. <ErrorCtaContainer>
  184. <ExternalLink href={linkFunction(value)} key={index}>
  185. {linkValueFunction(value)}
  186. </ExternalLink>
  187. </ErrorCtaContainer>
  188. </ErrorInlineContainer>
  189. ))}
  190. </ErrorMessageListContainer>
  191. </Fragment>
  192. );
  193. };
  194. return (codeowners || [])
  195. .filter(({errors}) => Object.values(errors).flat().length)
  196. .map(({id, codeMapping, errors}) => {
  197. const errMessage = (type, values) => {
  198. switch (type) {
  199. case 'missing_external_teams':
  200. return errMessageComponent(
  201. `The following teams do not have an association in the organization: ${organization.slug}`,
  202. values,
  203. `/settings/${organization.slug}/integrations/${codeMapping?.provider?.slug}/${codeMapping?.integrationId}/?tab=teamMappings`,
  204. 'Configure Team Mappings'
  205. );
  206. case 'missing_external_users':
  207. return errMessageComponent(
  208. `The following usernames do not have an association in the organization: ${organization.slug}`,
  209. values,
  210. `/settings/${organization.slug}/integrations/${codeMapping?.provider?.slug}/${codeMapping?.integrationId}/?tab=userMappings`,
  211. 'Configure User Mappings'
  212. );
  213. case 'missing_user_emails':
  214. return errMessageComponent(
  215. `The following emails do not have an Sentry user in the organization: ${organization.slug}`,
  216. values,
  217. `/settings/${organization.slug}/members/`,
  218. 'Invite Users'
  219. );
  220. case 'teams_without_access':
  221. return errMessageListComponent(
  222. `The following team do not have access to the project: ${project.slug}`,
  223. values,
  224. value =>
  225. `/settings/${organization.slug}/teams/${value.slice(1)}/projects/`,
  226. value => `Configure ${value} Permissions`
  227. );
  228. case 'users_without_access':
  229. return errMessageListComponent(
  230. `The following users are not on a team that has access to the project: ${project.slug}`,
  231. values,
  232. email => `/settings/${organization.slug}/members/?query=${email}`,
  233. _ => `Configure Member Settings`
  234. );
  235. default:
  236. return null;
  237. }
  238. };
  239. return (
  240. <Alert
  241. key={id}
  242. type="error"
  243. icon={<IconWarning size="md" />}
  244. expand={[
  245. <AlertContentContainer key="container">
  246. {Object.entries(errors)
  247. .filter(([_, values]) => values.length)
  248. .map(([type, values]) => (
  249. <ErrorContainer key={`${id}-${type}`}>
  250. {errMessage(type, values)}
  251. </ErrorContainer>
  252. ))}
  253. </AlertContentContainer>,
  254. ]}
  255. >
  256. {`There were ${
  257. Object.values(errors).flat().length
  258. } ownership issues within Sentry on the latest sync with the CODEOWNERS file`}
  259. </Alert>
  260. );
  261. });
  262. };
  263. renderBody() {
  264. const {project, organization} = this.props;
  265. const {ownership, codeowners} = this.state;
  266. const disabled = !organization.access.includes('project:write');
  267. return (
  268. <Fragment>
  269. <SettingsPageHeader
  270. title={t('Issue Owners')}
  271. action={
  272. <Fragment>
  273. <Button
  274. to={{
  275. pathname: `/organizations/${organization.slug}/issues/`,
  276. query: {project: project.id},
  277. }}
  278. size="small"
  279. >
  280. {t('View Issues')}
  281. </Button>
  282. <Feature features={['integrations-codeowners']}>
  283. <Access access={['org:integrations']}>
  284. {({hasAccess}) =>
  285. hasAccess ? (
  286. <CodeOwnerButton
  287. onClick={this.handleAddCodeOwner}
  288. size="small"
  289. priority="primary"
  290. data-test-id="add-codeowner-button"
  291. >
  292. {t('Add CODEOWNERS File')}
  293. </CodeOwnerButton>
  294. ) : (
  295. <CodeOwnerButton
  296. onClick={this.handleAddCodeOwnerRequest}
  297. size="small"
  298. priority="primary"
  299. data-test-id="add-codeowner-request-button"
  300. >
  301. {t('Request to Add CODEOWNERS File')}
  302. </CodeOwnerButton>
  303. )
  304. }
  305. </Access>
  306. </Feature>
  307. </Fragment>
  308. }
  309. />
  310. <IssueOwnerDetails>{this.getDetail()}</IssueOwnerDetails>
  311. <CodeOwnersHeader
  312. addCodeOwner={this.handleAddCodeOwner}
  313. handleRequest={this.handleAddCodeOwnerRequest}
  314. />
  315. <PermissionAlert />
  316. <FeedbackAlert />
  317. {this.renderCodeOwnerErrors()}
  318. <RulesPanel
  319. data-test-id="issueowners-panel"
  320. type="issueowners"
  321. raw={ownership.raw || ''}
  322. dateUpdated={ownership.lastUpdated}
  323. placeholder={this.getPlaceholder()}
  324. controls={[
  325. <Button
  326. key="edit"
  327. size="xsmall"
  328. onClick={() =>
  329. openEditOwnershipRules({
  330. organization,
  331. project,
  332. ownership,
  333. onSave: this.handleOwnershipSave,
  334. })
  335. }
  336. disabled={disabled}
  337. >
  338. {t('Edit')}
  339. </Button>,
  340. ]}
  341. />
  342. <Feature features={['integrations-codeowners']}>
  343. <CodeOwnersPanel
  344. codeowners={codeowners || []}
  345. onDelete={this.handleCodeOwnerDeleted}
  346. onUpdate={this.handleCodeOwnerUpdated}
  347. disabled={disabled}
  348. {...this.props}
  349. />
  350. </Feature>
  351. <Form
  352. apiEndpoint={`/projects/${organization.slug}/${project.slug}/ownership/`}
  353. apiMethod="PUT"
  354. saveOnBlur
  355. initialData={{
  356. fallthrough: ownership.fallthrough,
  357. autoAssignment: ownership.autoAssignment,
  358. codeownersAutoSync: ownership.codeownersAutoSync,
  359. }}
  360. hideFooter
  361. >
  362. <JsonForm
  363. forms={[
  364. {
  365. title: t('Issue Owners'),
  366. fields: [
  367. {
  368. name: 'autoAssignment',
  369. type: 'boolean',
  370. label: t('Automatically assign issues'),
  371. help: t('Assign issues when a new event matches the rules above.'),
  372. disabled,
  373. },
  374. {
  375. name: 'fallthrough',
  376. type: 'boolean',
  377. label: t(
  378. 'Send alert to project members if there’s no assigned owner'
  379. ),
  380. help: t(
  381. 'Alerts will be sent to all users who have access to this project.'
  382. ),
  383. disabled,
  384. },
  385. {
  386. name: 'codeownersAutoSync',
  387. type: 'boolean',
  388. label: tct(
  389. `Automatically sync changes from CODEOWNERS file to Code Owners [badge]`,
  390. {
  391. badge: (
  392. <FeatureBadge
  393. type="new"
  394. title={
  395. !(this.state.codeowners || []).length
  396. ? 'Setup Code Owners to use this feature.'
  397. : undefined
  398. }
  399. />
  400. ),
  401. }
  402. ),
  403. help: t(
  404. 'Sentry will watch for CODEOWNERS file changes during a Release and then update Code Owners.'
  405. ),
  406. disabled: disabled || !(this.state.codeowners || []).length,
  407. },
  408. ],
  409. },
  410. ]}
  411. />
  412. </Form>
  413. </Fragment>
  414. );
  415. }
  416. }
  417. export default ProjectOwnership;
  418. const CodeOwnerButton = styled(Button)`
  419. margin-left: ${space(1)};
  420. `;
  421. const AlertContentContainer = styled('div')`
  422. overflow-y: auto;
  423. max-height: 350px;
  424. `;
  425. const ErrorContainer = styled('div')`
  426. display: grid;
  427. grid-template-areas: 'message cta';
  428. grid-template-columns: 2fr 1fr;
  429. gap: ${space(2)};
  430. padding: ${space(1.5)} 0;
  431. `;
  432. const ErrorInlineContainer = styled(ErrorContainer)`
  433. gap: ${space(1.5)};
  434. grid-template-columns: 1fr 2fr;
  435. align-items: center;
  436. padding: 0;
  437. `;
  438. const ErrorMessageContainer = styled('div')`
  439. grid-area: message;
  440. display: grid;
  441. gap: ${space(1.5)};
  442. `;
  443. const ErrorMessageListContainer = styled('div')`
  444. grid-column: message / cta-end;
  445. gap: ${space(1.5)};
  446. `;
  447. const ErrorCtaContainer = styled('div')`
  448. grid-area: cta;
  449. justify-self: flex-end;
  450. text-align: right;
  451. line-height: 1.5;
  452. `;
  453. const IssueOwnerDetails = styled('div')`
  454. padding-bottom: ${space(3)};
  455. `;