index.tsx 15 KB

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