From: Damien Urruty Date: Fri, 8 Nov 2024 18:59:25 +0000 (+0100) Subject: CODEFIX-189 Allow admin to enable AI CodeFix at the project level X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=e0c824f58666eee38fa0239bce07aa81c1d30f44;p=sonarqube.git CODEFIX-189 Allow admin to enable AI CodeFix at the project level --- diff --git a/server/sonar-web/src/main/js/api/fix-suggestions.ts b/server/sonar-web/src/main/js/api/fix-suggestions.ts index 04eba994fc5..a337e8be7aa 100644 --- a/server/sonar-web/src/main/js/api/fix-suggestions.ts +++ b/server/sonar-web/src/main/js/api/fix-suggestions.ts @@ -19,7 +19,7 @@ */ import { axiosToCatch } from '../helpers/request'; -import { SuggestedFix } from '../types/fix-suggestions'; +import { AiCodeFixFeatureEnablement, SuggestedFix } from '../types/fix-suggestions'; export interface FixParam { issueId: string; @@ -41,6 +41,14 @@ export interface SuggestionServiceStatusCheckResponse { status: SuggestionServiceStatus; } +export interface UpdateFeatureEnablementParams { + changes: { + disabledProjectKeys: string[]; + enabledProjectKeys: string[]; + }; + enablement: AiCodeFixFeatureEnablement; +} + export function getSuggestions(data: FixParam): Promise { return axiosToCatch.post('/api/v2/fix-suggestions/ai-suggestions', data); } @@ -52,3 +60,9 @@ export function getFixSuggestionsIssues(data: FixParam): Promise { export function checkSuggestionServiceStatus(): Promise { return axiosToCatch.post(`/api/v2/fix-suggestions/service-status-checks`); } + +export function updateFeatureEnablement( + featureEnablementParams: UpdateFeatureEnablementParams, +): Promise { + return axiosToCatch.patch(`/api/v2/fix-suggestions/feature-enablements`, featureEnablementParams); +} diff --git a/server/sonar-web/src/main/js/api/mocks/ComponentsServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/ComponentsServiceMock.ts index 55cf9a0878d..ff3e47932e9 100644 --- a/server/sonar-web/src/main/js/api/mocks/ComponentsServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/ComponentsServiceMock.ts @@ -120,6 +120,7 @@ export default class ComponentsServiceMock { const query = data.filter.split('query=')[1]; return c.key.includes(query) || c.name.includes(query); } + return true; }) .map((c) => c); @@ -166,6 +167,10 @@ export default class ComponentsServiceMock { 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: [] }); }; diff --git a/server/sonar-web/src/main/js/api/mocks/FixIssueServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/FixIssueServiceMock.ts deleted file mode 100644 index 4ea23011cab..00000000000 --- a/server/sonar-web/src/main/js/api/mocks/FixIssueServiceMock.ts +++ /dev/null @@ -1,91 +0,0 @@ -/* - * 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(response: T): Promise { - return new Promise((resolve) => { - setTimeout(() => { - resolve(cloneDeep(response)); - }, 10); - }); - } - - setServiceStatus(status: MockSuggestionServiceStatus) { - this.serviceStatus = status; - } -} diff --git a/server/sonar-web/src/main/js/api/mocks/FixSuggestionsServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/FixSuggestionsServiceMock.ts new file mode 100644 index 00000000000..f46b2c36ac3 --- /dev/null +++ b/server/sonar-web/src/main/js/api/mocks/FixSuggestionsServiceMock.ts @@ -0,0 +1,98 @@ +/* + * 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(response: T): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(cloneDeep(response)); + }, 10); + }); + } + + setServiceStatus(status: MockSuggestionServiceStatus) { + this.serviceStatus = status; + } +} diff --git a/server/sonar-web/src/main/js/api/mocks/data/projects.ts b/server/sonar-web/src/main/js/api/mocks/data/projects.ts index fbffae15233..edbd6d42c29 100644 --- a/server/sonar-web/src/main/js/api/mocks/data/projects.ts +++ b/server/sonar-web/src/main/js/api/mocks/data/projects.ts @@ -133,6 +133,7 @@ export function mockProjects(): ComponentRaw[] { tags: ['sonarqube'], visibility: Visibility.Public, leakPeriodDate: '2023-08-10T12:28:45+0000', + isAiCodeFixEnabled: true, }, { key: 'org.sonarsource.javascript:javascript', diff --git a/server/sonar-web/src/main/js/apps/issues/test-utils.tsx b/server/sonar-web/src/main/js/apps/issues/test-utils.tsx index dc6200c22c5..e0e8347b482 100644 --- a/server/sonar-web/src/main/js/apps/issues/test-utils.tsx +++ b/server/sonar-web/src/main/js/apps/issues/test-utils.tsx @@ -24,7 +24,7 @@ import { byPlaceholderText, byRole, byTestId, byText } from '~sonar-aligned/help 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'; @@ -50,7 +50,7 @@ export const cveHandler = new CveServiceMock(); 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 = { diff --git a/server/sonar-web/src/main/js/apps/settings/components/AdditionalCategories.tsx b/server/sonar-web/src/main/js/apps/settings/components/AdditionalCategories.tsx index 015df89393c..ffd88b6893b 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/AdditionalCategories.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/AdditionalCategories.tsx @@ -23,18 +23,18 @@ import { translate } from '../../../helpers/l10n'; 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'; @@ -94,9 +94,9 @@ export const ADDITIONAL_CATEGORIES: AdditionalCategory[] = [ 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, @@ -152,8 +152,8 @@ function getAlmIntegrationComponent(props: AdditionalCategoryComponentProps) { return ; } -function getCodeFixComponent(props: AdditionalCategoryComponentProps) { - return ; +function getAiCodeFixComponent(props: AdditionalCategoryComponentProps) { + return ; } function getAuthenticationComponent(props: AdditionalCategoryComponentProps) { diff --git a/server/sonar-web/src/main/js/apps/settings/components/AiCodeFixAdmin.tsx b/server/sonar-web/src/main/js/apps/settings/components/AiCodeFixAdmin.tsx new file mode 100644 index 00000000000..f5a9526bf24 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/AiCodeFixAdmin.tsx @@ -0,0 +1,526 @@ +/* + * 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) { + 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>(new Map()); + + useEffect(() => { + setSavedAiCodeFixEnablement(initialAiCodeFixEnablement); + }, [initialAiCodeFixEnablement]); + + useEffect(() => { + setCurrentAiCodeFixEnablement(savedAiCodeFixEnablement); + }, [savedAiCodeFixEnablement]); + + const [currentSearchResults, setCurrentSearchResults] = React.useState(); + const [currentTabItems, setCurrentTabItems] = React.useState([]); + + 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 ( +
+ {project === undefined ? ( + projectKey + ) : ( + <> + {project.name} +
+ {project.key} + + )} +
+ ); + }; + + 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, + ) => { + 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 ( +
+
+ + {translate('property.aicodefix.admin.title')} + + +

{translate('property.aicodefix.admin.promoted_section.content1')}

+

+ {translate('property.aicodefix.admin.promoted_section.content2')} +

+ + } + title={translate('property.aicodefix.admin.promoted_section.title')} + /> +

{translate('property.aicodefix.admin.description')}

+ + setCurrentAiCodeFixEnablement( + currentAiCodeFixEnablement === AiCodeFixFeatureEnablement.disabled + ? AiCodeFixFeatureEnablement.allProjects + : AiCodeFixFeatureEnablement.disabled, + ) + } + helpText={ + + {translate('property.aicodefix.admin.acceptTerm.terms')} + + ), + }} + /> + } + /> +
+ {currentAiCodeFixEnablement !== AiCodeFixFeatureEnablement.disabled && ( + + setCurrentAiCodeFixEnablement(enablement) + } + /> + )} + {currentAiCodeFixEnablement === AiCodeFixFeatureEnablement.someProjects && ( +
+
+ + {translate('property.aicodefix.admin.enable.some.projects.note')} +
+ 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 + /> +
+ )} +
+
+
+ + +
+
+
+
+ + + {translate('property.aicodefix.admin.serviceCheck.title')} + +

{translate('property.aicodefix.admin.serviceCheck.description1')}

+ + {translate('property.aicodefix.admin.serviceCheck.learnMore')} + +

{translate('property.aicodefix.admin.serviceCheck.description2')}

+ + {!isIdle && ( +
+ + +
+ )} +
+
+
+ ); +} + +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) { + switch (status) { + case 'pending': + return ; + case 'error': + return ( + + ); + 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 ( + + ); + case 'TIMEOUT': + case 'CONNECTION_ERROR': + return ( +
+ +
+ +

+ +

+ + + + + + + + +
+
+ ); + case 'UNAUTHORIZED': + return ( + + ); + case 'SERVICE_ERROR': + return ( + + ); + default: + return ( + + ); + } +} + +function ErrorMessage({ text }: Readonly) { + return ( +
+ + +
+ ); +} + +function ErrorLabel({ text }: Readonly) { + return {text}; +} + +function SuccessMessage({ text }: Readonly) { + return ( +
+ + {text} +
+ ); +} + +const ErrorListItem = styled.li` + ::marker { + color: ${themeColor('errorText')}; + } +`; + +interface TextProps { + /** The text to display inside the component */ + text: string; +} + +export default withAvailableFeatures(AiCodeFixAdmin); diff --git a/server/sonar-web/src/main/js/apps/settings/components/CodeFixAdmin.tsx b/server/sonar-web/src/main/js/apps/settings/components/CodeFixAdmin.tsx deleted file mode 100644 index 486aeebeeb5..00000000000 --- a/server/sonar-web/src/main/js/apps/settings/components/CodeFixAdmin.tsx +++ /dev/null @@ -1,298 +0,0 @@ -/* - * 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) { - 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 ( -
-
- - {translate('property.codefix.admin.title')} - - -

{translate('property.codefix.admin.promoted_section.content1')}

-

- {translate('property.codefix.admin.promoted_section.content2')} -

- - } - title={translate('property.codefix.admin.promoted_section.title')} - /> -

{translate('property.codefix.admin.description')}

- setEnableCodeFix(!enableCodeFix)} - helpText={ - - {translate('property.codefix.admin.acceptTerm.terms')} - - ), - }} - /> - } - /> -
- -
- - -
-
-
-
- - - {translate('property.codefix.admin.serviceCheck.title')} - -

{translate('property.codefix.admin.serviceCheck.description1')}

- - {translate('property.codefix.admin.serviceCheck.learnMore')} - -

{translate('property.codefix.admin.serviceCheck.description2')}

- - {!isIdle && ( -
- - -
- )} -
-
-
- ); -} - -interface ServiceCheckResultViewProps { - data: SuggestionServiceStatusCheckResponse | undefined; - error: AxiosError | null; - status: MutationStatus; -} - -function ServiceCheckResultView({ data, error, status }: Readonly) { - switch (status) { - case 'pending': - return ; - case 'error': - return ( - - ); - 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 ( - - ); - case 'TIMEOUT': - case 'CONNECTION_ERROR': - return ( -
- -
- -

- -

- - - - - - - - -
-
- ); - case 'UNAUTHORIZED': - return ( - - ); - case 'SERVICE_ERROR': - return ( - - ); - default: - return ( - - ); - } -} - -function ErrorMessage({ text }: Readonly) { - return ( -
- - -
- ); -} - -function ErrorLabel({ text }: Readonly) { - return {text}; -} - -function SuccessMessage({ text }: Readonly) { - return ( -
- - {text} -
- ); -} - -const ErrorListItem = styled.li` - ::marker { - color: ${themeColor('errorText')}; - } -`; - -interface TextProps { - /** The text to display inside the component */ - text: string; -} - -export default withAvailableFeatures(CodeFixAdmin); diff --git a/server/sonar-web/src/main/js/apps/settings/components/__tests__/AiCodeFixAdmin-it.tsx b/server/sonar-web/src/main/js/apps/settings/components/__tests__/AiCodeFixAdmin-it.tsx new file mode 100644 index 00000000000..e5c576631da --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/__tests__/AiCodeFixAdmin-it.tsx @@ -0,0 +1,291 @@ +/* + * 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 = {}, + features?: Feature[], +) { + const props = { + definitions: DEFAULT_DEFINITIONS_MOCK, + categories: uniq(DEFAULT_DEFINITIONS_MOCK.map((d) => d.category)), + selectedCategory: 'general', + ...overrides, + }; + return renderComponent( + + + , + ); +} diff --git a/server/sonar-web/src/main/js/apps/settings/components/__tests__/CodeFixAdmin-it.tsx b/server/sonar-web/src/main/js/apps/settings/components/__tests__/CodeFixAdmin-it.tsx deleted file mode 100644 index a1f1ca87a23..00000000000 --- a/server/sonar-web/src/main/js/apps/settings/components/__tests__/CodeFixAdmin-it.tsx +++ /dev/null @@ -1,195 +0,0 @@ -/* - * 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 = {}, - features?: Feature[], -) { - const props = { - definitions: DEFAULT_DEFINITIONS_MOCK, - categories: uniq(DEFAULT_DEFINITIONS_MOCK.map((d) => d.category)), - selectedCategory: 'general', - component: mockComponent(), - ...overrides, - }; - return renderComponent( - - - , - ); -} diff --git a/server/sonar-web/src/main/js/apps/settings/constants.ts b/server/sonar-web/src/main/js/apps/settings/constants.ts index 12c0e96a1cf..d21ab6b25f2 100644 --- a/server/sonar-web/src/main/js/apps/settings/constants.ts +++ b/server/sonar-web/src/main/js/apps/settings/constants.ts @@ -23,7 +23,7 @@ import { ExtendedSettingDefinition } from '../../types/settings'; 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'; diff --git a/server/sonar-web/src/main/js/components/rules/IssueSuggestionFileSnippet.tsx b/server/sonar-web/src/main/js/components/rules/IssueSuggestionFileSnippet.tsx index 397fa877005..082999567ec 100644 --- a/server/sonar-web/src/main/js/components/rules/IssueSuggestionFileSnippet.tsx +++ b/server/sonar-web/src/main/js/components/rules/IssueSuggestionFileSnippet.tsx @@ -48,6 +48,7 @@ interface Props { issue: Issue; language?: string; } + const EXPAND_SIZE = 10; const BUFFER_CODE = 3; diff --git a/server/sonar-web/src/main/js/helpers/mocks/component.ts b/server/sonar-web/src/main/js/helpers/mocks/component.ts index dcef4629d10..fe93701c862 100644 --- a/server/sonar-web/src/main/js/helpers/mocks/component.ts +++ b/server/sonar-web/src/main/js/helpers/mocks/component.ts @@ -20,6 +20,7 @@ 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'; @@ -44,6 +45,17 @@ export function mockComponent(overrides: Partial = {}): Component { }; } +export function mockComponentRaw(overrides: Partial = {}): ComponentRaw { + return { + key: 'my-project', + name: 'MyProject', + qualifier: ComponentQualifier.Project, + tags: [], + ...overrides, + visibility: Visibility.Public, + }; +} + export function mockTreeComponent(overrides: Partial): TreeComponent { return { key: 'my-key', diff --git a/server/sonar-web/src/main/js/queries/fix-suggestions.tsx b/server/sonar-web/src/main/js/queries/fix-suggestions.tsx index fc70ff12e2c..8edcb03f044 100644 --- a/server/sonar-web/src/main/js/queries/fix-suggestions.tsx +++ b/server/sonar-web/src/main/js/queries/fix-suggestions.tsx @@ -27,6 +27,7 @@ import { getFixSuggestionsIssues, getSuggestions, SuggestionServiceStatusCheckResponse, + updateFeatureEnablement, } from '../api/fix-suggestions'; import { useAvailableFeatures } from '../app/components/available-features/withAvailableFeatures'; import { CurrentUserContext } from '../app/components/current-user/CurrentUserContext'; @@ -183,6 +184,12 @@ export function withUseGetFixSuggestionsIssues

( }; } +export function useUpdateFeatureEnablementMutation() { + return useMutation({ + mutationFn: updateFeatureEnablement, + }); +} + export function useCheckServiceMutation() { return useMutation({ mutationFn: checkSuggestionServiceStatus, diff --git a/server/sonar-web/src/main/js/types/fix-suggestions.ts b/server/sonar-web/src/main/js/types/fix-suggestions.ts index f88606353a1..35c52db4448 100644 --- a/server/sonar-web/src/main/js/types/fix-suggestions.ts +++ b/server/sonar-web/src/main/js/types/fix-suggestions.ts @@ -18,6 +18,12 @@ * 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; diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index fbe809bd8df..e19834083c2 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -1883,7 +1883,7 @@ property.category.localization=Localization 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 @@ -1922,29 +1922,35 @@ property.category.housekeeping.general=General 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: #------------------------------------------------------------------------------ #