Browse Source

SONAR-21822 Add monorepo setup for GitHub

tags/10.5.0.89998
Ambroise C 1 month ago
parent
commit
e4d510ae08
49 changed files with 2046 additions and 636 deletions
  1. 37
    0
      server/sonar-web/design-system/src/components/icons/AddNewIcon.tsx
  2. 1
    0
      server/sonar-web/design-system/src/components/icons/index.ts
  3. 9
    13
      server/sonar-web/src/main/js/api/dop-translation.ts
  4. 7
    0
      server/sonar-web/src/main/js/api/mocks/ComponentsServiceMock.ts
  5. 123
    0
      server/sonar-web/src/main/js/api/mocks/DopTranslationServiceMock.ts
  6. 34
    0
      server/sonar-web/src/main/js/api/mocks/data/dop-translation.ts
  7. 1
    0
      server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreate.tsx
  8. 1
    0
      server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudProjectCreate.tsx
  9. 1
    0
      server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectCreate.tsx
  10. 50
    23
      server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx
  11. 238
    264
      server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreate.tsx
  12. 33
    5
      server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreateRenderer.tsx
  13. 73
    0
      server/sonar-web/src/main/js/apps/create/project/Github/utils.ts
  14. 1
    0
      server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreate.tsx
  15. 4
    4
      server/sonar-web/src/main/js/apps/create/project/__tests__/Azure-it.tsx
  16. 4
    4
      server/sonar-web/src/main/js/apps/create/project/__tests__/Bitbucket-it.tsx
  17. 4
    4
      server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloud-it.tsx
  18. 6
    6
      server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectPage-it.tsx
  19. 17
    15
      server/sonar-web/src/main/js/apps/create/project/__tests__/GitHub-it.tsx
  20. 187
    0
      server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubMonorepoProjectCreate-it.tsx
  21. 4
    4
      server/sonar-web/src/main/js/apps/create/project/__tests__/GitLab-it.tsx
  22. 10
    2
      server/sonar-web/src/main/js/apps/create/project/__tests__/Manual-it.tsx
  23. 4
    4
      server/sonar-web/src/main/js/apps/create/project/components/AlmSettingsInstanceDropdown.tsx
  24. 99
    0
      server/sonar-web/src/main/js/apps/create/project/components/DopSettingDropdown.tsx
  25. 9
    2
      server/sonar-web/src/main/js/apps/create/project/components/NewCodeDefinitionSelection.tsx
  26. 336
    0
      server/sonar-web/src/main/js/apps/create/project/components/ProjectValidation.tsx
  27. 1
    0
      server/sonar-web/src/main/js/apps/create/project/constants.ts
  28. 33
    208
      server/sonar-web/src/main/js/apps/create/project/manual/ManualProjectCreate.tsx
  29. 362
    0
      server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectCreate.tsx
  30. 54
    0
      server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectHeader.tsx
  31. 0
    1
      server/sonar-web/src/main/js/apps/create/project/types.ts
  32. 7
    0
      server/sonar-web/src/main/js/apps/create/project/utils.ts
  33. 1
    2
      server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/AlmSpecificForm.tsx
  34. 2
    2
      server/sonar-web/src/main/js/components/tutorials/TutorialSelection.tsx
  35. 1
    0
      server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx
  36. 56
    3
      server/sonar-web/src/main/js/components/tutorials/__tests__/TutorialSelection-it.tsx
  37. 4
    2
      server/sonar-web/src/main/js/components/tutorials/components/DefaultProjectKey.tsx
  38. 10
    2
      server/sonar-web/src/main/js/components/tutorials/github-action/AnalysisCommand.tsx
  39. 16
    6
      server/sonar-web/src/main/js/components/tutorials/github-action/GitHubActionTutorial.tsx
  40. 8
    1
      server/sonar-web/src/main/js/components/tutorials/github-action/SecretStep.tsx
  41. 22
    17
      server/sonar-web/src/main/js/components/tutorials/github-action/commands/CFamily.tsx
  42. 8
    1
      server/sonar-web/src/main/js/components/tutorials/github-action/commands/DotNet.tsx
  43. 17
    10
      server/sonar-web/src/main/js/components/tutorials/github-action/commands/Gradle.tsx
  44. 8
    1
      server/sonar-web/src/main/js/components/tutorials/github-action/commands/JavaMaven.tsx
  45. 37
    0
      server/sonar-web/src/main/js/components/tutorials/github-action/commands/MonorepoDocLinkFallback.tsx
  46. 18
    11
      server/sonar-web/src/main/js/components/tutorials/github-action/commands/Others.tsx
  47. 21
    16
      server/sonar-web/src/main/js/queries/import-projects.ts
  48. 39
    0
      server/sonar-web/src/main/js/types/dop-translation.ts
  49. 28
    3
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

+ 37
- 0
server/sonar-web/design-system/src/components/icons/AddNewIcon.tsx View File

@@ -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>
);
}

+ 1
- 0
server/sonar-web/design-system/src/components/icons/index.ts View File

@@ -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';

+ 9
- 13
server/sonar-web/src/main/js/api/dop-translation.ts View File

@@ -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);
}

+ 7
- 0
server/sonar-web/src/main/js/api/mocks/ComponentsServiceMock.ts View File

@@ -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> {

+ 123
- 0
server/sonar-web/src/main/js/api/mocks/DopTranslationServiceMock.ts View File

@@ -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);
}
}

+ 34
- 0
server/sonar-web/src/main/js/api/mocks/data/dop-translation.ts View File

@@ -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,
};
}

+ 1
- 0
server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreate.tsx View File

@@ -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,

+ 1
- 0
server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudProjectCreate.tsx View File

@@ -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,

+ 1
- 0
server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectCreate.tsx View File

@@ -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,

+ 50
- 23
server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx View File

@@ -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}
/>
);
}

+ 238
- 264
server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreate.tsx View File

@@ -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 };
}

+ 33
- 5
server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreateRenderer.tsx View File

@@ -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">

+ 73
- 0
server/sonar-web/src/main/js/apps/create/project/Github/utils.ts View File

@@ -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}`);
}
}

+ 1
- 0
server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreate.tsx View File

@@ -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 }],
});
}

+ 4
- 4
server/sonar-web/src/main/js/apps/create/project/__tests__/Azure-it.tsx View File

@@ -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(() => {

+ 4
- 4
server/sonar-web/src/main/js/apps/create/project/__tests__/Bitbucket-it.tsx View File

@@ -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();
});


+ 4
- 4
server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloud-it.tsx View File

@@ -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();
});


+ 6
- 6
server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectPage-it.tsx View File

@@ -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();

+ 17
- 15
server/sonar-web/src/main/js/apps/create/project/__tests__/GitHub-it.tsx View File

@@ -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();


+ 187
- 0
server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubMonorepoProjectCreate-it.tsx View File

@@ -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],
});
}

+ 4
- 4
server/sonar-web/src/main/js/apps/create/project/__tests__/GitLab-it.tsx View File

@@ -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();
});


+ 10
- 2
server/sonar-web/src/main/js/apps/create/project/__tests__/Manual-it.tsx View File

@@ -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());
});


+ 4
- 4
server/sonar-web/src/main/js/apps/create/project/components/AlmSettingsInstanceDropdown.tsx View File

@@ -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>

+ 99
- 0
server/sonar-web/src/main/js/apps/create/project/components/DopSettingDropdown.tsx View File

@@ -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>
);
}

+ 9
- 2
server/sonar-web/src/main/js/apps/create/project/components/NewCodeDefinitionSelection.tsx View File

@@ -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,
},

+ 336
- 0
server/sonar-web/src/main/js/apps/create/project/components/ProjectValidation.tsx View File

@@ -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>
);
}

+ 1
- 0
server/sonar-web/src/main/js/apps/create/project/constants.ts View File

@@ -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;

+ 33
- 208
server/sonar-web/src/main/js/apps/create/project/manual/ManualProjectCreate.tsx View File

@@ -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>

+ 362
- 0
server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectCreate.tsx View File

@@ -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>
);
}

+ 54
- 0
server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectHeader.tsx View File

@@ -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>
</>
);
}

+ 0
- 1
server/sonar-web/src/main/js/apps/create/project/types.ts View File

@@ -24,5 +24,4 @@ export enum CreateProjectModes {
BitbucketCloud = 'bitbucketcloud',
GitHub = 'github',
GitLab = 'gitlab',
Monorepo = 'monorepo',
}

+ 7
- 0
server/sonar-web/src/main/js/apps/create/project/utils.ts View File

@@ -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, '-');
}

+ 1
- 2
server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/AlmSpecificForm.tsx View File

@@ -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>
),

+ 2
- 2
server/sonar-web/src/main/js/components/tutorials/TutorialSelection.tsx View File

@@ -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());

+ 1
- 0
server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx View File

@@ -258,6 +258,7 @@ export default function TutorialSelectionRenderer(props: TutorialSelectionRender
baseUrl={baseUrl}
component={component}
currentUser={currentUser}
monorepo={projectBinding?.monorepo}
mainBranchName={mainBranchName}
willRefreshAutomatically={willRefreshAutomatically}
/>

+ 56
- 3
server/sonar-web/src/main/js/components/tutorials/__tests__/TutorialSelection-it.tsx View File

@@ -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(

+ 4
- 2
server/sonar-web/src/main/js/components/tutorials/components/DefaultProjectKey.tsx View File

@@ -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>

+ 10
- 2
server/sonar-web/src/main/js/components/tutorials/github-action/AnalysisCommand.tsx View File

@@ -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;
}
}


+ 16
- 6
server/sonar-web/src/main/js/components/tutorials/github-action/GitHubActionTutorial.tsx View File

@@ -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>

+ 8
- 1
server/sonar-web/src/main/js/components/tutorials/github-action/SecretStep.tsx View File

@@ -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>
)}
</>
);
}

+ 22
- 17
server/sonar-web/src/main/js/components/tutorials/github-action/commands/CFamily.tsx View File

@@ -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 />
</>
))}
</>
);
}

+ 8
- 1
server/sonar-web/src/main/js/components/tutorials/github-action/commands/DotNet.tsx View File

@@ -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"

+ 17
- 10
server/sonar-web/src/main/js/components/tutorials/github-action/commands/Gradle.tsx View File

@@ -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,
)}
/>
)}
</>
);
}

+ 8
- 1
server/sonar-web/src/main/js/components/tutorials/github-action/commands/JavaMaven.tsx View File

@@ -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"

+ 37
- 0
server/sonar-web/src/main/js/components/tutorials/github-action/commands/MonorepoDocLinkFallback.tsx View File

@@ -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>
);
}

+ 18
- 11
server/sonar-web/src/main/js/components/tutorials/github-action/commands/Others.tsx View File

@@ -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),
)}
/>
)}
</>
);
}

+ 21
- 16
server/sonar-web/src/main/js/queries/import-projects.ts View File

@@ -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);

+ 39
- 0
server/sonar-web/src/main/js/types/dop-translation.ts View File

@@ -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;
}

+ 28
- 3
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -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

Loading…
Cancel
Save