index.tsx 14 KB

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