id: string;
}
+export type SuggestionServiceStatus =
+ | 'SUCCESS'
+ | 'TIMEOUT'
+ | 'UNAUTHORIZED'
+ | 'CONNECTION_ERROR'
+ | 'SERVICE_ERROR';
+
+export interface SuggestionServiceStatusCheckResponse {
+ status: SuggestionServiceStatus;
+}
+
export function getSuggestions(data: FixParam): Promise<SuggestedFix> {
return axiosToCatch.post<SuggestedFix>('/api/v2/fix-suggestions/ai-suggestions', data);
}
export function getFixSuggestionsIssues(data: FixParam): Promise<AiIssue> {
return axiosToCatch.get(`/api/v2/fix-suggestions/issues/${data.issueId}`);
}
+
+export function checkSuggestionServiceStatus(): Promise<SuggestionServiceStatusCheckResponse> {
+ return axiosToCatch.post(`/api/v2/fix-suggestions/service-status-checks`);
+}
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { cloneDeep } from 'lodash';
-import { FixParam, getFixSuggestionsIssues, getSuggestions } from '../fix-suggestions';
+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',
],
};
+ 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) => {
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(() => {
}, 10);
});
}
+
+ setServiceStatus(status: MockSuggestionServiceStatus) {
+ this.serviceStatus = status;
+ }
}
export const componentsHandler = new ComponentsServiceMock();
export const sourcesHandler = new SourcesServiceMock();
export const branchHandler = new BranchesServiceMock();
-export const fixIssueHanlder = new FixIssueServiceMock();
+export const fixIssueHandler = new FixIssueServiceMock();
export const settingsHandler = new SettingsServiceMock();
export const ui = {
loading: byText('issues.loading_issues'),
fixGenerated: byText('issues.code_fix.fix_is_being_generated'),
noFixAvailable: byText('issues.code_fix.something_went_wrong'),
- suggestedExplanation: byText(fixIssueHanlder.fixSuggestion.explanation),
+ suggestedExplanation: byText(fixIssueHandler.fixSuggestion.explanation),
issuePageHeadering: byRole('heading', { level: 1, name: 'issues.page' }),
issueItemAction1: byRole('link', { name: 'Issue with no location message' }),
issueItemAction2: byRole('link', { name: 'FlowIssue' }),
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { Button, ButtonVariety, Checkbox, LinkStandalone } from '@sonarsource/echoes-react';
-import { BasicSeparator, Title } from 'design-system';
+import styled from '@emotion/styled';
+import {
+ Button,
+ ButtonVariety,
+ Checkbox,
+ IconCheckCircle,
+ IconError,
+ LinkStandalone,
+ Spinner,
+ Text,
+} from '@sonarsource/echoes-react';
+import { MutationStatus } from '@tanstack/react-query';
+import { AxiosError } from 'axios';
+import {
+ BasicSeparator,
+ HighlightedSection,
+ themeColor,
+ Title,
+ UnorderedList,
+} from 'design-system';
import React, { useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
+import { SuggestionServiceStatusCheckResponse } from '../../../api/fix-suggestions';
import withAvailableFeatures, {
WithAvailableFeaturesProps,
} from '../../../app/components/available-features/withAvailableFeatures';
import { translate } from '../../../helpers/l10n';
import { getAiCodeFixTermsOfServiceUrl } from '../../../helpers/urls';
-import { useRemoveCodeSuggestionsCache } from '../../../queries/fix-suggestions';
+import {
+ useCheckServiceMutation,
+ useRemoveCodeSuggestionsCache,
+} from '../../../queries/fix-suggestions';
import { useGetValueQuery, useSaveSimpleValueMutation } from '../../../queries/settings';
import { Feature } from '../../../types/features';
import { SettingsKey } from '../../../types/settings';
const [enableCodeFix, setEnableCodeFix] = React.useState(isCodeFixEnabled);
const [acceptedTerms, setAcceptedTerms] = React.useState(false);
+ const {
+ mutate: checkService,
+ isIdle,
+ isPending,
+ status,
+ error,
+ data,
+ } = useCheckServiceMutation();
const isValueChanged = enableCodeFix !== isCodeFixEnabled;
useEffect(() => {
return (
<div className="sw-flex">
- <div className="sw-flex-1 sw-p-6">
+ <div className="sw-flex-grow sw-p-6">
<Title className="sw-heading-md sw-mb-6">{translate('property.codefix.admin.title')}</Title>
<PromotedSection
content={
</div>
)}
</div>
+ <div className="sw-flex-col sw-w-abs-600 sw-p-6">
+ <HighlightedSection className="sw-items-start">
+ <Title className="sw-heading-sm sw-mb-6">
+ {translate('property.codefix.admin.serviceCheck.title')}
+ </Title>
+ <p>{translate('property.codefix.admin.serviceCheck.description1')}</p>
+ <p className="sw-mt-4">{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);
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { waitFor } from '@testing-library/react';
+import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { uniq } from 'lodash';
import * as React from 'react';
import { byRole } from '~sonar-aligned/helpers/testSelector';
+import FixIssueServiceMock from '../../../../api/mocks/FixIssueServiceMock';
import SettingsServiceMock, {
DEFAULT_DEFINITIONS_MOCK,
} from '../../../../api/mocks/SettingsServiceMock';
import CodeFixAdmin from '../CodeFixAdmin';
let settingServiceMock: SettingsServiceMock;
+let fixIssueServiceMock: FixIssueServiceMock;
beforeAll(() => {
settingServiceMock = new SettingsServiceMock();
settingServiceMock.setDefinitions(definitions);
+ fixIssueServiceMock = new FixIssueServiceMock();
});
afterEach(() => {
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 () => {
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[],
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { AxiosError } from 'axios';
import { some } from 'lodash';
import React, { useContext } from 'react';
-import { getFixSuggestionsIssues, getSuggestions } from '../api/fix-suggestions';
+import {
+ checkSuggestionServiceStatus,
+ getFixSuggestionsIssues,
+ getSuggestions,
+ SuggestionServiceStatusCheckResponse,
+} from '../api/fix-suggestions';
import { useAvailableFeatures } from '../app/components/available-features/withAvailableFeatures';
import { CurrentUserContext } from '../app/components/current-user/CurrentUserContext';
import { Feature } from '../types/features';
return <Component aiSuggestionAvailable={data?.aiSuggestion === 'AVAILABLE'} {...props} />;
};
}
+
+export function useCheckServiceMutation() {
+ return useMutation<SuggestionServiceStatusCheckResponse, AxiosError>({
+ mutationFn: checkSuggestionServiceStatus,
+ });
+}
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 SonarQube 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.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 SonarQube 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. Please try again later.
+property.codefix.admin.serviceCheck.result.unauthorized=This SonarQube instance is not allowed to use AI CodeFix.
+property.codefix.admin.serviceCheck.result.unknown=The AI CodeFix service returned an unexpected message:
#------------------------------------------------------------------------------
#