feat: [CODE-1072] Support for importing multiple repositories (#788)

This commit is contained in:
Johannes Batzill 2023-11-11 01:21:38 +00:00 committed by Harness
parent 972c7d6c67
commit bd48d92ff5
8 changed files with 463 additions and 9 deletions

View File

@ -0,0 +1,370 @@
/*
* Copyright 2023 Harness, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState } from 'react'
import { Intent } from '@blueprintjs/core'
import * as yup from 'yup'
import { Color } from '@harnessio/design-system'
import { Button, Container, Label, Layout, FlexExpander, Formik, FormikForm, FormInput, Text } from '@harnessio/uicore'
import { Icon } from '@harnessio/icons'
import { useStrings } from 'framework/strings'
import { type ImportSpaceFormData, GitProviders, getProviders, getOrgLabel, getOrgPlaceholder } from 'utils/GitUtils'
import css from '../../NewSpaceModalButton/NewSpaceModalButton.module.scss'
interface ImportReposProps {
handleSubmit: (data: ImportSpaceFormData) => void
loading: boolean
// eslint-disable-next-line @typescript-eslint/no-explicit-any
hideModal: any
spaceRef: string
}
const getHostPlaceHolder = (gitProvider: string) => {
switch (gitProvider) {
case GitProviders.GITHUB:
case GitProviders.GITHUB_ENTERPRISE:
return 'enterGithubPlaceholder'
case GitProviders.GITLAB:
case GitProviders.GITLAB_SELF_HOSTED:
return 'enterGitlabPlaceholder'
case GitProviders.BITBUCKET:
case GitProviders.BITBUCKET_SERVER:
return 'enterBitbucketPlaceholder'
default:
return 'enterAddress'
}
}
const ImportReposForm = (props: ImportReposProps) => {
const { handleSubmit, loading, hideModal, spaceRef } = props
const { getString } = useStrings()
const [auth, setAuth] = useState(false)
const [step, setStep] = useState(0)
const [buttonLoading, setButtonLoading] = useState(false)
const formInitialValues: ImportSpaceFormData = {
gitProvider: GitProviders.GITHUB,
username: '',
password: '',
name: spaceRef,
description: '',
organization: '',
host: ''
}
const validationSchemaStepOne = yup.object().shape({
gitProvider: yup.string().trim().required(getString('importSpace.providerRequired'))
})
const validationSchemaStepTwo = yup.object().shape({
organization: yup.string().trim().required(getString('importSpace.orgRequired')),
name: yup.string().trim().required(getString('importSpace.spaceNameRequired'))
})
return (
<Formik
initialValues={formInitialValues}
formName="importReposForm"
enableReinitialize={true}
validateOnBlur
onSubmit={handleSubmit}>
{formik => {
const { values } = formik
const handleValidationClick = async () => {
try {
if (step === 0) {
await validationSchemaStepOne.validate(formik.values, { abortEarly: false })
setStep(1)
} else if (step === 1) {
await validationSchemaStepTwo.validate(formik.values, { abortEarly: false })
setButtonLoading(true)
} // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
formik.setErrors(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
err.inner.reduce((acc: { [x: string]: any }, current: { path: string | number; message: string }) => {
acc[current.path] = current.message
return acc
}, {})
)
}
}
const handleImport = async () => {
await handleSubmit(formik.values)
setButtonLoading(false)
}
return (
<Container className={css.hideContainer} width={'97%'}>
<FormikForm>
{step === 0 ? (
<>
<Container width={'70%'}>
<Layout.Horizontal>
<Text padding={{ left: 'small' }} font={{ size: 'small' }}>
{getString('importRepos.content')}
</Text>
</Layout.Horizontal>
</Container>
<hr className={css.dividerContainer} />
<Container className={css.textContainer} width={'70%'}>
<FormInput.Select
name={'gitProvider'}
label={getString('importSpace.gitProvider')}
items={getProviders()}
className={css.selectBox}
/>
{formik.errors.gitProvider ? (
<Text
margin={{ top: 'small', bottom: 'small' }}
color={Color.RED_500}
icon="circle-cross"
iconProps={{ color: Color.RED_500 }}>
{formik.errors.gitProvider}
</Text>
) : null}
{![GitProviders.GITHUB, GitProviders.GITLAB, GitProviders.BITBUCKET].includes(
values.gitProvider
) && (
<FormInput.Text
name="host"
label={getString('importRepo.url')}
placeholder={getString(getHostPlaceHolder(values.gitProvider))}
tooltipProps={{
dataTooltipId: 'spaceUserTextField'
}}
className={css.hostContainer}
/>
)}
{formik.errors.host ? (
<Text
margin={{ top: 'small', bottom: 'small' }}
color={Color.RED_500}
icon="circle-cross"
iconProps={{ color: Color.RED_500 }}>
{formik.errors.host}
</Text>
) : null}
<Layout.Horizontal flex>
{getString('importSpace.authorization')}
<Container padding={{ left: 'small' }} width={'100%'}>
<hr className={css.dividerContainer} />
</Container>
</Layout.Horizontal>
{formik.values.gitProvider === GitProviders.BITBUCKET && (
<FormInput.Text
name="username"
label={getString('userName')}
placeholder={getString('importRepo.userPlaceholder')}
tooltipProps={{
dataTooltipId: 'spaceUserTextField'
}}
/>
)}
{formik.errors.username ? (
<Text
margin={{ top: 'small', bottom: 'small' }}
color={Color.RED_500}
icon="circle-cross"
iconProps={{ color: Color.RED_500 }}>
{formik.errors.username}
</Text>
) : null}
<FormInput.Text
name="password"
label={
formik.values.gitProvider === GitProviders.BITBUCKET
? getString('importRepo.appPassword')
: getString('importRepo.passToken')
}
placeholder={
formik.values.gitProvider === GitProviders.BITBUCKET
? getString('importRepo.appPasswordPlaceholder')
: getString('importRepo.passTokenPlaceholder')
}
tooltipProps={{
dataTooltipId: 'spacePasswordTextField'
}}
inputGroup={{ type: 'password' }}
/>
{formik.errors.password ? (
<Text
margin={{ top: 'small', bottom: 'small' }}
color={Color.RED_500}
icon="circle-cross"
iconProps={{ color: Color.RED_500 }}>
{formik.errors.password}
</Text>
) : null}
</Container>
</>
) : null}
{step === 1 ? (
<>
<Layout.Horizontal flex>
<Text className={css.detailsLabel} font={{ size: 'small' }} flex>
{getString('importSpace.details')}
</Text>
<Container padding={{ left: 'small' }} width={'100%'}>
<hr className={css.dividerContainer} />
</Container>
</Layout.Horizontal>
<Container className={css.textContainer} width={'70%'}>
<FormInput.Text
name="organization"
label={getString(getOrgLabel(values.gitProvider))}
placeholder={getString(getOrgPlaceholder(values.gitProvider))}
tooltipProps={{
dataTooltipId: 'importSpaceOrgName'
}}
onChange={event => {
const target = event.target as HTMLInputElement
formik.setFieldValue('organization', target.value)
if (target.value) {
formik.validateField('organization')
}
}}
/>
{formik.errors.organization ? (
<Text
margin={{ bottom: 'small' }}
color={Color.RED_500}
icon="circle-cross"
iconProps={{ color: Color.RED_500 }}>
{formik.errors.organization}
</Text>
) : null}
<Layout.Horizontal>
<Label>{getString('importSpace.importLabel')}</Label>
<Icon padding={{ left: 'small' }} className={css.icon} name="code-info" size={16} />
</Layout.Horizontal>
<Container className={css.importContainer} padding={'medium'}>
<Layout.Horizontal>
<FormInput.CheckBox
name="repositories"
label={getString('pageTitle.repositories')}
tooltipProps={{
dataTooltipId: 'authorization'
}}
defaultChecked
onClick={() => {
setAuth(!auth)
}}
disabled
padding={{ right: 'small' }}
className={css.checkbox}
/>
<Container padding={{ left: 'xxxlarge' }}>
<FormInput.CheckBox
name="pipelines"
label={getString('pageTitle.pipelines')}
tooltipProps={{
dataTooltipId: 'pipelines'
}}
onClick={() => {
setAuth(!auth)
}}
/>
</Container>
</Layout.Horizontal>
</Container>
<Container>
<hr className={css.dividerContainer} />
<FormInput.Text
name="name"
label={getString('importSpace.spaceName')}
placeholder={getString('enterName')}
tooltipProps={{
dataTooltipId: 'importSpaceName'
}}
/>
{formik.errors.name ? (
<Text
margin={{ bottom: 'small' }}
color={Color.RED_500}
icon="circle-cross"
iconProps={{ color: Color.RED_500 }}>
{formik.errors.name}
</Text>
) : null}
</Container>
</Container>
</>
) : null}
<hr className={css.dividerContainer} />
<Layout.Horizontal
spacing="small"
padding={{ right: 'xxlarge', bottom: 'large' }}
style={{ alignItems: 'center' }}>
{step === 1 ? (
<Button
disabled={buttonLoading}
text={
buttonLoading ? (
<>
<Container className={css.loadingIcon} width={93.5} flex={{ alignItems: 'center' }}>
<Icon className={css.loadingIcon} name="steps-spinner" size={16} />
</Container>
</>
) : (
getString('importRepos.title')
)
}
intent={Intent.PRIMARY}
onClick={() => {
handleValidationClick()
if (formik.values.name !== '' && formik.values.organization !== '') {
handleImport()
setButtonLoading(false)
}
formik.setErrors({})
}}
/>
) : (
<Button
text={getString('importSpace.next')}
intent={Intent.PRIMARY}
onClick={() => {
handleValidationClick()
if (
(!formik.errors.gitProvider && formik.touched.gitProvider) ||
(!formik.errors.username && formik.touched.username) ||
(!formik.errors.password && formik.touched.password)
) {
formik.setErrors({})
setStep(1)
}
}}
/>
)}
<Button text={getString('cancel')} minimal onClick={hideModal} />
<FlexExpander />
{loading && <Icon intent={Intent.PRIMARY} name="steps-spinner" size={16} />}
</Layout.Horizontal>
</FormikForm>
</Container>
)
}}
</Formik>
)
}
export default ImportReposForm

View File

@ -59,15 +59,17 @@ import {
import {
GitProviders,
ImportFormData,
ImportSpaceFormData,
RepoCreationType,
RepoFormData,
RepoVisibility,
isGitBranchNameValid,
getProviderTypeMapping
} from 'utils/GitUtils'
import type { TypesRepository, OpenapiCreateRepositoryRequest } from 'services/code'
import type { TypesSpace, TypesRepository, OpenapiCreateRepositoryRequest } from 'services/code'
import { useAppContext } from 'AppContext'
import ImportForm from './ImportForm/ImportForm'
import ImportReposForm from './ImportReposForm/ImportReposForm'
import Private from '../../icons/private.svg'
import css from './NewRepoModalButton.module.scss'
@ -120,6 +122,10 @@ export const NewRepoModalButton: React.FC<NewRepoModalButtonProps> = ({
space_path: space
}
})
const { mutate: importMultipleRepositories, loading: submitImportLoading } = useMutate<TypesSpace>({
verb: 'POST',
path: `/api/v1/spaces/${space}/+/import`
})
const {
data: gitignores,
loading: gitIgnoreLoading,
@ -130,7 +136,7 @@ export const NewRepoModalButton: React.FC<NewRepoModalButtonProps> = ({
loading: licenseLoading,
error: licenseError
} = useGet({ path: '/api/v1/resources/license' })
const loading = submitLoading || gitIgnoreLoading || licenseLoading || importRepoLoading
const loading = submitLoading || gitIgnoreLoading || licenseLoading || importRepoLoading || submitImportLoading
useEffect(() => {
if (gitIgnoreError || licenseError) {
@ -192,6 +198,36 @@ export const NewRepoModalButton: React.FC<NewRepoModalButtonProps> = ({
showError(getErrorMessage(_error), 0, getString('importRepo.failedToImportRepo'))
})
}
const handleMultiRepoImportSubmit = async (formData: ImportSpaceFormData) => {
const type = getProviderTypeMapping(formData.gitProvider)
const provider = {
type,
username: formData.username,
password: formData.password,
host: ''
}
if (![GitProviders.GITHUB, GitProviders.GITLAB, GitProviders.BITBUCKET].includes(formData.gitProvider)) {
provider.host = formData.host
}
try {
const importPayload = {
description: (formData.description || '').trim(),
parent_ref: space,
uid: formData.name.trim(),
provider,
provider_space: formData.organization
}
const response = await importMultipleRepositories(importPayload)
hideModal()
onSubmit(response)
} catch (exception) {
showError(getErrorMessage(exception), 0, getString('failedToImportSpace'))
}
}
return (
<Dialog
isOpen
@ -204,12 +240,23 @@ export const NewRepoModalButton: React.FC<NewRepoModalButtonProps> = ({
style={{ height: '100%' }}
data-testid="add-target-to-flag-modal">
<Heading level={3} font={{ variation: FontVariation.H3 }} margin={{ bottom: 'xlarge' }}>
{repoOption.type === RepoCreationType.IMPORT ? getString('importRepo.title') : modalTitle}
{repoOption.type === RepoCreationType.IMPORT
? getString('importRepo.title')
: repoOption.type === RepoCreationType.IMPORT_MULTIPLE
? getString('importRepos.title')
: modalTitle}
</Heading>
<Container margin={{ right: 'xxlarge' }}>
{repoOption.type === RepoCreationType.IMPORT ? (
<ImportForm hideModal={hideModal} handleSubmit={handleImportSubmit} loading={false} />
) : repoOption.type === RepoCreationType.IMPORT_MULTIPLE ? (
<ImportReposForm
hideModal={hideModal}
handleSubmit={handleMultiRepoImportSubmit}
loading={false}
spaceRef={space}
/>
) : (
<Formik
initialValues={formInitialValues}
@ -361,6 +408,11 @@ export const NewRepoModalButton: React.FC<NewRepoModalButtonProps> = ({
type: RepoCreationType.IMPORT,
title: getString('importGitRepo'),
desc: getString('importGitRepo')
},
{
type: RepoCreationType.IMPORT_MULTIPLE,
title: getString('importGitRepos'),
desc: getString('importGitRepos')
}
]
const [repoOption, setRepoOption] = useState<RepoCreationOption>(repoCreateOptions[0])
@ -399,14 +451,14 @@ export const NewRepoModalButton: React.FC<NewRepoModalButtonProps> = ({
setRepoOption(repoCreateOptions[0])
setTimeout(() => openModal(), 0)
}}>
{[repoCreateOptions[1]].map(option => {
{[repoCreateOptions[1], repoCreateOptions[2]].map(option => {
return (
<Menu.Item
className={css.menuItem}
key={option.type}
text={<Text font={{ variation: FontVariation.BODY2 }}>{option.desc}</Text>}
onClick={() => {
setRepoOption(repoCreateOptions[1])
setRepoOption(option)
setTimeout(() => openModal(), 0)
}}
/>

View File

@ -321,6 +321,7 @@ export interface StringsMap {
'imageUpload.title': string
'imageUpload.upload': string
importGitRepo: string
importGitRepos: string
importProgress: string
'importRepo.appPassword': string
'importRepo.appPasswordPlaceholder': string
@ -347,6 +348,8 @@ export interface StringsMap {
'importRepo.validation': string
'importRepo.workspace': string
'importRepo.workspacePlaceholder': string
'importRepos.content': string
'importRepos.title': string
'importSpace.authorization': string
'importSpace.content': string
'importSpace.createASpace': string

View File

@ -106,7 +106,7 @@ repos:
activities: Monthly Activities
updated: Updated Date
lastChange: Last Change
noDataMessage: There are no repositories in this project. Create a new repository, or import an existing Git repository by clicking below
noDataMessage: There are no repositories in this project. Create a new repository, or import an existing Git repository by clicking below.
enterBranchName: Enter a branch name
createRepoModal:
branchLabel: 'Your repository will be initialized with a '
@ -748,6 +748,7 @@ pluginsPanel:
ifNotExists: If not exists
createNewRepo: Create New repository
importGitRepo: Import Repository
importGitRepos: Import Repositories
importRepo:
title: Import Repository
url: Host URL
@ -774,6 +775,9 @@ importRepo:
spaceNameReq: Enter a name for the new project
usernameReq: Username is required
passwordReq: Password is required
importRepos:
title: Import Repositories
content: Import multiple repositories from GitLab Group, GitHub Org or Bitbucket Project to this project in Gitness.
importSpace:
title: Import Project
createASpace: Create a project

View File

@ -35,7 +35,7 @@ import { useHistory } from 'react-router-dom'
import { useStrings } from 'framework/strings'
import { voidFn, formatDate, getErrorMessage, LIST_FETCHING_LIMIT, PageBrowserProps } from 'utils/Utils'
import { NewRepoModalButton } from 'components/NewRepoModalButton/NewRepoModalButton'
import type { TypesRepository } from 'services/code'
import type { TypesRepository, SpaceImportRepositoriesOutput } from 'services/code'
import { usePageIndex } from 'hooks/usePageIndex'
import { useQueryParams } from 'hooks/useQueryParams'
import { useUpdateQueryParams } from 'hooks/useUpdateQueryParams'
@ -176,8 +176,14 @@ export default function RepositoriesListing() {
onSubmit={repoInfo => {
if (repoInfo.importing) {
refetch()
} else if (repoInfo) {
const multipleImportRepoInfo = repoInfo as SpaceImportRepositoriesOutput
if (multipleImportRepoInfo.importing_repos) {
history.push(routes.toCODERepositories({ space: space as string }))
refetch()
}
} else {
history.push(routes.toCODERepository({ repoPath: repoInfo.path as string }))
history.push(routes.toCODERepository({ repoPath: (repoInfo as TypesRepository).path as string }))
}
}}
/>

View File

@ -585,6 +585,11 @@ export interface SpaceExportProgressOutput {
repos?: TypesJobProgress[] | null
}
export interface SpaceImportRepositoriesOutput {
duplicate_repos?: TypesRepository[] | null
importing_repos?: TypesRepository[] | null
}
export interface SystemConfigOutput {
user_signup_allowed?: boolean
}

View File

@ -7887,6 +7887,19 @@ components:
nullable: true
type: array
type: object
SpaceImportRepositoriesOutput:
properties:
duplicate_repos:
items:
$ref: '#/components/schemas/TypesRepository'
nullable: true
type: array
importing_repos:
items:
$ref: '#/components/schemas/TypesRepository'
nullable: true
type: array
type: object
SystemConfigOutput:
properties:
user_signup_allowed:

View File

@ -88,7 +88,8 @@ export enum RepoVisibility {
export enum RepoCreationType {
IMPORT = 'import',
CREATE = 'create'
CREATE = 'create',
IMPORT_MULTIPLE = 'import_multiple'
}
export enum SpaceCreationType {