aboutsummaryrefslogtreecommitdiffstats
path: root/server
diff options
context:
space:
mode:
authorAmbroise C <ambroise.christea@sonarsource.com>2024-04-08 16:46:47 +0200
committersonartech <sonartech@sonarsource.com>2024-04-10 20:02:55 +0000
commitb9a92dbaaaae23d7d94c8a201d23c932a7548aef (patch)
tree343dfc644c6321cd276afcabe8857cdaac445f1e /server
parent6b5fba4f0bdedee141b45bd9eb780a34b809b0b0 (diff)
downloadsonarqube-b9a92dbaaaae23d7d94c8a201d23c932a7548aef.tar.gz
sonarqube-b9a92dbaaaae23d7d94c8a201d23c932a7548aef.zip
SONAR-21822 Check if selected repository is already bound to an existing project
Diffstat (limited to 'server')
-rw-r--r--server/sonar-web/src/main/js/api/dop-translation.ts16
-rw-r--r--server/sonar-web/src/main/js/api/mocks/DopTranslationServiceMock.ts83
-rw-r--r--server/sonar-web/src/main/js/api/mocks/ProjectsManagementServiceMock.ts3
-rw-r--r--server/sonar-web/src/main/js/api/mocks/data/dop-translation.ts14
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/__tests__/MonorepoProjectCreate-it.tsx (renamed from server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubMonorepoProjectCreate-it.tsx)60
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectCreate.tsx105
-rw-r--r--server/sonar-web/src/main/js/queries/dop-translation.ts38
-rw-r--r--server/sonar-web/src/main/js/types/dop-translation.ts9
8 files changed, 268 insertions, 60 deletions
diff --git a/server/sonar-web/src/main/js/api/dop-translation.ts b/server/sonar-web/src/main/js/api/dop-translation.ts
index 9cc4e482739..fdceb34f90c 100644
--- a/server/sonar-web/src/main/js/api/dop-translation.ts
+++ b/server/sonar-web/src/main/js/api/dop-translation.ts
@@ -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,
+ });
}
diff --git a/server/sonar-web/src/main/js/api/mocks/DopTranslationServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/DopTranslationServiceMock.ts
index 1393c846867..9852c49c535 100644
--- a/server/sonar-web/src/main/js/api/mocks/DopTranslationServiceMock.ts
+++ b/server/sonar-web/src/main/js/api/mocks/DopTranslationServiceMock.ts
@@ -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));
+ }
}
diff --git a/server/sonar-web/src/main/js/api/mocks/ProjectsManagementServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/ProjectsManagementServiceMock.ts
index e004e7a1a6e..57955a41fda 100644
--- a/server/sonar-web/src/main/js/api/mocks/ProjectsManagementServiceMock.ts
+++ b/server/sonar-web/src/main/js/api/mocks/ProjectsManagementServiceMock.ts
@@ -119,6 +119,9 @@ export default class ProjectManagementServiceMock {
) {
return false;
}
+ if (params.projects !== undefined && !params.projects.split(',').includes(item.key)) {
+ return false;
+ }
return true;
});
diff --git a/server/sonar-web/src/main/js/api/mocks/data/dop-translation.ts b/server/sonar-web/src/main/js/api/mocks/data/dop-translation.ts
index bc8a5d9a991..fbb5fa88df3 100644
--- a/server/sonar-web/src/main/js/api/mocks/data/dop-translation.ts
+++ b/server/sonar-web/src/main/js/api/mocks/data/dop-translation.ts
@@ -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,
+ };
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubMonorepoProjectCreate-it.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/MonorepoProjectCreate-it.tsx
index 84cd593471a..bdd41870f99 100644
--- a/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubMonorepoProjectCreate-it.tsx
+++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/MonorepoProjectCreate-it.tsx
@@ -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 });
diff --git a/server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectCreate.tsx
index 25a7399a0b7..4ec9b5e0470 100644
--- a/server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectCreate.tsx
+++ b/server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectCreate.tsx
@@ -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>
diff --git a/server/sonar-web/src/main/js/queries/dop-translation.ts b/server/sonar-web/src/main/js/queries/dop-translation.ts
new file mode 100644
index 00000000000..c0de50fc031
--- /dev/null
+++ b/server/sonar-web/src/main/js/queries/dop-translation.ts
@@ -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),
+ });
+}
diff --git a/server/sonar-web/src/main/js/types/dop-translation.ts b/server/sonar-web/src/main/js/types/dop-translation.ts
index 916752b4823..5d6a6baf982 100644
--- a/server/sonar-web/src/main/js/types/dop-translation.ts
+++ b/server/sonar-web/src/main/js/types/dop-translation.ts
@@ -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;
+}