index.tsx 15 KB

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