index.tsx 14 KB

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