import {Fragment} from 'react'; import {RouteComponentProps} from 'react-router'; import styled from '@emotion/styled'; import {openEditOwnershipRules, openModal} from 'sentry/actionCreators/modal'; import Access from 'sentry/components/acl/access'; import Feature from 'sentry/components/acl/feature'; import Alert from 'sentry/components/alert'; import Button from 'sentry/components/button'; import Form from 'sentry/components/forms/form'; import JsonForm from 'sentry/components/forms/jsonForm'; import ExternalLink from 'sentry/components/links/externalLink'; import {t, tct} from 'sentry/locale'; import space from 'sentry/styles/space'; import {CodeOwner, IssueOwnership, Organization, Project} from 'sentry/types'; import routeTitleGen from 'sentry/utils/routeTitle'; import AsyncView from 'sentry/views/asyncView'; import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader'; import PermissionAlert from 'sentry/views/settings/project/permissionAlert'; import AddCodeOwnerModal from 'sentry/views/settings/project/projectOwnership/addCodeOwnerModal'; import CodeOwnersPanel from 'sentry/views/settings/project/projectOwnership/codeowners'; import RulesPanel from 'sentry/views/settings/project/projectOwnership/rulesPanel'; type Props = { organization: Organization; project: Project; } & RouteComponentProps<{orgId: string; projectId: string}, {}>; type State = { codeowners?: CodeOwner[]; ownership?: null | IssueOwnership; } & AsyncView['state']; class ProjectOwnership extends AsyncView { getTitle() { const {project} = this.props; return routeTitleGen(t('Issue Owners'), project.slug, false); } getEndpoints(): ReturnType { const {organization, project} = this.props; const endpoints: ReturnType = [ ['ownership', `/projects/${organization.slug}/${project.slug}/ownership/`], ]; if (organization.features.includes('integrations-codeowners')) { endpoints.push([ 'codeowners', `/projects/${organization.slug}/${project.slug}/codeowners/`, {query: {expand: ['codeMapping', 'ownershipSyntax']}}, ]); } return endpoints; } handleAddCodeOwner = () => { openModal(modalProps => ( )); }; getPlaceholder() { return `#example usage path:src/example/pipeline/* person@sentry.io #infra module:com.module.name.example #sdks url:http://example.com/settings/* #product tags.sku_class:enterprise #enterprise`; } getDetail() { return tct( `Auto-assign issues to users and teams. To learn more, [link:read the docs].`, { link: ( ), } ); } handleOwnershipSave = (text: string | null) => { this.setState(prevState => ({ ...(prevState.ownership ? { ownership: { ...prevState.ownership, raw: text || '', }, } : {}), })); }; handleCodeOwnerAdded = (data: CodeOwner) => { const {codeowners} = this.state; const newCodeowners = [data, ...(codeowners || [])]; this.setState({codeowners: newCodeowners}); }; handleCodeOwnerDeleted = (data: CodeOwner) => { const {codeowners} = this.state; const newCodeowners = (codeowners || []).filter( codeowner => codeowner.id !== data.id ); this.setState({codeowners: newCodeowners}); }; handleCodeOwnerUpdated = (data: CodeOwner) => { const codeowners = this.state.codeowners || []; const index = codeowners.findIndex(item => item.id === data.id); this.setState({ codeowners: [...codeowners.slice(0, index), data, ...codeowners.slice(index + 1)], }); }; renderCodeOwnerErrors = () => { const {project, organization} = this.props; const {codeowners} = this.state; const errMessageComponent = (message, values, link, linkValue) => ( {message} {values.join(', ')} {linkValue} ); const errMessageListComponent = ( message: string, values: string[], linkFunction: (s: string) => string, linkValueFunction: (s: string) => string ) => { return ( {message} {values.map((value, index) => ( {value} {linkValueFunction(value)} ))} ); }; return (codeowners || []) .filter(({errors}) => Object.values(errors).flat().length) .map(({id, codeMapping, errors}) => { const errMessage = (type, values) => { switch (type) { case 'missing_external_teams': return errMessageComponent( `The following teams do not have an association in the organization: ${organization.slug}`, values, `/settings/${organization.slug}/integrations/${codeMapping?.provider?.slug}/${codeMapping?.integrationId}/?tab=teamMappings`, 'Configure Team Mappings' ); case 'missing_external_users': return errMessageComponent( `The following usernames do not have an association in the organization: ${organization.slug}`, values, `/settings/${organization.slug}/integrations/${codeMapping?.provider?.slug}/${codeMapping?.integrationId}/?tab=userMappings`, 'Configure User Mappings' ); case 'missing_user_emails': return errMessageComponent( `The following emails do not have an Sentry user in the organization: ${organization.slug}`, values, `/settings/${organization.slug}/members/`, 'Invite Users' ); case 'teams_without_access': return errMessageListComponent( `The following team do not have access to the project: ${project.slug}`, values, value => `/settings/${organization.slug}/teams/${value.slice(1)}/projects/`, value => `Configure ${value} Permissions` ); case 'users_without_access': return errMessageListComponent( `The following users are not on a team that has access to the project: ${project.slug}`, values, email => `/settings/${organization.slug}/members/?query=${email}`, _ => `Configure Member Settings` ); default: return null; } }; return ( {Object.entries(errors) .filter(([_, values]) => values.length) .map(([type, values]) => ( {errMessage(type, values)} ))} , ]} > {`There were ${ Object.values(errors).flat().length } ownership issues within Sentry on the latest sync with the CODEOWNERS file`} ); }); }; renderBody() { const {project, organization} = this.props; const {ownership, codeowners} = this.state; const disabled = !organization.access.includes('project:write'); return ( {({hasAccess}) => hasAccess ? ( {t('Add CODEOWNERS')} ) : null } } /> {this.getDetail()} {this.renderCodeOwnerErrors()} {ownership && ( openEditOwnershipRules({ organization, project, ownership, onSave: this.handleOwnershipSave, }) } disabled={disabled} > {t('Edit')} , ]} /> )} {ownership && (
)} ); } } export default ProjectOwnership; const CodeOwnerButton = styled(Button)` margin-left: ${space(1)}; `; const AlertContentContainer = styled('div')` overflow-y: auto; max-height: 350px; `; const ErrorContainer = styled('div')` display: grid; grid-template-areas: 'message cta'; grid-template-columns: 2fr 1fr; gap: ${space(2)}; padding: ${space(1.5)} 0; `; const ErrorInlineContainer = styled(ErrorContainer)` gap: ${space(1.5)}; grid-template-columns: 1fr 2fr; align-items: center; padding: 0; `; const ErrorMessageContainer = styled('div')` grid-area: message; display: grid; gap: ${space(1.5)}; `; const ErrorMessageListContainer = styled('div')` grid-column: message / cta-end; gap: ${space(1.5)}; `; const ErrorCtaContainer = styled('div')` grid-area: cta; justify-self: flex-end; text-align: right; line-height: 1.5; `; const IssueOwnerDetails = styled('div')` padding-bottom: ${space(3)}; `;