From: Ambroise C Date: Tue, 19 Mar 2024 10:47:48 +0000 (+0100) Subject: SONAR-21822 Add monorepo setup for GitHub X-Git-Tag: 10.5.0.89998~19 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=e4d510ae08bffe482350633e423b455c006ca119;p=sonarqube.git SONAR-21822 Add monorepo setup for GitHub --- diff --git a/server/sonar-web/design-system/src/components/icons/AddNewIcon.tsx b/server/sonar-web/design-system/src/components/icons/AddNewIcon.tsx new file mode 100644 index 00000000000..6eee2ba2f12 --- /dev/null +++ b/server/sonar-web/design-system/src/components/icons/AddNewIcon.tsx @@ -0,0 +1,37 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { useTheme } from '@emotion/react'; +import { themeColor } from '../../helpers'; +import { CustomIcon, IconProps } from './Icon'; + +export function AddNewIcon({ fill = 'currentColor', ...iconProps }: Readonly) { + const theme = useTheme(); + + return ( + + + + ); +} diff --git a/server/sonar-web/design-system/src/components/icons/index.ts b/server/sonar-web/design-system/src/components/icons/index.ts index 1ab42332abc..caeb1088b5a 100644 --- a/server/sonar-web/design-system/src/components/icons/index.ts +++ b/server/sonar-web/design-system/src/components/icons/index.ts @@ -17,6 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +export { AddNewIcon } from './AddNewIcon'; export { BranchIcon } from './BranchIcon'; export { BugIcon } from './BugIcon'; export { CalendarIcon } from './CalendarIcon'; diff --git a/server/sonar-web/src/main/js/api/dop-translation.ts b/server/sonar-web/src/main/js/api/dop-translation.ts index 393fb86da32..9cc4e482739 100644 --- a/server/sonar-web/src/main/js/api/dop-translation.ts +++ b/server/sonar-web/src/main/js/api/dop-translation.ts @@ -18,21 +18,17 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import axios from 'axios'; +import { BoundProject, DopSetting } from '../types/dop-translation'; +import { Paging } from '../types/types'; const DOP_TRANSLATION_PATH = '/api/v2/dop-translation'; +const BOUND_PROJECTS_PATH = `${DOP_TRANSLATION_PATH}/bound-projects`; +const DOP_SETTINGS_PATH = `${DOP_TRANSLATION_PATH}/dop-settings`; -// Imported projects - -const IMPORTED_PROJECTS_PATH = `${DOP_TRANSLATION_PATH}/bound-projects`; +export function createBoundProject(data: BoundProject) { + return axios.post(BOUND_PROJECTS_PATH, data); +} -export function createImportedProjects(data: { - devOpsPlatformSettingId: string; - monorepo: boolean; - newCodeDefinitionType?: string; - newCodeDefinitionValue?: string; - projectKey: string; - projectName: string; - repositoryIdentifier: string; -}) { - return axios.post(IMPORTED_PROJECTS_PATH, data); +export function getDopSettings() { + return axios.get<{ paging: Paging; dopSettings: DopSetting[] }>(DOP_SETTINGS_PATH); } diff --git a/server/sonar-web/src/main/js/api/mocks/ComponentsServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/ComponentsServiceMock.ts index 8f0404dda49..9c3e7948066 100644 --- a/server/sonar-web/src/main/js/api/mocks/ComponentsServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/ComponentsServiceMock.ts @@ -37,6 +37,7 @@ import { ComponentRaw, GetTreeParams, changeKey, + doesComponentExists, getBreadcrumbs, getChildren, getComponentData, @@ -106,6 +107,7 @@ export default class ComponentsServiceMock { jest.mocked(setProjectTags).mockImplementation(this.handleSetProjectTags); jest.mocked(setApplicationTags).mockImplementation(this.handleSetApplicationTags); jest.mocked(searchProjects).mockImplementation(this.handleSearchProjects); + jest.mocked(doesComponentExists).mockImplementation(this.handleDoesComponentExists); } handleSearchProjects: typeof searchProjects = (data) => { @@ -420,6 +422,11 @@ export default class ComponentsServiceMock { return this.reply(); }; + handleDoesComponentExists: typeof doesComponentExists = ({ component }) => { + const exists = this.components.some(({ component: { key } }) => key === component); + return this.reply(exists); + }; + reply(): Promise; reply(response: T): Promise; reply(response?: T): Promise { diff --git a/server/sonar-web/src/main/js/api/mocks/DopTranslationServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/DopTranslationServiceMock.ts new file mode 100644 index 00000000000..1393c846867 --- /dev/null +++ b/server/sonar-web/src/main/js/api/mocks/DopTranslationServiceMock.ts @@ -0,0 +1,123 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { cloneDeep } from 'lodash'; +import { mockPaging } from '../../helpers/testMocks'; +import { AlmKeys } from '../../types/alm-settings'; +import { BoundProject, DopSetting } from '../../types/dop-translation'; +import { createBoundProject, getDopSettings } from '../dop-translation'; +import { mockDopSetting } from './data/dop-translation'; + +jest.mock('../dop-translation'); + +const defaultDopSettings = [ + mockDopSetting({ key: 'conf-final-1', type: AlmKeys.GitLab }), + mockDopSetting({ key: 'conf-final-2', type: AlmKeys.GitLab }), + mockDopSetting({ key: 'conf-github-1', type: AlmKeys.GitHub, url: 'http://url' }), + mockDopSetting({ key: 'conf-github-2', type: AlmKeys.GitHub, url: 'http://url' }), + mockDopSetting({ key: 'conf-github-3', type: AlmKeys.GitHub, url: 'javascript://url' }), + mockDopSetting({ key: 'conf-azure-1', type: AlmKeys.Azure, url: 'url' }), + mockDopSetting({ key: 'conf-azure-2', type: AlmKeys.Azure, url: 'url' }), + mockDopSetting({ + key: 'conf-bitbucketcloud-1', + type: AlmKeys.BitbucketCloud, + url: 'url', + }), + mockDopSetting({ + key: 'conf-bitbucketcloud-2', + type: AlmKeys.BitbucketCloud, + url: 'url', + }), + mockDopSetting({ + key: 'conf-bitbucketserver-1', + type: AlmKeys.BitbucketServer, + url: 'url', + }), + mockDopSetting({ + key: 'conf-bitbucketserver-2', + type: AlmKeys.BitbucketServer, + url: 'url', + }), + mockDopSetting(), + mockDopSetting({ id: 'dop-setting-test-id-2', key: 'Test/DopSetting2' }), +]; + +export default class DopTranslationServiceMock { + boundProjects: BoundProject[] = []; + dopSettings: DopSetting[] = [ + mockDopSetting({ key: 'conf-final-1', type: AlmKeys.GitLab }), + mockDopSetting({ key: 'conf-final-2', type: AlmKeys.GitLab }), + mockDopSetting({ key: 'conf-github-1', type: AlmKeys.GitHub, url: 'http://url' }), + mockDopSetting({ key: 'conf-github-2', type: AlmKeys.GitHub, url: 'http://url' }), + mockDopSetting({ key: 'conf-github-3', type: AlmKeys.GitHub, url: 'javascript://url' }), + mockDopSetting({ key: 'conf-azure-1', type: AlmKeys.Azure, url: 'url' }), + mockDopSetting({ key: 'conf-azure-2', type: AlmKeys.Azure, url: 'url' }), + mockDopSetting({ + key: 'conf-bitbucketcloud-1', + type: AlmKeys.BitbucketCloud, + url: 'url', + }), + mockDopSetting({ + key: 'conf-bitbucketcloud-2', + type: AlmKeys.BitbucketCloud, + url: 'url', + }), + mockDopSetting({ + key: 'conf-bitbucketserver-1', + type: AlmKeys.BitbucketServer, + url: 'url', + }), + mockDopSetting({ + key: 'conf-bitbucketserver-2', + type: AlmKeys.BitbucketServer, + url: 'url', + }), + mockDopSetting(), + mockDopSetting({ id: 'dop-setting-test-id-2', key: 'Test/DopSetting2' }), + ]; + + constructor() { + jest.mocked(createBoundProject).mockImplementation(this.createBoundProject); + jest.mocked(getDopSettings).mockImplementation(this.getDopSettings); + } + + createBoundProject: typeof createBoundProject = (data) => { + this.boundProjects.push(data); + return Promise.resolve({}); + }; + + getDopSettings = () => { + const total = this.getDopSettings.length; + return Promise.resolve({ + dopSettings: this.dopSettings, + paging: mockPaging({ pageSize: total, total }), + }); + }; + + removeDopTypeFromSettings = (type: AlmKeys) => { + this.dopSettings = cloneDeep(defaultDopSettings).filter( + (dopSetting) => dopSetting.type !== type, + ); + }; + + reset() { + this.boundProjects = []; + this.dopSettings = cloneDeep(defaultDopSettings); + } +} diff --git a/server/sonar-web/src/main/js/api/mocks/data/dop-translation.ts b/server/sonar-web/src/main/js/api/mocks/data/dop-translation.ts new file mode 100644 index 00000000000..bc8a5d9a991 --- /dev/null +++ b/server/sonar-web/src/main/js/api/mocks/data/dop-translation.ts @@ -0,0 +1,34 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +/* eslint-disable local-rules/use-metrickey-enum */ + +import { AlmKeys } from '../../../types/alm-settings'; +import { DopSetting } from '../../../types/dop-translation'; + +export function mockDopSetting(overrides?: Partial): DopSetting { + return { + id: overrides?.id ?? overrides?.key ?? 'dop-setting-test-id', + key: 'Test/DopSetting', + type: AlmKeys.GitHub, + url: 'https://github.com', + ...overrides, + }; +} diff --git a/server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreate.tsx index 5d1e48b42c0..118f69405ec 100644 --- a/server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreate.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreate.tsx @@ -213,6 +213,7 @@ export default class AzureProjectCreate extends React.PureComponent { + location.query.mono = undefined; + router.replace(location); + }, 0); + } if (location.query?.setncd === 'true') { // Timeout is required to force the refresh of the URL setTimeout(() => { @@ -164,23 +188,28 @@ export class CreateProjectPage extends React.PureComponent { this.setState({ loading: true }); - return getAlmSettings() - .then((almSettings) => { - if (this.mounted) { - this.setState({ - azureSettings: almSettings.filter((s) => s.alm === AlmKeys.Azure), - bitbucketSettings: almSettings.filter((s) => s.alm === AlmKeys.BitbucketServer), - bitbucketCloudSettings: almSettings.filter((s) => s.alm === AlmKeys.BitbucketCloud), - githubSettings: almSettings.filter((s) => s.alm === AlmKeys.GitHub), - gitlabSettings: almSettings.filter((s) => s.alm === AlmKeys.GitLab), - loading: false, - }); - } + + return getDopSettings() + .then(({ dopSettings }) => { + this.setState({ + azureSettings: dopSettings + .filter(({ type }) => type === AlmKeys.Azure) + .map(({ key, type, url }) => ({ alm: type, key, url })), + bitbucketSettings: dopSettings + .filter(({ type }) => type === AlmKeys.BitbucketServer) + .map(({ key, type, url }) => ({ alm: type, key, url })), + bitbucketCloudSettings: dopSettings + .filter(({ type }) => type === AlmKeys.BitbucketCloud) + .map(({ key, type, url }) => ({ alm: type, key, url })), + githubSettings: dopSettings.filter(({ type }) => type === AlmKeys.GitHub), + gitlabSettings: dopSettings + .filter(({ type }) => type === AlmKeys.GitLab) + .map(({ key, type, url }) => ({ alm: type, key, url })), + loading: false, + }); }) .catch(() => { - if (this.mounted) { - this.setState({ loading: false }); - } + this.setState({ loading: false }); }); }; @@ -285,11 +314,9 @@ export class CreateProjectPage extends React.PureComponent ); } diff --git a/server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreate.tsx index 3e8ba0ba017..529879f1a5b 100644 --- a/server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreate.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreate.tsx @@ -17,305 +17,279 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { debounce } from 'lodash'; -import * as React from 'react'; -import { isWebUri } from 'valid-url'; -import { - getGithubClientId, - getGithubOrganizations, - getGithubRepositories, -} from '../../../../api/alm-integrations'; -import { Location, Router } from '../../../../components/hoc/withRouter'; -import { getHostUrl } from '../../../../helpers/urls'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { getGithubOrganizations, getGithubRepositories } from '../../../../api/alm-integrations'; +import { useLocation, useRouter } from '../../../../components/hoc/withRouter'; +import { LabelValueSelectOption } from '../../../../helpers/search'; import { GithubOrganization, GithubRepository } from '../../../../types/alm-integration'; -import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings'; +import { AlmSettingsInstance } from '../../../../types/alm-settings'; +import { DopSetting } from '../../../../types/dop-translation'; import { Paging } from '../../../../types/types'; import { ImportProjectParam } from '../CreateProjectPage'; +import MonorepoProjectCreate from '../monorepo/MonorepoProjectCreate'; import { CreateProjectModes } from '../types'; import GitHubProjectCreateRenderer from './GitHubProjectCreateRenderer'; +import { redirectToGithub } from './utils'; interface Props { canAdmin: boolean; - loadingBindings: boolean; + isLoadingBindings: boolean; onProjectSetupDone: (importProjects: ImportProjectParam) => void; - almInstances: AlmSettingsInstance[]; - location: Location; - router: Router; -} - -interface State { - error: boolean; - loadingOrganizations: boolean; - loadingRepositories: boolean; - organizations: GithubOrganization[]; - repositoryPaging: Paging; - repositories: GithubRepository[]; - searchQuery: string; - selectedOrganization?: GithubOrganization; - selectedAlmInstance?: AlmSettingsInstance; + dopSettings: DopSetting[]; } const REPOSITORY_PAGE_SIZE = 50; +const REPOSITORY_SEARCH_DEBOUNCE_TIME = 250; + +export default function GitHubProjectCreate(props: Readonly) { + const { canAdmin, dopSettings, isLoadingBindings, onProjectSetupDone } = props; + + const repositorySearchDebounceId = useRef(); + + const [isInError, setIsInError] = useState(false); + const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(true); + const [isLoadingRepositories, setIsLoadingRepositories] = useState(false); + const [organizations, setOrganizations] = useState([]); + const [repositories, setRepositories] = useState([]); + const [repositoryPaging, setRepositoryPaging] = useState({ + pageSize: REPOSITORY_PAGE_SIZE, + total: 0, + pageIndex: 1, + }); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedDopSetting, setSelectedDopSetting] = useState(); + const [selectedOrganization, setSelectedOrganization] = useState(); + const [selectedRepository, setSelectedRepository] = useState(); + + const location = useLocation(); + const router = useRouter(); + + const isMonorepoSetup = location.query?.mono === 'true'; + const hasDopSettings = Boolean(dopSettings?.length); + const organizationOptions = useMemo(() => { + return organizations.map(transformToOption); + }, [organizations]); + const repositoryOptions = useMemo(() => { + return repositories.map(transformToOption); + }, [repositories]); + + const fetchRepositories = useCallback( + async (params: { organizationKey: string; page?: number; query?: string }) => { + const { organizationKey, page = 1, query } = params; + + if (selectedDopSetting === undefined) { + setIsInError(true); + return; + } -export default class GitHubProjectCreate extends React.Component { - mounted = false; - - constructor(props: Props) { - super(props); - - this.state = { - error: false, - loadingOrganizations: true, - loadingRepositories: false, - organizations: [], - repositories: [], - repositoryPaging: { pageSize: REPOSITORY_PAGE_SIZE, total: 0, pageIndex: 1 }, - searchQuery: '', - selectedAlmInstance: this.getInitialSelectedAlmInstance(), - }; - - this.triggerSearch = debounce(this.triggerSearch, 250); - } - - componentDidMount() { - this.mounted = true; - this.initialize(); - } + setIsLoadingRepositories(true); - componentDidUpdate(prevProps: Props) { - if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) { - this.setState({ selectedAlmInstance: this.getInitialSelectedAlmInstance() }, () => { - this.initialize().catch(() => { - /* noop */ + try { + const { paging, repositories } = await getGithubRepositories({ + almSetting: selectedDopSetting.key, + organization: organizationKey, + pageSize: REPOSITORY_PAGE_SIZE, + page, + query, }); - }); - } - } - - componentWillUnmount() { - this.mounted = false; - } - getInitialSelectedAlmInstance() { - const { - location: { - query: { almInstance: selectedAlmInstanceKey }, - }, - almInstances, - } = this.props; - const selectedAlmInstance = almInstances.find( - (instance) => instance.key === selectedAlmInstanceKey, - ); - if (selectedAlmInstance) { - return selectedAlmInstance; - } - return this.props.almInstances.length > 1 ? undefined : this.props.almInstances[0]; - } + setRepositoryPaging(paging); + setRepositories((prevRepositories) => + page === 1 ? repositories : [...prevRepositories, ...repositories], + ); + } catch (_) { + setRepositoryPaging({ pageIndex: 1, pageSize: REPOSITORY_PAGE_SIZE, total: 0 }); + setRepositories([]); + } finally { + setIsLoadingRepositories(false); + } + }, + [selectedDopSetting], + ); + + const handleImportRepository = useCallback( + (repoKeys: string[]) => { + if (selectedDopSetting && selectedOrganization && repoKeys.length > 0) { + onProjectSetupDone({ + almSetting: selectedDopSetting.key, + creationMode: CreateProjectModes.GitHub, + monorepo: false, + projects: repoKeys.map((repositoryKey) => ({ repositoryKey })), + }); + } + }, + [onProjectSetupDone, selectedDopSetting, selectedOrganization], + ); - async initialize() { - const { location, router } = this.props; - const { selectedAlmInstance } = this.state; - if (!selectedAlmInstance || !selectedAlmInstance.url) { - this.setState({ error: true }); - return; + const handleLoadMore = useCallback(() => { + if (selectedOrganization) { + fetchRepositories({ + organizationKey: selectedOrganization.key, + page: repositoryPaging.pageIndex + 1, + query: searchQuery, + }); } - this.setState({ error: false }); - - const code = location.query?.code; + }, [fetchRepositories, repositoryPaging.pageIndex, searchQuery, selectedOrganization]); + + const handleSelectOrganization = useCallback( + (organizationKey: string) => { + setSearchQuery(''); + setSelectedOrganization(organizations.find(({ key }) => key === organizationKey)); + fetchRepositories({ organizationKey }); + }, + [fetchRepositories, organizations], + ); + + const handleSelectRepository = useCallback( + (repositoryIdentifier: string) => { + setSelectedRepository(repositories.find(({ key }) => key === repositoryIdentifier)); + }, + [repositories], + ); + + const authenticateToGithub = useCallback(async () => { try { - if (!code) { - await this.redirectToGithub(selectedAlmInstance); - } else { - delete location.query.code; - router.replace(location); - await this.fetchOrganizations(selectedAlmInstance, code); - } - } catch (e) { - if (this.mounted) { - this.setState({ error: true }); - } + await redirectToGithub({ isMonorepoSetup, selectedDopSetting }); + } catch { + setIsInError(true); } - } + }, [isMonorepoSetup, selectedDopSetting]); + + const onSelectDopSetting = useCallback((setting: DopSetting | undefined) => { + setSelectedDopSetting(setting); + setOrganizations([]); + setRepositories([]); + setSearchQuery(''); + }, []); + + const onSelectedAlmInstanceChange = useCallback( + (instance: AlmSettingsInstance) => { + onSelectDopSetting(dopSettings.find((dopSetting) => dopSetting.key === instance.key)); + }, + [dopSettings, onSelectDopSetting], + ); + + useEffect(() => { + const selectedDopSettingId = location.query?.dopSetting; + if (selectedDopSettingId !== undefined) { + const selectedDopSetting = dopSettings.find(({ id }) => id === selectedDopSettingId); + + if (selectedDopSetting) { + setSelectedDopSetting(selectedDopSetting); + } - async redirectToGithub(selectedAlmInstance: AlmSettingsInstance) { - if (!selectedAlmInstance.url) { return; } - const { clientId } = await getGithubClientId(selectedAlmInstance.key); - - if (!clientId) { - this.setState({ error: true }); + if (dopSettings.length > 1) { + setSelectedDopSetting(undefined); return; } - const queryParams = [ - { param: 'client_id', value: clientId }, - { - param: 'redirect_uri', - value: encodeURIComponent( - `${getHostUrl()}/projects/create?mode=${AlmKeys.GitHub}&almInstance=${ - selectedAlmInstance.key - }`, - ), - }, - ] - .map(({ param, value }) => `${param}=${value}`) - .join('&'); - - let instanceRootUrl; - // Strip the api section from the url, since we're not hitting the api here. - if (selectedAlmInstance.url.includes('/api/v3')) { - // GitHub Enterprise - instanceRootUrl = selectedAlmInstance.url.replace('/api/v3', ''); - } else { - // github.com - instanceRootUrl = selectedAlmInstance.url.replace('api.', ''); - } - - // strip the trailing / - instanceRootUrl = instanceRootUrl.replace(/\/$/, ''); - if (!isWebUri(instanceRootUrl)) { - this.setState({ error: true }); - } else { - window.location.replace(`${instanceRootUrl}/login/oauth/authorize?${queryParams}`); - } - } - async fetchOrganizations(selectedAlmInstance: AlmSettingsInstance, token: string) { - const { organizations } = await getGithubOrganizations(selectedAlmInstance.key, token); + setSelectedDopSetting(dopSettings[0]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hasDopSettings]); - if (this.mounted) { - this.setState({ loadingOrganizations: false, organizations }); - } - } - - async fetchRepositories(params: { organizationKey: string; page?: number; query?: string }) { - const { organizationKey, page = 1, query } = params; - const { selectedAlmInstance } = this.state; - - if (!selectedAlmInstance) { - this.setState({ error: true }); + useEffect(() => { + if (selectedDopSetting?.url === undefined) { + setIsInError(true); return; } + setIsInError(false); - this.setState({ loadingRepositories: true }); - - try { - const data = await getGithubRepositories({ - almSetting: selectedAlmInstance.key, - organization: organizationKey, - pageSize: REPOSITORY_PAGE_SIZE, - page, - query, + const code = location.query?.code; + if (code === undefined) { + authenticateToGithub().catch(() => { + setIsInError(true); }); - - if (this.mounted) { - this.setState(({ repositories }) => ({ - loadingRepositories: false, - repositoryPaging: data.paging, - repositories: page === 1 ? data.repositories : [...repositories, ...data.repositories], - })); - } - } catch (_) { - if (this.mounted) { - this.setState({ - loadingRepositories: false, - repositoryPaging: { pageIndex: 1, pageSize: REPOSITORY_PAGE_SIZE, total: 0 }, - repositories: [], + } else { + delete location.query.code; + router.replace(location); + + getGithubOrganizations(selectedDopSetting.key, code) + .then(({ organizations }) => { + setOrganizations(organizations); + setIsLoadingOrganizations(false); + }) + .catch(() => { + setIsInError(true); }); - } - } - } - - triggerSearch = (query: string) => { - const { selectedOrganization } = this.state; - if (selectedOrganization) { - this.fetchRepositories({ organizationKey: selectedOrganization.key, query }); - } - }; - - handleSelectOrganization = (key: string) => { - this.setState(({ organizations }) => ({ - searchQuery: '', - selectedOrganization: organizations.find((o) => o.key === key), - })); - this.fetchRepositories({ organizationKey: key }); - }; - - handleSearch = (searchQuery: string) => { - this.setState({ searchQuery }); - this.triggerSearch(searchQuery); - }; - - handleLoadMore = () => { - const { repositoryPaging, searchQuery, selectedOrganization } = this.state; - - if (selectedOrganization) { - this.fetchRepositories({ - organizationKey: selectedOrganization.key, - page: repositoryPaging.pageIndex + 1, - query: searchQuery, - }); - } - }; - - handleImportRepository = (repoKeys: string[]) => { - const { selectedOrganization, selectedAlmInstance } = this.state; - - if (selectedAlmInstance && selectedOrganization && repoKeys.length > 0) { - this.props.onProjectSetupDone({ - almSetting: selectedAlmInstance.key, - creationMode: CreateProjectModes.GitHub, - projects: repoKeys.map((repositoryKey) => ({ repositoryKey })), - }); } - }; - - onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => { - this.setState( - { selectedAlmInstance: instance, searchQuery: '', organizations: [], repositories: [] }, - () => { - this.initialize().catch(() => { - /* noop */ + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedDopSetting]); + + useEffect(() => { + repositorySearchDebounceId.current = setTimeout(() => { + if (selectedOrganization) { + fetchRepositories({ + organizationKey: selectedOrganization.key, + query: searchQuery, }); - }, - ); - }; + } + }, REPOSITORY_SEARCH_DEBOUNCE_TIME); - render() { - const { canAdmin, loadingBindings, almInstances } = this.props; - const { - error, - loadingOrganizations, - loadingRepositories, - organizations, - repositoryPaging, - repositories, - searchQuery, - selectedOrganization, - selectedAlmInstance, - } = this.state; + return () => { + clearTimeout(repositorySearchDebounceId.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery]); + + return isMonorepoSetup ? ( + + ) : ( + ({ + alm: type, + key, + url, + }))} + canAdmin={canAdmin} + error={isInError} + loadingBindings={isLoadingBindings} + loadingOrganizations={isLoadingOrganizations} + loadingRepositories={isLoadingRepositories} + onImportRepository={handleImportRepository} + onLoadMore={handleLoadMore} + onSearch={setSearchQuery} + onSelectedAlmInstanceChange={onSelectedAlmInstanceChange} + onSelectOrganization={handleSelectOrganization} + organizations={organizations} + repositories={repositories} + repositoryPaging={repositoryPaging} + searchQuery={searchQuery} + selectedAlmInstance={ + selectedDopSetting && { + alm: selectedDopSetting.type, + key: selectedDopSetting.key, + url: selectedDopSetting.url, + } + } + selectedOrganization={selectedOrganization} + /> + ); +} - return ( - - ); - } +function transformToOption({ + key, + name, +}: GithubOrganization | GithubRepository): LabelValueSelectOption { + return { value: key, label: name }; } diff --git a/server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreateRenderer.tsx b/server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreateRenderer.tsx index 9abb7f27e35..d1fba96d8c3 100644 --- a/server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreateRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreateRenderer.tsx @@ -20,6 +20,7 @@ /* eslint-disable react/no-unused-prop-types */ import styled from '@emotion/styled'; +import { Link, Spinner } from '@sonarsource/echoes-react'; import { ButtonPrimary, Checkbox, @@ -28,23 +29,25 @@ import { InputSearch, InputSelect, LightPrimary, - Link, - Spinner, Title, themeBorder, themeColor, } from 'design-system'; -import React, { useState } from 'react'; +import React, { useContext, useState } from 'react'; import { FormattedMessage } from 'react-intl'; +import { AvailableFeaturesContext } from '../../../../app/components/available-features/AvailableFeaturesContext'; import ListFooter from '../../../../components/controls/ListFooter'; import { translate } from '../../../../helpers/l10n'; import { LabelValueSelectOption } from '../../../../helpers/search'; import { getBaseUrl } from '../../../../helpers/system'; +import { queryToSearch } from '../../../../helpers/urls'; import { GithubOrganization, GithubRepository } from '../../../../types/alm-integration'; import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings'; +import { Feature } from '../../../../types/features'; import { Paging } from '../../../../types/types'; import AlmRepoItem from '../components/AlmRepoItem'; import AlmSettingsInstanceDropdown from '../components/AlmSettingsInstanceDropdown'; +import { CreateProjectModes } from '../types'; interface GitHubProjectCreateRendererProps { canAdmin: boolean; @@ -173,6 +176,10 @@ function RepositoryList(props: RepositoryListProps) { } export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRendererProps) { + const isMonorepoSupported = useContext(AvailableFeaturesContext).includes( + Feature.MonoRepositoryPullRequestDecoration, + ); + const { canAdmin, error, @@ -211,7 +218,28 @@ export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRe
{translate('onboarding.create_project.github.title')} - {translate('onboarding.create_project.github.subtitle')} + {isMonorepoSupported ? ( + + + + ), + }} + /> + ) : ( + + )}
@@ -246,7 +274,7 @@ export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRe
- + {!error && (
diff --git a/server/sonar-web/src/main/js/apps/create/project/Github/utils.ts b/server/sonar-web/src/main/js/apps/create/project/Github/utils.ts new file mode 100644 index 00000000000..6df0e64aba3 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/Github/utils.ts @@ -0,0 +1,73 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { isWebUri } from 'valid-url'; +import { getGithubClientId } from '../../../../api/alm-integrations'; +import { getHostUrl } from '../../../../helpers/urls'; +import { AlmKeys } from '../../../../types/alm-settings'; +import { DopSetting } from '../../../../types/dop-translation'; + +export async function redirectToGithub(params: { + isMonorepoSetup: boolean; + selectedDopSetting?: DopSetting; +}) { + const { isMonorepoSetup, selectedDopSetting } = params; + + if (selectedDopSetting?.url === undefined) { + return; + } + + const { clientId } = await getGithubClientId(selectedDopSetting.key); + + if (clientId === undefined) { + throw new Error('Received no GitHub client id'); + } + const queryParams = [ + { param: 'client_id', value: clientId }, + { + param: 'redirect_uri', + value: encodeURIComponent( + `${getHostUrl()}/projects/create?mode=${AlmKeys.GitHub}&dopSetting=${ + selectedDopSetting.id + }${isMonorepoSetup ? '&mono=true' : ''}`, + ), + }, + ] + .map(({ param, value }) => `${param}=${value}`) + .join('&'); + + let instanceRootUrl; + // Strip the api section from the url, since we're not hitting the api here. + if (selectedDopSetting.url.includes('/api/v3')) { + // GitHub Enterprise + instanceRootUrl = selectedDopSetting.url.replace('/api/v3', ''); + } else { + // github.com + instanceRootUrl = selectedDopSetting.url.replace('api.', ''); + } + + // strip the trailing / + instanceRootUrl = instanceRootUrl.replace(/\/$/, ''); + if (isWebUri(instanceRootUrl) === undefined) { + throw new Error('Invalid GitHub URL'); + } else { + window.location.replace(`${instanceRootUrl}/login/oauth/authorize?${queryParams}`); + } +} diff --git a/server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreate.tsx index b5160f7f69b..94dc946da03 100644 --- a/server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreate.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreate.tsx @@ -143,6 +143,7 @@ export default class GitlabProjectCreate extends React.PureComponent { value: { replace: jest.fn() }, }); almIntegrationHandler = new AlmIntegrationsServiceMock(); - almSettingsHandler = new AlmSettingsServiceMock(); + dopTranslationHandler = new DopTranslationServiceMock(); newCodePeriodHandler = new NewCodeDefinitionServiceMock(); }); beforeEach(() => { jest.clearAllMocks(); almIntegrationHandler.reset(); - almSettingsHandler.reset(); + dopTranslationHandler.reset(); newCodePeriodHandler.reset(); }); afterAll(() => { diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/Bitbucket-it.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/Bitbucket-it.tsx index 496b37ba2b9..11f52a278aa 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/Bitbucket-it.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/Bitbucket-it.tsx @@ -24,7 +24,7 @@ import * as React from 'react'; import selectEvent from 'react-select-event'; import { searchForBitbucketServerRepositories } from '../../../../api/alm-integrations'; import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock'; -import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock'; +import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServiceMock'; import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock'; import { renderApp } from '../../../../helpers/testReactTestingUtils'; import { byLabelText, byRole, byText } from '../../../../helpers/testSelector'; @@ -34,7 +34,7 @@ jest.mock('../../../../api/alm-integrations'); jest.mock('../../../../api/alm-settings'); let almIntegrationHandler: AlmIntegrationsServiceMock; -let almSettingsHandler: AlmSettingsServiceMock; +let dopTranslationHandler: DopTranslationServiceMock; let newCodePeriodHandler: NewCodeDefinitionServiceMock; const ui = { @@ -52,14 +52,14 @@ beforeAll(() => { value: { replace: jest.fn() }, }); almIntegrationHandler = new AlmIntegrationsServiceMock(); - almSettingsHandler = new AlmSettingsServiceMock(); + dopTranslationHandler = new DopTranslationServiceMock(); newCodePeriodHandler = new NewCodeDefinitionServiceMock(); }); beforeEach(() => { jest.clearAllMocks(); almIntegrationHandler.reset(); - almSettingsHandler.reset(); + dopTranslationHandler.reset(); newCodePeriodHandler.reset(); }); diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloud-it.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloud-it.tsx index 22ea3acb318..ff7f37cc8b6 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloud-it.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloud-it.tsx @@ -24,7 +24,7 @@ import * as React from 'react'; import selectEvent from 'react-select-event'; import { searchForBitbucketCloudRepositories } from '../../../../api/alm-integrations'; import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock'; -import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock'; +import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServiceMock'; import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock'; import { renderApp } from '../../../../helpers/testReactTestingUtils'; import { byLabelText, byRole, byText } from '../../../../helpers/testSelector'; @@ -35,7 +35,7 @@ jest.mock('../../../../api/alm-integrations'); jest.mock('../../../../api/alm-settings'); let almIntegrationHandler: AlmIntegrationsServiceMock; -let almSettingsHandler: AlmSettingsServiceMock; +let dopTranslationHandler: DopTranslationServiceMock; let newCodePeriodHandler: NewCodeDefinitionServiceMock; const ui = { @@ -56,14 +56,14 @@ beforeAll(() => { value: { replace: jest.fn() }, }); almIntegrationHandler = new AlmIntegrationsServiceMock(); - almSettingsHandler = new AlmSettingsServiceMock(); + dopTranslationHandler = new DopTranslationServiceMock(); newCodePeriodHandler = new NewCodeDefinitionServiceMock(); }); beforeEach(() => { jest.clearAllMocks(); almIntegrationHandler.reset(); - almSettingsHandler.reset(); + dopTranslationHandler.reset(); newCodePeriodHandler.reset(); }); diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectPage-it.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectPage-it.tsx index cf6366a897b..ceb37315dcd 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectPage-it.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectPage-it.tsx @@ -21,7 +21,7 @@ import { screen } from '@testing-library/react'; import * as React from 'react'; import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock'; -import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock'; +import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServiceMock'; import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock'; import { mockAppState } from '../../../../helpers/testMocks'; import { renderApp } from '../../../../helpers/testReactTestingUtils'; @@ -32,7 +32,7 @@ jest.mock('../../../../api/alm-integrations'); jest.mock('../../../../api/alm-settings'); let almIntegrationHandler: AlmIntegrationsServiceMock; -let almSettingsHandler: AlmSettingsServiceMock; +let dopTranslationHandler: DopTranslationServiceMock; let newCodePeriodHandler: NewCodeDefinitionServiceMock; const original = window.location; @@ -43,14 +43,14 @@ beforeAll(() => { value: { replace: jest.fn() }, }); almIntegrationHandler = new AlmIntegrationsServiceMock(); - almSettingsHandler = new AlmSettingsServiceMock(); + dopTranslationHandler = new DopTranslationServiceMock(); newCodePeriodHandler = new NewCodeDefinitionServiceMock(); }); beforeEach(() => { jest.clearAllMocks(); almIntegrationHandler.reset(); - almSettingsHandler.reset(); + dopTranslationHandler.reset(); newCodePeriodHandler.reset(); }); afterAll(() => { @@ -58,14 +58,14 @@ afterAll(() => { }); it('should be able to setup if no config and admin', async () => { - almSettingsHandler.removeFromAlmSettings(AlmKeys.Azure); + dopTranslationHandler.removeDopTypeFromSettings(AlmKeys.Azure); renderCreateProject(true); expect(await screen.findByText('onboarding.create_project.select_method')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'setup' })).toBeInTheDocument(); }); it('should not be able to setup if no config and no admin rights', async () => { - almSettingsHandler.removeFromAlmSettings(AlmKeys.Azure); + dopTranslationHandler.removeDopTypeFromSettings(AlmKeys.Azure); renderCreateProject(); expect(await screen.findByText('onboarding.create_project.select_method')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'setup' })).not.toBeInTheDocument(); diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHub-it.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHub-it.tsx index a742d14d7ab..e34500da034 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHub-it.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHub-it.tsx @@ -24,7 +24,7 @@ import * as React from 'react'; import selectEvent from 'react-select-event'; import { getGithubRepositories } from '../../../../api/alm-integrations'; import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock'; -import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock'; +import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServiceMock'; import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock'; import { mockGitHubRepository } from '../../../../helpers/mocks/alm-integrations'; import { renderApp } from '../../../../helpers/testReactTestingUtils'; @@ -37,7 +37,7 @@ jest.mock('../../../../api/alm-settings'); const original = window.location; let almIntegrationHandler: AlmIntegrationsServiceMock; -let almSettingsHandler: AlmSettingsServiceMock; +let dopTranslationHandler: DopTranslationServiceMock; let newCodePeriodHandler: NewCodeDefinitionServiceMock; const ui = { @@ -76,14 +76,14 @@ beforeAll(() => { value: { replace: jest.fn() }, }); almIntegrationHandler = new AlmIntegrationsServiceMock(); - almSettingsHandler = new AlmSettingsServiceMock(); + dopTranslationHandler = new DopTranslationServiceMock(); newCodePeriodHandler = new NewCodeDefinitionServiceMock(); }); beforeEach(() => { jest.clearAllMocks(); almIntegrationHandler.reset(); - almSettingsHandler.reset(); + dopTranslationHandler.reset(); newCodePeriodHandler.reset(); }); @@ -120,7 +120,7 @@ it('should not redirect to github when url is malformated', async () => { it('should show import project feature when the authentication is successfull', async () => { const user = userEvent.setup(); - renderCreateProject('project/create?mode=github&almInstance=conf-github-2&code=213321213'); + renderCreateProject('project/create?mode=github&dopSetting=conf-github-2&code=213321213'); expect(await ui.instanceSelector.find()).toBeInTheDocument(); @@ -172,7 +172,7 @@ it('should import several projects', async () => { mockGitHubRepository({ name: 'Github repo 3', key: 'key3' }), ]); - renderCreateProject('project/create?mode=github&almInstance=conf-github-2&code=213321213'); + renderCreateProject('project/create?mode=github&dopSetting=conf-github-2&code=213321213'); expect(await ui.instanceSelector.find()).toBeInTheDocument(); @@ -237,7 +237,7 @@ it('should import several projects', async () => { it('should show search filter when the authentication is successful', async () => { const user = userEvent.setup(); - renderCreateProject('project/create?mode=github&almInstance=conf-github-2&code=213321213'); + renderCreateProject('project/create?mode=github&dopSetting=conf-github-2&code=213321213'); expect(await ui.instanceSelector.find()).toBeInTheDocument(); @@ -247,12 +247,14 @@ it('should show search filter when the authentication is successful', async () = await user.click(inputSearch); await user.keyboard('search'); - expect(getGithubRepositories).toHaveBeenLastCalledWith({ - almSetting: 'conf-github-2', - organization: 'org-1', - page: 1, - pageSize: 50, - query: 'search', + await waitFor(() => { + expect(getGithubRepositories).toHaveBeenLastCalledWith({ + almSetting: 'conf-github-2', + organization: 'org-1', + page: 1, + pageSize: 50, + query: 'search', + }); }); }); @@ -260,7 +262,7 @@ it('should have load more', async () => { const user = userEvent.setup(); almIntegrationHandler.createRandomGithubRepositoriessWithLoadMore(10, 20); - renderCreateProject('project/create?mode=github&almInstance=conf-github-2&code=213321213'); + renderCreateProject('project/create?mode=github&dopSetting=conf-github-2&code=213321213'); expect(await ui.instanceSelector.find()).toBeInTheDocument(); @@ -288,7 +290,7 @@ it('should have load more', async () => { it('should show no result message when there are no projects', async () => { almIntegrationHandler.setGithubRepositories([]); - renderCreateProject('project/create?mode=github&almInstance=conf-github-2&code=213321213'); + renderCreateProject('project/create?mode=github&dopSetting=conf-github-2&code=213321213'); expect(await ui.instanceSelector.find()).toBeInTheDocument(); diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubMonorepoProjectCreate-it.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubMonorepoProjectCreate-it.tsx new file mode 100644 index 00000000000..84cd593471a --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubMonorepoProjectCreate-it.tsx @@ -0,0 +1,187 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import selectEvent from 'react-select-event'; +import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock'; +import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock'; +import ComponentsServiceMock from '../../../../api/mocks/ComponentsServiceMock'; +import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServiceMock'; +import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock'; +import { renderApp } from '../../../../helpers/testReactTestingUtils'; +import { byRole } from '../../../../helpers/testSelector'; +import { AlmKeys } from '../../../../types/alm-settings'; +import { Feature } from '../../../../types/features'; +import CreateProjectPage from '../CreateProjectPage'; +import { CreateProjectModes } from '../types'; + +jest.mock('../../../../api/alm-integrations'); +jest.mock('../../../../api/alm-settings'); + +let almIntegrationHandler: AlmIntegrationsServiceMock; +let almSettingsHandler: AlmSettingsServiceMock; +let componentsHandler: ComponentsServiceMock; +let dopTranslationHandler: DopTranslationServiceMock; +let newCodePeriodHandler: NewCodeDefinitionServiceMock; + +const ui = { + addButton: byRole('button', { name: 'onboarding.create_project.monorepo.add_project' }), + cancelButton: byRole('button', { name: 'cancel' }), + dopSettingSelector: byRole('combobox', { + name: `onboarding.create_project.monorepo.choose_dop_setting.${AlmKeys.GitHub}`, + }), + gitHubOnboardingTitle: byRole('heading', { name: 'onboarding.create_project.github.title' }), + monorepoProjectTitle: byRole('heading', { + name: 'onboarding.create_project.monorepo.project_title', + }), + monorepoSetupLink: byRole('link', { name: 'onboarding.create_project.github.subtitle.link' }), + monorepoTitle: byRole('heading', { name: 'onboarding.create_project.monorepo.titlealm.github' }), + organizationSelector: byRole('combobox', { + name: `onboarding.create_project.monorepo.choose_organization.${AlmKeys.GitHub}`, + }), + removeButton: byRole('button', { name: 'onboarding.create_project.monorepo.remove_project' }), + repositorySelector: byRole('combobox', { + name: `onboarding.create_project.monorepo.choose_repository.${AlmKeys.GitHub}`, + }), + submitButton: byRole('button', { name: 'next' }), +}; + +beforeAll(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: { replace: jest.fn() }, + }); + almIntegrationHandler = new AlmIntegrationsServiceMock(); + almSettingsHandler = new AlmSettingsServiceMock(); + componentsHandler = new ComponentsServiceMock(); + dopTranslationHandler = new DopTranslationServiceMock(); + newCodePeriodHandler = new NewCodeDefinitionServiceMock(); +}); + +beforeEach(() => { + jest.clearAllMocks(); + almIntegrationHandler.reset(); + almSettingsHandler.reset(); + componentsHandler.reset(); + dopTranslationHandler.reset(); + newCodePeriodHandler.reset(); +}); + +describe('github monorepo project setup', () => { + it('should be able to access monorepo setup page from GitHub project import page', async () => { + const user = userEvent.setup(); + renderCreateProject({ isMonorepo: false }); + + await ui.monorepoSetupLink.find(); + + await user.click(await ui.monorepoSetupLink.find()); + + expect(ui.monorepoTitle.get()).toBeInTheDocument(); + }); + + it('should be able to go back to GitHub onboarding page from monorepo setup page', async () => { + const user = userEvent.setup(); + renderCreateProject(); + + await user.click(await ui.cancelButton.find()); + + expect(ui.gitHubOnboardingTitle.get()).toBeInTheDocument(); + }); + + it('should be able to set a monorepo project', async () => { + const user = userEvent.setup(); + renderCreateProject({ code: '123', dopSetting: 'dop-setting-test-id', isMonorepo: true }); + + expect(await ui.monorepoTitle.find()).toBeInTheDocument(); + + expect(await ui.dopSettingSelector.find()).toBeInTheDocument(); + expect(ui.monorepoProjectTitle.query()).not.toBeInTheDocument(); + + await waitFor(async () => { + await selectEvent.select(await ui.organizationSelector.find(), 'org-1'); + }); + expect(ui.monorepoProjectTitle.query()).not.toBeInTheDocument(); + + await selectEvent.select(await ui.repositorySelector.find(), 'Github repo 1'); + expect(await ui.monorepoProjectTitle.find()).toBeInTheDocument(); + let projects = byRole('textbox', { + name: /onboarding.create_project.project_key/, + }).getAll(); + expect(projects).toHaveLength(1); + expect(projects[0]).toHaveValue('org-1_Github-repo-1_add-your-reference'); + expect(ui.submitButton.get()).toBeEnabled(); + + await user.click(ui.addButton.get()); + await waitFor(() => { + projects = byRole('textbox', { + name: /onboarding.create_project.project_key/, + }).getAll(); + expect(projects).toHaveLength(2); + }); + expect(projects[0]).toHaveValue('org-1_Github-repo-1_add-your-reference'); + expect(projects[1]).toHaveValue('org-1_Github-repo-1_add-your-reference-1'); + expect(ui.submitButton.get()).toBeEnabled(); + + await user.type(projects[0], '-1'); + expect(ui.submitButton.get()).toBeDisabled(); + await user.clear(projects[1]); + expect(ui.submitButton.get()).toBeDisabled(); + + await user.click(ui.removeButton.getAll()[0]); + await waitFor(() => { + projects = byRole('textbox', { + name: /onboarding.create_project.project_key/, + }).getAll(); + expect(projects).toHaveLength(1); + }); + expect(projects[0]).toHaveValue(''); + expect(ui.submitButton.get()).toBeDisabled(); + + await user.type(projects[0], 'project-key'); + expect(ui.submitButton.get()).toBeEnabled(); + }); +}); + +function renderCreateProject({ + code, + dopSetting, + isMonorepo = true, +}: { + code?: string; + dopSetting?: string; + isMonorepo?: boolean; +} = {}) { + let queryString = `mode=${CreateProjectModes.GitHub}`; + if (isMonorepo) { + queryString += '&mono=true'; + } + if (dopSetting !== undefined) { + queryString += `&dopSetting=${dopSetting}`; + } + if (code !== undefined) { + queryString += `&code=${code}`; + } + + renderApp('projects/create', , { + navigateTo: `projects/create?${queryString}`, + featureList: [Feature.MonoRepositoryPullRequestDecoration], + }); +} diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/GitLab-it.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/GitLab-it.tsx index a0133945c74..5853b155855 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/GitLab-it.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/GitLab-it.tsx @@ -23,7 +23,7 @@ import * as React from 'react'; import selectEvent from 'react-select-event'; import { getGitlabProjects } from '../../../../api/alm-integrations'; import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock'; -import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock'; +import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServiceMock'; import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock'; import { renderApp } from '../../../../helpers/testReactTestingUtils'; import { byLabelText, byRole, byText } from '../../../../helpers/testSelector'; @@ -33,7 +33,7 @@ jest.mock('../../../../api/alm-integrations'); jest.mock('../../../../api/alm-settings'); let almIntegrationHandler: AlmIntegrationsServiceMock; -let almSettingsHandler: AlmSettingsServiceMock; +let dopTranslationHandler: DopTranslationServiceMock; let newCodePeriodHandler: NewCodeDefinitionServiceMock; const ui = { @@ -53,14 +53,14 @@ beforeAll(() => { value: { replace: jest.fn() }, }); almIntegrationHandler = new AlmIntegrationsServiceMock(); - almSettingsHandler = new AlmSettingsServiceMock(); + dopTranslationHandler = new DopTranslationServiceMock(); newCodePeriodHandler = new NewCodeDefinitionServiceMock(); }); beforeEach(() => { jest.clearAllMocks(); almIntegrationHandler.reset(); - almSettingsHandler.reset(); + dopTranslationHandler.reset(); newCodePeriodHandler.reset(); }); diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/Manual-it.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/Manual-it.tsx index bb6f76a3e91..ebdbc3c1c7d 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/Manual-it.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/Manual-it.tsx @@ -17,9 +17,11 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock'; +import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServiceMock'; import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock'; import { ProjectsServiceMock } from '../../../../api/mocks/ProjectsServiceMock'; import { getNewCodeDefinition } from '../../../../api/newCodeDefinition'; @@ -34,6 +36,7 @@ import routes from '../../../projects/routes'; jest.mock('../../../../api/measures'); jest.mock('../../../../api/favorites'); jest.mock('../../../../api/alm-settings'); +jest.mock('../../../../api/dop-translation'); jest.mock('../../../../api/newCodeDefinition'); jest.mock('../../../../api/project-management', () => ({ createProject: jest.fn().mockReturnValue(Promise.resolve({ project: mockProject() })), @@ -98,6 +101,7 @@ async function fillFormAndNext(displayName: string, user: UserEvent) { } let almSettingsHandler: AlmSettingsServiceMock; +let dopTranslationHandler: DopTranslationServiceMock; let newCodePeriodHandler: NewCodeDefinitionServiceMock; let projectHandler: ProjectsServiceMock; @@ -109,6 +113,7 @@ beforeAll(() => { value: { replace: jest.fn() }, }); almSettingsHandler = new AlmSettingsServiceMock(); + dopTranslationHandler = new DopTranslationServiceMock(); newCodePeriodHandler = new NewCodeDefinitionServiceMock(); projectHandler = new ProjectsServiceMock(); }); @@ -116,6 +121,7 @@ beforeAll(() => { beforeEach(() => { jest.clearAllMocks(); almSettingsHandler.reset(); + dopTranslationHandler.reset(); newCodePeriodHandler.reset(); projectHandler.reset(); }); @@ -192,7 +198,7 @@ it('the project onboarding page should be displayed when the project is created' expect(await ui.projectDashboardText.find()).toBeInTheDocument(); }); -it('validate the provate key field', async () => { +it('validate the private key field', async () => { const user = userEvent.setup(); renderCreateProject(); expect(ui.manualProjectHeader.get()).toBeInTheDocument(); @@ -200,7 +206,9 @@ it('validate the provate key field', async () => { await user.click(ui.displayNameField.get()); await user.keyboard('exists'); - expect(ui.projectNextButton.get()).toBeDisabled(); + await waitFor(() => { + expect(ui.projectNextButton.get()).toBeDisabled(); + }); await user.click(ui.projectNextButton.get()); }); diff --git a/server/sonar-web/src/main/js/apps/create/project/components/AlmSettingsInstanceDropdown.tsx b/server/sonar-web/src/main/js/apps/create/project/components/AlmSettingsInstanceDropdown.tsx index d4d82d2b94e..92193fd3ec1 100644 --- a/server/sonar-web/src/main/js/apps/create/project/components/AlmSettingsInstanceDropdown.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/components/AlmSettingsInstanceDropdown.tsx @@ -23,7 +23,7 @@ import AlmSettingsInstanceSelector from '../../../../components/devops-platform/ import { hasMessage, translate, translateWithParameters } from '../../../../helpers/l10n'; import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings'; -export interface AlmSettingsInstanceDropdownProps { +interface Props { almKey: AlmKeys; almInstances?: AlmSettingsInstance[]; selectedAlmInstance?: AlmSettingsInstance; @@ -32,7 +32,7 @@ export interface AlmSettingsInstanceDropdownProps { const MIN_SIZE_INSTANCES = 2; -export default function AlmSettingsInstanceDropdown(props: AlmSettingsInstanceDropdownProps) { +export default function AlmSettingsInstanceDropdown(props: Readonly) { const { almKey, almInstances, selectedAlmInstance } = props; if (!almInstances || almInstances.length < MIN_SIZE_INSTANCES) { return null; @@ -43,7 +43,7 @@ export default function AlmSettingsInstanceDropdown(props: AlmSettingsInstanceDr : `alm.${almKey}`; return ( -
+
{translateWithParameters('alm.configuration.selector.label', translate(almKeyTranslation))} @@ -51,7 +51,7 @@ export default function AlmSettingsInstanceDropdown(props: AlmSettingsInstanceDr instances={almInstances} onChange={props.onChangeConfig} initialValue={selectedAlmInstance ? selectedAlmInstance.key : undefined} - className="sw-w-abs-400 sw-mb-9" + className="sw-w-abs-400" inputId="alm-config-selector" />
diff --git a/server/sonar-web/src/main/js/apps/create/project/components/DopSettingDropdown.tsx b/server/sonar-web/src/main/js/apps/create/project/components/DopSettingDropdown.tsx new file mode 100644 index 00000000000..f0400c50075 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/components/DopSettingDropdown.tsx @@ -0,0 +1,99 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import classNames from 'classnames'; +import { DarkLabel, InputSelect, LabelValueSelectOption, Note } from 'design-system'; +import * as React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { OptionProps, SingleValueProps, components } from 'react-select'; +import { translate } from '../../../../helpers/l10n'; +import { AlmKeys } from '../../../../types/alm-settings'; +import { DopSetting } from '../../../../types/dop-translation'; + +export interface DopSettingDropdownProps { + almKey: AlmKeys; + className?: string; + dopSettings?: DopSetting[]; + onChangeSetting: (setting: DopSetting) => void; + selectedDopSetting?: DopSetting; +} + +const MIN_SIZE_INSTANCES = 2; + +function optionRenderer(props: OptionProps, false>) { + return {customOptions(props.data.value)}; +} + +function singleValueRenderer(props: SingleValueProps, false>) { + return ( + {customOptions(props.data.value)} + ); +} + +function customOptions(setting: DopSetting) { + return setting.url ? ( + <> + {setting.key} — + {setting.url} + + ) : ( + {setting.key} + ); +} + +function orgToOption(alm: DopSetting) { + return { value: alm, label: alm.key }; +} + +export default function DopSettingDropdown(props: Readonly) { + const { almKey, className, dopSettings, onChangeSetting, selectedDopSetting } = props; + if (!dopSettings || dopSettings.length < MIN_SIZE_INSTANCES) { + return null; + } + + return ( +
+ + + + + ) => { + onChangeSetting(data.value); + }} + components={{ + Option: optionRenderer, + SingleValue: singleValueRenderer, + }} + placeholder={translate('alm.configuration.selector.placeholder')} + getOptionValue={(opt: LabelValueSelectOption) => opt.value.key} + value={ + dopSettings.map(orgToOption).find((opt) => opt.value.key === selectedDopSetting?.key) ?? + null + } + size="full" + /> +
+ ); +} diff --git a/server/sonar-web/src/main/js/apps/create/project/components/NewCodeDefinitionSelection.tsx b/server/sonar-web/src/main/js/apps/create/project/components/NewCodeDefinitionSelection.tsx index e35f0e6907f..9f90b8bc8a4 100644 --- a/server/sonar-web/src/main/js/apps/create/project/components/NewCodeDefinitionSelection.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/components/NewCodeDefinitionSelection.tsx @@ -34,6 +34,7 @@ import * as React from 'react'; import { useEffect } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; import { useNavigate, unstable_usePrompt as usePrompt } from 'react-router-dom'; +import { useLocation } from '../../../../components/hoc/withRouter'; import NewCodeDefinitionSelector from '../../../../components/new-code-definition/NewCodeDefinitionSelector'; import { useDocUrl } from '../../../../helpers/docs'; import { translate } from '../../../../helpers/l10n'; @@ -65,6 +66,7 @@ export default function NewCodeDefinitionSelection(props: Props) { const mutateCount = useImportProjectProgress(); const isImporting = mutateCount > 0; const intl = useIntl(); + const location = useLocation(); const navigate = useNavigate(); const getDocUrl = useDocUrl(); usePrompt({ @@ -74,10 +76,11 @@ export default function NewCodeDefinitionSelection(props: Props) { const projectCount = importProjects.projects.length; const isMultipleProjects = projectCount > 1; + const isMonorepo = location.query?.mono === 'true'; useEffect(() => { const redirect = (projectCount: number) => { - if (projectCount === 1 && data) { + if (!isMonorepo && projectCount === 1 && data) { if (redirectTo === '/projects') { navigate(getProjectUrl(data.project.key)); } else { @@ -110,7 +113,11 @@ export default function NewCodeDefinitionSelection(props: Props) { if (redirectTo === '/projects') { addGlobalSuccessMessage( intl.formatMessage( - { id: 'onboarding.create_project.success' }, + { + id: isMonorepo + ? 'onboarding.create_project.monorepo.success' + : 'onboarding.create_project.success', + }, { count: projectCount - failedImports, }, diff --git a/server/sonar-web/src/main/js/apps/create/project/components/ProjectValidation.tsx b/server/sonar-web/src/main/js/apps/create/project/components/ProjectValidation.tsx new file mode 100644 index 00000000000..f07613fa84a --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/components/ProjectValidation.tsx @@ -0,0 +1,336 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import classNames from 'classnames'; +import { + ButtonSecondary, + Card, + FlagErrorIcon, + FlagSuccessIcon, + FormField, + InputField, + Note, + TextError, + TrashIcon, +} from 'design-system'; +import { isEmpty } from 'lodash'; +import * as React from 'react'; +import { doesComponentExists } from '../../../../api/components'; +import { translate } from '../../../../helpers/l10n'; +import { validateProjectKey } from '../../../../helpers/projects'; +import { ProjectKeyValidationResult } from '../../../../types/component'; +import { PROJECT_NAME_MAX_LEN } from '../constants'; +import { getSanitizedProjectKey } from '../utils'; + +interface Props { + initialKey?: string; + initialName?: string; + monorepoSetupProjectKeys?: string[]; + onChange: (project: ProjectData) => void; + onRemove?: () => void; + projectId?: I; +} + +interface State { + name: string; + nameError?: boolean; + nameTouched: boolean; + key: string; + keyError?: ProjectKeyErrors; + keyTouched: boolean; + validatingKey: boolean; +} + +export interface ProjectData { + hasError: boolean; + id?: I; + name: string; + key: string; + touched: boolean; +} + +enum ProjectKeyErrors { + DuplicateKey = 'DUPLICATE_KEY', + MonorepoDuplicateKey = 'MONOREPO_DUPLICATE_KEY', + WrongFormat = 'WRONG_FORMAT', +} + +const DEBOUNCE_DELAY = 250; + +export default function ProjectValidation(props: Readonly>) { + const { + initialKey = '', + initialName = '', + monorepoSetupProjectKeys, + onChange, + projectId, + } = props; + const checkFreeKeyTimeout = React.useRef(); + const [project, setProject] = React.useState({ + key: initialKey, + name: initialName, + keyTouched: false, + nameTouched: false, + validatingKey: false, + }); + + const { key, keyError, keyTouched, name, nameError, nameTouched, validatingKey } = project; + + React.useEffect(() => { + onChange({ + hasError: keyError !== undefined || nameError !== undefined, + id: projectId, + key, + name, + touched: keyTouched || nameTouched, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key, name, keyError, keyTouched, nameError, nameTouched]); + + const checkFreeKey = (keyVal: string) => { + setProject((prevProject) => ({ ...prevProject, validatingKey: true })); + + doesComponentExists({ component: keyVal }) + .then((alreadyExist) => { + setProject((prevProject) => { + if (keyVal === prevProject.key) { + return { + ...prevProject, + keyError: alreadyExist ? ProjectKeyErrors.DuplicateKey : undefined, + validatingKey: false, + }; + } + return prevProject; + }); + }) + .catch(() => { + setProject((prevProject) => { + if (keyVal === prevProject.key) { + return { + ...prevProject, + keyError: undefined, + validatingKey: false, + }; + } + return prevProject; + }); + }); + }; + + const handleProjectKeyChange = (projectKey: string, fromUI = false) => { + const keyError = validateKey(projectKey); + + setProject((prevProject) => ({ + ...prevProject, + key: projectKey, + keyError, + keyTouched: fromUI, + })); + }; + + React.useEffect(() => { + if (nameTouched && !keyTouched) { + const sanitizedProjectKey = getSanitizedProjectKey(name); + + handleProjectKeyChange(sanitizedProjectKey); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [name, keyTouched]); + + React.useEffect(() => { + if (!keyError && key !== '') { + checkFreeKeyTimeout.current = setTimeout(() => { + checkFreeKey(key); + checkFreeKeyTimeout.current = undefined; + }, DEBOUNCE_DELAY); + } + + return () => { + if (checkFreeKeyTimeout.current !== undefined) { + clearTimeout(checkFreeKeyTimeout.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]); + + React.useEffect(() => { + if ( + (keyError === undefined || keyError === ProjectKeyErrors.MonorepoDuplicateKey) && + key !== '' + ) { + if (monorepoSetupProjectKeys?.indexOf(key) !== monorepoSetupProjectKeys?.lastIndexOf(key)) { + setProject((prevProject) => ({ + ...prevProject, + keyError: ProjectKeyErrors.MonorepoDuplicateKey, + })); + } else { + setProject((prevProject) => { + if (prevProject.keyError === ProjectKeyErrors.MonorepoDuplicateKey) { + return { + ...prevProject, + keyError: undefined, + }; + } + + return prevProject; + }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [monorepoSetupProjectKeys]); + + const handleProjectNameChange = (projectName: string, fromUI = false) => { + setProject({ + ...project, + name: projectName, + nameError: validateName(projectName), + nameTouched: fromUI, + }); + }; + + const validateKey = (projectKey: string) => { + const result = validateProjectKey(projectKey); + if (result !== ProjectKeyValidationResult.Valid) { + return ProjectKeyErrors.WrongFormat; + } + return undefined; + }; + + const validateName = (projectName: string) => { + if (isEmpty(projectName)) { + return true; + } + return undefined; + }; + + const touched = Boolean(keyTouched || nameTouched); + const projectNameIsInvalid = nameTouched && nameError !== undefined; + const projectNameIsValid = nameTouched && nameError === undefined; + const projectKeyIsInvalid = touched && keyError !== undefined; + const projectKeyIsValid = touched && !validatingKey && keyError === undefined; + const projectKeyInputId = projectId !== undefined ? `project-key-${projectId}` : 'project-key'; + const projectNameInputId = projectId !== undefined ? `project-name-${projectId}` : 'project-name'; + + return ( + <> + +
+ handleProjectNameChange(e.currentTarget.value, true)} + type="text" + value={name} + autoFocus + isInvalid={projectNameIsInvalid} + isValid={projectNameIsValid} + required + /> + {projectNameIsInvalid && } + {projectNameIsValid && } +
+ {nameError !== undefined && ( + + {translate('onboarding.create_project.display_name.description')} + + )} +
+ + +
+ handleProjectKeyChange(e.currentTarget.value, true)} + type="text" + value={key} + isInvalid={projectKeyIsInvalid} + isValid={projectKeyIsValid} + required + /> + {projectKeyIsInvalid && } + {projectKeyIsValid && } +
+ {keyError !== undefined && ( + + {keyError === ProjectKeyErrors.DuplicateKey || + (keyError === ProjectKeyErrors.MonorepoDuplicateKey && ( + + ))} + {!isEmpty(key) && keyError === ProjectKeyErrors.WrongFormat && ( + + )} +

{translate('onboarding.create_project.project_key.description')}

+
+ )} +
+ + ); +} + +export function ProjectValidationCard({ + initialKey, + initialName, + monorepoSetupProjectKeys, + onChange, + onRemove, + projectId, + ...cardProps +}: Readonly< + Props & Omit, 'onChange' | 'children'> +>) { + return ( + + + } + onClick={onRemove} + type="button" + > + {translate('onboarding.create_project.monorepo.remove_project')} + + + ); +} diff --git a/server/sonar-web/src/main/js/apps/create/project/constants.ts b/server/sonar-web/src/main/js/apps/create/project/constants.ts index bfa875ca219..b6ff4ea675a 100644 --- a/server/sonar-web/src/main/js/apps/create/project/constants.ts +++ b/server/sonar-web/src/main/js/apps/create/project/constants.ts @@ -17,6 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + export const PROJECT_NAME_MAX_LEN = 255; export const DEFAULT_BBS_PAGE_SIZE = 25; diff --git a/server/sonar-web/src/main/js/apps/create/project/manual/ManualProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/manual/ManualProjectCreate.tsx index 0df7e73c469..6804fc11ddf 100644 --- a/server/sonar-web/src/main/js/apps/create/project/manual/ManualProjectCreate.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/manual/ManualProjectCreate.tsx @@ -30,21 +30,17 @@ import { InteractiveIcon, Link, Note, - TextError, Title, } from 'design-system'; -import { debounce, isEmpty } from 'lodash'; +import { isEmpty } from 'lodash'; import * as React from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; -import { doesComponentExists } from '../../../../api/components'; import { getValue } from '../../../../api/settings'; import { useDocUrl } from '../../../../helpers/docs'; import { translate } from '../../../../helpers/l10n'; -import { PROJECT_KEY_INVALID_CHARACTERS, validateProjectKey } from '../../../../helpers/projects'; -import { ProjectKeyValidationResult } from '../../../../types/component'; import { GlobalSettingKeys } from '../../../../types/settings'; import { ImportProjectParam } from '../CreateProjectPage'; -import { PROJECT_NAME_MAX_LEN } from '../constants'; +import ProjectValidation, { ProjectData } from '../components/ProjectValidation'; import { CreateProjectModes } from '../types'; interface Props { @@ -53,94 +49,36 @@ interface Props { onClose: () => void; } -interface State { - projectName: string; - projectNameError?: boolean; - projectNameTouched: boolean; - projectKey: string; - projectKeyError?: 'DUPLICATE_KEY' | 'WRONG_FORMAT'; - projectKeyTouched: boolean; - validatingProjectKey: boolean; +interface MainBranchState { mainBranchName: string; mainBranchNameError?: boolean; mainBranchNameTouched: boolean; } -const DEBOUNCE_DELAY = 250; - -type ValidState = State & Required>; +type ValidState = ProjectData & Required>; export default function ManualProjectCreate(props: Readonly) { - const [project, setProject] = React.useState({ - projectKey: '', - projectName: '', - projectKeyTouched: false, - projectNameTouched: false, + const [mainBranch, setMainBranch] = React.useState({ mainBranchName: 'main', mainBranchNameTouched: false, - validatingProjectKey: false, }); + const [project, setProject] = React.useState({ + hasError: false, + key: '', + name: '', + touched: false, + }); + const intl = useIntl(); const docUrl = useDocUrl(); - const checkFreeKey = React.useCallback( - debounce((key: string) => { - setProject((prevProject) => ({ ...prevProject, validatingProjectKey: true })); - - doesComponentExists({ component: key }) - .then((alreadyExist) => { - setProject((prevProject) => { - if (key === prevProject.projectKey) { - return { - ...prevProject, - projectKeyError: alreadyExist ? 'DUPLICATE_KEY' : undefined, - validatingProjectKey: false, - }; - } - return prevProject; - }); - }) - .catch(() => { - setProject((prevProject) => { - if (key === prevProject.projectKey) { - return { - ...prevProject, - projectKeyError: undefined, - validatingProjectKey: false, - }; - } - return prevProject; - }); - }); - }, DEBOUNCE_DELAY), - [], - ); - - const handleProjectKeyChange = React.useCallback( - (projectKey: string, fromUI = false) => { - const projectKeyError = validateKey(projectKey); - - setProject((prevProject) => ({ - ...prevProject, - projectKey, - projectKeyError, - projectKeyTouched: fromUI, - })); - - if (projectKeyError === undefined) { - checkFreeKey(projectKey); - } - }, - [checkFreeKey], - ); - React.useEffect(() => { async function fetchMainBranchName() { const { value: mainBranchName } = await getValue({ key: GlobalSettingKeys.MainBranchName }); if (mainBranchName !== undefined) { - setProject((prevProject) => ({ - ...prevProject, + setMainBranch((prevBranchName) => ({ + ...prevBranchName, mainBranchName, })); } @@ -149,37 +87,25 @@ export default function ManualProjectCreate(props: Readonly) { fetchMainBranchName(); }, []); - React.useEffect(() => { - if (!project.projectKeyTouched) { - const sanitizedProjectKey = project.projectName - .trim() - .replace(PROJECT_KEY_INVALID_CHARACTERS, '-'); - - handleProjectKeyChange(sanitizedProjectKey); - } - }, [project.projectName, project.projectKeyTouched, handleProjectKeyChange]); - - const canSubmit = (state: State): state is ValidState => { - const { projectKey, projectKeyError, projectName, projectNameError, mainBranchName } = state; - return Boolean( - projectKeyError === undefined && - projectNameError === undefined && - !isEmpty(projectKey) && - !isEmpty(projectName) && - !isEmpty(mainBranchName), - ); + const canSubmit = ( + mainBranch: MainBranchState, + projectData: ProjectData, + ): projectData is ValidState => { + const { mainBranchName } = mainBranch; + const { key, name, hasError } = projectData; + return Boolean(!hasError && !isEmpty(key) && !isEmpty(name) && !isEmpty(mainBranchName)); }; const handleFormSubmit = (event: React.FormEvent) => { event.preventDefault(); - const { projectKey, projectName, mainBranchName } = project; - if (canSubmit(project)) { + if (canSubmit(mainBranch, project)) { props.onProjectSetupDone({ creationMode: CreateProjectModes.Manual, + monorepo: false, projects: [ { - project: projectKey, - name: (projectName || projectKey).trim(), + project: project.key, + name: (project.name ?? project.key).trim(), mainBranch: mainBranchName, }, ], @@ -187,39 +113,14 @@ export default function ManualProjectCreate(props: Readonly) { } }; - const handleProjectNameChange = (projectName: string, fromUI = false) => { - setProject({ - ...project, - projectName, - projectNameError: validateName(projectName), - projectNameTouched: fromUI, - }); - }; - const handleBranchNameChange = (mainBranchName: string, fromUI = false) => { - setProject({ - ...project, + setMainBranch({ mainBranchName, mainBranchNameError: validateMainBranchName(mainBranchName), mainBranchNameTouched: fromUI, }); }; - const validateKey = (projectKey: string) => { - const result = validateProjectKey(projectKey); - if (result !== ProjectKeyValidationResult.Valid) { - return 'WRONG_FORMAT'; - } - return undefined; - }; - - const validateName = (projectName: string) => { - if (isEmpty(projectName)) { - return true; - } - return undefined; - }; - const validateMainBranchName = (mainBranchName: string) => { if (isEmpty(mainBranchName)) { return true; @@ -227,25 +128,9 @@ export default function ManualProjectCreate(props: Readonly) { return undefined; }; - const { - projectKey, - projectKeyError, - projectKeyTouched, - projectName, - projectNameError, - projectNameTouched, - validatingProjectKey, - mainBranchName, - mainBranchNameError, - mainBranchNameTouched, - } = project; + const { mainBranchName, mainBranchNameError, mainBranchNameTouched } = mainBranch; const { branchesEnabled } = props; - const touched = Boolean(projectKeyTouched || projectNameTouched); - const projectNameIsInvalid = projectNameTouched && projectNameError !== undefined; - const projectNameIsValid = projectNameTouched && projectNameError === undefined; - const projectKeyIsInvalid = touched && projectKeyError !== undefined; - const projectKeyIsValid = touched && !validatingProjectKey && projectKeyError === undefined; const mainBranchNameIsValid = mainBranchNameTouched && mainBranchNameError === undefined; const mainBranchNameIsInvalid = mainBranchNameTouched && mainBranchNameError !== undefined; @@ -279,71 +164,7 @@ export default function ManualProjectCreate(props: Readonly) { className="sw-flex-col sw-body-sm" onSubmit={handleFormSubmit} > - -
- handleProjectNameChange(e.currentTarget.value, true)} - type="text" - value={projectName} - autoFocus - isInvalid={projectNameIsInvalid} - isValid={projectNameIsValid} - required - /> - {projectNameIsInvalid && } - {projectNameIsValid && } -
- - {translate('onboarding.create_project.display_name.description')} - -
- - -
- handleProjectKeyChange(e.currentTarget.value, true)} - type="text" - value={projectKey} - isInvalid={projectKeyIsInvalid} - isValid={projectKeyIsValid} - required - /> - {projectKeyIsInvalid && } - {projectKeyIsValid && } -
- - {projectKeyError === 'DUPLICATE_KEY' && ( - - )} - {!isEmpty(projectKey) && projectKeyError === 'WRONG_FORMAT' && ( - - )} -

{translate('onboarding.create_project.project_key.description')}

-
-
+ ) { {intl.formatMessage({ id: 'cancel' })} - + {translate('next')} diff --git a/server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectCreate.tsx new file mode 100644 index 00000000000..25a7399a0b7 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectCreate.tsx @@ -0,0 +1,362 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { Link, Spinner } from '@sonarsource/echoes-react'; +import { + AddNewIcon, + BlueGreySeparator, + ButtonPrimary, + ButtonSecondary, + DarkLabel, + FlagMessage, + InputSelect, + SubTitle, + Title, +} from 'design-system'; +import React, { useEffect, useRef } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { useLocation, useRouter } from '../../../../components/hoc/withRouter'; +import { translate } from '../../../../helpers/l10n'; +import { LabelValueSelectOption } from '../../../../helpers/search'; +import { AlmKeys } from '../../../../types/alm-settings'; +import { DopSetting } from '../../../../types/dop-translation'; +import { ImportProjectParam } from '../CreateProjectPage'; +import DopSettingDropdown from '../components/DopSettingDropdown'; +import { ProjectData, ProjectValidationCard } from '../components/ProjectValidation'; +import { CreateProjectModes } from '../types'; +import { getSanitizedProjectKey } from '../utils'; +import { MonorepoProjectHeader } from './MonorepoProjectHeader'; + +interface MonorepoProjectCreateProps { + canAdmin: boolean; + dopSettings: DopSetting[]; + error: boolean; + loadingBindings: boolean; + loadingOrganizations: boolean; + loadingRepositories: boolean; + onProjectSetupDone: (importProjects: ImportProjectParam) => void; + onSearchRepositories: (query: string) => void; + onSelectDopSetting: (instance: DopSetting) => void; + onSelectOrganization: (organizationKey: string) => void; + onSelectRepository: (repositoryIdentifier: string) => void; + organizationOptions?: LabelValueSelectOption[]; + repositoryOptions?: LabelValueSelectOption[]; + repositorySearchQuery: string; + selectedDopSetting?: DopSetting; + selectedOrganization?: LabelValueSelectOption; + selectedRepository?: LabelValueSelectOption; +} + +type ProjectItem = Required>; + +export default function MonorepoProjectCreate(props: Readonly) { + const { + dopSettings, + canAdmin, + error, + loadingBindings, + loadingOrganizations, + loadingRepositories, + onProjectSetupDone, + onSearchRepositories, + onSelectDopSetting, + onSelectOrganization, + onSelectRepository, + organizationOptions, + repositoryOptions, + repositorySearchQuery, + selectedDopSetting, + selectedOrganization, + selectedRepository, + } = props; + + const projectCounter = useRef(0); + + const [projects, setProjects] = React.useState([]); + + const location = useLocation(); + const { push } = useRouter(); + const { formatMessage } = useIntl(); + + const projectKeys = React.useMemo(() => projects.map(({ key }) => key), [projects]); + + const almKey = location.query.mode as AlmKeys; + + const isSetupInvalid = + selectedDopSetting === undefined || + selectedOrganization === undefined || + selectedRepository === undefined || + projects.length === 0 || + projects.some(({ hasError, key, name }) => hasError || key === '' || name === ''); + + const addProject = () => { + if (selectedOrganization === undefined || selectedRepository === undefined) { + return; + } + + const id = projectCounter.current; + projectCounter.current += 1; + + const projectKeySuffix = id === 0 ? '' : `-${id}`; + const projectKey = getSanitizedProjectKey( + `${selectedOrganization.label}_${selectedRepository.label}_add-your-reference${projectKeySuffix}`, + ); + + const newProjects = [ + ...projects, + { + hasError: false, + id, + key: projectKey, + name: projectKey, + touched: false, + }, + ]; + + setProjects(newProjects); + }; + + const onProjectChange = (project: ProjectItem) => { + const newProjects = projects.filter(({ id }) => id !== project.id); + newProjects.push({ + ...project, + }); + newProjects.sort((a, b) => a.id - b.id); + + setProjects(newProjects); + }; + + const onProjectRemove = (id: number) => { + const newProjects = projects.filter(({ id: projectId }) => projectId !== id); + + setProjects(newProjects); + }; + + const cancelMonorepoSetup = () => { + push({ + pathname: location.pathname, + query: { mode: AlmKeys.GitHub }, + }); + }; + + const submitProjects = () => { + if (isSetupInvalid) { + return; + } + + const monorepoSetup: ImportProjectParam = { + creationMode: almKey as unknown as CreateProjectModes, + devOpsPlatformSettingId: selectedDopSetting.id, + monorepo: true, + projects: projects.map(({ key: projectKey, name: projectName }) => ({ + projectKey, + projectName, + })), + repositoryIdentifier: selectedRepository.value, + }; + + onProjectSetupDone(monorepoSetup); + }; + + useEffect(() => { + if (selectedRepository !== undefined && projects.length === 0) { + addProject(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedRepository]); + + if (loadingBindings) { + return ; + } + + return ( +
+ + + + +
+ + <FormattedMessage + id={`onboarding.create_project.monorepo.choose_organization_and_repository.${almKey}`} + /> + + + + + {error && selectedDopSetting && !loadingOrganizations && ( + + + {canAdmin ? ( + + {translate('onboarding.create_project.github.warning.message_admin.link')} + + ), + }} + /> + ) : ( + translate('onboarding.create_project.github.warning.message') + )} + + + )} + +
+ + {!error && ( + <> + + + + {(organizationOptions?.length ?? 0) > 0 ? ( + { + onSelectOrganization(value); + }} + placeholder={formatMessage({ + id: `onboarding.create_project.monorepo.choose_organization.${almKey}.placeholder`, + })} + value={selectedOrganization} + /> + ) : ( + !loadingOrganizations && ( + + + {canAdmin ? ( + + {translate( + 'onboarding.create_project.github.warning.message_admin.link', + )} + + ), + }} + /> + ) : ( + translate('onboarding.create_project.github.no_orgs') + )} + + + ) + )} + + )} + +
+ +
+ {selectedOrganization && ( + + + + )} + {selectedOrganization && ( + formatMessage({ id: 'no_results' })} + onChange={({ value }: LabelValueSelectOption) => { + onSelectRepository(value); + }} + onInputChange={onSearchRepositories} + options={repositoryOptions} + placeholder={formatMessage({ + id: `onboarding.create_project.monorepo.choose_repository.${almKey}.placeholder`, + })} + size="full" + value={selectedRepository} + /> + )} +
+
+ + {selectedRepository !== undefined && ( + <> + + +
+ + + +
+ {projects.map(({ id, key, name }) => ( + { + onProjectRemove(id); + }} + projectId={id} + /> + ))} +
+ +
+ + + + +
+
+ + )} + +
+ + + + + + +
+
+ ); +} diff --git a/server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectHeader.tsx b/server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectHeader.tsx new file mode 100644 index 00000000000..d4ba81dc8bb --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectHeader.tsx @@ -0,0 +1,54 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { LinkStandalone } from '@sonarsource/echoes-react'; +import { LightPrimary, Title } from 'design-system/lib'; +import React from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { useLocation } from '../../../../components/hoc/withRouter'; +import { useDocUrl } from '../../../../helpers/docs'; + +export function MonorepoProjectHeader() { + const { formatMessage } = useIntl(); + const { query } = useLocation(); + const almKey = query.mode as string; + + return ( + <> + + <FormattedMessage + id="onboarding.create_project.monorepo.title" + values={{ + almName: formatMessage({ id: `alm.${almKey}` }), + }} + /> + +
+ + + +
+
+ + + +
+ + ); +} diff --git a/server/sonar-web/src/main/js/apps/create/project/types.ts b/server/sonar-web/src/main/js/apps/create/project/types.ts index a4352b54e4a..cbda3f656ef 100644 --- a/server/sonar-web/src/main/js/apps/create/project/types.ts +++ b/server/sonar-web/src/main/js/apps/create/project/types.ts @@ -24,5 +24,4 @@ export enum CreateProjectModes { BitbucketCloud = 'bitbucketcloud', GitHub = 'github', GitLab = 'gitlab', - Monorepo = 'monorepo', } diff --git a/server/sonar-web/src/main/js/apps/create/project/utils.ts b/server/sonar-web/src/main/js/apps/create/project/utils.ts index 4992a550c05..475c13b6dd4 100644 --- a/server/sonar-web/src/main/js/apps/create/project/utils.ts +++ b/server/sonar-web/src/main/js/apps/create/project/utils.ts @@ -17,6 +17,13 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + +import { PROJECT_KEY_INVALID_CHARACTERS } from '../../../helpers/projects'; + export function tokenExistedBefore(error?: string) { return error?.includes('is missing'); } + +export function getSanitizedProjectKey(projectKey: string) { + return projectKey.trim().replace(PROJECT_KEY_INVALID_CHARACTERS, '-'); +} diff --git a/server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/AlmSpecificForm.tsx b/server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/AlmSpecificForm.tsx index 1d4a0e36426..0e3fa7f9235 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/AlmSpecificForm.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/AlmSpecificForm.tsx @@ -24,7 +24,6 @@ import withAvailableFeatures, { WithAvailableFeaturesProps, } from '../../../../app/components/available-features/withAvailableFeatures'; import DocumentationLink from '../../../../components/common/DocumentationLink'; -import { ALM_DOCUMENTATION_PATHS } from '../../../../helpers/constants'; import { translate } from '../../../../helpers/l10n'; import { convertGithubApiUrlToLink, stripTrailingSlash } from '../../../../helpers/urls'; import { @@ -294,7 +293,7 @@ export function AlmSpecificForm(props: AlmSpecificFormProps) { help: true, helpParams: { doc_link: ( - + {translate('learn_more')} ), diff --git a/server/sonar-web/src/main/js/components/tutorials/TutorialSelection.tsx b/server/sonar-web/src/main/js/components/tutorials/TutorialSelection.tsx index b743484d85b..920de7f5f4a 100644 --- a/server/sonar-web/src/main/js/components/tutorials/TutorialSelection.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/TutorialSelection.tsx @@ -33,14 +33,14 @@ import { Location, withRouter } from '../hoc/withRouter'; import TutorialSelectionRenderer from './TutorialSelectionRenderer'; import { TutorialModes } from './types'; -interface Props { +export interface TutorialSelectionProps { component: Component; currentUser: LoggedInUser; willRefreshAutomatically?: boolean; location: Location; } -export function TutorialSelection(props: Props) { +export function TutorialSelection(props: Readonly) { const { component, currentUser, location, willRefreshAutomatically } = props; const [currentUserCanScanProject, setCurrentUserCanScanProject] = React.useState(false); const [baseUrl, setBaseUrl] = React.useState(getHostUrl()); diff --git a/server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx b/server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx index 9a5328edeaa..842584e7d61 100644 --- a/server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx @@ -258,6 +258,7 @@ export default function TutorialSelectionRenderer(props: TutorialSelectionRender baseUrl={baseUrl} component={component} currentUser={currentUser} + monorepo={projectBinding?.monorepo} mainBranchName={mainBranchName} willRefreshAutomatically={willRefreshAutomatically} /> diff --git a/server/sonar-web/src/main/js/components/tutorials/__tests__/TutorialSelection-it.tsx b/server/sonar-web/src/main/js/components/tutorials/__tests__/TutorialSelection-it.tsx index a2db9276d06..285dc145251 100644 --- a/server/sonar-web/src/main/js/components/tutorials/__tests__/TutorialSelection-it.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/__tests__/TutorialSelection-it.tsx @@ -29,12 +29,11 @@ import { mockComponent } from '../../../helpers/mocks/component'; import { mockLoggedInUser } from '../../../helpers/testMocks'; import { renderApp } from '../../../helpers/testReactTestingUtils'; import { byRole, byText } from '../../../helpers/testSelector'; -import { ComponentPropsType } from '../../../helpers/testUtils'; import { AlmKeys } from '../../../types/alm-settings'; import { Feature } from '../../../types/features'; import { Permissions } from '../../../types/permissions'; import { SettingsKey } from '../../../types/settings'; -import TutorialSelection from '../TutorialSelection'; +import TutorialSelection, { TutorialSelectionProps } from '../TutorialSelection'; import { TutorialModes } from '../types'; jest.mock('../../../api/branches'); @@ -71,8 +70,14 @@ beforeEach(() => { const ui = { loading: byText('loading'), noScanRights: byText('onboarding.tutorial.no_scan_rights'), + monoRepoSecretInfo: byText('onboarding.tutorial.with.github_action.create_secret.monorepo_info'), + monoRepoYamlDocLink: byRole('link', { + name: 'onboarding.tutorial.with.github_action.monorepo.see_yaml_instructions', + }), chooseTutorialLink: (mode: TutorialModes) => byRole('link', { name: `onboarding.tutorial.choose_method.${mode}` }), + chooseBootstrapper: (bootstrapper: string) => + byRole('radio', { name: `onboarding.build.${bootstrapper}` }), }; it.each([ @@ -100,6 +105,54 @@ it.each([ expect(screen.getByText(breadcrumbs)).toBeInTheDocument(); }); +it('should properly detect and render GitHub monorepo-specific instructions for GitHub Actions', async () => { + almMock.handleSetProjectBinding(AlmKeys.GitHub, { + project: 'foo', + almSetting: 'foo', + repository: 'repo', + monorepo: true, + }); + const user = userEvent.setup(); + renderTutorialSelection({}); + await waitOnDataLoaded(); + + await user.click(ui.chooseTutorialLink(TutorialModes.GitHubActions).get()); + + expect(ui.monoRepoSecretInfo.get()).toBeInTheDocument(); + + expect(ui.monoRepoYamlDocLink.query()).not.toBeInTheDocument(); + await user.click(ui.chooseBootstrapper('maven').get()); + expect(ui.monoRepoYamlDocLink.get()).toBeInTheDocument(); + + await user.click(ui.chooseBootstrapper('gradle').get()); + expect(ui.monoRepoYamlDocLink.get()).toBeInTheDocument(); + + await user.click(ui.chooseBootstrapper('dotnet').get()); + expect(ui.monoRepoYamlDocLink.get()).toBeInTheDocument(); + + await user.click(ui.chooseBootstrapper('other').get()); + expect(ui.monoRepoYamlDocLink.get()).toBeInTheDocument(); +}); + +it('should properly render GitHub project tutorials for GitHub Actions', async () => { + almMock.handleSetProjectBinding(AlmKeys.GitHub, { + project: 'foo', + almSetting: 'foo', + repository: 'repo', + monorepo: false, + }); + const user = userEvent.setup(); + renderTutorialSelection({}); + await waitOnDataLoaded(); + + await user.click(ui.chooseTutorialLink(TutorialModes.GitHubActions).get()); + + expect(ui.monoRepoSecretInfo.query()).not.toBeInTheDocument(); + + await user.click(ui.chooseBootstrapper('maven').get()); + expect(ui.monoRepoYamlDocLink.query()).not.toBeInTheDocument(); +}); + it.each([ [ AlmKeys.GitHub, @@ -189,7 +242,7 @@ async function startLocalTutorial(user: UserEvent) { } function renderTutorialSelection( - props: Partial> = {}, + props: Partial = {}, navigateTo: string = 'tutorials?id=bar', ) { return renderApp( diff --git a/server/sonar-web/src/main/js/components/tutorials/components/DefaultProjectKey.tsx b/server/sonar-web/src/main/js/components/tutorials/components/DefaultProjectKey.tsx index 66980f58fa5..c059bc3de82 100644 --- a/server/sonar-web/src/main/js/components/tutorials/components/DefaultProjectKey.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/components/DefaultProjectKey.tsx @@ -24,17 +24,19 @@ import SentenceWithFilename from './SentenceWithFilename'; export interface DefaultProjectKeyProps { component: Component; + monorepo?: boolean; } const sonarProjectSnippet = (key: string) => `sonar.projectKey=${key}`; export default function DefaultProjectKey(props: DefaultProjectKeyProps) { - const { component } = props; + const { component, monorepo } = props; + return ( diff --git a/server/sonar-web/src/main/js/components/tutorials/github-action/AnalysisCommand.tsx b/server/sonar-web/src/main/js/components/tutorials/github-action/AnalysisCommand.tsx index 341eaafaac9..63b119c5516 100644 --- a/server/sonar-web/src/main/js/components/tutorials/github-action/AnalysisCommand.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/github-action/AnalysisCommand.tsx @@ -34,10 +34,11 @@ export interface AnalysisCommandProps extends WithAvailableFeaturesProps { buildTool: BuildTools; mainBranchName: string; component: Component; + monorepo?: boolean; } -export function AnalysisCommand(props: AnalysisCommandProps) { - const { buildTool, component, mainBranchName } = props; +export function AnalysisCommand(props: Readonly) { + const { buildTool, component, mainBranchName, monorepo } = props; const branchSupportEnabled = props.hasFeature(Feature.BranchSupport); switch (buildTool) { @@ -46,6 +47,7 @@ export function AnalysisCommand(props: AnalysisCommandProps) { ); @@ -54,6 +56,7 @@ export function AnalysisCommand(props: AnalysisCommandProps) { ); @@ -62,6 +65,7 @@ export function AnalysisCommand(props: AnalysisCommandProps) { ); @@ -70,6 +74,7 @@ export function AnalysisCommand(props: AnalysisCommandProps) { ); @@ -78,9 +83,12 @@ export function AnalysisCommand(props: AnalysisCommandProps) { ); + default: + return undefined; } } diff --git a/server/sonar-web/src/main/js/components/tutorials/github-action/GitHubActionTutorial.tsx b/server/sonar-web/src/main/js/components/tutorials/github-action/GitHubActionTutorial.tsx index 73f6acffbcd..73ae10cb840 100644 --- a/server/sonar-web/src/main/js/components/tutorials/github-action/GitHubActionTutorial.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/github-action/GitHubActionTutorial.tsx @@ -33,27 +33,36 @@ export interface GitHubActionTutorialProps { baseUrl: string; component: Component; currentUser: LoggedInUser; + monorepo?: boolean; mainBranchName: string; willRefreshAutomatically?: boolean; } export default function GitHubActionTutorial(props: GitHubActionTutorialProps) { const [done, setDone] = React.useState(false); - const { almBinding, baseUrl, currentUser, component, mainBranchName, willRefreshAutomatically } = - props; + const { + almBinding, + baseUrl, + currentUser, + component, + monorepo, + mainBranchName, + willRefreshAutomatically, + } = props; + + const secretStepTitle = `onboarding.tutorial.with.github_action.create_secret.title${monorepo ? '.monorepo' : ''}`; + return ( <> {translate('onboarding.tutorial.with.github_ci.title')} - - + @@ -63,6 +72,7 @@ export default function GitHubActionTutorial(props: GitHubActionTutorialProps) { buildTool={buildTool} mainBranchName={mainBranchName} component={component} + monorepo={monorepo} /> )} diff --git a/server/sonar-web/src/main/js/components/tutorials/github-action/SecretStep.tsx b/server/sonar-web/src/main/js/components/tutorials/github-action/SecretStep.tsx index 8c1850b08c8..3af2806b6fc 100644 --- a/server/sonar-web/src/main/js/components/tutorials/github-action/SecretStep.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/github-action/SecretStep.tsx @@ -20,6 +20,7 @@ import { BasicSeparator, ClipboardIconButton, + FlagMessage, NumberedList, NumberedListItem, StandoutLink, @@ -41,10 +42,11 @@ export interface SecretStepProps { baseUrl: string; component: Component; currentUser: LoggedInUser; + monorepo?: boolean; } export default function SecretStep(props: SecretStepProps) { - const { almBinding, baseUrl, component, currentUser } = props; + const { almBinding, baseUrl, component, currentUser, monorepo } = props; const { data: projectBinding } = useProjectBindingQuery(component.key); return ( @@ -132,6 +134,11 @@ export default function SecretStep(props: SecretStepProps) { /> + {monorepo && ( + + {translate('onboarding.tutorial.with.github_action.create_secret.monorepo_info')} + + )} ); } diff --git a/server/sonar-web/src/main/js/components/tutorials/github-action/commands/CFamily.tsx b/server/sonar-web/src/main/js/components/tutorials/github-action/commands/CFamily.tsx index 3121724aade..cfe75e6fe70 100644 --- a/server/sonar-web/src/main/js/components/tutorials/github-action/commands/CFamily.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/github-action/commands/CFamily.tsx @@ -28,10 +28,12 @@ import GithubCFamilyExampleRepositories from '../../components/GithubCFamilyExam import RenderOptions from '../../components/RenderOptions'; import { OSs, TutorialModes } from '../../types'; import { generateGitHubActionsYaml } from '../utils'; +import MonorepoDocLinkFallback from './MonorepoDocLinkFallback'; export interface CFamilyProps { branchesEnabled?: boolean; mainBranchName: string; + monorepo?: boolean; component: Component; } @@ -84,7 +86,7 @@ const STEPS = { }; export default function CFamily(props: CFamilyProps) { - const { component, branchesEnabled, mainBranchName } = props; + const { component, branchesEnabled, mainBranchName, monorepo } = props; const [os, setOs] = React.useState(OSs.Linux); const runsOn = { @@ -94,7 +96,7 @@ export default function CFamily(props: CFamilyProps) { }; return ( <> - + {translate('onboarding.build.other.os')} )} - {os && ( - <> - + ) : ( + <> + - - - )} + )} + /> + + + ))} ); } diff --git a/server/sonar-web/src/main/js/components/tutorials/github-action/commands/DotNet.tsx b/server/sonar-web/src/main/js/components/tutorials/github-action/commands/DotNet.tsx index 2bd5b8a90c8..98f02aac2a4 100644 --- a/server/sonar-web/src/main/js/components/tutorials/github-action/commands/DotNet.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/github-action/commands/DotNet.tsx @@ -22,10 +22,12 @@ import { Component } from '../../../../types/types'; import CreateYmlFile from '../../components/CreateYmlFile'; import { GITHUB_ACTIONS_RUNS_ON_WINDOWS } from '../constants'; import { generateGitHubActionsYaml } from '../utils'; +import MonorepoDocLinkFallback from './MonorepoDocLinkFallback'; export interface DotNetProps { branchesEnabled?: boolean; mainBranchName: string; + monorepo?: boolean; component: Component; } @@ -63,7 +65,12 @@ function dotnetYamlSteps(projectKey: string) { } export default function DotNet(props: DotNetProps) { - const { component, branchesEnabled, mainBranchName } = props; + const { component, branchesEnabled, mainBranchName, monorepo } = props; + + if (monorepo) { + return ; + } + return ( - + + {monorepo ? ( + + ) : ( + + )} ); } diff --git a/server/sonar-web/src/main/js/components/tutorials/github-action/commands/JavaMaven.tsx b/server/sonar-web/src/main/js/components/tutorials/github-action/commands/JavaMaven.tsx index db2bf5c02b3..2b3f89d7fcd 100644 --- a/server/sonar-web/src/main/js/components/tutorials/github-action/commands/JavaMaven.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/github-action/commands/JavaMaven.tsx @@ -22,10 +22,12 @@ import { Component } from '../../../../types/types'; import CreateYmlFile from '../../components/CreateYmlFile'; import { GITHUB_ACTIONS_RUNS_ON_LINUX } from '../constants'; import { generateGitHubActionsYaml } from '../utils'; +import MonorepoDocLinkFallback from './MonorepoDocLinkFallback'; export interface JavaMavenProps { branchesEnabled?: boolean; mainBranchName: string; + monorepo?: boolean; component: Component; } @@ -55,7 +57,12 @@ function mavenYamlSteps(projectKey: string, projectName: string) { } export default function JavaMaven(props: JavaMavenProps) { - const { component, branchesEnabled, mainBranchName } = props; + const { component, branchesEnabled, mainBranchName, monorepo } = props; + + if (monorepo) { + return ; + } + return ( + + {translate('onboarding.tutorial.with.github_action.monorepo.see_yaml_instructions')} + {' '} + {translate('onboarding.tutorial.with.github_action.monorepo.pre_see_yaml_instructions')} + + ); +} diff --git a/server/sonar-web/src/main/js/components/tutorials/github-action/commands/Others.tsx b/server/sonar-web/src/main/js/components/tutorials/github-action/commands/Others.tsx index e34071be030..b4e5bbc07fb 100644 --- a/server/sonar-web/src/main/js/components/tutorials/github-action/commands/Others.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/github-action/commands/Others.tsx @@ -23,10 +23,12 @@ import CreateYmlFile from '../../components/CreateYmlFile'; import DefaultProjectKey from '../../components/DefaultProjectKey'; import { GITHUB_ACTIONS_RUNS_ON_LINUX } from '../constants'; import { generateGitHubActionsYaml } from '../utils'; +import MonorepoDocLinkFallback from './MonorepoDocLinkFallback'; export interface OthersProps { branchesEnabled?: boolean; mainBranchName: string; + monorepo?: boolean; component: Component; } @@ -55,19 +57,24 @@ function otherYamlSteps(branchesEnabled: boolean) { } export default function Others(props: OthersProps) { - const { component, branchesEnabled, mainBranchName } = props; + const { component, branchesEnabled, mainBranchName, monorepo } = props; return ( <> - - + + + {monorepo ? ( + + ) : ( + + )} ); } diff --git a/server/sonar-web/src/main/js/queries/import-projects.ts b/server/sonar-web/src/main/js/queries/import-projects.ts index fae4bd0d7ac..996fdf94b24 100644 --- a/server/sonar-web/src/main/js/queries/import-projects.ts +++ b/server/sonar-web/src/main/js/queries/import-projects.ts @@ -25,7 +25,7 @@ import { importGithubRepository, importGitlabProject, } from '../api/alm-integrations'; -import { createImportedProjects } from '../api/dop-translation'; +import { createBoundProject } from '../api/dop-translation'; import { createProject } from '../api/project-management'; import { ImportProjectParam } from '../apps/create/project/CreateProjectPage'; import { CreateProjectModes } from '../apps/create/project/types'; @@ -34,20 +34,22 @@ export type MutationArg { - if (data.creationMode === CreateProjectModes.GitHub) { - return importGithubRepository(data); - } else if (data.creationMode === CreateProjectModes.AzureDevOps) { - return importAzureRepository(data); - } else if (data.creationMode === CreateProjectModes.BitbucketCloud) { - return importBitbucketCloudRepository(data); - } else if (data.creationMode === CreateProjectModes.BitbucketServer) { - return importBitbucketServerProject(data); - } else if (data.creationMode === CreateProjectModes.GitLab) { - return importGitlabProject(data); - } else if (data.creationMode === CreateProjectModes.Monorepo) { - return createImportedProjects(data); + if (data.monorepo === true) { + return createBoundProject(data); + } + + switch (data.creationMode) { + case CreateProjectModes.GitHub: + return importGithubRepository(data); + case CreateProjectModes.AzureDevOps: + return importAzureRepository(data); + case CreateProjectModes.BitbucketCloud: + return importBitbucketCloudRepository(data); + case CreateProjectModes.BitbucketServer: + return importBitbucketServerProject(data); + case CreateProjectModes.GitLab: + return importGitlabProject(data); } return createProject(data); diff --git a/server/sonar-web/src/main/js/types/dop-translation.ts b/server/sonar-web/src/main/js/types/dop-translation.ts new file mode 100644 index 00000000000..916752b4823 --- /dev/null +++ b/server/sonar-web/src/main/js/types/dop-translation.ts @@ -0,0 +1,39 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { AlmKeys } from './alm-settings'; + +export interface DopSetting { + appId?: string; + id: string; + key: string; + type: AlmKeys; + url?: string; +} + +export interface BoundProject { + devOpsPlatformSettingId: string; + monorepo: boolean; + newCodeDefinitionType?: string; + newCodeDefinitionValue?: string; + projectKey: string; + projectName: string; + repositoryIdentifier: string; +} diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 10d1f1557d9..9e89edd1f4d 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -417,6 +417,7 @@ alm.bitbucketcloud.short=Bitbucket alm.bitbucketcloud.long=Bitbucket Cloud alm.github=GitHub alm.github.short=GitHub +alm.github.organization=organization alm.gitlab=GitLab alm.gitlab.short=GitLab alm.configuration.selector.label={0} configuration @@ -1671,9 +1672,9 @@ settings.pr_decoration.binding.check_configuration.contact_admin=Please contact settings.pr_decoration.binding.check_configuration.success=Configuration valid. settings.pr_decoration.binding.form.name=Configuration name settings.pr_decoration.binding.form.name.help=Each DevOps Platform instance must be configured globally first, and given a unique name. Pick the instance your project is hosted on. -settings.pr_decoration.binding.form.monorepo=Enable mono repository support -settings.pr_decoration.binding.form.monorepo.help=Enable this setting if your project is part of a mono repository. {doc_link} -settings.pr_decoration.binding.form.monorepo.warning=This setting must be enabled for all SonarQube projects that are part of a mono repository. +settings.pr_decoration.binding.form.monorepo=Enable monorepository support +settings.pr_decoration.binding.form.monorepo.help=Enable this setting if your project is part of a monorepository. {doc_link} +settings.pr_decoration.binding.form.monorepo.warning=This setting must be enabled for all SonarQube projects that are part of a monorepository. settings.pr_decoration.binding.form.azure.project=Project name settings.pr_decoration.binding.form.azure.project.help=The name of the Azure DevOps project containing your repository. You can find this name on your project's Overview page. settings.pr_decoration.binding.form.azure.repository=Repository name @@ -4407,16 +4408,21 @@ onboarding.create_project.bitbucketcloud.no_projects=No projects could be fetche onboarding.create_project.bitbucketcloud.link=See on Bitbucket onboarding.create_project.github.title=GitHub project onboarding onboarding.create_project.github.subtitle=Import repositories from one of your GitHub organizations. +onboarding.create_project.github.subtitle.with_monorepo=Import repositories from one of your GitHub organizations or {monorepoSetupLink}. +onboarding.create_project.github.subtitle.link=set up a monorepo onboarding.create_project.github.choose_organization=Choose an organization +onboarding.create_project.github.choose_repository=Choose the repository onboarding.create_project.github.warning.message=Could not connect to GitHub. Please contact an administrator to configure GitHub integration. onboarding.create_project.github.warning.message_admin=Could not connect to GitHub. Please make sure the GitHub instance is correctly configured in the {link} to create a new project from a repository. onboarding.create_project.github.warning.message_admin.link=DevOps Platform integration settings onboarding.create_project.github.no_orgs=We couldn't load any organizations with your key. Contact an administrator. onboarding.create_project.github.no_orgs_admin=We couldn't load any organizations. Make sure the GitHub App is installed in at least one organization and check the GitHub instance configuration in the {link}. +onboarding.create_project.github.no_projects=No projects could be fetched from GitHub. Contact your system administrator. onboarding.create_project.gitlab.title=Gitlab project onboarding onboarding.create_project.gitlab.subtitle=Import projects from one of your GitLab groups onboarding.create_project.gitlab.no_projects=No projects could be fetched from Gitlab. Contact your system administrator, or {link}. onboarding.create_project.gitlab.link=See on GitLab +onboarding.create_project.monorepo.no_projects=No projects could be fetch from {almKey}. Contact your system administrator. onboarding.create_project.bitbucket.title=Bitbucket Server project onboarding onboarding.create_project.bitbucket.subtitle=Import projects from one of your Bitbucket server workspaces onboarding.create_project.x_repositories_selected={count} {count, plural, one {repository} other {repositories}} selected @@ -4424,6 +4430,19 @@ onboarding.create_project.x_repository_created={count} {count, plural, one {repo onboarding.create_project.please_dont_leave=If you leave the page the import could fail. Are you sure you want to leave? onboarding.create_project.import_in_progress={count} of {total} projects imported. Please do not close this page until the import is complete. +onboarding.create_project.monorepo.title={almName} monorepo project onboarding +onboarding.create_project.monorepo.subtitle=Create multiple SonarQube projects corresponding to the same monorepo and bound to the same repository. +onboarding.create_project.monorepo.doc_link=Learn more and get help setting up your monorepo +onboarding.create_project.monorepo.choose_organization_and_repository.github=Choose the organization and the repository +onboarding.create_project.monorepo.choose_dop_setting.github=Choose the GitHub configuration +onboarding.create_project.monorepo.choose_organization.github=Choose the organization +onboarding.create_project.monorepo.choose_organization.github.placeholder=List of organizations +onboarding.create_project.monorepo.choose_repository.github=Choose the repository +onboarding.create_project.monorepo.choose_repository.github.placeholder=List of repositories +onboarding.create_project.monorepo.project_title=Create new projects +onboarding.create_project.monorepo.add_project=Add new project +onboarding.create_project.monorepo.remove_project=Remove project + onboarding.create_project.new_code_definition.title=Set up project for Clean as You Code onboarding.create_x_project.new_code_definition.title=Set up {count, plural, one {project} other {# projects}} for Clean as You Code onboarding.create_project.new_code_definition.title=Set up project for Clean as You Code @@ -4432,6 +4451,7 @@ onboarding.create_project.new_code_definition.description.link=Defining New Code onboarding.create_project.new_code_definition.create_x_projects=Create {count, plural, one {project} other {# projects}} onboarding.create_projects.new_code_definition.change_info=You can change this setting for each project individually at any time in the project administration settings. onboarding.create_project.success=Your {count, plural, one {project has} other {# projects have}} been created. +onboarding.create_project.monorepo.success=Your monorepo has been set up successfully. {count, plural, one {1 new project was} other {# new projects were}} created onboarding.create_project.success.admin=Project {project_link} has been successfully created. onboarding.create_project.failure=Import of {count, plural, one {# project} other {# projects}} failed. @@ -4550,6 +4570,7 @@ onboarding.tutorial.ci_outro.commit.why.no_branches=Each new push you make on yo onboarding.tutorial.ci_outro.refresh=This page will then refresh with your analysis results. onboarding.tutorial.ci_outro.refresh.why=If the page doesn't refresh after a while, please double-check the analysis configuration, and check your logs. onboarding.tutorial.other.project_key.sentence=Create a {file} file in your repository and paste the following code: +onboarding.tutorial.other.project_key.monorepo.sentence=Create a {file} file at the root of your project and paste the following code: onboarding.tutorial.cfamilly.compilation_database_info=If you have trouble using the build wrapper, you can try using a {link}. onboarding.tutorial.cfamilly.compilation_database_info.link=compilation database onboarding.tutorial.cfamilly.speed_caching=You can also speed up your analysis by enabling {link}. @@ -4598,6 +4619,10 @@ onboarding.tutorial.with.bitbucket_pipelines.variables.secured.sentence.secured= onboarding.tutorial.with.github_ci.title=Analyze your project with GitHub CI onboarding.tutorial.with.github_action.create_secret.title=Create GitHub Secrets +onboarding.tutorial.with.github_action.create_secret.title.monorepo=Create GitHub Secrets (once per monorepository) +onboarding.tutorial.with.github_action.create_secret.monorepo_info=If the secrets were created already for one of the projects in the mono repository, please skip this step +onboarding.tutorial.with.github_action.monorepo.pre_see_yaml_instructions=(once per monorepository) +onboarding.tutorial.with.github_action.monorepo.see_yaml_instructions=See the documentation to create the Workflow YAML file at the root of your repository onboarding.tutorial.with.github_action.secret.intro=In your GitHub repository, go to {settings_secret} and create two new secrets: onboarding.tutorial.with.github_action.secret.intro.link=Settings > Secrets onboarding.tutorial.with.github_action.secret.name.sentence=In the {name} field, enter