aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src
diff options
context:
space:
mode:
authorIsmail Cherri <ismail.cherri@sonarsource.com>2024-11-26 15:16:13 +0100
committersonartech <sonartech@sonarsource.com>2024-11-29 20:03:08 +0000
commit04286a5eb6d1241da378709ee6ec918d2a71e329 (patch)
treeef06751b525c9b8c5313645efde33dd2dac8d565 /server/sonar-web/src
parent318799ac3c16659383b30ad47cc2ad716deb42eb (diff)
downloadsonarqube-04286a5eb6d1241da378709ee6ec918d2a71e329.tar.gz
sonarqube-04286a5eb6d1241da378709ee6ec918d2a71e329.zip
SONAR-23620 Users can qualify/disqualify QG for AI code assurance
Diffstat (limited to 'server/sonar-web/src')
-rw-r--r--server/sonar-web/src/main/js/api/mocks/QualityGatesServiceMock.ts94
-rw-r--r--server/sonar-web/src/main/js/api/quality-gates.ts61
-rw-r--r--server/sonar-web/src/main/js/apps/quality-gates/__tests__/utils-test.ts4
-rw-r--r--server/sonar-web/src/main/js/apps/quality-gates/components/Conditions.tsx1
-rw-r--r--server/sonar-web/src/main/js/apps/quality-gates/components/DetailsHeader.tsx63
-rw-r--r--server/sonar-web/src/main/js/apps/quality-gates/components/DisqualifyAiQualityGateForm.tsx67
-rw-r--r--server/sonar-web/src/main/js/apps/quality-gates/components/Projects.tsx7
-rw-r--r--server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/QualityGate-it.tsx63
-rw-r--r--server/sonar-web/src/main/js/apps/quality-gates/utils.ts2
-rw-r--r--server/sonar-web/src/main/js/queries/quality-gates.ts42
-rw-r--r--server/sonar-web/src/main/js/types/types.ts1
11 files changed, 368 insertions, 37 deletions
diff --git a/server/sonar-web/src/main/js/api/mocks/QualityGatesServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/QualityGatesServiceMock.ts
index 8bd2e8158b3..bcaaebd6291 100644
--- a/server/sonar-web/src/main/js/api/mocks/QualityGatesServiceMock.ts
+++ b/server/sonar-web/src/main/js/api/mocks/QualityGatesServiceMock.ts
@@ -35,6 +35,7 @@ import {
SearchPermissionsParameters,
} from '../../types/quality-gates';
import { CaycStatus, Condition, QualityGate } from '../../types/types';
+import { AiCodeAssuranceStatus } from '../ai-code-assurance';
import {
addGroup,
addUser,
@@ -47,6 +48,7 @@ import {
dissociateGateWithProject,
fetchQualityGate,
fetchQualityGates,
+ getAllQualityGateProjects,
getApplicationQualityGate,
getGateForProject,
getQualityGateProjectStatus,
@@ -54,6 +56,7 @@ import {
searchGroups,
searchProjects,
searchUsers,
+ setQualityGateAiQualified,
setQualityGateAsDefault,
updateCondition,
} from '../quality-gates';
@@ -242,10 +245,10 @@ export class QualityGatesServiceMock {
isCaycCondition: false,
},
{
- id: '92561420-727c-49f8-837e-87e9d413b403',
- metric: 'security_review_rating',
- op: 'GT',
- error: '1',
+ id: 'fc3d8a6e-e020-48a8-8bcb-ceccd1f9ca63',
+ metric: 'security_hotspots_reviewed',
+ op: 'LT',
+ error: '100',
isCaycCondition: false,
},
{
@@ -401,12 +404,48 @@ export class QualityGatesServiceMock {
this.list = cloneDeep(this.readOnlyList);
this.projects = [
- { key: 'test1', name: 'test1', selected: false, isAiCodeAssured: false },
- { key: 'test2', name: 'test2', selected: false, isAiCodeAssured: false },
- { key: 'test3', name: 'test3', selected: true, isAiCodeAssured: false },
- { key: 'test4', name: 'test4', selected: true, isAiCodeAssured: false },
- { key: 'test5', name: 'test5', selected: true, isAiCodeAssured: true },
- { key: 'test6', name: 'test6', selected: false, isAiCodeAssured: true },
+ {
+ key: 'test1',
+ name: 'test1',
+ selected: false,
+ aiCodeAssurance: AiCodeAssuranceStatus.NONE,
+ },
+ {
+ key: 'test2',
+ name: 'test2',
+ selected: false,
+ aiCodeAssurance: AiCodeAssuranceStatus.NONE,
+ },
+ {
+ key: 'test3',
+ name: 'test3',
+ selected: true,
+ aiCodeAssurance: AiCodeAssuranceStatus.NONE,
+ },
+ {
+ key: 'test4',
+ name: 'test4',
+ selected: true,
+ aiCodeAssurance: AiCodeAssuranceStatus.NONE,
+ },
+ {
+ key: 'test5',
+ name: 'test5',
+ selected: true,
+ aiCodeAssurance: AiCodeAssuranceStatus.CONTAINS_AI_CODE,
+ },
+ {
+ key: 'test6',
+ name: 'test6',
+ selected: false,
+ aiCodeAssurance: AiCodeAssuranceStatus.CONTAINS_AI_CODE,
+ },
+ {
+ key: 'test7',
+ name: 'test7',
+ selected: true,
+ aiCodeAssurance: AiCodeAssuranceStatus.AI_CODE_ASSURED,
+ },
];
this.getGateForProjectGateName = 'SonarSource way';
@@ -422,6 +461,9 @@ export class QualityGatesServiceMock {
jest.mocked(updateCondition).mockImplementation(this.updateConditionHandler);
jest.mocked(deleteCondition).mockImplementation(this.deleteConditionHandler);
jest.mocked(searchProjects).mockImplementation(this.searchProjectsHandler);
+ jest
+ .mocked(getAllQualityGateProjects)
+ .mockImplementation(this.getAllQualityGateProjectsHandler);
jest.mocked(searchUsers).mockImplementation(this.searchUsersHandler);
jest.mocked(searchGroups).mockImplementation(this.searchGroupsHandler);
jest.mocked(associateGateWithProject).mockImplementation(this.selectHandler);
@@ -430,6 +472,7 @@ export class QualityGatesServiceMock {
jest.mocked(getGateForProject).mockImplementation(this.projectGateHandler);
jest.mocked(getQualityGateProjectStatus).mockImplementation(this.handleQualityGetProjectStatus);
jest.mocked(getApplicationQualityGate).mockImplementation(this.handleGetApplicationQualityGate);
+ jest.mocked(setQualityGateAiQualified).mockImplementation(this.handleSetQualityGateAiQualified);
this.qualityGateProjectStatus = mockQualityGateProjectStatus({});
this.applicationQualityGate = mockQualityGateApplicationStatus({});
@@ -478,6 +521,7 @@ export class QualityGatesServiceMock {
delete: q.isBuiltIn ? false : this.isAdmin,
manageConditions: this.isAdmin,
delegate: this.isAdmin,
+ manageAiCodeAssurance: this.isAdmin && !q.isBuiltIn,
};
}
@@ -685,6 +729,27 @@ export class QualityGatesServiceMock {
return this.reply(response);
};
+ getAllQualityGateProjectsHandler = async ({
+ gateName,
+ selected,
+ query,
+ }: {
+ gateName: string;
+ query: string | undefined;
+ selected: string;
+ }) => {
+ const initialResponse = await this.searchProjectsHandler({ gateName: '', query, selected });
+
+ const response = {
+ paging: { pageIndex: 3, pageSize: 3, total: 55 },
+ results:
+ gateName === 'SonarSource way'
+ ? initialResponse.results.filter((p) => p.aiCodeAssurance !== 'AI_CODE_ASSURED')
+ : initialResponse.results,
+ };
+ return this.reply(response);
+ };
+
searchUsersHandler = ({ selected }: SearchPermissionsParameters) => {
if (selected === 'selected') {
return this.reply({ users: [] });
@@ -757,6 +822,15 @@ export class QualityGatesServiceMock {
}
};
+ handleSetQualityGateAiQualified = (gateName: string, aiCodeAssurance: boolean) => {
+ const targetQG = this.list.find((q) => q.name === gateName);
+ if (targetQG === undefined) {
+ return Promise.reject(new Error(`No quality gate has been found for name ${gateName}`));
+ }
+ targetQG.isAiCodeSupported = aiCodeAssurance;
+ return this.reply(undefined);
+ };
+
reply<T>(response: T): Promise<T> {
return Promise.resolve(cloneDeep(response));
}
diff --git a/server/sonar-web/src/main/js/api/quality-gates.ts b/server/sonar-web/src/main/js/api/quality-gates.ts
index ec210060b66..3c5ceb95aec 100644
--- a/server/sonar-web/src/main/js/api/quality-gates.ts
+++ b/server/sonar-web/src/main/js/api/quality-gates.ts
@@ -32,6 +32,25 @@ import {
} from '../types/quality-gates';
import { Condition, Paging, QualityGate, QualityGatePreview } from '../types/types';
import { UserBase } from '../types/users';
+import { AiCodeAssuranceStatus } from './ai-code-assurance';
+
+export interface SearchQualityGateProjectsData {
+ gateName: string;
+ page?: number;
+ pageSize?: number;
+ query?: string;
+ selected?: string;
+}
+
+export interface SearchQualityGateProjectsResponse {
+ paging: Paging;
+ results: Array<{
+ aiCodeAssurance: AiCodeAssuranceStatus;
+ key: string;
+ name: string;
+ selected: boolean;
+ }>;
+}
export function fetchQualityGates(): Promise<{
actions: { create: boolean };
@@ -67,6 +86,16 @@ export function setQualityGateAsDefault(data: { name: string }): Promise<void |
return post('/api/qualitygates/set_as_default', data).catch(throwGlobalError);
}
+export function setQualityGateAiQualified(
+ gateName: string,
+ aiCodeAssurance: boolean,
+): Promise<void> {
+ return post('/api/qualitygates/set_ai_code_assurance', {
+ aiCodeAssurance,
+ gateName,
+ }).catch(throwGlobalError);
+}
+
export function createCondition(
data: {
gateName: string;
@@ -93,19 +122,31 @@ export function getGateForProject(data: { project: string }): Promise<QualityGat
);
}
-export function searchProjects(data: {
- gateName: string;
- page?: number;
- pageSize?: number;
- query?: string;
- selected?: string;
-}): Promise<{
- paging: Paging;
- results: Array<{ isAiCodeAssured: boolean; key: string; name: string; selected: boolean }>;
-}> {
+export function searchProjects(
+ data: SearchQualityGateProjectsData,
+): Promise<SearchQualityGateProjectsResponse> {
return getJSON('/api/qualitygates/search', data).catch(throwGlobalError);
}
+export function getAllQualityGateProjects(
+ data: SearchQualityGateProjectsData,
+ prev?: SearchQualityGateProjectsResponse,
+): Promise<SearchQualityGateProjectsResponse> {
+ return searchProjects({ ...data, pageSize: 1000 }).then((r) => {
+ const result = prev
+ ? {
+ results: [...prev.results, ...r.results],
+ paging: r.paging,
+ }
+ : r;
+
+ if (result.paging.pageIndex * result.paging.pageSize >= result.paging.total) {
+ return result;
+ }
+ return getAllQualityGateProjects({ ...data, page: result.paging.pageIndex + 1 }, result);
+ });
+}
+
export function associateGateWithProject(data: {
gateName: string;
projectKey: string;
diff --git a/server/sonar-web/src/main/js/apps/quality-gates/__tests__/utils-test.ts b/server/sonar-web/src/main/js/apps/quality-gates/__tests__/utils-test.ts
index f0b0e293860..149ee5da7e8 100644
--- a/server/sonar-web/src/main/js/apps/quality-gates/__tests__/utils-test.ts
+++ b/server/sonar-web/src/main/js/apps/quality-gates/__tests__/utils-test.ts
@@ -90,7 +90,7 @@ describe('groupAndSortByPriorityConditions', () => {
];
const expectedConditionsOrderAIOverall = [
MetricKey.software_quality_security_rating,
- MetricKey.security_review_rating,
+ MetricKey.security_hotspots_reviewed,
MetricKey.software_quality_reliability_rating,
];
@@ -108,7 +108,7 @@ describe('groupAndSortByPriorityConditions', () => {
it('should return grouped conditions by overall/new code and sort them for builtIn Ai QG', () => {
const aiConditions = [
...conditions,
- mockCondition({ metric: MetricKey.security_review_rating }),
+ mockCondition({ metric: MetricKey.security_hotspots_reviewed }),
mockCondition({ metric: MetricKey.software_quality_reliability_rating }),
mockCondition({ metric: MetricKey.software_quality_security_rating }),
];
diff --git a/server/sonar-web/src/main/js/apps/quality-gates/components/Conditions.tsx b/server/sonar-web/src/main/js/apps/quality-gates/components/Conditions.tsx
index 3d17ea40a77..cbf71aa585a 100644
--- a/server/sonar-web/src/main/js/apps/quality-gates/components/Conditions.tsx
+++ b/server/sonar-web/src/main/js/apps/quality-gates/components/Conditions.tsx
@@ -360,7 +360,6 @@ export default function Conditions({ qualityGate, isFetching }: Readonly<Props>)
<div>
<FormattedMessage
id="quality_gates.cayc_unfollow.description"
- defaultMessage={translate('quality_gates.cayc_unfollow.description')}
values={{
cayc_link: <Link to={docUrl}>{translate('quality_gates.cayc')}</Link>,
}}
diff --git a/server/sonar-web/src/main/js/apps/quality-gates/components/DetailsHeader.tsx b/server/sonar-web/src/main/js/apps/quality-gates/components/DetailsHeader.tsx
index 75d63a98363..09df183df4d 100644
--- a/server/sonar-web/src/main/js/apps/quality-gates/components/DetailsHeader.tsx
+++ b/server/sonar-web/src/main/js/apps/quality-gates/components/DetailsHeader.tsx
@@ -18,17 +18,30 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { ButtonIcon, DropdownMenu, IconMoreVertical, Tooltip } from '@sonarsource/echoes-react';
+import {
+ ButtonIcon,
+ DropdownMenu,
+ DropdownMenuAlign,
+ IconMoreVertical,
+ Tooltip,
+} from '@sonarsource/echoes-react';
import { countBy } from 'lodash';
import * as React from 'react';
+import { useCallback } from 'react';
import { Badge, ButtonSecondary, DangerButtonPrimary, SubTitle } from '~design-system';
+import { AiCodeAssuranceStatus } from '../../../api/ai-code-assurance';
import LegacyTooltip from '../../../components/controls/Tooltip';
import { translate } from '../../../helpers/l10n';
-import { useSetQualityGateAsDefaultMutation } from '../../../queries/quality-gates';
+import {
+ useGetAllQualityGateProjectsQuery,
+ useSetAiSupportedQualityGateMutation,
+ useSetQualityGateAsDefaultMutation,
+} from '../../../queries/quality-gates';
import { CaycStatus, QualityGate } from '../../../types/types';
import BuiltInQualityGateBadge from './BuiltInQualityGateBadge';
import CopyQualityGateForm from './CopyQualityGateForm';
import DeleteQualityGateForm from './DeleteQualityGateForm';
+import DisqualifyAiQualityGateForm from './DisqualifyAiQualityGateForm';
import RenameQualityGateForm from './RenameQualityGateForm';
interface Props {
@@ -39,6 +52,7 @@ export default function DetailsHeader({ qualityGate }: Readonly<Props>) {
const [isRenameFormOpen, setIsRenameFormOpen] = React.useState(false);
const [isCopyFormOpen, setIsCopyFormOpen] = React.useState(false);
const [isRemoveFormOpen, setIsRemoveFormOpen] = React.useState(false);
+ const [isQualifyAiFormOpen, setIsQualifyAiFormOpen] = React.useState(false);
const actions = qualityGate.actions ?? {};
const actionsCount = countBy([
actions.rename,
@@ -47,6 +61,17 @@ export default function DetailsHeader({ qualityGate }: Readonly<Props>) {
actions.setAsDefault,
])['true'];
const { mutateAsync: setQualityGateAsDefault } = useSetQualityGateAsDefaultMutation();
+ const { mutateAsync: setAiSupportedQualityGate } = useSetAiSupportedQualityGateMutation(
+ qualityGate.name,
+ );
+ const { data: qualityGateProjectsHavingAiCode = [], isLoading: isCountLoading } =
+ useGetAllQualityGateProjectsQuery(
+ { gateName: qualityGate.name, selected: 'selected' },
+ {
+ select: (data) =>
+ data.results.filter((p) => p.aiCodeAssurance === AiCodeAssuranceStatus.AI_CODE_ASSURED),
+ },
+ );
const handleSetAsDefaultClick = () => {
if (!qualityGate.isDefault) {
@@ -54,6 +79,23 @@ export default function DetailsHeader({ qualityGate }: Readonly<Props>) {
}
};
+ const handleSetQualityGateAiCodeAssurance = () => {
+ if (qualityGateProjectsHavingAiCode?.length > 0 && qualityGate.isAiCodeSupported) {
+ setIsQualifyAiFormOpen(true);
+ return;
+ }
+
+ updateQualityGateAiCodeAssurance();
+ };
+
+ const updateQualityGateAiCodeAssurance = useCallback(() => {
+ setAiSupportedQualityGate({
+ isQualityGateAiSupported: !qualityGate.isAiCodeSupported,
+ name: qualityGate.name,
+ });
+ setIsQualifyAiFormOpen(false);
+ }, [qualityGate.isAiCodeSupported, qualityGate.name, setAiSupportedQualityGate]);
+
return (
<>
<div className="it__layout-page-main-header sw-flex sw-items-center sw-justify-between sw-mb-9">
@@ -115,6 +157,7 @@ export default function DetailsHeader({ qualityGate }: Readonly<Props>) {
{actionsCount > 1 && (
<DropdownMenu.Root
+ align={DropdownMenuAlign.End}
id="quality-gate-actions"
items={
<>
@@ -155,6 +198,15 @@ export default function DetailsHeader({ qualityGate }: Readonly<Props>) {
</DropdownMenu.ItemButton>
</Tooltip>
)}
+ {actions.manageAiCodeAssurance && !isCountLoading && (
+ <DropdownMenu.ItemButton onClick={handleSetQualityGateAiCodeAssurance}>
+ {translate(
+ qualityGate.isAiCodeSupported
+ ? 'quality_gates.actions.disqualify_for_ai_code_assurance'
+ : 'quality_gates.actions.qualify_for_ai_code_assurance',
+ )}
+ </DropdownMenu.ItemButton>
+ )}
{actions.delete && (
<>
<DropdownMenu.Separator />
@@ -188,6 +240,13 @@ export default function DetailsHeader({ qualityGate }: Readonly<Props>) {
qualityGate={qualityGate}
/>
)}
+ {isQualifyAiFormOpen && (
+ <DisqualifyAiQualityGateForm
+ onClose={() => setIsQualifyAiFormOpen(false)}
+ onConfirm={updateQualityGateAiCodeAssurance}
+ count={qualityGateProjectsHavingAiCode.length}
+ />
+ )}
</>
);
}
diff --git a/server/sonar-web/src/main/js/apps/quality-gates/components/DisqualifyAiQualityGateForm.tsx b/server/sonar-web/src/main/js/apps/quality-gates/components/DisqualifyAiQualityGateForm.tsx
new file mode 100644
index 00000000000..56ca78df6c0
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/quality-gates/components/DisqualifyAiQualityGateForm.tsx
@@ -0,0 +1,67 @@
+/*
+ * 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 { Button, ButtonVariety, Modal, ModalSize } from '@sonarsource/echoes-react';
+import { useIntl } from 'react-intl';
+
+interface Props {
+ count: number;
+ onClose: () => void;
+ onConfirm: () => void;
+}
+
+export default function DisqualifyAiQualityGateForm({
+ onConfirm,
+ onClose,
+ count = 0,
+}: Readonly<Props>) {
+ const intl = useIntl();
+
+ return (
+ <Modal
+ isOpen
+ size={ModalSize.Wide}
+ title={intl.formatMessage({ id: 'quality_gates.disqualify_ai_modal.title' }, { count })}
+ onOpenChange={onClose}
+ content={
+ <>
+ <p>
+ {intl.formatMessage(
+ { id: 'quality_gates.disqualify_ai_modal.content.line1' },
+ { count },
+ )}
+ </p>
+ <br />
+ <p>{intl.formatMessage({ id: 'quality_gates.disqualify_ai_modal.content.line2' })}</p>
+ </>
+ }
+ primaryButton={
+ <Button onClick={onConfirm} variety={ButtonVariety.Primary}>
+ {intl.formatMessage({ id: 'quality_gates.disqualify_ai_modal.confirm' })}
+ </Button>
+ }
+ secondaryButton={
+ <Button hasAutoFocus onClick={onClose}>
+ {intl.formatMessage({ id: 'cancel' })}
+ </Button>
+ }
+ />
+ );
+}
diff --git a/server/sonar-web/src/main/js/apps/quality-gates/components/Projects.tsx b/server/sonar-web/src/main/js/apps/quality-gates/components/Projects.tsx
index 028b5e4aa48..4578b2a11d9 100644
--- a/server/sonar-web/src/main/js/apps/quality-gates/components/Projects.tsx
+++ b/server/sonar-web/src/main/js/apps/quality-gates/components/Projects.tsx
@@ -21,6 +21,7 @@
import { find, without } from 'lodash';
import * as React from 'react';
import { Note } from '~design-system';
+import { AiCodeAssuranceStatus } from '../../../api/ai-code-assurance';
import {
associateGateWithProject,
dissociateGateWithProject,
@@ -48,7 +49,7 @@ interface State {
// exported for testing
export interface Project {
- isAiCodeAssured: boolean;
+ aiCodeAssurance: AiCodeAssuranceStatus;
key: string;
name: string;
selected: boolean;
@@ -142,7 +143,7 @@ export default class Projects extends React.PureComponent<Props, State> {
{project.name}
<br />
<Note>{project.key}</Note>
- {project.isAiCodeAssured && (
+ {project.aiCodeAssurance === AiCodeAssuranceStatus.CONTAINS_AI_CODE && (
<p>
<Note>{translate('quality_gates.projects.ai_assured_message')}</Note>
</p>
@@ -166,7 +167,7 @@ export default class Projects extends React.PureComponent<Props, State> {
<SelectList
elements={this.state.projects.map((project) => project.key)}
disabledElements={this.state.projects
- .filter((project) => project.isAiCodeAssured)
+ .filter((project) => project.aiCodeAssurance === AiCodeAssuranceStatus.CONTAINS_AI_CODE)
.map((project) => project.key)}
elementsTotalCount={this.state.projectsTotalCount}
labelAll={translate('quality_gates.projects.all')}
diff --git a/server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/QualityGate-it.tsx b/server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/QualityGate-it.tsx
index f0828d2edcf..cf7f770416d 100644
--- a/server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/QualityGate-it.tsx
+++ b/server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/QualityGate-it.tsx
@@ -245,6 +245,59 @@ it('should be able to set as default a quality gate which is CaYC compliant', as
).toBeInTheDocument();
});
+it('should be able to qualify/disqualify a quality gate for AI code assurance', async () => {
+ const user = userEvent.setup();
+ qualityGateHandler.setIsAdmin(true);
+ renderQualityGateApp();
+ await user.click(await screen.findByLabelText('actions'));
+ const qualifyButton = screen.getByRole('menuitem', {
+ name: 'quality_gates.actions.qualify_for_ai_code_assurance',
+ });
+ expect(qualifyButton).toBeInTheDocument();
+ await user.click(qualifyButton);
+
+ await user.click(await screen.findByLabelText('actions'));
+ const disqualifyButton = screen.getByRole('menuitem', {
+ name: 'quality_gates.actions.disqualify_for_ai_code_assurance',
+ });
+
+ expect(disqualifyButton).toBeInTheDocument();
+ await user.click(disqualifyButton);
+});
+
+it('should show confirmation when disqualifying a quality gate with projects having AI code', async () => {
+ const user = userEvent.setup();
+ qualityGateHandler.setIsAdmin(true);
+ renderQualityGateApp();
+
+ await user.click(await screen.findByText('SonarSource way - CFamily'));
+
+ await user.click(await screen.findByLabelText('actions'));
+ const qualifyButton = screen.getByRole('menuitem', {
+ name: 'quality_gates.actions.qualify_for_ai_code_assurance',
+ });
+ await user.click(qualifyButton);
+
+ await user.click(await screen.findByLabelText('actions'));
+ const disqualifyButton = screen.getByRole('menuitem', {
+ name: 'quality_gates.actions.disqualify_for_ai_code_assurance',
+ });
+
+ await user.click(disqualifyButton);
+
+ expect(await screen.findByRole('dialog')).toBeInTheDocument();
+ await user.click(
+ screen.getByRole('button', { name: 'quality_gates.disqualify_ai_modal.confirm' }),
+ );
+
+ await user.click(await screen.findByLabelText('actions'));
+ expect(
+ screen.getByRole('menuitem', {
+ name: 'quality_gates.actions.qualify_for_ai_code_assurance',
+ }),
+ ).toBeInTheDocument();
+});
+
it('should be able to add a condition on new code', async () => {
const user = userEvent.setup();
qualityGateHandler.setIsAdmin(true);
@@ -724,7 +777,7 @@ describe('The Project section', () => {
await user.click(notDefaultQualityGate);
// by default it shows "selected" values
- expect(await screen.findAllByRole('checkbox')).toHaveLength(3);
+ expect(await screen.findAllByRole('checkbox')).toHaveLength(4);
// change tabs to show deselected projects
await user.click(screen.getByRole('radio', { name: 'quality_gates.projects.without' }));
@@ -732,7 +785,7 @@ describe('The Project section', () => {
// change tabs to show all projects
await user.click(screen.getByRole('radio', { name: 'quality_gates.projects.all' }));
- expect(screen.getAllByRole('checkbox')).toHaveLength(6);
+ expect(screen.getAllByRole('checkbox')).toHaveLength(7);
});
it('should handle select and deselect correctly', async () => {
@@ -744,7 +797,7 @@ describe('The Project section', () => {
await user.click(notDefaultQualityGate);
- expect(await screen.findAllByRole('checkbox')).toHaveLength(3);
+ expect(await screen.findAllByRole('checkbox')).toHaveLength(4);
const checkedProjects = screen.getAllByRole('checkbox')[0];
await user.click(checkedProjects);
const reloadButton = screen.getByRole('button', { name: 'reload' });
@@ -753,7 +806,7 @@ describe('The Project section', () => {
// FP
// eslint-disable-next-line jest-dom/prefer-in-document
- expect(screen.getAllByRole('checkbox')).toHaveLength(2);
+ expect(screen.getAllByRole('checkbox')).toHaveLength(3);
// projects with disabled as true are not selectable
// last checked project in mock service is disabled
@@ -781,7 +834,7 @@ describe('The Project section', () => {
// change tabs to show all projects
await user.click(screen.getByRole('radio', { name: 'quality_gates.projects.all' }));
- expect(screen.getAllByRole('checkbox')).toHaveLength(6);
+ expect(screen.getAllByRole('checkbox')).toHaveLength(7);
const disabledCheckedProjectsAll = screen.getByRole('checkbox', {
name: 'test5 test5 quality_gates.projects.ai_assured_message',
diff --git a/server/sonar-web/src/main/js/apps/quality-gates/utils.ts b/server/sonar-web/src/main/js/apps/quality-gates/utils.ts
index 8724fbf3fa3..044ce62921d 100644
--- a/server/sonar-web/src/main/js/apps/quality-gates/utils.ts
+++ b/server/sonar-web/src/main/js/apps/quality-gates/utils.ts
@@ -140,7 +140,7 @@ export const CAYC_CONDITION_ORDER_PRIORITIES: Dict<number> = {
export const AI_SUPPORTED_CONDITION_ORDER_PRIORITIES: Dict<number> = {
[MetricKey.software_quality_security_rating]: 1,
[MetricKey.security_rating]: 1,
- [MetricKey.security_review_rating]: 2,
+ [MetricKey.security_hotspots_reviewed]: 2,
[MetricKey.software_quality_reliability_rating]: 3,
[MetricKey.reliability_rating]: 3,
};
diff --git a/server/sonar-web/src/main/js/queries/quality-gates.ts b/server/sonar-web/src/main/js/queries/quality-gates.ts
index d18dca53474..caff5bb8257 100644
--- a/server/sonar-web/src/main/js/queries/quality-gates.ts
+++ b/server/sonar-web/src/main/js/queries/quality-gates.ts
@@ -30,10 +30,12 @@ import {
deleteQualityGate,
fetchQualityGate,
fetchQualityGates,
+ getAllQualityGateProjects,
getApplicationQualityGate,
getGateForProject,
getQualityGateProjectStatus,
renameQualityGate,
+ setQualityGateAiQualified,
setQualityGateAsDefault,
updateCondition,
} from '../api/quality-gates';
@@ -46,11 +48,13 @@ const QUERY_STALE_TIME = 5 * 60 * 1000;
const qualityQuery = {
all: () => ['quality-gate'] as const,
- list: () => ['quality-gate', 'list'] as const,
- details: () => ['quality-gate', 'details'] as const,
+ list: () => [qualityQuery.all(), 'list'] as const,
+ details: () => [qualityQuery.all(), 'details'] as const,
detail: (name?: string) => [...qualityQuery.details(), name ?? ''] as const,
- projectsAssoc: () => ['quality-gate', 'project-assoc'] as const,
+ projectsAssoc: () => [qualityQuery.all(), 'project-assoc'] as const,
projectAssoc: (project: string) => [...qualityQuery.projectsAssoc(), project] as const,
+ allProjectsSearch: (qualityGate: string) =>
+ [qualityQuery.all(), 'all-project-search', qualityGate] as const,
};
// This is internal to "enable" query when searching from the project page
@@ -94,6 +98,17 @@ export const useQualityGatesQuery = createQueryHook(() => {
});
});
+export const useGetAllQualityGateProjectsQuery = createQueryHook(
+ (data: Parameters<typeof getAllQualityGateProjects>[0]) => {
+ return queryOptions({
+ queryKey: qualityQuery.allProjectsSearch(data?.gateName ?? ''),
+ queryFn: () => {
+ return getAllQualityGateProjects(data);
+ },
+ });
+ },
+);
+
export function useCreateQualityGateMutation() {
const queryClient = useQueryClient();
@@ -164,6 +179,27 @@ export function useDeleteQualityGateMutation(name: string) {
});
}
+export function useSetAiSupportedQualityGateMutation(name: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({
+ name,
+ isQualityGateAiSupported,
+ }: {
+ isQualityGateAiSupported: boolean;
+ name: string;
+ }) => {
+ return setQualityGateAiQualified(name, isQualityGateAiSupported);
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: qualityQuery.list() });
+ queryClient.invalidateQueries({ queryKey: qualityQuery.projectsAssoc() });
+ queryClient.invalidateQueries({ queryKey: qualityQuery.detail(name) });
+ },
+ });
+}
+
export function useFixQualityGateMutation(gateName: string) {
const queryClient = useQueryClient();
diff --git a/server/sonar-web/src/main/js/types/types.ts b/server/sonar-web/src/main/js/types/types.ts
index 42698c8c3d5..ad23b911c75 100644
--- a/server/sonar-web/src/main/js/types/types.ts
+++ b/server/sonar-web/src/main/js/types/types.ts
@@ -477,6 +477,7 @@ export interface QualityGate extends QualityGatePreview {
copy?: boolean;
delegate?: boolean;
delete?: boolean;
+ manageAiCodeAssurance?: boolean;
manageConditions?: boolean;
rename?: boolean;
setAsDefault?: boolean;