*/
import { axiosToCatch } from '../helpers/request';
-import { SuggestedFix } from '../types/fix-suggestions';
+import { AiCodeFixFeatureEnablement, SuggestedFix } from '../types/fix-suggestions';
export interface FixParam {
issueId: string;
status: SuggestionServiceStatus;
}
+export interface UpdateFeatureEnablementParams {
+ changes: {
+ disabledProjectKeys: string[];
+ enabledProjectKeys: string[];
+ };
+ enablement: AiCodeFixFeatureEnablement;
+}
+
export function getSuggestions(data: FixParam): Promise<SuggestedFix> {
return axiosToCatch.post<SuggestedFix>('/api/v2/fix-suggestions/ai-suggestions', data);
}
export function checkSuggestionServiceStatus(): Promise<SuggestionServiceStatusCheckResponse> {
return axiosToCatch.post(`/api/v2/fix-suggestions/service-status-checks`);
}
+
+export function updateFeatureEnablement(
+ featureEnablementParams: UpdateFeatureEnablementParams,
+): Promise<void> {
+ return axiosToCatch.patch(`/api/v2/fix-suggestions/feature-enablements`, featureEnablementParams);
+}
const query = data.filter.split('query=')[1];
return c.key.includes(query) || c.name.includes(query);
}
+ return true;
})
.map((c) => c);
throw new Error(`Couldn't find source file for key ${key}`);
};
+ registerProject = (project: ComponentRaw) => {
+ this.projects.push(project);
+ };
+
registerComponent = (component: Component, ancestors: Component[] = []) => {
this.components.push({ component, ancestors, children: [] });
};
+++ /dev/null
-/*
- * 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 {
- checkSuggestionServiceStatus,
- FixParam,
- getFixSuggestionsIssues,
- getSuggestions,
- SuggestionServiceStatus,
- SuggestionServiceStatusCheckResponse,
-} from '../fix-suggestions';
-import { ISSUE_101, ISSUE_1101 } from './data/ids';
-
-jest.mock('../fix-suggestions');
-
-export type MockSuggestionServiceStatus = SuggestionServiceStatus | 'WTF' | undefined;
-
-export default class FixIssueServiceMock {
- fixSuggestion = {
- id: '70b14d4c-d302-4979-9121-06ac7d563c5c',
- issueId: 'AYsVhClEbjXItrbcN71J',
- explanation:
- "Replaced 'require' statements with 'import' statements to comply with ECMAScript 2015 module management standards.",
- changes: [
- {
- startLine: 6,
- endLine: 7,
- newCode: "import { glob } from 'glob';\nimport fs from 'fs';",
- },
- ],
- };
-
- serviceStatus: MockSuggestionServiceStatus = 'SUCCESS';
-
- constructor() {
- jest.mocked(getSuggestions).mockImplementation(this.handleGetFixSuggestion);
- jest.mocked(getFixSuggestionsIssues).mockImplementation(this.handleGetFixSuggestionsIssues);
- jest.mocked(checkSuggestionServiceStatus).mockImplementation(this.handleCheckService);
- }
-
- handleGetFixSuggestionsIssues = (data: FixParam) => {
- if (data.issueId === ISSUE_1101) {
- return this.reply({ aiSuggestion: 'NOT_AVAILABLE_FILE_LEVEL_ISSUE', id: 'id1' } as const);
- }
- return this.reply({ aiSuggestion: 'AVAILABLE', id: 'id1' } as const);
- };
-
- handleGetFixSuggestion = (data: FixParam) => {
- if (data.issueId === ISSUE_101) {
- return Promise.reject({ error: { msg: 'Invalid issue' } });
- }
- return this.reply(this.fixSuggestion);
- };
-
- handleCheckService = () => {
- if (this.serviceStatus) {
- return this.reply({ status: this.serviceStatus } as SuggestionServiceStatusCheckResponse);
- }
- return Promise.reject({ error: { msg: 'Error' } });
- };
-
- reply<T>(response: T): Promise<T> {
- return new Promise((resolve) => {
- setTimeout(() => {
- resolve(cloneDeep(response));
- }, 10);
- });
- }
-
- setServiceStatus(status: MockSuggestionServiceStatus) {
- this.serviceStatus = status;
- }
-}
--- /dev/null
+/*
+ * 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 {
+ checkSuggestionServiceStatus,
+ FixParam,
+ getFixSuggestionsIssues,
+ getSuggestions,
+ SuggestionServiceStatus,
+ SuggestionServiceStatusCheckResponse,
+ updateFeatureEnablement,
+ UpdateFeatureEnablementParams,
+} from '../fix-suggestions';
+import { ISSUE_101, ISSUE_1101 } from './data/ids';
+
+jest.mock('../fix-suggestions');
+
+export type MockSuggestionServiceStatus = SuggestionServiceStatus | 'WTF' | undefined;
+
+export default class FixSuggestionsServiceMock {
+ fixSuggestion = {
+ id: '70b14d4c-d302-4979-9121-06ac7d563c5c',
+ issueId: 'AYsVhClEbjXItrbcN71J',
+ explanation:
+ "Replaced 'require' statements with 'import' statements to comply with ECMAScript 2015 module management standards.",
+ changes: [
+ {
+ startLine: 6,
+ endLine: 7,
+ newCode: "import { glob } from 'glob';\nimport fs from 'fs';",
+ },
+ ],
+ };
+
+ serviceStatus: MockSuggestionServiceStatus = 'SUCCESS';
+
+ constructor() {
+ jest.mocked(getSuggestions).mockImplementation(this.handleGetFixSuggestion);
+ jest.mocked(getFixSuggestionsIssues).mockImplementation(this.handleGetFixSuggestionsIssues);
+ jest.mocked(checkSuggestionServiceStatus).mockImplementation(this.handleCheckService);
+ jest.mocked(updateFeatureEnablement).mockImplementation(this.handleUpdateFeatureEnablement);
+ }
+
+ handleGetFixSuggestionsIssues = (data: FixParam) => {
+ if (data.issueId === ISSUE_1101) {
+ return this.reply({ aiSuggestion: 'NOT_AVAILABLE_FILE_LEVEL_ISSUE', id: 'id1' } as const);
+ }
+ return this.reply({ aiSuggestion: 'AVAILABLE', id: 'id1' } as const);
+ };
+
+ handleGetFixSuggestion = (data: FixParam) => {
+ if (data.issueId === ISSUE_101) {
+ return Promise.reject({ error: { msg: 'Invalid issue' } });
+ }
+ return this.reply(this.fixSuggestion);
+ };
+
+ handleCheckService = () => {
+ if (this.serviceStatus) {
+ return this.reply({ status: this.serviceStatus } as SuggestionServiceStatusCheckResponse);
+ }
+ return Promise.reject({ error: { msg: 'Error' } });
+ };
+
+ handleUpdateFeatureEnablement = (_: UpdateFeatureEnablementParams) => {
+ return Promise.resolve();
+ };
+
+ reply<T>(response: T): Promise<T> {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve(cloneDeep(response));
+ }, 10);
+ });
+ }
+
+ setServiceStatus(status: MockSuggestionServiceStatus) {
+ this.serviceStatus = status;
+ }
+}
tags: ['sonarqube'],
visibility: Visibility.Public,
leakPeriodDate: '2023-08-10T12:28:45+0000',
+ isAiCodeFixEnabled: true,
},
{
key: 'org.sonarsource.javascript:javascript',
import BranchesServiceMock from '../../api/mocks/BranchesServiceMock';
import ComponentsServiceMock from '../../api/mocks/ComponentsServiceMock';
import CveServiceMock from '../../api/mocks/CveServiceMock';
-import FixIssueServiceMock from '../../api/mocks/FixIssueServiceMock';
+import FixSuggestionsServiceMock from '../../api/mocks/FixSuggestionsServiceMock';
import IssuesServiceMock from '../../api/mocks/IssuesServiceMock';
import SettingsServiceMock from '../../api/mocks/SettingsServiceMock';
import SourcesServiceMock from '../../api/mocks/SourcesServiceMock';
export const componentsHandler = new ComponentsServiceMock();
export const sourcesHandler = new SourcesServiceMock();
export const branchHandler = new BranchesServiceMock();
-export const fixIssueHandler = new FixIssueServiceMock();
+export const fixIssueHandler = new FixSuggestionsServiceMock();
export const settingsHandler = new SettingsServiceMock();
export const ui = {
import { ExtendedSettingDefinition } from '../../../types/settings';
import { Component } from '../../../types/types';
import {
+ AI_CODE_FIX_CATEGORY,
ALM_INTEGRATION_CATEGORY,
ANALYSIS_SCOPE_CATEGORY,
AUTHENTICATION_CATEGORY,
- CODE_FIX_CATEGORY,
EMAIL_NOTIFICATION_CATEGORY,
LANGUAGES_CATEGORY,
MODE_CATEGORY,
NEW_CODE_PERIOD_CATEGORY,
PULL_REQUEST_DECORATION_BINDING_CATEGORY,
} from '../constants';
+import AiCodeFixAdmin from './AiCodeFixAdmin';
import { AnalysisScope } from './AnalysisScope';
-import CodeFixAdmin from './CodeFixAdmin';
import Languages from './Languages';
import { Mode } from './Mode';
import NewCodeDefinition from './NewCodeDefinition';
displayTab: true,
},
{
- key: CODE_FIX_CATEGORY,
- name: translate('property.category.codefix'),
- renderComponent: getCodeFixComponent,
+ key: AI_CODE_FIX_CATEGORY,
+ name: translate('property.category.aicodefix'),
+ renderComponent: getAiCodeFixComponent,
availableGlobally: true,
availableForProject: false,
displayTab: true,
return <AlmIntegration {...props} />;
}
-function getCodeFixComponent(props: AdditionalCategoryComponentProps) {
- return <CodeFixAdmin {...props} />;
+function getAiCodeFixComponent(props: AdditionalCategoryComponentProps) {
+ return <AiCodeFixAdmin {...props} />;
}
function getAuthenticationComponent(props: AdditionalCategoryComponentProps) {
--- /dev/null
+/*
+ * 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 styled from '@emotion/styled';
+import {
+ Button,
+ ButtonVariety,
+ Checkbox,
+ Heading,
+ IconCheckCircle,
+ IconError,
+ IconInfo,
+ Link,
+ RadioButtonGroup,
+ Spinner,
+ Text,
+} from '@sonarsource/echoes-react';
+import { MutationStatus } from '@tanstack/react-query';
+import { AxiosError } from 'axios';
+import React, { useEffect } from 'react';
+import { FormattedMessage } from 'react-intl';
+import {
+ BasicSeparator,
+ HighlightedSection,
+ Note,
+ themeColor,
+ UnorderedList,
+} from '~design-system';
+import { throwGlobalError } from '~sonar-aligned/helpers/error';
+import { searchProjects } from '../../../api/components';
+import { SuggestionServiceStatusCheckResponse } from '../../../api/fix-suggestions';
+import withAvailableFeatures, {
+ WithAvailableFeaturesProps,
+} from '../../../app/components/available-features/withAvailableFeatures';
+import DocumentationLink from '../../../components/common/DocumentationLink';
+import SelectList, {
+ SelectListFilter,
+ SelectListSearchParams,
+} from '../../../components/controls/SelectList';
+import { DocLink } from '../../../helpers/doc-links';
+import { translate } from '../../../helpers/l10n';
+import { getAiCodeFixTermsOfServiceUrl } from '../../../helpers/urls';
+import {
+ useCheckServiceMutation,
+ useRemoveCodeSuggestionsCache,
+ useUpdateFeatureEnablementMutation,
+} from '../../../queries/fix-suggestions';
+import { useGetValueQuery } from '../../../queries/settings';
+import { Feature } from '../../../types/features';
+import { AiCodeFixFeatureEnablement } from '../../../types/fix-suggestions';
+import { SettingsKey } from '../../../types/settings';
+import PromotedSection from '../../overview/branches/PromotedSection';
+
+interface Props extends WithAvailableFeaturesProps {}
+
+const AI_CODE_FIX_SETTING_KEY = SettingsKey.CodeSuggestion;
+
+function AiCodeFixAdmin({ hasFeature }: Readonly<Props>) {
+ const { data: aiCodeFixSetting } = useGetValueQuery({
+ key: AI_CODE_FIX_SETTING_KEY,
+ });
+ const removeCodeSuggestionsCache = useRemoveCodeSuggestionsCache();
+
+ const initialAiCodeFixEnablement =
+ (aiCodeFixSetting?.value as AiCodeFixFeatureEnablement) || AiCodeFixFeatureEnablement.disabled;
+
+ const [savedAiCodeFixEnablement, setSavedAiCodeFixEnablement] = React.useState(
+ initialAiCodeFixEnablement,
+ );
+ const [currentAiCodeFixEnablement, setCurrentAiCodeFixEnablement] =
+ React.useState(savedAiCodeFixEnablement);
+ const {
+ mutate: checkService,
+ isIdle,
+ isPending: isServiceCheckPending,
+ status,
+ error,
+ data,
+ } = useCheckServiceMutation();
+
+ const { mutate: updateFeatureEnablement } = useUpdateFeatureEnablementMutation();
+ const [changedProjects, setChangedProjects] = React.useState<Map<string, boolean>>(new Map());
+
+ useEffect(() => {
+ setSavedAiCodeFixEnablement(initialAiCodeFixEnablement);
+ }, [initialAiCodeFixEnablement]);
+
+ useEffect(() => {
+ setCurrentAiCodeFixEnablement(savedAiCodeFixEnablement);
+ }, [savedAiCodeFixEnablement]);
+
+ const [currentSearchResults, setCurrentSearchResults] = React.useState<ProjectSearchResult>();
+ const [currentTabItems, setCurrentTabItems] = React.useState<ProjectItem[]>([]);
+
+ const handleSave = () => {
+ updateFeatureEnablement(
+ {
+ enablement: currentAiCodeFixEnablement,
+ changes: {
+ enabledProjectKeys: [...changedProjects]
+ .filter(([_, enabled]) => enabled)
+ .map(([project]) => project),
+ disabledProjectKeys: [...changedProjects]
+ .filter(([_, enabled]) => !enabled)
+ .map(([project]) => project),
+ },
+ },
+ {
+ onSuccess: () => {
+ removeCodeSuggestionsCache();
+ const savedChanges = changedProjects;
+ setChangedProjects(new Map());
+ setSavedAiCodeFixEnablement(currentAiCodeFixEnablement);
+ if (currentSearchResults) {
+ // some items might not be in the right tab if they were toggled just before saving, we need to refresh the view
+ updateItemsWithSearchResult(currentSearchResults, savedChanges);
+ }
+ },
+ },
+ );
+ };
+
+ const handleCancel = () => {
+ setCurrentAiCodeFixEnablement(savedAiCodeFixEnablement);
+ setChangedProjects(new Map());
+ if (currentSearchResults) {
+ // some items might have moved to another tab than the current one, we need to refresh the view
+ updateItemsWithSearchResult(currentSearchResults, new Map());
+ }
+ };
+
+ if (!hasFeature(Feature.FixSuggestions)) {
+ return null;
+ }
+
+ const renderProjectElement = (projectKey: string): React.ReactNode => {
+ const project = currentTabItems.find((project) => project.key === projectKey);
+ return (
+ <div>
+ {project === undefined ? (
+ projectKey
+ ) : (
+ <>
+ {project.name}
+ <br />
+ <Note>{project.key}</Note>
+ </>
+ )}
+ </div>
+ );
+ };
+
+ const onProjectSelected = (projectKey: string) => {
+ const newChangedProjects = new Map(changedProjects);
+ newChangedProjects.set(projectKey, true);
+ setChangedProjects(newChangedProjects);
+ const project = currentTabItems.find((project) => project.key === projectKey);
+ if (project) {
+ project.selected = true;
+ setCurrentTabItems([...currentTabItems]);
+ }
+ return Promise.resolve();
+ };
+
+ const onProjectUnselected = (projectKey: string) => {
+ const newChangedProjects = new Map(changedProjects);
+ newChangedProjects.set(projectKey, false);
+ setChangedProjects(newChangedProjects);
+ const project = currentTabItems.find((project) => project.key === projectKey);
+ if (project) {
+ project.selected = false;
+ setCurrentTabItems([...currentTabItems]);
+ }
+ return Promise.resolve();
+ };
+
+ const onSearch = (searchParams: SelectListSearchParams) => {
+ searchProjects({
+ p: searchParams.page,
+ filter: searchParams.query !== '' ? `query=${searchParams.query}` : undefined,
+ })
+ .then((response) => {
+ const searchResults = {
+ filter: searchParams.filter,
+ projects: response.components.map((project) => {
+ return {
+ key: project.key,
+ name: project.name,
+ isAiCodeFixEnabled: project.isAiCodeFixEnabled === true,
+ };
+ }),
+ totalCount: response.paging.total,
+ };
+ setCurrentSearchResults(searchResults);
+ updateItemsWithSearchResult(searchResults, changedProjects);
+ })
+ .catch(throwGlobalError);
+ };
+
+ const updateItemsWithSearchResult = (
+ searchResult: ProjectSearchResult,
+ changedProjects: Map<string, boolean>,
+ ) => {
+ const { filter } = searchResult;
+ setCurrentTabItems(
+ searchResult.projects
+ .filter(
+ (project) =>
+ filter === SelectListFilter.All ||
+ (filter === SelectListFilter.Selected &&
+ (changedProjects.has(project.key)
+ ? changedProjects.get(project.key) === true
+ : project.isAiCodeFixEnabled)) ||
+ (filter === SelectListFilter.Unselected &&
+ (changedProjects.has(project.key)
+ ? !changedProjects.get(project.key)
+ : !project.isAiCodeFixEnabled)),
+ )
+ .map((project) => {
+ return {
+ key: project.key,
+ name: project.name,
+ selected: changedProjects.has(project.key)
+ ? changedProjects.get(project.key) === true
+ : project.isAiCodeFixEnabled,
+ };
+ }),
+ );
+ };
+
+ return (
+ <div className="sw-flex">
+ <div className="sw-flex-grow sw-p-6">
+ <Heading as="h2" hasMarginBottom>
+ {translate('property.aicodefix.admin.title')}
+ </Heading>
+ <PromotedSection
+ content={
+ <>
+ <p>{translate('property.aicodefix.admin.promoted_section.content1')}</p>
+ <p className="sw-mt-2">
+ {translate('property.aicodefix.admin.promoted_section.content2')}
+ </p>
+ </>
+ }
+ title={translate('property.aicodefix.admin.promoted_section.title')}
+ />
+ <p>{translate('property.aicodefix.admin.description')}</p>
+ <Checkbox
+ className="sw-my-6"
+ label={translate('property.aicodefix.admin.checkbox.label')}
+ checked={currentAiCodeFixEnablement !== AiCodeFixFeatureEnablement.disabled}
+ onCheck={() =>
+ setCurrentAiCodeFixEnablement(
+ currentAiCodeFixEnablement === AiCodeFixFeatureEnablement.disabled
+ ? AiCodeFixFeatureEnablement.allProjects
+ : AiCodeFixFeatureEnablement.disabled,
+ )
+ }
+ helpText={
+ <FormattedMessage
+ id="property.aicodefix.admin.terms"
+ defaultMessage={translate('property.aicodefix.admin.acceptTerm.label')}
+ values={{
+ terms: (
+ <Link shouldOpenInNewTab to={getAiCodeFixTermsOfServiceUrl()}>
+ {translate('property.aicodefix.admin.acceptTerm.terms')}
+ </Link>
+ ),
+ }}
+ />
+ }
+ />
+ <div className="sw-ml-6">
+ {currentAiCodeFixEnablement !== AiCodeFixFeatureEnablement.disabled && (
+ <RadioButtonGroup
+ label={translate('property.aicodefix.admin.enable.title')}
+ id="ai-code-fix-enablement"
+ isRequired
+ options={[
+ {
+ helpText: translate('property.aicodefix.admin.enable.all.projects.help'),
+ label: translate('property.aicodefix.admin.enable.all.projects.label'),
+ value: AiCodeFixFeatureEnablement.allProjects,
+ },
+ {
+ helpText: translate('property.aicodefix.admin.enable.some.projects.help'),
+ label: translate('property.aicodefix.admin.enable.some.projects.label'),
+ value: AiCodeFixFeatureEnablement.someProjects,
+ },
+ ]}
+ value={currentAiCodeFixEnablement}
+ onChange={(enablement: AiCodeFixFeatureEnablement) =>
+ setCurrentAiCodeFixEnablement(enablement)
+ }
+ />
+ )}
+ {currentAiCodeFixEnablement === AiCodeFixFeatureEnablement.someProjects && (
+ <div className="sw-ml-6">
+ <div className="sw-flex sw-mb-6">
+ <IconInfo className="sw-mr-1" color="echoes-color-icon-info" />
+ <Text>{translate('property.aicodefix.admin.enable.some.projects.note')}</Text>
+ </div>
+ <SelectList
+ loading={false}
+ elements={currentTabItems.map((project) => project.key)}
+ elementsTotalCount={currentSearchResults?.totalCount}
+ labelAll={translate('all')}
+ labelSelected={translate('selected')}
+ labelUnselected={translate('unselected')}
+ needToReload={false}
+ onSearch={onSearch}
+ onSelect={onProjectSelected}
+ onUnselect={onProjectUnselected}
+ renderElement={renderProjectElement}
+ selectedElements={currentTabItems.filter((p) => p.selected).map((u) => u.key)}
+ withPaging
+ />
+ </div>
+ )}
+ </div>
+ <div>
+ <div className="sw-mt-6">
+ <Button
+ variety={ButtonVariety.Primary}
+ isDisabled={
+ currentAiCodeFixEnablement === savedAiCodeFixEnablement &&
+ (currentAiCodeFixEnablement !== AiCodeFixFeatureEnablement.someProjects ||
+ changedProjects.size === 0)
+ }
+ onClick={() => {
+ handleSave();
+ }}
+ >
+ {translate('save')}
+ </Button>
+ <Button className="sw-ml-3" variety={ButtonVariety.Default} onClick={handleCancel}>
+ {translate('cancel')}
+ </Button>
+ </div>
+ </div>
+ </div>
+ <div className="sw-flex-col sw-w-abs-600 sw-p-6">
+ <HighlightedSection className="sw-items-start">
+ <Heading as="h3" hasMarginBottom>
+ {translate('property.aicodefix.admin.serviceCheck.title')}
+ </Heading>
+ <p>{translate('property.aicodefix.admin.serviceCheck.description1')}</p>
+ <DocumentationLink to={DocLink.AiCodeFixEnabling}>
+ {translate('property.aicodefix.admin.serviceCheck.learnMore')}
+ </DocumentationLink>
+ <p>{translate('property.aicodefix.admin.serviceCheck.description2')}</p>
+ <Button
+ className="sw-mt-4"
+ variety={ButtonVariety.Default}
+ onClick={() => checkService()}
+ isDisabled={isServiceCheckPending}
+ >
+ {translate('property.aicodefix.admin.serviceCheck.action')}
+ </Button>
+ {!isIdle && (
+ <div>
+ <BasicSeparator className="sw-my-4" />
+ <ServiceCheckResultView data={data} error={error} status={status} />
+ </div>
+ )}
+ </HighlightedSection>
+ </div>
+ </div>
+ );
+}
+
+interface ProjectSearchResult {
+ filter: SelectListFilter;
+ projects: RemoteProject[];
+ totalCount: number;
+}
+
+interface RemoteProject {
+ isAiCodeFixEnabled: boolean;
+ key: string;
+ name: string;
+}
+
+interface ProjectItem {
+ key: string;
+ name: string;
+ selected: boolean;
+}
+
+interface ServiceCheckResultViewProps {
+ data: SuggestionServiceStatusCheckResponse | undefined;
+ error: AxiosError | null;
+ status: MutationStatus;
+}
+
+function ServiceCheckResultView({ data, error, status }: Readonly<ServiceCheckResultViewProps>) {
+ switch (status) {
+ case 'pending':
+ return <Spinner label={translate('property.aicodefix.admin.serviceCheck.spinner.label')} />;
+ case 'error':
+ return (
+ <ErrorMessage
+ text={`${translate('property.aicodefix.admin.serviceCheck.result.requestError')} ${error?.status ?? 'No status'}`}
+ />
+ );
+ case 'success':
+ return ServiceCheckValidResponseView(data);
+ }
+ // normally unreachable
+ throw Error(`Unexpected response from the service status check, received ${status}`);
+}
+
+function ServiceCheckValidResponseView(data: SuggestionServiceStatusCheckResponse | undefined) {
+ switch (data?.status) {
+ case 'SUCCESS':
+ return (
+ <SuccessMessage text={translate('property.aicodefix.admin.serviceCheck.result.success')} />
+ );
+ case 'TIMEOUT':
+ case 'CONNECTION_ERROR':
+ return (
+ <div className="sw-flex">
+ <IconError className="sw-mr-1" color="echoes-color-icon-danger" />
+ <div className="sw-flex-col">
+ <ErrorLabel
+ text={translate('property.aicodefix.admin.serviceCheck.result.unresponsive.message')}
+ />
+ <p className="sw-mt-4">
+ <ErrorLabel
+ text={translate(
+ 'property.aicodefix.admin.serviceCheck.result.unresponsive.causes.title',
+ )}
+ />
+ </p>
+ <UnorderedList className="sw-ml-8" ticks>
+ <ErrorListItem className="sw-mb-2">
+ <ErrorLabel
+ text={translate(
+ 'property.aicodefix.admin.serviceCheck.result.unresponsive.causes.1',
+ )}
+ />
+ </ErrorListItem>
+ <ErrorListItem>
+ <ErrorLabel
+ text={translate(
+ 'property.aicodefix.admin.serviceCheck.result.unresponsive.causes.2',
+ )}
+ />
+ </ErrorListItem>
+ </UnorderedList>
+ </div>
+ </div>
+ );
+ case 'UNAUTHORIZED':
+ return (
+ <ErrorMessage
+ text={translate('property.aicodefix.admin.serviceCheck.result.unauthorized')}
+ />
+ );
+ case 'SERVICE_ERROR':
+ return (
+ <ErrorMessage
+ text={translate('property.aicodefix.admin.serviceCheck.result.serviceError')}
+ />
+ );
+ default:
+ return (
+ <ErrorMessage
+ text={`${translate('property.aicodefix.admin.serviceCheck.result.unknown')} ${data?.status ?? 'no status returned from the service'}`}
+ />
+ );
+ }
+}
+
+function ErrorMessage({ text }: Readonly<TextProps>) {
+ return (
+ <div className="sw-flex">
+ <IconError className="sw-mr-1" color="echoes-color-icon-danger" />
+ <ErrorLabel text={text} />
+ </div>
+ );
+}
+
+function ErrorLabel({ text }: Readonly<TextProps>) {
+ return <Text colorOverride="echoes-color-text-danger">{text}</Text>;
+}
+
+function SuccessMessage({ text }: Readonly<TextProps>) {
+ return (
+ <div className="sw-flex">
+ <IconCheckCircle className="sw-mr-1" color="echoes-color-icon-success" />
+ <Text colorOverride="echoes-color-text-success">{text}</Text>
+ </div>
+ );
+}
+
+const ErrorListItem = styled.li`
+ ::marker {
+ color: ${themeColor('errorText')};
+ }
+`;
+
+interface TextProps {
+ /** The text to display inside the component */
+ text: string;
+}
+
+export default withAvailableFeatures(AiCodeFixAdmin);
+++ /dev/null
-/*
- * 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 styled from '@emotion/styled';
-import {
- Button,
- ButtonVariety,
- Checkbox,
- Heading,
- IconCheckCircle,
- IconError,
- Link,
- Spinner,
- Text
-} from '@sonarsource/echoes-react';
-import { MutationStatus } from '@tanstack/react-query';
-import { AxiosError } from 'axios';
-import React, { useEffect } from 'react';
-import { FormattedMessage } from 'react-intl';
-import { BasicSeparator, HighlightedSection, themeColor, UnorderedList } from '~design-system';
-import { SuggestionServiceStatusCheckResponse } from '../../../api/fix-suggestions';
-import withAvailableFeatures, {
- WithAvailableFeaturesProps
-} from '../../../app/components/available-features/withAvailableFeatures';
-import DocumentationLink from '../../../components/common/DocumentationLink';
-import { DocLink } from '../../../helpers/doc-links';
-import { translate } from '../../../helpers/l10n';
-import { getAiCodeFixTermsOfServiceUrl } from '../../../helpers/urls';
-import { useCheckServiceMutation, useRemoveCodeSuggestionsCache } from '../../../queries/fix-suggestions';
-import { useGetValueQuery, useSaveSimpleValueMutation } from '../../../queries/settings';
-import { Feature } from '../../../types/features';
-import { SettingsKey } from '../../../types/settings';
-import PromotedSection from '../../overview/branches/PromotedSection';
-
-interface Props extends WithAvailableFeaturesProps {}
-
-const CODE_FIX_SETTING_KEY = SettingsKey.CodeSuggestion;
-
-function CodeFixAdmin({ hasFeature }: Readonly<Props>) {
- const { data: codeFixSetting } = useGetValueQuery({
- key: CODE_FIX_SETTING_KEY,
- });
-
- const removeCodeSuggestionsCache = useRemoveCodeSuggestionsCache();
-
- const { mutate: saveSetting } = useSaveSimpleValueMutation();
-
- const isCodeFixEnabled = codeFixSetting?.value === 'true';
-
- const [enableCodeFix, setEnableCodeFix] = React.useState(isCodeFixEnabled);
- const {
- mutate: checkService,
- isIdle,
- isPending,
- status,
- error,
- data,
- } = useCheckServiceMutation();
- const isValueChanged = enableCodeFix !== isCodeFixEnabled;
-
- useEffect(() => {
- setEnableCodeFix(isCodeFixEnabled);
- }, [isCodeFixEnabled]);
-
- const handleSave = () => {
- saveSetting(
- { key: CODE_FIX_SETTING_KEY, value: enableCodeFix ? 'true' : 'false' },
- {
- onSuccess: removeCodeSuggestionsCache,
- },
- );
- };
-
- const handleCancel = () => {
- setEnableCodeFix(isCodeFixEnabled);
- };
-
- if (!hasFeature(Feature.FixSuggestions)) {
- return null;
- }
-
- return (
- <div className="sw-flex">
- <div className="sw-flex-grow sw-p-6">
- <Heading as="h2" hasMarginBottom>
- {translate('property.codefix.admin.title')}
- </Heading>
- <PromotedSection
- content={
- <>
- <p>{translate('property.codefix.admin.promoted_section.content1')}</p>
- <p className="sw-mt-2">
- {translate('property.codefix.admin.promoted_section.content2')}
- </p>
- </>
- }
- title={translate('property.codefix.admin.promoted_section.title')}
- />
- <p>{translate('property.codefix.admin.description')}</p>
- <Checkbox
- className="sw-mt-6"
- label={translate('property.codefix.admin.checkbox.label')}
- checked={Boolean(enableCodeFix)}
- onCheck={() => setEnableCodeFix(!enableCodeFix)}
- helpText={
- <FormattedMessage
- id="property.codefix.admin.terms"
- defaultMessage={translate('property.codefix.admin.acceptTerm.label')}
- values={{
- terms: (
- <Link shouldOpenInNewTab to={getAiCodeFixTermsOfServiceUrl()}>
- {translate('property.codefix.admin.acceptTerm.terms')}
- </Link>
- ),
- }}
- />
- }
- />
- <div>
- <BasicSeparator className="sw-mt-6" />
- <div className="sw-mt-6">
- <Button
- variety={ButtonVariety.Primary}
- isDisabled={!isValueChanged}
- onClick={() => {
- handleSave();
- }}
- >
- {translate('save')}
- </Button>
- <Button className="sw-ml-3" variety={ButtonVariety.Default} onClick={handleCancel}>
- {translate('cancel')}
- </Button>
- </div>
- </div>
- </div>
- <div className="sw-flex-col sw-w-abs-600 sw-p-6">
- <HighlightedSection className="sw-items-start">
- <Heading as="h3" hasMarginBottom>
- {translate('property.codefix.admin.serviceCheck.title')}
- </Heading>
- <p>{translate('property.codefix.admin.serviceCheck.description1')}</p>
- <DocumentationLink to={DocLink.AiCodeFixEnabling}>
- {translate('property.codefix.admin.serviceCheck.learnMore')}
- </DocumentationLink>
- <p>{translate('property.codefix.admin.serviceCheck.description2')}</p>
- <Button
- className="sw-mt-4"
- variety={ButtonVariety.Default}
- onClick={() => checkService()}
- isDisabled={isPending}
- >
- {translate('property.codefix.admin.serviceCheck.action')}
- </Button>
- {!isIdle && (
- <div>
- <BasicSeparator className="sw-my-4" />
- <ServiceCheckResultView data={data} error={error} status={status} />
- </div>
- )}
- </HighlightedSection>
- </div>
- </div>
- );
-}
-
-interface ServiceCheckResultViewProps {
- data: SuggestionServiceStatusCheckResponse | undefined;
- error: AxiosError | null;
- status: MutationStatus;
-}
-
-function ServiceCheckResultView({ data, error, status }: Readonly<ServiceCheckResultViewProps>) {
- switch (status) {
- case 'pending':
- return <Spinner label={translate('property.codefix.admin.serviceCheck.spinner.label')} />;
- case 'error':
- return (
- <ErrorMessage
- text={`${translate('property.codefix.admin.serviceCheck.result.requestError')} ${error?.status ?? 'No status'}`}
- />
- );
- case 'success':
- return ServiceCheckValidResponseView(data);
- }
- // normally unreachable
- throw Error(`Unexpected response from the service status check, received ${status}`);
-}
-
-function ServiceCheckValidResponseView(data: SuggestionServiceStatusCheckResponse | undefined) {
- switch (data?.status) {
- case 'SUCCESS':
- return (
- <SuccessMessage text={translate('property.codefix.admin.serviceCheck.result.success')} />
- );
- case 'TIMEOUT':
- case 'CONNECTION_ERROR':
- return (
- <div className="sw-flex">
- <IconError className="sw-mr-1" color="echoes-color-icon-danger" />
- <div className="sw-flex-col">
- <ErrorLabel
- text={translate('property.codefix.admin.serviceCheck.result.unresponsive.message')}
- />
- <p className="sw-mt-4">
- <ErrorLabel
- text={translate(
- 'property.codefix.admin.serviceCheck.result.unresponsive.causes.title',
- )}
- />
- </p>
- <UnorderedList className="sw-ml-8" ticks>
- <ErrorListItem className="sw-mb-2">
- <ErrorLabel
- text={translate(
- 'property.codefix.admin.serviceCheck.result.unresponsive.causes.1',
- )}
- />
- </ErrorListItem>
- <ErrorListItem>
- <ErrorLabel
- text={translate(
- 'property.codefix.admin.serviceCheck.result.unresponsive.causes.2',
- )}
- />
- </ErrorListItem>
- </UnorderedList>
- </div>
- </div>
- );
- case 'UNAUTHORIZED':
- return (
- <ErrorMessage text={translate('property.codefix.admin.serviceCheck.result.unauthorized')} />
- );
- case 'SERVICE_ERROR':
- return (
- <ErrorMessage text={translate('property.codefix.admin.serviceCheck.result.serviceError')} />
- );
- default:
- return (
- <ErrorMessage
- text={`${translate('property.codefix.admin.serviceCheck.result.unknown')} ${data?.status ?? 'no status returned from the service'}`}
- />
- );
- }
-}
-
-function ErrorMessage({ text }: Readonly<TextProps>) {
- return (
- <div className="sw-flex">
- <IconError className="sw-mr-1" color="echoes-color-icon-danger" />
- <ErrorLabel text={text} />
- </div>
- );
-}
-
-function ErrorLabel({ text }: Readonly<TextProps>) {
- return <Text colorOverride="echoes-color-text-danger">{text}</Text>;
-}
-
-function SuccessMessage({ text }: Readonly<TextProps>) {
- return (
- <div className="sw-flex">
- <IconCheckCircle className="sw-mr-1" color="echoes-color-icon-success" />
- <Text colorOverride="echoes-color-text-success">{text}</Text>
- </div>
- );
-}
-
-const ErrorListItem = styled.li`
- ::marker {
- color: ${themeColor('errorText')};
- }
-`;
-
-interface TextProps {
- /** The text to display inside the component */
- text: string;
-}
-
-export default withAvailableFeatures(CodeFixAdmin);
--- /dev/null
+/*
+ * 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 { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { uniq } from 'lodash';
+import { byRole, byText } from '~sonar-aligned/helpers/testSelector';
+import ComponentsServiceMock from '../../../../api/mocks/ComponentsServiceMock';
+import FixSuggestionsServiceMock from '../../../../api/mocks/FixSuggestionsServiceMock';
+import SettingsServiceMock, {
+ DEFAULT_DEFINITIONS_MOCK,
+} from '../../../../api/mocks/SettingsServiceMock';
+import { AvailableFeaturesContext } from '../../../../app/components/available-features/AvailableFeaturesContext';
+import { mockComponent, mockComponentRaw } from '../../../../helpers/mocks/component';
+import { definitions } from '../../../../helpers/mocks/definitions-list';
+import { renderComponent } from '../../../../helpers/testReactTestingUtils';
+import { Feature } from '../../../../types/features';
+import { AdditionalCategoryComponentProps } from '../AdditionalCategories';
+import AiCodeFixAdmin from '../AiCodeFixAdmin';
+
+let settingServiceMock: SettingsServiceMock;
+let componentsServiceMock: ComponentsServiceMock;
+let fixIssueServiceMock: FixSuggestionsServiceMock;
+
+beforeAll(() => {
+ settingServiceMock = new SettingsServiceMock();
+ settingServiceMock.setDefinitions(definitions);
+ componentsServiceMock = new ComponentsServiceMock();
+ fixIssueServiceMock = new FixSuggestionsServiceMock();
+});
+
+afterEach(() => {
+ settingServiceMock.reset();
+});
+
+const ui = {
+ codeFixTitle: byRole('heading', { name: 'property.aicodefix.admin.title' }),
+ enableAiCodeFixCheckbox: byRole('checkbox', {
+ name: 'property.aicodefix.admin.checkbox.label property.aicodefix.admin.terms property.aicodefix.admin.acceptTerm.terms open_in_new_tab',
+ }),
+ saveButton: byRole('button', { name: 'save' }),
+ cancelButton: byRole('button', { name: 'cancel' }),
+ checkServiceStatusButton: byRole('button', {
+ name: 'property.aicodefix.admin.serviceCheck.action',
+ }),
+ allProjectsEnabledRadio: byRole('radio', {
+ name: 'property.aicodefix.admin.enable.all.projects.label',
+ }),
+ someProjectsEnabledRadio: byRole('radio', {
+ name: 'property.aicodefix.admin.enable.some.projects.label',
+ }),
+ selectedTab: byRole('radio', { name: 'selected' }),
+ unselectedTab: byRole('radio', { name: 'unselected' }),
+ allTab: byRole('radio', { name: 'all' }),
+};
+
+it('should by default propose enabling for all projects when enabling the feature', async () => {
+ settingServiceMock.set('sonar.ai.suggestions.enabled', 'DISABLED');
+ const user = userEvent.setup();
+ renderCodeFixAdmin();
+
+ expect(await ui.codeFixTitle.find()).toBeInTheDocument();
+ expect(ui.enableAiCodeFixCheckbox.get()).not.toBeChecked();
+
+ await user.click(ui.enableAiCodeFixCheckbox.get());
+ expect(ui.allProjectsEnabledRadio.get()).toBeEnabled();
+});
+
+it('should be able to enable the code fix feature for all projects', async () => {
+ settingServiceMock.set('sonar.ai.suggestions.enabled', 'DISABLED');
+ const user = userEvent.setup();
+ renderCodeFixAdmin();
+
+ expect(await ui.codeFixTitle.find()).toBeInTheDocument();
+ expect(ui.enableAiCodeFixCheckbox.get()).not.toBeChecked();
+
+ await user.click(ui.enableAiCodeFixCheckbox.get());
+ expect(ui.allProjectsEnabledRadio.get()).toBeEnabled();
+ expect(ui.saveButton.get()).toBeEnabled();
+
+ await user.click(ui.saveButton.get());
+ expect(ui.enableAiCodeFixCheckbox.get()).toBeChecked();
+ await waitFor(() => {
+ expect(ui.saveButton.get()).toBeDisabled();
+ });
+});
+
+it('should be able to enable the code fix feature for some projects', async () => {
+ settingServiceMock.set('sonar.ai.suggestions.enabled', 'DISABLED');
+ const project = mockComponentRaw({ isAiCodeFixEnabled: false });
+ componentsServiceMock.registerProject(project);
+ const user = userEvent.setup();
+ renderCodeFixAdmin();
+
+ expect(ui.enableAiCodeFixCheckbox.get()).not.toBeChecked();
+
+ await user.click(ui.enableAiCodeFixCheckbox.get());
+ expect(ui.someProjectsEnabledRadio.get()).toBeEnabled();
+ await user.click(ui.someProjectsEnabledRadio.get());
+
+ expect(ui.selectedTab.get()).toBeVisible();
+ expect(await ui.unselectedTab.find()).toBeVisible();
+ expect(await ui.allTab.find()).toBeVisible();
+ await user.click(ui.unselectedTab.get());
+ const projectCheckBox = byText(project.name);
+ await waitFor(() => {
+ expect(projectCheckBox.get()).toBeVisible();
+ });
+ await user.click(projectCheckBox.get());
+
+ await user.click(ui.saveButton.get());
+ expect(ui.enableAiCodeFixCheckbox.get()).toBeChecked();
+ await waitFor(() => {
+ expect(ui.saveButton.get()).toBeDisabled();
+ });
+});
+
+it('should be able to disable the feature for a single project', async () => {
+ settingServiceMock.set('sonar.ai.suggestions.enabled', 'ENABLED_FOR_SOME_PROJECTS');
+ const project = mockComponentRaw({ isAiCodeFixEnabled: true });
+ componentsServiceMock.registerProject(project);
+ const user = userEvent.setup();
+ renderCodeFixAdmin();
+
+ await waitFor(() => {
+ expect(ui.enableAiCodeFixCheckbox.get()).toBeChecked();
+ });
+ expect(ui.someProjectsEnabledRadio.get()).toBeEnabled();
+
+ // this project is by default registered by the mock
+ const projectName = 'sonar-plugin-api';
+ const projectCheckBox = byText(projectName);
+ expect(await projectCheckBox.find()).toBeInTheDocument();
+ await user.click(projectCheckBox.get());
+
+ await user.click(ui.saveButton.get());
+ expect(ui.enableAiCodeFixCheckbox.get()).toBeChecked();
+ await waitFor(() => {
+ expect(ui.saveButton.get()).toBeDisabled();
+ });
+});
+
+it('should be able to disable the code fix feature', async () => {
+ settingServiceMock.set('sonar.ai.suggestions.enabled', 'ENABLED_FOR_ALL_PROJECTS');
+ const user = userEvent.setup();
+ renderCodeFixAdmin();
+
+ await waitFor(() => {
+ expect(ui.enableAiCodeFixCheckbox.get()).toBeChecked();
+ });
+
+ await user.click(ui.enableAiCodeFixCheckbox.get());
+ expect(await ui.saveButton.find()).toBeInTheDocument();
+ await user.click(await ui.saveButton.find());
+ expect(ui.enableAiCodeFixCheckbox.get()).not.toBeChecked();
+});
+
+it('should be able to reset the form when canceling', async () => {
+ settingServiceMock.set('sonar.ai.suggestions.enabled', 'ENABLED_FOR_ALL_PROJECTS');
+ componentsServiceMock.registerComponent(mockComponent());
+ const user = userEvent.setup();
+ renderCodeFixAdmin();
+
+ await waitFor(() => {
+ expect(ui.enableAiCodeFixCheckbox.get()).toBeChecked();
+ });
+
+ await user.click(ui.enableAiCodeFixCheckbox.get());
+ expect(ui.enableAiCodeFixCheckbox.get()).not.toBeChecked();
+ expect(await ui.cancelButton.find()).toBeInTheDocument();
+ await user.click(await ui.cancelButton.find());
+ expect(ui.enableAiCodeFixCheckbox.get()).toBeChecked();
+});
+
+it('should display a success message when the service status can be successfully checked', async () => {
+ fixIssueServiceMock.setServiceStatus('SUCCESS');
+ const user = userEvent.setup();
+ renderCodeFixAdmin();
+
+ await user.click(ui.checkServiceStatusButton.get());
+
+ expect(
+ await screen.findByText('property.aicodefix.admin.serviceCheck.result.success'),
+ ).toBeInTheDocument();
+});
+
+it('should display an error message when the service is not responsive', async () => {
+ fixIssueServiceMock.setServiceStatus('TIMEOUT');
+ const user = userEvent.setup();
+ renderCodeFixAdmin();
+
+ await user.click(ui.checkServiceStatusButton.get());
+
+ expect(
+ await screen.findByText('property.aicodefix.admin.serviceCheck.result.unresponsive.message'),
+ ).toBeInTheDocument();
+});
+
+it('should display an error message when there is a connection error with the service', async () => {
+ fixIssueServiceMock.setServiceStatus('CONNECTION_ERROR');
+ const user = userEvent.setup();
+ renderCodeFixAdmin();
+
+ await user.click(ui.checkServiceStatusButton.get());
+
+ expect(
+ await screen.findByText('property.aicodefix.admin.serviceCheck.result.unresponsive.message'),
+ ).toBeInTheDocument();
+});
+
+it('should display an error message when the current instance is unauthorized', async () => {
+ fixIssueServiceMock.setServiceStatus('UNAUTHORIZED');
+ const user = userEvent.setup();
+ renderCodeFixAdmin();
+
+ await user.click(ui.checkServiceStatusButton.get());
+
+ expect(
+ await screen.findByText('property.aicodefix.admin.serviceCheck.result.unauthorized'),
+ ).toBeInTheDocument();
+});
+
+it('should display an error message when an error happens at service level', async () => {
+ fixIssueServiceMock.setServiceStatus('SERVICE_ERROR');
+ const user = userEvent.setup();
+ renderCodeFixAdmin();
+
+ await user.click(ui.checkServiceStatusButton.get());
+
+ expect(
+ await screen.findByText('property.aicodefix.admin.serviceCheck.result.serviceError'),
+ ).toBeInTheDocument();
+});
+
+it('should display an error message when the service answers with an unknown status', async () => {
+ fixIssueServiceMock.setServiceStatus('WTF');
+ const user = userEvent.setup();
+ renderCodeFixAdmin();
+
+ await user.click(ui.checkServiceStatusButton.get());
+
+ expect(
+ await screen.findByText('property.aicodefix.admin.serviceCheck.result.unknown WTF'),
+ ).toBeInTheDocument();
+});
+
+it('should display an error message when the backend answers with an error', async () => {
+ fixIssueServiceMock.setServiceStatus(undefined);
+ const user = userEvent.setup();
+ renderCodeFixAdmin();
+
+ await user.click(ui.checkServiceStatusButton.get());
+
+ expect(
+ await screen.findByText('property.aicodefix.admin.serviceCheck.result.requestError No status'),
+ ).toBeInTheDocument();
+});
+
+function renderCodeFixAdmin(
+ overrides: Partial<AdditionalCategoryComponentProps> = {},
+ features?: Feature[],
+) {
+ const props = {
+ definitions: DEFAULT_DEFINITIONS_MOCK,
+ categories: uniq(DEFAULT_DEFINITIONS_MOCK.map((d) => d.category)),
+ selectedCategory: 'general',
+ ...overrides,
+ };
+ return renderComponent(
+ <AvailableFeaturesContext.Provider value={features ?? [Feature.FixSuggestions]}>
+ <AiCodeFixAdmin {...props} />
+ </AvailableFeaturesContext.Provider>,
+ );
+}
+++ /dev/null
-/*
- * 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 { screen, waitFor } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { uniq } from 'lodash';
-import { byRole } from '~sonar-aligned/helpers/testSelector';
-import FixIssueServiceMock from '../../../../api/mocks/FixIssueServiceMock';
-import SettingsServiceMock, {
- DEFAULT_DEFINITIONS_MOCK,
-} from '../../../../api/mocks/SettingsServiceMock';
-import { AvailableFeaturesContext } from '../../../../app/components/available-features/AvailableFeaturesContext';
-import { mockComponent } from '../../../../helpers/mocks/component';
-import { definitions } from '../../../../helpers/mocks/definitions-list';
-import { renderComponent } from '../../../../helpers/testReactTestingUtils';
-import { Feature } from '../../../../types/features';
-import { AdditionalCategoryComponentProps } from '../AdditionalCategories';
-import CodeFixAdmin from '../CodeFixAdmin';
-
-let settingServiceMock: SettingsServiceMock;
-let fixIssueServiceMock: FixIssueServiceMock;
-
-beforeAll(() => {
- settingServiceMock = new SettingsServiceMock();
- settingServiceMock.setDefinitions(definitions);
- fixIssueServiceMock = new FixIssueServiceMock();
-});
-
-afterEach(() => {
- settingServiceMock.reset();
-});
-
-const ui = {
- codeFixTitle: byRole('heading', { name: 'property.codefix.admin.title' }),
- changeCodeFixCheckbox: byRole('checkbox', { name: 'property.codefix.admin.checkbox.label' }),
- acceptTermCheckbox: byRole('checkbox', {
- name: 'property.codefix.admin.terms property.codefix.admin.acceptTerm.terms',
- }),
- saveButton: byRole('button', { name: 'save' }),
- checkServiceStatusButton: byRole('button', {
- name: 'property.codefix.admin.serviceCheck.action',
- }),
-};
-
-it('should be able to enable the code fix feature', async () => {
- const user = userEvent.setup();
- renderCodeFixAdmin();
-
- expect(await ui.codeFixTitle.find()).toBeInTheDocument();
- expect(ui.changeCodeFixCheckbox.get()).not.toBeChecked();
-
- await user.click(ui.changeCodeFixCheckbox.get());
- expect(ui.acceptTermCheckbox.get()).toBeInTheDocument();
- expect(ui.saveButton.get()).toBeDisabled();
-
- await user.click(ui.acceptTermCheckbox.get());
- expect(ui.saveButton.get()).toBeEnabled();
-
- await user.click(ui.saveButton.get());
- expect(ui.changeCodeFixCheckbox.get()).toBeChecked();
-});
-
-it('should be able to disable the code fix feature', async () => {
- settingServiceMock.set('sonar.ai.suggestions.enabled', 'true');
- const user = userEvent.setup();
- renderCodeFixAdmin();
-
- await waitFor(() => {
- expect(ui.changeCodeFixCheckbox.get()).toBeChecked();
- });
-
- await user.click(ui.changeCodeFixCheckbox.get());
- expect(await ui.saveButton.find()).toBeInTheDocument();
- await user.click(await ui.saveButton.find());
- expect(ui.changeCodeFixCheckbox.get()).not.toBeChecked();
-});
-
-it('should display a success message when the service status can be successfully checked', async () => {
- fixIssueServiceMock.setServiceStatus('SUCCESS');
- const user = userEvent.setup();
- renderCodeFixAdmin();
-
- await user.click(ui.checkServiceStatusButton.get());
-
- expect(
- await screen.findByText('property.codefix.admin.serviceCheck.result.success'),
- ).toBeInTheDocument();
-});
-
-it('should display an error message when the service is not responsive', async () => {
- fixIssueServiceMock.setServiceStatus('TIMEOUT');
- const user = userEvent.setup();
- renderCodeFixAdmin();
-
- await user.click(ui.checkServiceStatusButton.get());
-
- expect(
- await screen.findByText('property.codefix.admin.serviceCheck.result.unresponsive.message'),
- ).toBeInTheDocument();
-});
-
-it('should display an error message when there is a connection error with the service', async () => {
- fixIssueServiceMock.setServiceStatus('CONNECTION_ERROR');
- const user = userEvent.setup();
- renderCodeFixAdmin();
-
- await user.click(ui.checkServiceStatusButton.get());
-
- expect(
- await screen.findByText('property.codefix.admin.serviceCheck.result.unresponsive.message'),
- ).toBeInTheDocument();
-});
-
-it('should display an error message when the current instance is unauthorized', async () => {
- fixIssueServiceMock.setServiceStatus('UNAUTHORIZED');
- const user = userEvent.setup();
- renderCodeFixAdmin();
-
- await user.click(ui.checkServiceStatusButton.get());
-
- expect(
- await screen.findByText('property.codefix.admin.serviceCheck.result.unauthorized'),
- ).toBeInTheDocument();
-});
-
-it('should display an error message when an error happens at service level', async () => {
- fixIssueServiceMock.setServiceStatus('SERVICE_ERROR');
- const user = userEvent.setup();
- renderCodeFixAdmin();
-
- await user.click(ui.checkServiceStatusButton.get());
-
- expect(
- await screen.findByText('property.codefix.admin.serviceCheck.result.serviceError'),
- ).toBeInTheDocument();
-});
-
-it('should display an error message when the service answers with an unknown status', async () => {
- fixIssueServiceMock.setServiceStatus('WTF');
- const user = userEvent.setup();
- renderCodeFixAdmin();
-
- await user.click(ui.checkServiceStatusButton.get());
-
- expect(
- await screen.findByText('property.codefix.admin.serviceCheck.result.unknown WTF'),
- ).toBeInTheDocument();
-});
-
-it('should display an error message when the backend answers with an error', async () => {
- fixIssueServiceMock.setServiceStatus(undefined);
- const user = userEvent.setup();
- renderCodeFixAdmin();
-
- await user.click(ui.checkServiceStatusButton.get());
-
- expect(
- await screen.findByText('property.codefix.admin.serviceCheck.result.requestError No status'),
- ).toBeInTheDocument();
-});
-
-function renderCodeFixAdmin(
- overrides: Partial<AdditionalCategoryComponentProps> = {},
- features?: Feature[],
-) {
- const props = {
- definitions: DEFAULT_DEFINITIONS_MOCK,
- categories: uniq(DEFAULT_DEFINITIONS_MOCK.map((d) => d.category)),
- selectedCategory: 'general',
- component: mockComponent(),
- ...overrides,
- };
- return renderComponent(
- <AvailableFeaturesContext.Provider value={features ?? [Feature.FixSuggestions]}>
- <CodeFixAdmin {...props} />
- </AvailableFeaturesContext.Provider>,
- );
-}
import { Dict } from '../../types/types';
export const ALM_INTEGRATION_CATEGORY = 'almintegration';
-export const CODE_FIX_CATEGORY = 'codefix';
+export const AI_CODE_FIX_CATEGORY = 'ai_codefix';
export const AUTHENTICATION_CATEGORY = 'authentication';
export const ANALYSIS_SCOPE_CATEGORY = 'exclusions';
export const LANGUAGES_CATEGORY = 'languages';
issue: Issue;
language?: string;
}
+
const EXPAND_SIZE = 10;
const BUFFER_CODE = 3;
import { ComponentQualifier, Visibility } from '~sonar-aligned/types/component';
import { MetricKey } from '~sonar-aligned/types/metrics';
+import { ComponentRaw } from '../../api/components';
import { TreeComponent } from '../../types/component';
import { Component, ComponentMeasure, ComponentMeasureEnhanced } from '../../types/types';
import { mockMeasureEnhanced } from '../testMocks';
};
}
+export function mockComponentRaw(overrides: Partial<ComponentRaw> = {}): ComponentRaw {
+ return {
+ key: 'my-project',
+ name: 'MyProject',
+ qualifier: ComponentQualifier.Project,
+ tags: [],
+ ...overrides,
+ visibility: Visibility.Public,
+ };
+}
+
export function mockTreeComponent(overrides: Partial<TreeComponent>): TreeComponent {
return {
key: 'my-key',
getFixSuggestionsIssues,
getSuggestions,
SuggestionServiceStatusCheckResponse,
+ updateFeatureEnablement,
} from '../api/fix-suggestions';
import { useAvailableFeatures } from '../app/components/available-features/withAvailableFeatures';
import { CurrentUserContext } from '../app/components/current-user/CurrentUserContext';
};
}
+export function useUpdateFeatureEnablementMutation() {
+ return useMutation({
+ mutationFn: updateFeatureEnablement,
+ });
+}
+
export function useCheckServiceMutation() {
return useMutation<SuggestionServiceStatusCheckResponse, AxiosError>({
mutationFn: checkSuggestionServiceStatus,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
+export enum AiCodeFixFeatureEnablement {
+ disabled = 'DISABLED',
+ allProjects = 'ENABLED_FOR_ALL_PROJECTS',
+ someProjects = 'ENABLED_FOR_SOME_PROJECTS',
+}
+
interface SuggestedChange {
endLine: number;
newCode: string;
property.category.exclusions=Analysis Scope
property.category.webhooks=Webhooks
property.category.languages=Languages
-property.category.codefix=AI CodeFix
+property.category.aicodefix=AI CodeFix
property.sonar.inclusions.name=Source File Inclusions
property.sonar.inclusions.description=Patterns used to include some source files and only these ones in analysis.
property.sonar.test.inclusions.name=Test File Inclusions
property.category.housekeeping.branchesAndPullRequests=Branches and Pull Requests
property.category.housekeeping.auditLogs=Audit Logs
-property.codefix.admin.title=Enable AI-generated fix suggestions
-property.codefix.admin.description=Activate this option to enable any user in your organization to generate an AI-suggested code fix for an issue using the Sonar AI CodeFix service.
-property.codefix.admin.checkbox.label=Enable AI CodeFix
-property.codefix.admin.acceptTerm.label=By activating this option, you agree to the {terms}
-property.codefix.admin.acceptTerm.terms=AI CodeFix Terms
-property.codefix.admin.promoted_section.title=Free - early access feature
-property.codefix.admin.promoted_section.content1=This no cost trial is offered to you at Sonar’s discretion. Sonar can decide to stop this trial anytime.
-property.codefix.admin.promoted_section.content2=At the end of the trial, this feature will be deactivated and your choice to “enable AI CodeFix” below will be ignored. Your organisation will not be charged.
-property.codefix.admin.serviceCheck.title=Test the AI CodeFix service
-property.codefix.admin.serviceCheck.description1=Make sure this {productName} instance can communicate with the AI CodeFix service, which requires network connectivity to function.
-property.codefix.admin.serviceCheck.description2=This test is free and should only take a few seconds.
-property.codefix.admin.serviceCheck.learnMore=Read more about enabling AI CodeFix
-property.codefix.admin.serviceCheck.action=Test AI CodeFix service
-property.codefix.admin.serviceCheck.spinner.label=Waiting for AI CodeFix service to respond...
-property.codefix.admin.serviceCheck.result.success=The AI CodeFix service responded successfully.
-property.codefix.admin.serviceCheck.result.unresponsive.message=The AI CodeFix service does not respond or is not reachable.
-property.codefix.admin.serviceCheck.result.unresponsive.causes.title=Here are some possible causes of this error:
-property.codefix.admin.serviceCheck.result.unresponsive.causes.1=The network may not be properly configured on this {productName} instance. Please check the firewall and connectivity settings.
-property.codefix.admin.serviceCheck.result.unresponsive.causes.2=The AI CodeFix service may be down.
-property.codefix.admin.serviceCheck.result.requestError=Error checking the AI CodeFix service:
-property.codefix.admin.serviceCheck.result.serviceError=The AI CodeFix service is reachable but returned an error. Check logs for more details.
-property.codefix.admin.serviceCheck.result.unauthorized=This {productName} instance is not allowed to use AI CodeFix.
-property.codefix.admin.serviceCheck.result.unknown=The AI CodeFix service returned an unexpected message:
+property.aicodefix.admin.title=Enable AI-generated fix suggestions
+property.aicodefix.admin.description=Activate this option to enable users of all or part of the projects to generate an AI-suggested code fix for an issue using the Sonar AI CodeFix service.
+property.aicodefix.admin.checkbox.label=Enable AI CodeFix
+property.aicodefix.admin.acceptTerm.label=By activating this option, you agree to the {terms}
+property.aicodefix.admin.acceptTerm.terms=AI CodeFix Terms
+property.aicodefix.admin.enable.title=Choose which projects should have AI CodeFix enabled
+property.aicodefix.admin.enable.all.projects.label=All projects
+property.aicodefix.admin.enable.all.projects.help=Enable AI CodeFix on all existing and future projects
+property.aicodefix.admin.enable.some.projects.label=Only selected projects
+property.aicodefix.admin.enable.some.projects.help=Enable AI CodeFix on selected projects only
+property.aicodefix.admin.enable.some.projects.note=AI CodeFix will not be automatically enabled on new projects.
+property.aicodefix.admin.promoted_section.title=Free - early access feature
+property.aicodefix.admin.promoted_section.content1=This no cost trial is offered to you at Sonar’s discretion. Sonar can decide to stop this trial anytime.
+property.aicodefix.admin.promoted_section.content2=At the end of the trial, this feature will be deactivated and your choice to “enable AI CodeFix” below will be ignored. Your organisation will not be charged.
+property.aicodefix.admin.serviceCheck.title=Test the AI CodeFix service
+property.aicodefix.admin.serviceCheck.description1=Make sure this SonarQube instance can communicate with the AI CodeFix service, which requires network connectivity to function.
+property.aicodefix.admin.serviceCheck.description2=This test is free and should only take a few seconds.
+property.aicodefix.admin.serviceCheck.learnMore=Read more about enabling AI CodeFix
+property.aicodefix.admin.serviceCheck.action=Test AI CodeFix service
+property.aicodefix.admin.serviceCheck.spinner.label=Waiting for AI CodeFix service to respond...
+property.aicodefix.admin.serviceCheck.result.success=The AI CodeFix service responded successfully.
+property.aicodefix.admin.serviceCheck.result.unresponsive.message=The AI CodeFix service does not respond or is not reachable.
+property.aicodefix.admin.serviceCheck.result.unresponsive.causes.title=Here are some possible causes of this error:
+property.aicodefix.admin.serviceCheck.result.unresponsive.causes.1=The network may not be properly configured on this SonarQube instance. Please check the firewall and connectivity settings.
+property.aicodefix.admin.serviceCheck.result.unresponsive.causes.2=The AI CodeFix service may be down.
+property.aicodefix.admin.serviceCheck.result.requestError=Error checking the AI CodeFix service:
+property.aicodefix.admin.serviceCheck.result.serviceError=The AI CodeFix service is reachable but returned an error. Check logs for more details.
+property.aicodefix.admin.serviceCheck.result.unauthorized=This SonarQube instance is not allowed to use AI CodeFix.
+property.aicodefix.admin.serviceCheck.result.unknown=The AI CodeFix service returned an unexpected message:
#------------------------------------------------------------------------------
#