import {Fragment} from 'react'; import styled from '@emotion/styled'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import type {ModalRenderProps} from 'sentry/actionCreators/modal'; import {Alert} from 'sentry/components/alert'; import {Button} from 'sentry/components/button'; import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent'; import SelectField from 'sentry/components/forms/fields/selectField'; import Form from 'sentry/components/forms/form'; import Link from 'sentry/components/links/link'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import Panel from 'sentry/components/panels/panel'; import PanelBody from 'sentry/components/panels/panelBody'; import {IconCheckmark, IconNot} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type { CodeOwner, CodeownersFile, Integration, Organization, Project, RepositoryProjectPathConfig, } from 'sentry/types'; import {getIntegrationIcon} from 'sentry/utils/integrationUtil'; type Props = { organization: Organization; project: Project; onSave?: (data: CodeOwner) => void; } & ModalRenderProps & DeprecatedAsyncComponent['props']; type State = { codeMappingId: string | null; codeMappings: RepositoryProjectPathConfig[]; codeownersFile: CodeownersFile | null; error: boolean; errorJSON: {raw?: string} | null; integrations: Integration[]; isLoading: boolean; } & DeprecatedAsyncComponent['state']; class AddCodeOwnerModal extends DeprecatedAsyncComponent<Props, State> { getDefaultState() { return { ...super.getDefaultState(), codeownersFile: null, codeMappingId: null, isLoading: false, error: false, errorJSON: null, }; } getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> { const {organization, project} = this.props; const endpoints: ReturnType<DeprecatedAsyncComponent['getEndpoints']> = [ [ 'codeMappings', `/organizations/${organization.slug}/code-mappings/`, {query: {project: project.id}}, ], [ 'integrations', `/organizations/${organization.slug}/integrations/`, {query: {features: ['codeowners']}}, ], ]; return endpoints; } fetchFile = async (codeMappingId: string) => { const {organization} = this.props; this.setState({ codeMappingId, codeownersFile: null, error: false, errorJSON: null, isLoading: true, }); try { const data: CodeownersFile = await this.api.requestPromise( `/organizations/${organization.slug}/code-mappings/${codeMappingId}/codeowners/`, { method: 'GET', } ); this.setState({codeownersFile: data, isLoading: false}); } catch (_err) { this.setState({isLoading: false}); } }; addFile = async () => { const {organization, project} = this.props; const {codeownersFile, codeMappingId, codeMappings} = this.state; if (codeownersFile) { const postData: { codeMappingId: string | null; raw: string; } = { codeMappingId, raw: codeownersFile.raw, }; try { const data = await this.api.requestPromise( `/projects/${organization.slug}/${project.slug}/codeowners/`, { method: 'POST', data: postData, } ); const codeMapping = codeMappings.find( mapping => mapping.id === codeMappingId?.toString() ); this.handleAddedFile({...data, codeMapping}); } catch (err) { if (err.responseJSON.raw) { this.setState({error: true, errorJSON: err.responseJSON, isLoading: false}); } else { addErrorMessage(Object.values(err.responseJSON).flat().join(' ')); } } } }; handleAddedFile(data: CodeOwner) { this.props.onSave?.(data); this.props.closeModal(); } sourceFile(codeownersFile: CodeownersFile) { return ( <Panel> <SourceFileBody> <IconCheckmark size="md" isCircled color="green200" /> {codeownersFile.filepath} <Button size="sm" href={codeownersFile.html_url} external> {t('Preview File')} </Button> </SourceFileBody> </Panel> ); } errorMessage(baseUrl) { const {errorJSON, codeMappingId, codeMappings} = this.state; const codeMapping = codeMappings.find(mapping => mapping.id === codeMappingId); const {integrationId, provider} = codeMapping as RepositoryProjectPathConfig; const errActors = errorJSON?.raw?.[0].split('\n').map((el, i) => <p key={i}>{el}</p>); return ( <Alert type="error" showIcon> {errActors} {codeMapping && ( <p> {tct( 'Configure [userMappingsLink:User Mappings] or [teamMappingsLink:Team Mappings] for any missing associations.', { userMappingsLink: ( <Link to={`${baseUrl}/${provider?.key}/${integrationId}/?tab=userMappings&referrer=add-codeowners`} /> ), teamMappingsLink: ( <Link to={`${baseUrl}/${provider?.key}/${integrationId}/?tab=teamMappings&referrer=add-codeowners`} /> ), } )} </p> )} {tct( '[addAndSkip:Add and Skip Missing Associations] will add your codeowner file and skip any rules that having missing associations. You can add associations later for any skipped rules.', {addAndSkip: <strong>Add and Skip Missing Associations</strong>} )} </Alert> ); } noSourceFile() { const {codeMappingId, isLoading} = this.state; if (isLoading) { return ( <Container> <LoadingIndicator mini /> </Container> ); } if (!codeMappingId) { return null; } return ( <Panel> <NoSourceFileBody> {codeMappingId ? ( <Fragment> <IconNot size="md" color="red200" /> {t('No codeowner file found.')} </Fragment> ) : null} </NoSourceFileBody> </Panel> ); } renderBody() { const {Header, Body, Footer} = this.props; const {codeownersFile, error, errorJSON, codeMappings, integrations} = this.state; const {organization} = this.props; const baseUrl = `/settings/${organization.slug}/integrations`; return ( <Fragment> <Header closeButton>{t('Add Code Owner File')}</Header> <Body> {!codeMappings.length ? ( !integrations.length ? ( <Fragment> <div> {t('Install a GitHub or GitLab integration to use this feature.')} </div> <Container style={{paddingTop: space(2)}}> <Button priority="primary" size="sm" to={baseUrl}> Setup Integration </Button> </Container> </Fragment> ) : ( <Fragment> <div> {t( "Configure code mapping to add your CODEOWNERS file. Select the integration you'd like to use for mapping:" )} </div> <IntegrationsList> {integrations.map(integration => ( <Button key={integration.id} to={`${baseUrl}/${integration.provider.key}/${integration.id}/?tab=codeMappings&referrer=add-codeowners`} > {getIntegrationIcon(integration.provider.key)} <IntegrationName>{integration.name}</IntegrationName> </Button> ))} </IntegrationsList> </Fragment> ) ) : null} {codeMappings.length > 0 && ( <Form apiMethod="POST" apiEndpoint="/code-mappings/" hideFooter initialData={{}} > <StyledSelectField name="codeMappingId" label={t('Apply an existing code mapping')} options={codeMappings.map((cm: RepositoryProjectPathConfig) => ({ value: cm.id, label: cm.repoName, }))} onChange={this.fetchFile} required inline={false} flexibleControlStateSize stacked /> <FileResult> {codeownersFile ? this.sourceFile(codeownersFile) : this.noSourceFile()} {error && errorJSON && this.errorMessage(baseUrl)} </FileResult> </Form> )} </Body> <Footer> <Button disabled={codeownersFile ? false : true} aria-label={t('Add File')} priority="primary" onClick={this.addFile} > {t('Add File')} </Button> </Footer> </Fragment> ); } } export default AddCodeOwnerModal; export {AddCodeOwnerModal}; const StyledSelectField = styled(SelectField)` border-bottom: None; padding-right: 16px; `; const FileResult = styled('div')` width: inherit; `; const NoSourceFileBody = styled(PanelBody)` display: grid; padding: 12px; grid-template-columns: 30px 1fr; align-items: center; `; const SourceFileBody = styled(PanelBody)` display: grid; padding: 12px; grid-template-columns: 30px 1fr 100px; align-items: center; `; const IntegrationsList = styled('div')` display: grid; gap: ${space(1)}; justify-items: center; margin-top: ${space(2)}; `; const IntegrationName = styled('p')` padding-left: 10px; `; const Container = styled('div')` display: flex; justify-content: center; `;