Browse Source

SONAR-21822 Check if selected repository is already bound to an existing project

tags/10.5.0.89998
Ambroise C 1 month ago
parent
commit
b9a92dbaaa

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

@@ -18,17 +18,29 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import axios from 'axios';
import { BoundProject, DopSetting } from '../types/dop-translation';
import { BoundProject, DopSetting, ProjectBinding } 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`;
const PROJECT_BINDINGS_PATH = `${DOP_TRANSLATION_PATH}/project-bindings`;

export function createBoundProject(data: BoundProject) {
return axios.post(BOUND_PROJECTS_PATH, data);
}

export function getDopSettings() {
return axios.get<{ paging: Paging; dopSettings: DopSetting[] }>(DOP_SETTINGS_PATH);
return axios.get<{ dopSettings: DopSetting[]; page: Paging }>(DOP_SETTINGS_PATH);
}

export function getProjectBindings(data: {
dopSettingId?: string;
pageIndex?: number;
pageSize?: number;
repository?: string;
}) {
return axios.get<{ page: Paging; projectBindings: ProjectBinding[] }>(PROJECT_BINDINGS_PATH, {
params: data,
});
}

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

@@ -20,9 +20,9 @@
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';
import { DopSetting, ProjectBinding } from '../../types/dop-translation';
import { createBoundProject, getDopSettings, getProjectBindings } from '../dop-translation';
import { mockDopSetting, mockProjectBinding } from './data/dop-translation';

jest.mock('../dop-translation');

@@ -57,48 +57,37 @@ const defaultDopSettings = [
mockDopSetting(),
mockDopSetting({ id: 'dop-setting-test-id-2', key: 'Test/DopSetting2' }),
];
const defaultProjectBindings = [
mockProjectBinding({
dopSetting: 'conf-github-1',
id: 'project-binding-1',
projectId: 'key123',
projectKey: 'key123',
repository: 'Github repo 1',
slug: 'Slug/Repository-1',
}),
];

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' }),
];
projectBindings: ProjectBinding[] = [];
dopSettings: DopSetting[] = [];

constructor() {
this.reset();
jest.mocked(createBoundProject).mockImplementation(this.createBoundProject);
jest.mocked(getDopSettings).mockImplementation(this.getDopSettings);
jest.mocked(getProjectBindings).mockImplementation(this.getProjectBindings);
}

createBoundProject: typeof createBoundProject = (data) => {
this.boundProjects.push(data);
this.projectBindings.push(
mockProjectBinding({
dopSetting: data.devOpsPlatformSettingId,
id: `${data.devOpsPlatformSettingId}-${data.repositoryIdentifier}-${data.projectKey}`,
projectId: data.projectKey,
repository: data.repositoryIdentifier,
}),
);
return Promise.resolve({});
};

@@ -106,7 +95,21 @@ export default class DopTranslationServiceMock {
const total = this.getDopSettings.length;
return Promise.resolve({
dopSettings: this.dopSettings,
paging: mockPaging({ pageSize: total, total }),
page: mockPaging({ pageSize: total, total }),
});
};

getProjectBindings: typeof getProjectBindings = (params) => {
const pageIndex = params.pageIndex ?? 1;
const pageSize = params.pageSize ?? 50;

return this.reply({
page: {
pageIndex,
pageSize,
total: this.projectBindings.length,
},
projectBindings: this.projectBindings.slice((pageIndex - 1) * pageSize, pageIndex * pageSize),
});
};

@@ -117,7 +120,11 @@ export default class DopTranslationServiceMock {
};

reset() {
this.boundProjects = [];
this.projectBindings = cloneDeep(defaultProjectBindings);
this.dopSettings = cloneDeep(defaultDopSettings);
}

reply<T>(response: T): Promise<T> {
return Promise.resolve(cloneDeep(response));
}
}

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

@@ -119,6 +119,9 @@ export default class ProjectManagementServiceMock {
) {
return false;
}
if (params.projects !== undefined && !params.projects.split(',').includes(item.key)) {
return false;
}
return true;
});


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

@@ -21,7 +21,7 @@
/* eslint-disable local-rules/use-metrickey-enum */

import { AlmKeys } from '../../../types/alm-settings';
import { DopSetting } from '../../../types/dop-translation';
import { DopSetting, ProjectBinding } from '../../../types/dop-translation';

export function mockDopSetting(overrides?: Partial<DopSetting>): DopSetting {
return {
@@ -32,3 +32,15 @@ export function mockDopSetting(overrides?: Partial<DopSetting>): DopSetting {
...overrides,
};
}

export function mockProjectBinding(overrides?: Partial<ProjectBinding>): ProjectBinding {
return {
dopSetting: 'dop-setting-test-id',
id: 'project-binding-test-id',
projectId: 'project-id',
projectKey: 'project-key',
repository: 'repository',
slug: 'Slug/Project',
...overrides,
};
}

server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubMonorepoProjectCreate-it.tsx → server/sonar-web/src/main/js/apps/create/project/__tests__/MonorepoProjectCreate-it.tsx View File

@@ -26,8 +26,11 @@ 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 ProjectManagementServiceMock from '../../../../api/mocks/ProjectsManagementServiceMock';
import SettingsServiceMock from '../../../../api/mocks/SettingsServiceMock';
import { mockProject } from '../../../../helpers/mocks/projects';
import { renderApp } from '../../../../helpers/testReactTestingUtils';
import { byRole } from '../../../../helpers/testSelector';
import { byRole, byText } from '../../../../helpers/testSelector';
import { AlmKeys } from '../../../../types/alm-settings';
import { Feature } from '../../../../types/features';
import CreateProjectPage from '../CreateProjectPage';
@@ -41,6 +44,8 @@ let almSettingsHandler: AlmSettingsServiceMock;
let componentsHandler: ComponentsServiceMock;
let dopTranslationHandler: DopTranslationServiceMock;
let newCodePeriodHandler: NewCodeDefinitionServiceMock;
let projectManagementHandler: ProjectManagementServiceMock;
let settingsHandler: SettingsServiceMock;

const ui = {
addButton: byRole('button', { name: 'onboarding.create_project.monorepo.add_project' }),
@@ -61,6 +66,12 @@ const ui = {
repositorySelector: byRole('combobox', {
name: `onboarding.create_project.monorepo.choose_repository.${AlmKeys.GitHub}`,
}),
notBoundRepositoryMessage: byText(
'onboarding.create_project.monorepo.choose_repository.no_already_bound_projects',
),
alreadyBoundRepositoryMessage: byText(
/onboarding.create_project.monorepo.choose_repository.existing_already_bound_projects/,
),
submitButton: byRole('button', { name: 'next' }),
};

@@ -74,6 +85,8 @@ beforeAll(() => {
componentsHandler = new ComponentsServiceMock();
dopTranslationHandler = new DopTranslationServiceMock();
newCodePeriodHandler = new NewCodeDefinitionServiceMock();
settingsHandler = new SettingsServiceMock();
projectManagementHandler = new ProjectManagementServiceMock(settingsHandler);
});

beforeEach(() => {
@@ -83,6 +96,8 @@ beforeEach(() => {
componentsHandler.reset();
dopTranslationHandler.reset();
newCodePeriodHandler.reset();
projectManagementHandler.reset();
settingsHandler.reset();
});

describe('github monorepo project setup', () => {
@@ -106,6 +121,49 @@ describe('github monorepo project setup', () => {
expect(ui.gitHubOnboardingTitle.get()).toBeInTheDocument();
});

it('should display that selected repository is not bound to any existing project', async () => {
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.notBoundRepositoryMessage.find()).toBeInTheDocument();
});

it('should display that selected repository is already bound to an existing project', async () => {
projectManagementHandler.setProjects([
mockProject({
key: 'key123',
name: 'Project GitHub 1',
}),
]);
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.alreadyBoundRepositoryMessage.find()).toBeInTheDocument();
expect(byRole('link', { name: 'Project GitHub 1' }).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 });

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

@@ -17,7 +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.
*/
import { Link, Spinner } from '@sonarsource/echoes-react';
import { Link, LinkHighlight, LinkStandalone, Spinner } from '@sonarsource/echoes-react';
import {
AddNewIcon,
BlueGreySeparator,
@@ -31,9 +31,13 @@ import {
} from 'design-system';
import React, { useEffect, useRef } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { getComponents } from '../../../../api/project-management';
import { useLocation, useRouter } from '../../../../components/hoc/withRouter';
import { throwGlobalError } from '../../../../helpers/error';
import { translate } from '../../../../helpers/l10n';
import { LabelValueSelectOption } from '../../../../helpers/search';
import { getProjectUrl } from '../../../../helpers/urls';
import { useProjectBindingsQuery } from '../../../../queries/dop-translation';
import { AlmKeys } from '../../../../types/alm-settings';
import { DopSetting } from '../../../../types/dop-translation';
import { ImportProjectParam } from '../CreateProjectPage';
@@ -89,12 +93,26 @@ export default function MonorepoProjectCreate(props: Readonly<MonorepoProjectCre
const projectCounter = useRef(0);

const [projects, setProjects] = React.useState<ProjectItem[]>([]);
const [alreadyBoundProjects, setAlreadyBoundProjects] = React.useState<
Array<{ projectId: string; projectName: string }>
>([]);

const location = useLocation();
const { push } = useRouter();
const { formatMessage } = useIntl();

const projectKeys = React.useMemo(() => projects.map(({ key }) => key), [projects]);
const {
data: alreadyBoundProjectBindings,
isFetching: isFetchingAlreadyBoundProjects,
isLoading: isLoadingAlreadyBoundProjects,
} = useProjectBindingsQuery(
{
dopSettingId: selectedDopSetting?.id,
repository: selectedRepository?.value,
},
selectedRepository !== undefined,
);

const almKey = location.query.mode as AlmKeys;

@@ -181,6 +199,30 @@ export default function MonorepoProjectCreate(props: Readonly<MonorepoProjectCre
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedRepository]);

useEffect(() => {
if (alreadyBoundProjectBindings === undefined) {
return;
}

if (alreadyBoundProjectBindings.projectBindings.length === 0) {
setAlreadyBoundProjects([]);
return;
}

getComponents({
projects: alreadyBoundProjectBindings.projectBindings.reduce(
(projectsSearchParam, { projectKey }) => `${projectsSearchParam},${projectKey}`,
'',
),
})
.then(({ components }) => {
setAlreadyBoundProjects(
components.map(({ key, name }) => ({ projectId: key, projectName: name })),
);
})
.catch(throwGlobalError);
}, [alreadyBoundProjectBindings]);

if (loadingBindings) {
return <Spinner />;
}
@@ -293,23 +335,50 @@ export default function MonorepoProjectCreate(props: Readonly<MonorepoProjectCre
</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}
/>
<>
<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}
/>
{selectedRepository &&
!isLoadingAlreadyBoundProjects &&
!isFetchingAlreadyBoundProjects && (
<FlagMessage className="sw-mt-2" variant="info">
{alreadyBoundProjects.length === 0 ? (
<FormattedMessage id="onboarding.create_project.monorepo.choose_repository.no_already_bound_projects" />
) : (
<div>
<FormattedMessage id="onboarding.create_project.monorepo.choose_repository.existing_already_bound_projects" />
<ul className="sw-mt-4">
{alreadyBoundProjects.map(({ projectId, projectName }) => (
<li key={projectId}>
<LinkStandalone
to={getProjectUrl(projectId)}
highlight={LinkHighlight.Subdued}
>
{projectName}
</LinkStandalone>
</li>
))}
</ul>
</div>
)}
</FlagMessage>
)}
</>
)}
</div>
</div>

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

@@ -0,0 +1,38 @@
/*
* 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 { useQuery } from '@tanstack/react-query';
import { getProjectBindings } from '../api/dop-translation';

export function useProjectBindingsQuery(
data: {
dopSettingId?: string;
pageIndex?: number;
pageSize?: number;
repository?: string;
},
enabled = true,
) {
return useQuery({
enabled,
queryKey: ['dop-translation', 'project-bindings', data],
queryFn: () => getProjectBindings(data),
});
}

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

@@ -37,3 +37,12 @@ export interface BoundProject {
projectName: string;
repositoryIdentifier: string;
}

export interface ProjectBinding {
dopSetting: string;
id: string;
projectId: string;
projectKey: string;
repository: string;
slug: string;
}

+ 2
- 0
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -4439,6 +4439,8 @@ onboarding.create_project.monorepo.choose_organization.github=Choose the organiz
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.choose_repository.no_already_bound_projects=This repository has no imported projects in SonarQube
onboarding.create_project.monorepo.choose_repository.existing_already_bound_projects=This repository has already been imported, and it's linked to these projects in SonarQube:
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

Loading…
Cancel
Save