aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAmbroise C <ambroise.christea@sonarsource.com>2024-03-19 11:47:48 +0100
committersonartech <sonartech@sonarsource.com>2024-04-10 20:02:55 +0000
commite4d510ae08bffe482350633e423b455c006ca119 (patch)
tree6588ea997773482098f196b390b7dd3512d6b17a
parentd5c21bcc36de8f5a0fcd9ce44b02eac9e30f9cab (diff)
downloadsonarqube-e4d510ae08bffe482350633e423b455c006ca119.tar.gz
sonarqube-e4d510ae08bffe482350633e423b455c006ca119.zip
SONAR-21822 Add monorepo setup for GitHub
-rw-r--r--server/sonar-web/design-system/src/components/icons/AddNewIcon.tsx37
-rw-r--r--server/sonar-web/design-system/src/components/icons/index.ts1
-rw-r--r--server/sonar-web/src/main/js/api/dop-translation.ts22
-rw-r--r--server/sonar-web/src/main/js/api/mocks/ComponentsServiceMock.ts7
-rw-r--r--server/sonar-web/src/main/js/api/mocks/DopTranslationServiceMock.ts123
-rw-r--r--server/sonar-web/src/main/js/api/mocks/data/dop-translation.ts34
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreate.tsx1
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudProjectCreate.tsx1
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectCreate.tsx1
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx73
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreate.tsx502
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreateRenderer.tsx38
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/Github/utils.ts73
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreate.tsx1
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/__tests__/Azure-it.tsx8
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/__tests__/Bitbucket-it.tsx8
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloud-it.tsx8
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectPage-it.tsx12
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/__tests__/GitHub-it.tsx32
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubMonorepoProjectCreate-it.tsx187
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/__tests__/GitLab-it.tsx8
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/__tests__/Manual-it.tsx12
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/components/AlmSettingsInstanceDropdown.tsx8
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/components/DopSettingDropdown.tsx99
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/components/NewCodeDefinitionSelection.tsx11
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/components/ProjectValidation.tsx336
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/constants.ts1
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/manual/ManualProjectCreate.tsx241
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectCreate.tsx362
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectHeader.tsx54
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/types.ts1
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/utils.ts7
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/AlmSpecificForm.tsx3
-rw-r--r--server/sonar-web/src/main/js/components/tutorials/TutorialSelection.tsx4
-rw-r--r--server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx1
-rw-r--r--server/sonar-web/src/main/js/components/tutorials/__tests__/TutorialSelection-it.tsx59
-rw-r--r--server/sonar-web/src/main/js/components/tutorials/components/DefaultProjectKey.tsx6
-rw-r--r--server/sonar-web/src/main/js/components/tutorials/github-action/AnalysisCommand.tsx12
-rw-r--r--server/sonar-web/src/main/js/components/tutorials/github-action/GitHubActionTutorial.tsx22
-rw-r--r--server/sonar-web/src/main/js/components/tutorials/github-action/SecretStep.tsx9
-rw-r--r--server/sonar-web/src/main/js/components/tutorials/github-action/commands/CFamily.tsx39
-rw-r--r--server/sonar-web/src/main/js/components/tutorials/github-action/commands/DotNet.tsx9
-rw-r--r--server/sonar-web/src/main/js/components/tutorials/github-action/commands/Gradle.tsx27
-rw-r--r--server/sonar-web/src/main/js/components/tutorials/github-action/commands/JavaMaven.tsx9
-rw-r--r--server/sonar-web/src/main/js/components/tutorials/github-action/commands/MonorepoDocLinkFallback.tsx37
-rw-r--r--server/sonar-web/src/main/js/components/tutorials/github-action/commands/Others.tsx29
-rw-r--r--server/sonar-web/src/main/js/queries/import-projects.ts37
-rw-r--r--server/sonar-web/src/main/js/types/dop-translation.ts39
-rw-r--r--sonar-core/src/main/resources/org/sonar/l10n/core.properties31
49 files changed, 2046 insertions, 636 deletions
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<IconProps>) {
+ const theme = useTheme();
+
+ return (
+ <CustomIcon {...iconProps}>
+ <path
+ clipRule="evenodd"
+ d="M8 0c-.55228 0-1 .44771-1 1v6H1c-.55229 0-1 .44771-1 1 0 .55228.44771 1 1 1h6v6c0 .5523.44772 1 1 1 .55229 0 1-.4477 1-1V9h6c.5523 0 1-.44771 1-1 0-.55228-.4477-1-1-1H9V1c0-.55229-.44771-1-1-1Z"
+ fill={themeColor(fill)({ theme })}
+ fillRule="evenodd"
+ />
+ </CustomIcon>
+ );
+}
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<T>(): Promise<void>;
reply<T>(response: T): Promise<T>;
reply<T>(response?: T): Promise<T | void> {
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>): 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<Props, State
this.props.onProjectSetupDone({
creationMode: CreateProjectModes.AzureDevOps,
almSetting: selectedAlmInstance.key,
+ monorepo: false,
projects: [
{
projectName: selectedRepository.projectName,
diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudProjectCreate.tsx
index c35ba76a560..958473b2c7e 100644
--- a/server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudProjectCreate.tsx
+++ b/server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudProjectCreate.tsx
@@ -198,6 +198,7 @@ export default class BitbucketCloudProjectCreate extends React.PureComponent<Pro
this.props.onProjectSetupDone({
creationMode: CreateProjectModes.BitbucketCloud,
almSetting: selectedAlmInstance.key,
+ monorepo: false,
projects: [
{
repositorySlug,
diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectCreate.tsx
index ad64bf13476..d4670719f41 100644
--- a/server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectCreate.tsx
+++ b/server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectCreate.tsx
@@ -189,6 +189,7 @@ export default class BitbucketProjectCreate extends React.PureComponent<Props, S
this.props.onProjectSetupDone({
creationMode: CreateProjectModes.BitbucketServer,
almSetting: selectedAlmInstance.key,
+ monorepo: false,
projects: [
{
projectKey: selectedRepository.projectKey,
diff --git a/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx b/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx
index 33921b2e60d..7b5ad35ffa8 100644
--- a/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx
+++ b/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx
@@ -21,7 +21,7 @@ import classNames from 'classnames';
import { LargeCenteredLayout } from 'design-system';
import * as React from 'react';
import { Helmet } from 'react-helmet-async';
-import { getAlmSettings } from '../../../api/alm-settings';
+import { getDopSettings } from '../../../api/dop-translation';
import withAppStateContext from '../../../app/components/app-state/withAppStateContext';
import withAvailableFeatures, {
WithAvailableFeaturesProps,
@@ -31,6 +31,7 @@ import { Location, Router, withRouter } from '../../../components/hoc/withRouter
import { translate } from '../../../helpers/l10n';
import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
import { AppState } from '../../../types/appstate';
+import { DopSetting } from '../../../types/dop-translation';
import { Feature } from '../../../types/features';
import AlmBindingDefinitionForm from '../../settings/components/almIntegration/AlmBindingDefinitionForm';
import AzureProjectCreate from './Azure/AzureProjectCreate';
@@ -53,7 +54,7 @@ interface State {
azureSettings: AlmSettingsInstance[];
bitbucketSettings: AlmSettingsInstance[];
bitbucketCloudSettings: AlmSettingsInstance[];
- githubSettings: AlmSettingsInstance[];
+ githubSettings: DopSetting[];
gitlabSettings: AlmSettingsInstance[];
loading: boolean;
creatingAlmDefinition?: AlmKeys;
@@ -73,6 +74,7 @@ export type ImportProjectParam =
| {
creationMode: CreateProjectModes.AzureDevOps;
almSetting: string;
+ monorepo: false;
projects: {
projectName: string;
repositoryName: string;
@@ -81,6 +83,7 @@ export type ImportProjectParam =
| {
creationMode: CreateProjectModes.BitbucketCloud;
almSetting: string;
+ monorepo: false;
projects: {
repositorySlug: string;
}[];
@@ -88,6 +91,7 @@ export type ImportProjectParam =
| {
creationMode: CreateProjectModes.BitbucketServer;
almSetting: string;
+ monorepo: false;
projects: {
repositorySlug: string;
projectKey: string;
@@ -96,6 +100,7 @@ export type ImportProjectParam =
| {
creationMode: CreateProjectModes.GitHub;
almSetting: string;
+ monorepo: false;
projects: {
repositoryKey: string;
}[];
@@ -103,12 +108,14 @@ export type ImportProjectParam =
| {
creationMode: CreateProjectModes.GitLab;
almSetting: string;
+ monorepo: false;
projects: {
gitlabProjectId: string;
}[];
}
| {
creationMode: CreateProjectModes.Manual;
+ monorepo: false;
projects: {
project: string;
name: string;
@@ -116,9 +123,9 @@ export type ImportProjectParam =
}[];
}
| {
- creationMode: CreateProjectModes.Monorepo;
+ creationMode: CreateProjectModes;
devOpsPlatformSettingId: string;
- monorepo: boolean;
+ monorepo: true;
projects: {
projectKey: string;
projectName: string;
@@ -146,6 +153,14 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp
this.fetchAlmBindings();
}
+ componentDidUpdate(prevProps: CreateProjectPageProps) {
+ const { location } = this.props;
+
+ if (location.query.mono !== prevProps.location.query.mono) {
+ this.fetchAlmBindings();
+ }
+ }
+
componentWillUnmount() {
this.mounted = false;
}
@@ -153,6 +168,15 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp
cleanQueryParameters() {
const { location, router } = this.props;
+ const isMonorepoSupported = this.props.hasFeature(Feature.MonoRepositoryPullRequestDecoration);
+
+ if (location.query?.mono === 'true' && !isMonorepoSupported) {
+ // Timeout is required to force the refresh of the URL
+ setTimeout(() => {
+ 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<CreateProjectPageProp
fetchAlmBindings = () => {
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<CreateProjectPageProp
return (
<GitHubProjectCreate
canAdmin={!!canAdmin}
- loadingBindings={loading}
- location={location}
+ isLoadingBindings={loading}
onProjectSetupDone={this.handleProjectSetupDone}
- router={router}
- almInstances={githubSettings}
+ dopSettings={githubSettings}
/>
);
}
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<Props>) {
+ const { canAdmin, dopSettings, isLoadingBindings, onProjectSetupDone } = props;
+
+ const repositorySearchDebounceId = useRef<NodeJS.Timeout | undefined>();
+
+ const [isInError, setIsInError] = useState(false);
+ const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(true);
+ const [isLoadingRepositories, setIsLoadingRepositories] = useState(false);
+ const [organizations, setOrganizations] = useState<GithubOrganization[]>([]);
+ const [repositories, setRepositories] = useState<GithubRepository[]>([]);
+ const [repositoryPaging, setRepositoryPaging] = useState<Paging>({
+ pageSize: REPOSITORY_PAGE_SIZE,
+ total: 0,
+ pageIndex: 1,
+ });
+ const [searchQuery, setSearchQuery] = useState('');
+ const [selectedDopSetting, setSelectedDopSetting] = useState<DopSetting>();
+ const [selectedOrganization, setSelectedOrganization] = useState<GithubOrganization>();
+ const [selectedRepository, setSelectedRepository] = useState<GithubRepository>();
+
+ 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<Props, State> {
- 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 ? (
+ <MonorepoProjectCreate
+ dopSettings={dopSettings}
+ canAdmin={canAdmin}
+ error={isInError}
+ loadingBindings={isLoadingBindings}
+ loadingOrganizations={isLoadingOrganizations}
+ loadingRepositories={isLoadingRepositories}
+ onProjectSetupDone={onProjectSetupDone}
+ onSearchRepositories={setSearchQuery}
+ onSelectDopSetting={onSelectDopSetting}
+ onSelectOrganization={handleSelectOrganization}
+ onSelectRepository={handleSelectRepository}
+ organizationOptions={organizationOptions}
+ repositoryOptions={repositoryOptions}
+ repositorySearchQuery={searchQuery}
+ selectedDopSetting={selectedDopSetting}
+ selectedOrganization={selectedOrganization && transformToOption(selectedOrganization)}
+ selectedRepository={selectedRepository && transformToOption(selectedRepository)}
+ />
+ ) : (
+ <GitHubProjectCreateRenderer
+ almInstances={dopSettings.map(({ key, type, url }) => ({
+ 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 (
- <GitHubProjectCreateRenderer
- canAdmin={canAdmin}
- error={error}
- loadingBindings={loadingBindings}
- loadingOrganizations={loadingOrganizations}
- loadingRepositories={loadingRepositories}
- onImportRepository={this.handleImportRepository}
- onLoadMore={this.handleLoadMore}
- onSearch={this.handleSearch}
- onSelectOrganization={this.handleSelectOrganization}
- organizations={organizations}
- repositoryPaging={repositoryPaging}
- searchQuery={searchQuery}
- repositories={repositories}
- selectedOrganization={selectedOrganization}
- almInstances={almInstances}
- selectedAlmInstance={selectedAlmInstance}
- onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
- />
- );
- }
+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
<header className="sw-mb-10">
<Title className="sw-mb-4">{translate('onboarding.create_project.github.title')}</Title>
<LightPrimary className="sw-body-sm">
- {translate('onboarding.create_project.github.subtitle')}
+ {isMonorepoSupported ? (
+ <FormattedMessage
+ id="onboarding.create_project.github.subtitle.with_monorepo"
+ values={{
+ monorepoSetupLink: (
+ <Link
+ to={{
+ pathname: '/projects/create',
+ search: queryToSearch({
+ mode: CreateProjectModes.GitHub,
+ mono: true,
+ }),
+ }}
+ >
+ <FormattedMessage id="onboarding.create_project.github.subtitle.link" />
+ </Link>
+ ),
+ }}
+ />
+ ) : (
+ <FormattedMessage id="onboarding.create_project.github.subtitle" />
+ )}
</LightPrimary>
</header>
@@ -246,7 +274,7 @@ export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRe
<div className="sw-flex sw-gap-12">
<LargeColumn>
- <Spinner loading={loadingOrganizations && !error}>
+ <Spinner isLoading={loadingOrganizations && !error}>
{!error && (
<div className="sw-flex sw-flex-col">
<DarkLabel htmlFor="github-choose-organization" className="sw-mb-2">
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<Props, Stat
this.props.onProjectSetupDone({
creationMode: CreateProjectModes.GitLab,
almSetting: selectedAlmInstance.key,
+ monorepo: false,
projects: [{ gitlabProjectId }],
});
}
diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/Azure-it.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/Azure-it.tsx
index 26e66c1b210..63194307211 100644
--- a/server/sonar-web/src/main/js/apps/create/project/__tests__/Azure-it.tsx
+++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/Azure-it.tsx
@@ -24,7 +24,7 @@ import * as React from 'react';
import selectEvent from 'react-select-event';
import { searchAzureRepositories } 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 = {
@@ -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();
});
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', <CreateProjectPage />, {
+ 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<Props>) {
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 (
- <div className="sw-flex sw-flex-col">
+ <div className="sw-flex sw-flex-col sw-mb-9">
<DarkLabel htmlFor="alm-config-selector" className="sw-mb-2">
{translateWithParameters('alm.configuration.selector.label', translate(almKeyTranslation))}
</DarkLabel>
@@ -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"
/>
</div>
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<LabelValueSelectOption<DopSetting>, false>) {
+ return <components.Option {...props}>{customOptions(props.data.value)}</components.Option>;
+}
+
+function singleValueRenderer(props: SingleValueProps<LabelValueSelectOption<DopSetting>, false>) {
+ return (
+ <components.SingleValue {...props}>{customOptions(props.data.value)}</components.SingleValue>
+ );
+}
+
+function customOptions(setting: DopSetting) {
+ return setting.url ? (
+ <>
+ <span>{setting.key} — </span>
+ <Note>{setting.url}</Note>
+ </>
+ ) : (
+ <span>{setting.key}</span>
+ );
+}
+
+function orgToOption(alm: DopSetting) {
+ return { value: alm, label: alm.key };
+}
+
+export default function DopSettingDropdown(props: Readonly<DopSettingDropdownProps>) {
+ const { almKey, className, dopSettings, onChangeSetting, selectedDopSetting } = props;
+ if (!dopSettings || dopSettings.length < MIN_SIZE_INSTANCES) {
+ return null;
+ }
+
+ return (
+ <div className={classNames('sw-flex sw-flex-col', className)}>
+ <DarkLabel htmlFor="dop-setting-dropdown" className="sw-mb-2">
+ <FormattedMessage id={`onboarding.create_project.monorepo.choose_dop_setting.${almKey}`} />
+ </DarkLabel>
+
+ <InputSelect
+ inputId="dop-setting-dropdown"
+ className={className}
+ isClearable={false}
+ isSearchable={false}
+ options={dopSettings.map(orgToOption)}
+ onChange={(data: LabelValueSelectOption<DopSetting>) => {
+ onChangeSetting(data.value);
+ }}
+ components={{
+ Option: optionRenderer,
+ SingleValue: singleValueRenderer,
+ }}
+ placeholder={translate('alm.configuration.selector.placeholder')}
+ getOptionValue={(opt: LabelValueSelectOption<DopSetting>) => opt.value.key}
+ value={
+ dopSettings.map(orgToOption).find((opt) => opt.value.key === selectedDopSetting?.key) ??
+ null
+ }
+ size="full"
+ />
+ </div>
+ );
+}
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<I> {
+ initialKey?: string;
+ initialName?: string;
+ monorepoSetupProjectKeys?: string[];
+ onChange: (project: ProjectData<I>) => void;
+ onRemove?: () => void;
+ projectId?: I;
+}
+
+interface State {
+ name: string;
+ nameError?: boolean;
+ nameTouched: boolean;
+ key: string;
+ keyError?: ProjectKeyErrors;
+ keyTouched: boolean;
+ validatingKey: boolean;
+}
+
+export interface ProjectData<I = string> {
+ 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<I>(props: Readonly<Props<I>>) {
+ const {
+ initialKey = '',
+ initialName = '',
+ monorepoSetupProjectKeys,
+ onChange,
+ projectId,
+ } = props;
+ const checkFreeKeyTimeout = React.useRef<NodeJS.Timeout | undefined>();
+ const [project, setProject] = React.useState<State>({
+ 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 (
+ <>
+ <FormField
+ htmlFor={projectNameInputId}
+ label={translate('onboarding.create_project.display_name')}
+ required
+ >
+ <div>
+ <InputField
+ className={classNames({
+ 'js__is-invalid': projectNameIsInvalid,
+ })}
+ size="large"
+ id={projectNameInputId}
+ maxLength={PROJECT_NAME_MAX_LEN}
+ minLength={1}
+ onChange={(e) => handleProjectNameChange(e.currentTarget.value, true)}
+ type="text"
+ value={name}
+ autoFocus
+ isInvalid={projectNameIsInvalid}
+ isValid={projectNameIsValid}
+ required
+ />
+ {projectNameIsInvalid && <FlagErrorIcon className="sw-ml-2" />}
+ {projectNameIsValid && <FlagSuccessIcon className="sw-ml-2" />}
+ </div>
+ {nameError !== undefined && (
+ <Note className="sw-mt-2">
+ {translate('onboarding.create_project.display_name.description')}
+ </Note>
+ )}
+ </FormField>
+
+ <FormField
+ htmlFor={projectKeyInputId}
+ label={translate('onboarding.create_project.project_key')}
+ required
+ >
+ <div>
+ <InputField
+ className={classNames({
+ 'js__is-invalid': projectKeyIsInvalid,
+ })}
+ size="large"
+ id={projectKeyInputId}
+ minLength={1}
+ onChange={(e) => handleProjectKeyChange(e.currentTarget.value, true)}
+ type="text"
+ value={key}
+ isInvalid={projectKeyIsInvalid}
+ isValid={projectKeyIsValid}
+ required
+ />
+ {projectKeyIsInvalid && <FlagErrorIcon className="sw-ml-2" />}
+ {projectKeyIsValid && <FlagSuccessIcon className="sw-ml-2" />}
+ </div>
+ {keyError !== undefined && (
+ <Note className="sw-flex-col sw-mt-2">
+ {keyError === ProjectKeyErrors.DuplicateKey ||
+ (keyError === ProjectKeyErrors.MonorepoDuplicateKey && (
+ <TextError
+ text={translate('onboarding.create_project.project_key.duplicate_key')}
+ />
+ ))}
+ {!isEmpty(key) && keyError === ProjectKeyErrors.WrongFormat && (
+ <TextError text={translate('onboarding.create_project.project_key.wrong_format')} />
+ )}
+ <p>{translate('onboarding.create_project.project_key.description')}</p>
+ </Note>
+ )}
+ </FormField>
+ </>
+ );
+}
+
+export function ProjectValidationCard<I>({
+ initialKey,
+ initialName,
+ monorepoSetupProjectKeys,
+ onChange,
+ onRemove,
+ projectId,
+ ...cardProps
+}: Readonly<
+ Props<I> & Omit<React.ComponentPropsWithoutRef<typeof Card>, 'onChange' | 'children'>
+>) {
+ return (
+ <Card {...cardProps}>
+ <ProjectValidation
+ initialKey={initialKey}
+ initialName={initialName}
+ monorepoSetupProjectKeys={monorepoSetupProjectKeys}
+ onChange={onChange}
+ projectId={projectId}
+ />
+ <ButtonSecondary
+ className="sw-mt-4 sw-mr-4"
+ icon={<TrashIcon />}
+ onClick={onRemove}
+ type="button"
+ >
+ {translate('onboarding.create_project.monorepo.remove_project')}
+ </ButtonSecondary>
+ </Card>
+ );
+}
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<Pick<State, 'projectKey' | 'projectName'>>;
+type ValidState = ProjectData & Required<Pick<ProjectData, 'key' | 'name'>>;
export default function ManualProjectCreate(props: Readonly<Props>) {
- const [project, setProject] = React.useState<State>({
- projectKey: '',
- projectName: '',
- projectKeyTouched: false,
- projectNameTouched: false,
+ const [mainBranch, setMainBranch] = React.useState<MainBranchState>({
mainBranchName: 'main',
mainBranchNameTouched: false,
- validatingProjectKey: false,
});
+ const [project, setProject] = React.useState<ProjectData>({
+ 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<Props>) {
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<HTMLFormElement>) => {
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<Props>) {
}
};
- 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<Props>) {
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<Props>) {
className="sw-flex-col sw-body-sm"
onSubmit={handleFormSubmit}
>
- <FormField
- htmlFor="project-name"
- label={translate('onboarding.create_project.display_name')}
- required
- >
- <div>
- <InputField
- className={classNames({
- 'js__is-invalid': projectNameIsInvalid,
- })}
- size="large"
- id="project-name"
- maxLength={PROJECT_NAME_MAX_LEN}
- minLength={1}
- onChange={(e) => handleProjectNameChange(e.currentTarget.value, true)}
- type="text"
- value={projectName}
- autoFocus
- isInvalid={projectNameIsInvalid}
- isValid={projectNameIsValid}
- required
- />
- {projectNameIsInvalid && <FlagErrorIcon className="sw-ml-2" />}
- {projectNameIsValid && <FlagSuccessIcon className="sw-ml-2" />}
- </div>
- <Note className="sw-mt-2">
- {translate('onboarding.create_project.display_name.description')}
- </Note>
- </FormField>
-
- <FormField
- htmlFor="project-key"
- label={translate('onboarding.create_project.project_key')}
- required
- >
- <div>
- <InputField
- className={classNames({
- 'js__is-invalid': projectKeyIsInvalid,
- })}
- size="large"
- id="project-key"
- minLength={1}
- onChange={(e) => handleProjectKeyChange(e.currentTarget.value, true)}
- type="text"
- value={projectKey}
- isInvalid={projectKeyIsInvalid}
- isValid={projectKeyIsValid}
- required
- />
- {projectKeyIsInvalid && <FlagErrorIcon className="sw-ml-2" />}
- {projectKeyIsValid && <FlagSuccessIcon className="sw-ml-2" />}
- </div>
- <Note className="sw-flex-col sw-mt-2">
- {projectKeyError === 'DUPLICATE_KEY' && (
- <TextError
- text={translate('onboarding.create_project.project_key.duplicate_key')}
- />
- )}
- {!isEmpty(projectKey) && projectKeyError === 'WRONG_FORMAT' && (
- <TextError text={translate('onboarding.create_project.project_key.wrong_format')} />
- )}
- <p>{translate('onboarding.create_project.project_key.description')}</p>
- </Note>
- </FormField>
+ <ProjectValidation onChange={setProject} />
<FormField
htmlFor="main-branch-name"
@@ -386,7 +207,11 @@ export default function ManualProjectCreate(props: Readonly<Props>) {
<ButtonSecondary className="sw-mt-4 sw-mr-4" onClick={props.onClose} type="button">
{intl.formatMessage({ id: 'cancel' })}
</ButtonSecondary>
- <ButtonPrimary className="sw-mt-4" type="submit" disabled={!canSubmit(project)}>
+ <ButtonPrimary
+ className="sw-mt-4"
+ type="submit"
+ disabled={!canSubmit(mainBranch, project)}
+ >
{translate('next')}
</ButtonPrimary>
</form>
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<ProjectData<number>>;
+
+export default function MonorepoProjectCreate(props: Readonly<MonorepoProjectCreateProps>) {
+ 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<ProjectItem[]>([]);
+
+ 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 <Spinner />;
+ }
+
+ return (
+ <div>
+ <MonorepoProjectHeader />
+
+ <BlueGreySeparator className="sw-my-5" />
+
+ <div className="sw-flex sw-flex-col sw-gap-6">
+ <Title>
+ <FormattedMessage
+ id={`onboarding.create_project.monorepo.choose_organization_and_repository.${almKey}`}
+ />
+ </Title>
+
+ <DopSettingDropdown
+ almKey={almKey}
+ dopSettings={dopSettings}
+ selectedDopSetting={selectedDopSetting}
+ onChangeSetting={onSelectDopSetting}
+ />
+
+ {error && selectedDopSetting && !loadingOrganizations && (
+ <FlagMessage variant="warning">
+ <span>
+ {canAdmin ? (
+ <FormattedMessage
+ id="onboarding.create_project.github.warning.message_admin"
+ defaultMessage={translate(
+ 'onboarding.create_project.github.warning.message_admin',
+ )}
+ values={{
+ link: (
+ <Link to="/admin/settings?category=almintegration">
+ {translate('onboarding.create_project.github.warning.message_admin.link')}
+ </Link>
+ ),
+ }}
+ />
+ ) : (
+ translate('onboarding.create_project.github.warning.message')
+ )}
+ </span>
+ </FlagMessage>
+ )}
+
+ <div className="sw-flex sw-flex-col">
+ <Spinner isLoading={loadingOrganizations && !error}>
+ {!error && (
+ <>
+ <DarkLabel htmlFor="monorepo-choose-organization" className="sw-mb-2">
+ <FormattedMessage
+ id={`onboarding.create_project.monorepo.choose_organization.${almKey}`}
+ />
+ </DarkLabel>
+ {(organizationOptions?.length ?? 0) > 0 ? (
+ <InputSelect
+ size="full"
+ isSearchable
+ inputId="monorepo-choose-organization"
+ options={organizationOptions}
+ onChange={({ value }: LabelValueSelectOption) => {
+ onSelectOrganization(value);
+ }}
+ placeholder={formatMessage({
+ id: `onboarding.create_project.monorepo.choose_organization.${almKey}.placeholder`,
+ })}
+ value={selectedOrganization}
+ />
+ ) : (
+ !loadingOrganizations && (
+ <FlagMessage variant="error" className="sw-mb-2">
+ <span>
+ {canAdmin ? (
+ <FormattedMessage
+ id="onboarding.create_project.github.no_orgs_admin"
+ defaultMessage={translate(
+ 'onboarding.create_project.github.no_orgs_admin',
+ )}
+ values={{
+ link: (
+ <Link to="/admin/settings?category=almintegration">
+ {translate(
+ 'onboarding.create_project.github.warning.message_admin.link',
+ )}
+ </Link>
+ ),
+ }}
+ />
+ ) : (
+ translate('onboarding.create_project.github.no_orgs')
+ )}
+ </span>
+ </FlagMessage>
+ )
+ )}
+ </>
+ )}
+ </Spinner>
+ </div>
+
+ <div className="sw-flex sw-flex-col">
+ {selectedOrganization && (
+ <DarkLabel className="sw-mb-2" htmlFor="monorepo-choose-repository">
+ <FormattedMessage
+ id={`onboarding.create_project.monorepo.choose_repository.${almKey}`}
+ />
+ </DarkLabel>
+ )}
+ {selectedOrganization && (
+ <InputSelect
+ inputId="monorepo-choose-repository"
+ inputValue={repositorySearchQuery}
+ isLoading={loadingRepositories}
+ isSearchable
+ noOptionsMessage={() => 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}
+ />
+ )}
+ </div>
+ </div>
+
+ {selectedRepository !== undefined && (
+ <>
+ <BlueGreySeparator className="sw-my-5" />
+
+ <div>
+ <SubTitle>
+ <FormattedMessage id="onboarding.create_project.monorepo.project_title" />
+ </SubTitle>
+ <div>
+ {projects.map(({ id, key, name }) => (
+ <ProjectValidationCard
+ className="sw-mt-4"
+ initialKey={key}
+ initialName={name}
+ key={id}
+ monorepoSetupProjectKeys={projectKeys}
+ onChange={onProjectChange}
+ onRemove={() => {
+ onProjectRemove(id);
+ }}
+ projectId={id}
+ />
+ ))}
+ </div>
+
+ <div className="sw-flex sw-justify-end sw-mt-4">
+ <ButtonSecondary onClick={addProject}>
+ <AddNewIcon className="sw-mr-2" />
+ <FormattedMessage id="onboarding.create_project.monorepo.add_project" />
+ </ButtonSecondary>
+ </div>
+ </div>
+ </>
+ )}
+
+ <div className="sw-my-5">
+ <ButtonSecondary onClick={cancelMonorepoSetup}>
+ <FormattedMessage id="cancel" />
+ </ButtonSecondary>
+ <ButtonPrimary className="sw-ml-3" disabled={isSetupInvalid} onClick={submitProjects}>
+ <FormattedMessage id="next" />
+ </ButtonPrimary>
+ </div>
+ </div>
+ );
+}
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 (
+ <>
+ <Title>
+ <FormattedMessage
+ id="onboarding.create_project.monorepo.title"
+ values={{
+ almName: formatMessage({ id: `alm.${almKey}` }),
+ }}
+ />
+ </Title>
+ <div>
+ <LightPrimary>
+ <FormattedMessage id="onboarding.create_project.monorepo.subtitle" />
+ </LightPrimary>
+ </div>
+ <div className="sw-mt-3">
+ <LinkStandalone isExternal to={useDocUrl('/project-administration/monorepos/')}>
+ <FormattedMessage id="onboarding.create_project.monorepo.doc_link" />
+ </LinkStandalone>
+ </div>
+ </>
+ );
+}
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: (
- <DocumentationLink to={ALM_DOCUMENTATION_PATHS[alm]}>
+ <DocumentationLink to="/project-administration/monorepos/">
{translate('learn_more')}
</DocumentationLink>
),
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<TutorialSelectionProps>) {
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<ComponentPropsType<typeof TutorialSelection>> = {},
+ props: Partial<TutorialSelectionProps> = {},
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 (
<NumberedListItem>
<SentenceWithFilename
filename="sonar-project.properties"
- translationKey="onboarding.tutorial.other.project_key"
+ translationKey={`onboarding.tutorial.other.project_key${monorepo ? '.monorepo' : ''}`}
/>
<CodeSnippet snippet={sonarProjectSnippet(component.key)} isOneLine className="sw-p-6" />
</NumberedListItem>
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<AnalysisCommandProps>) {
+ const { buildTool, component, mainBranchName, monorepo } = props;
const branchSupportEnabled = props.hasFeature(Feature.BranchSupport);
switch (buildTool) {
@@ -46,6 +47,7 @@ export function AnalysisCommand(props: AnalysisCommandProps) {
<JavaMaven
branchesEnabled={branchSupportEnabled}
mainBranchName={mainBranchName}
+ monorepo={monorepo}
component={component}
/>
);
@@ -54,6 +56,7 @@ export function AnalysisCommand(props: AnalysisCommandProps) {
<Gradle
branchesEnabled={branchSupportEnabled}
mainBranchName={mainBranchName}
+ monorepo={monorepo}
component={component}
/>
);
@@ -62,6 +65,7 @@ export function AnalysisCommand(props: AnalysisCommandProps) {
<DotNet
branchesEnabled={branchSupportEnabled}
mainBranchName={mainBranchName}
+ monorepo={monorepo}
component={component}
/>
);
@@ -70,6 +74,7 @@ export function AnalysisCommand(props: AnalysisCommandProps) {
<CFamily
branchesEnabled={branchSupportEnabled}
mainBranchName={mainBranchName}
+ monorepo={monorepo}
component={component}
/>
);
@@ -78,9 +83,12 @@ export function AnalysisCommand(props: AnalysisCommandProps) {
<Others
branchesEnabled={branchSupportEnabled}
mainBranchName={mainBranchName}
+ monorepo={monorepo}
component={component}
/>
);
+ 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<boolean>(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 (
<>
<Title>{translate('onboarding.tutorial.with.github_ci.title')}</Title>
-
<TutorialStepList className="sw-mb-8">
- <TutorialStep
- title={translate('onboarding.tutorial.with.github_action.create_secret.title')}
- >
+ <TutorialStep title={translate(secretStepTitle)}>
<SecretStep
almBinding={almBinding}
baseUrl={baseUrl}
component={component}
currentUser={currentUser}
+ monorepo={monorepo}
/>
</TutorialStep>
<TutorialStep title={translate('onboarding.tutorial.with.github_action.yaml.title')}>
@@ -63,6 +72,7 @@ export default function GitHubActionTutorial(props: GitHubActionTutorialProps) {
buildTool={buildTool}
mainBranchName={mainBranchName}
component={component}
+ monorepo={monorepo}
/>
)}
</YamlFileStep>
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) {
/>
</NumberedListItem>
</NumberedList>
+ {monorepo && (
+ <FlagMessage variant="info" className="sw-block sw-w-fit sw-mt-4">
+ {translate('onboarding.tutorial.with.github_action.create_secret.monorepo_info')}
+ </FlagMessage>
+ )}
</>
);
}
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<undefined | OSs>(OSs.Linux);
const runsOn = {
@@ -94,7 +96,7 @@ export default function CFamily(props: CFamilyProps) {
};
return (
<>
- <DefaultProjectKey component={component} />
+ <DefaultProjectKey component={component} monorepo={monorepo} />
<NumberedListItem>
<span>{translate('onboarding.build.other.os')}</span>
<RenderOptions
@@ -112,22 +114,25 @@ export default function CFamily(props: CFamilyProps) {
/>
)}
</NumberedListItem>
- {os && (
- <>
- <CreateYmlFile
- yamlFileName=".github/workflows/build.yml"
- yamlTemplate={generateGitHubActionsYaml(
- mainBranchName,
- !!branchesEnabled,
- runsOn[os],
- STEPS[os],
- `env:
+ {os &&
+ (monorepo ? (
+ <MonorepoDocLinkFallback />
+ ) : (
+ <>
+ <CreateYmlFile
+ yamlFileName=".github/workflows/build.yml"
+ yamlTemplate={generateGitHubActionsYaml(
+ mainBranchName,
+ !!branchesEnabled,
+ runsOn[os],
+ STEPS[os],
+ `env:
BUILD_WRAPPER_OUT_DIR: build_wrapper_output_directory # Directory where build-wrapper output will be placed`,
- )}
- />
- <CompilationInfo />
- </>
- )}
+ )}
+ />
+ <CompilationInfo />
+ </>
+ ))}
</>
);
}
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 <MonorepoDocLinkFallback />;
+ }
+
return (
<CreateYmlFile
yamlFileName=".github/workflows/build.yml"
diff --git a/server/sonar-web/src/main/js/components/tutorials/github-action/commands/Gradle.tsx b/server/sonar-web/src/main/js/components/tutorials/github-action/commands/Gradle.tsx
index 75b562d06bf..0aaaa78d00f 100644
--- a/server/sonar-web/src/main/js/components/tutorials/github-action/commands/Gradle.tsx
+++ b/server/sonar-web/src/main/js/components/tutorials/github-action/commands/Gradle.tsx
@@ -23,10 +23,12 @@ import CreateYmlFile from '../../components/CreateYmlFile';
import GradleBuild from '../../components/GradleBuild';
import { GITHUB_ACTIONS_RUNS_ON_LINUX } from '../constants';
import { generateGitHubActionsYaml } from '../utils';
+import MonorepoDocLinkFallback from './MonorepoDocLinkFallback';
export interface GradleProps {
branchesEnabled?: boolean;
mainBranchName: string;
+ monorepo?: boolean;
component: Component;
}
@@ -54,20 +56,25 @@ const GRADLE_YAML_STEPS = `
run: ./gradlew build sonar --info`;
export default function Gradle(props: GradleProps) {
- const { component, branchesEnabled, mainBranchName } = props;
+ const { component, branchesEnabled, mainBranchName, monorepo } = props;
return (
<>
<GradleBuild component={component} />
- <CreateYmlFile
- yamlFileName=".github/workflows/build.yml"
- yamlTemplate={generateGitHubActionsYaml(
- mainBranchName,
- !!branchesEnabled,
- GITHUB_ACTIONS_RUNS_ON_LINUX,
- GRADLE_YAML_STEPS,
- )}
- />
+
+ {monorepo ? (
+ <MonorepoDocLinkFallback />
+ ) : (
+ <CreateYmlFile
+ yamlFileName=".github/workflows/build.yml"
+ yamlTemplate={generateGitHubActionsYaml(
+ mainBranchName,
+ !!branchesEnabled,
+ GITHUB_ACTIONS_RUNS_ON_LINUX,
+ GRADLE_YAML_STEPS,
+ )}
+ />
+ )}
</>
);
}
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 <MonorepoDocLinkFallback />;
+ }
+
return (
<CreateYmlFile
yamlFileName=".github/workflows/build.yml"
diff --git a/server/sonar-web/src/main/js/components/tutorials/github-action/commands/MonorepoDocLinkFallback.tsx b/server/sonar-web/src/main/js/components/tutorials/github-action/commands/MonorepoDocLinkFallback.tsx
new file mode 100644
index 00000000000..6c0d3ebffb5
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/tutorials/github-action/commands/MonorepoDocLinkFallback.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 { NumberedListItem } from 'design-system';
+import * as React from 'react';
+import { translate } from '../../../../helpers/l10n';
+import DocumentationLink from '../../../common/DocumentationLink';
+
+const MONOREPO_DOC =
+ '/devops-platform-integration/github-integration/monorepo/#workflow-file-example';
+
+export default function MonorepoDocLinkFallback() {
+ return (
+ <NumberedListItem>
+ <DocumentationLink className="sw-mt-4" to={MONOREPO_DOC}>
+ {translate('onboarding.tutorial.with.github_action.monorepo.see_yaml_instructions')}
+ </DocumentationLink>{' '}
+ {translate('onboarding.tutorial.with.github_action.monorepo.pre_see_yaml_instructions')}
+ </NumberedListItem>
+ );
+}
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 (
<>
- <DefaultProjectKey component={component} />
- <CreateYmlFile
- yamlFileName=".github/workflows/build.yml"
- yamlTemplate={generateGitHubActionsYaml(
- mainBranchName,
- !!branchesEnabled,
- GITHUB_ACTIONS_RUNS_ON_LINUX,
- otherYamlSteps(!!branchesEnabled),
- )}
- />
+ <DefaultProjectKey component={component} monorepo={monorepo} />
+
+ {monorepo ? (
+ <MonorepoDocLinkFallback />
+ ) : (
+ <CreateYmlFile
+ yamlFileName=".github/workflows/build.yml"
+ yamlTemplate={generateGitHubActionsYaml(
+ mainBranchName,
+ !!branchesEnabled,
+ GITHUB_ACTIONS_RUNS_ON_LINUX,
+ otherYamlSteps(!!branchesEnabled),
+ )}
+ />
+ )}
</>
);
}
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<AlmImport extends ImportProjectParam = ImportProjectPara
AlmImport extends {
creationMode: infer A;
almSetting: string;
+ monorepo: false;
projects: (infer R)[];
}
- ? { creationMode: A; almSetting: string } & R
+ ? { creationMode: A; almSetting: string; monorepo: false } & R
:
| {
creationMode: CreateProjectModes.Manual;
project: string;
name: string;
mainBranch: string;
+ monorepo: false;
}
| {
- creationMode: CreateProjectModes.Monorepo;
+ creationMode: CreateProjectModes;
devOpsPlatformSettingId: string;
- monorepo: boolean;
+ monorepo: true;
projectKey: string;
projectName: string;
repositoryIdentifier: string;
@@ -61,18 +63,21 @@ export function useImportProjectMutation() {
newCodeDefinitionValue?: string;
} & 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