]> source.dussan.org Git - sonarqube.git/commitdiff
CODEFIX-189 Allow admin to enable AI CodeFix at the project level
authorDamien Urruty <damien.urruty@sonarsource.com>
Fri, 8 Nov 2024 18:59:25 +0000 (19:59 +0100)
committersonartech <sonartech@sonarsource.com>
Tue, 19 Nov 2024 20:02:54 +0000 (20:02 +0000)
17 files changed:
server/sonar-web/src/main/js/api/fix-suggestions.ts
server/sonar-web/src/main/js/api/mocks/ComponentsServiceMock.ts
server/sonar-web/src/main/js/api/mocks/FixIssueServiceMock.ts [deleted file]
server/sonar-web/src/main/js/api/mocks/FixSuggestionsServiceMock.ts [new file with mode: 0644]
server/sonar-web/src/main/js/api/mocks/data/projects.ts
server/sonar-web/src/main/js/apps/issues/test-utils.tsx
server/sonar-web/src/main/js/apps/settings/components/AdditionalCategories.tsx
server/sonar-web/src/main/js/apps/settings/components/AiCodeFixAdmin.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/CodeFixAdmin.tsx [deleted file]
server/sonar-web/src/main/js/apps/settings/components/__tests__/AiCodeFixAdmin-it.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/__tests__/CodeFixAdmin-it.tsx [deleted file]
server/sonar-web/src/main/js/apps/settings/constants.ts
server/sonar-web/src/main/js/components/rules/IssueSuggestionFileSnippet.tsx
server/sonar-web/src/main/js/helpers/mocks/component.ts
server/sonar-web/src/main/js/queries/fix-suggestions.tsx
server/sonar-web/src/main/js/types/fix-suggestions.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 04eba994fc50c62ce4a934218e24c6aa1ecff15e..a337e8be7aaa3889c138c8e5bbda63325e5e5413 100644 (file)
@@ -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<SuggestedFix> {
   return axiosToCatch.post<SuggestedFix>('/api/v2/fix-suggestions/ai-suggestions', data);
 }
@@ -52,3 +60,9 @@ export function getFixSuggestionsIssues(data: FixParam): Promise<AiIssue> {
 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);
+}
index 55cf9a0878d47d0f649efa68cbb74ff7d5df63bc..ff3e47932e935aa876f8d91d0c0b9d5f8a8fef4d 100644 (file)
@@ -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 (file)
index 4ea2301..0000000
+++ /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<T>(response: T): Promise<T> {
-    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 (file)
index 0000000..f46b2c3
--- /dev/null
@@ -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<T>(response: T): Promise<T> {
+    return new Promise((resolve) => {
+      setTimeout(() => {
+        resolve(cloneDeep(response));
+      }, 10);
+    });
+  }
+
+  setServiceStatus(status: MockSuggestionServiceStatus) {
+    this.serviceStatus = status;
+  }
+}
index fbffae152334e9843d22538a6187bcbdd590e724..edbd6d42c29008059bec35ef2b8a5b2bbb92f760 100644 (file)
@@ -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',
index dc6200c22c5765cc6212db60a18c8a3efa5b0ae6..e0e8347b4820a6267d866c6608f3140d3f7a71bf 100644 (file)
@@ -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 = {
index 015df89393ca0680b0f4a73bff68f00ca9092fa2..ffd88b6893bcf773960fd9bdb0477d71899793fb 100644 (file)
@@ -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 <AlmIntegration {...props} />;
 }
 
-function getCodeFixComponent(props: AdditionalCategoryComponentProps) {
-  return <CodeFixAdmin {...props} />;
+function getAiCodeFixComponent(props: AdditionalCategoryComponentProps) {
+  return <AiCodeFixAdmin {...props} />;
 }
 
 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 (file)
index 0000000..f5a9526
--- /dev/null
@@ -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<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);
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 (file)
index 486aeeb..0000000
+++ /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<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);
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 (file)
index 0000000..e5c5766
--- /dev/null
@@ -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<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>,
+  );
+}
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 (file)
index a1f1ca8..0000000
+++ /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<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>,
-  );
-}
index 12c0e96a1cfc8b2f656104ca572592275657bfcf..d21ab6b25f25eba103f90ce250a65ffcccd400f1 100644 (file)
@@ -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';
index 397fa8770057092691aca7ce996de342542e6cb0..082999567ecbd5084b40664bd61f1e6c73154206 100644 (file)
@@ -48,6 +48,7 @@ interface Props {
   issue: Issue;
   language?: string;
 }
+
 const EXPAND_SIZE = 10;
 const BUFFER_CODE = 3;
 
index dcef4629d108b9f242af9fc44bbf08d5a78c4436..fe93701c8624a515b8f91845101479e86412abe1 100644 (file)
@@ -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> = {}): Component {
   };
 }
 
+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',
index fc70ff12e2c66f29b029167da29e55817a5193f1..8edcb03f04410da0df3de607da64ac6d492acb1b 100644 (file)
@@ -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<P extends { issue: Issue }>(
   };
 }
 
+export function useUpdateFeatureEnablementMutation() {
+  return useMutation({
+    mutationFn: updateFeatureEnablement,
+  });
+}
+
 export function useCheckServiceMutation() {
   return useMutation<SuggestionServiceStatusCheckResponse, AxiosError>({
     mutationFn: checkSuggestionServiceStatus,
index f88606353a1f1d6d7e493c4cbc8d25fc6360e2d3..35c52db44489356d2bb6bac0a8e6e1f27105f199 100644 (file)
  * 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;
index fbe809bd8df89c0bf352b54b446fe60096b47872..e19834083c22758bf2e5622472e3b104f470f688 100644 (file)
@@ -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:
 
 #------------------------------------------------------------------------------
 #