issueId: string;
}
+export interface AiIssue {
+ aiSuggestion: 'AVAILABLE' | 'NOT_AVAILABLE_FILE_LEVEL_ISSUE' | 'NOT_AVAILABLE_UNSUPPORTED_RULE';
+ id: string;
+}
+
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}`);
+}
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { cloneDeep } from 'lodash';
-import { FixParam, getSuggestions } from '../fix-suggestions';
-import { ISSUE_101 } from './data/ids';
+import { FixParam, getFixSuggestionsIssues, getSuggestions } from '../fix-suggestions';
+import { ISSUE_101, ISSUE_1101 } from './data/ids';
jest.mock('../fix-suggestions');
constructor() {
jest.mocked(getSuggestions).mockImplementation(this.handleGetFixSuggestion);
+ jest.mocked(getFixSuggestionsIssues).mockImplementation(this.handleGetFixSuggestionsIssues);
}
+ 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' } });
import { range } from 'lodash';
import React from 'react';
import { byRole, byText } from '~sonar-aligned/helpers/testSelector';
-import { ISSUE_101 } from '../../../api/mocks/data/ids';
+import { ISSUE_101, ISSUE_1101 } from '../../../api/mocks/data/ids';
import { TabKeys } from '../../../components/rules/RuleTabViewer';
import { mockCurrentUser, mockCve, mockLoggedInUser } from '../../../helpers/testMocks';
import { Feature } from '../../../types/features';
[Feature.BranchSupport, Feature.FixSuggestions],
);
- expect(await ui.getFixSuggestion.find()).toBeInTheDocument();
+ expect(await ui.getFixSuggestion.find(undefined, { timeout: 5000 })).toBeInTheDocument();
await user.click(ui.getFixSuggestion.get());
expect(await ui.suggestedExplanation.find()).toBeInTheDocument();
expect(ui.issueCodeFixTab.query()).not.toBeInTheDocument();
});
+ it('should not be able to trigger a fix when issue is not eligible', async () => {
+ renderProjectIssuesApp(
+ `project/issues?issueStatuses=CONFIRMED&open=${ISSUE_1101}&id=myproject`,
+ {},
+ mockCurrentUser(),
+ [Feature.BranchSupport, Feature.FixSuggestions],
+ );
+ expect(await ui.issueCodeTab.find()).toBeInTheDocument();
+ expect(ui.getFixSuggestion.query()).not.toBeInTheDocument();
+ expect(ui.issueCodeFixTab.query()).not.toBeInTheDocument();
+ });
+
it('should show error when no fix is available', async () => {
const user = userEvent.setup();
renderProjectIssuesApp(
import { FormattedMessage } from 'react-intl';
import { getBranchLikeQuery } from '~sonar-aligned/helpers/branch-like';
import { getSources } from '../../../api/components';
-import { AvailableFeaturesContext } from '../../../app/components/available-features/AvailableFeaturesContext';
-import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext';
import { TabKeys } from '../../../components/rules/IssueTabViewer';
import { TabSelectorContext } from '../../../components/rules/TabSelectorContext';
import getCoverageStatus from '../../../components/SourceViewer/helpers/getCoverageStatus';
import { locationsByLine } from '../../../components/SourceViewer/helpers/indexing';
import { translate } from '../../../helpers/l10n';
import {
+ useGetFixSuggestionsIssuesQuery,
usePrefetchSuggestion,
useUnifiedSuggestionsQuery,
} from '../../../queries/fix-suggestions';
import { BranchLike } from '../../../types/branch-like';
import { isFile } from '../../../types/component';
-import { Feature } from '../../../types/features';
import { IssueDeprecatedStatus } from '../../../types/issues';
import {
Dict,
SourceViewerFile,
Issue as TypeIssue,
} from '../../../types/types';
-import { CurrentUser, isLoggedIn } from '../../../types/users';
import { IssueSourceViewerScrollContext } from '../components/IssueSourceViewerScrollContext';
import { IssueSourceViewerHeader } from './IssueSourceViewerHeader';
import SnippetViewer from './SnippetViewer';
interface Props {
branchLike: BranchLike | undefined;
- currentUser: CurrentUser;
duplications?: Duplication[];
duplicationsByLine?: { [line: number]: number[] };
highlightedLocationMessage: { index: number; text: string | undefined } | undefined;
snippets: Snippet[];
}
-class ComponentSourceSnippetGroupViewer extends React.PureComponent<Readonly<Props>, State> {
+export default class ComponentSourceSnippetGroupViewer extends React.PureComponent<
+ Readonly<Props>,
+ State
+> {
mounted = false;
constructor(props: Readonly<Props>) {
};
renderIssuesList = (line: SourceLine) => {
- const { isLastOccurenceOfPrimaryComponent, issue, issuesByLine, snippetGroup, currentUser } =
- this.props;
+ const { isLastOccurenceOfPrimaryComponent, issue, issuesByLine, snippetGroup } = this.props;
const locations =
issue.component === snippetGroup.component.key && issue.textRange !== undefined
? locationsByLine([issue])
ref={isSelectedIssue ? ctx?.registerPrimaryLocationRef : undefined}
onIssueSelect={this.props.onIssueSelect}
getFixButton={
- isSelectedIssue ? (
- <GetFixButton issue={issueToDisplay} currentUser={currentUser} />
- ) : undefined
+ isSelectedIssue ? <GetFixButton issue={issueToDisplay} /> : undefined
}
/>
)}
selected
ref={ctx?.registerPrimaryLocationRef}
onIssueSelect={this.props.onIssueSelect}
- className="sw-m-0 sw-cursor-default"
+ className="sw-m-0 sw-cursor-default sw-justify-between"
+ getFixButton={<GetFixButton issue={issue} />}
/>
)}
</IssueSourceViewerScrollContext.Consumer>
border: 1px solid ${themeColor('codeLineBorder')};
`;
-function GetFixButton({
- currentUser,
- issue,
-}: Readonly<{ currentUser: CurrentUser; issue: Issue }>) {
+function GetFixButton({ issue }: Readonly<{ issue: Issue }>) {
const handler = React.useContext(TabSelectorContext);
const { data: suggestion, isLoading } = useUnifiedSuggestionsQuery(issue, false);
const prefetchSuggestion = usePrefetchSuggestion(issue.key);
- const isSuggestionFeatureEnabled = React.useContext(AvailableFeaturesContext).includes(
- Feature.FixSuggestions,
- );
+ const { data } = useGetFixSuggestionsIssuesQuery(issue);
- if (!isLoggedIn(currentUser) || !isSuggestionFeatureEnabled) {
+ if (data?.aiSuggestion !== 'AVAILABLE') {
return null;
}
+
return (
<Spinner ariaLabel={translate('issues.code_fix.fix_is_being_generated')} isLoading={isLoading}>
{suggestion !== undefined && (
</Spinner>
);
}
-
-export default withCurrentUserContext(ComponentSourceSnippetGroupViewer);
import * as React from 'react';
import { Location } from 'react-router-dom';
import { dismissNotice } from '../../api/users';
-import withAvailableFeatures from '../../app/components/available-features/withAvailableFeatures';
import { CurrentUserContextInterface } from '../../app/components/current-user/CurrentUserContext';
import withCurrentUserContext from '../../app/components/current-user/withCurrentUserContext';
import { RuleDescriptionSections } from '../../apps/coding-rules/rule';
import StyledHeader from '../../apps/issues/components/StyledHeader';
import { fillBranchLike } from '../../helpers/branch-like';
import { translate } from '../../helpers/l10n';
+import { withUseGetFixSuggestionsIssues } from '../../queries/fix-suggestions';
import { Cve } from '../../types/cves';
-import { Feature } from '../../types/features';
import { Issue, RuleDetails } from '../../types/types';
import { CurrentUser, NoticeType } from '../../types/users';
import ScreenPositionHelper from '../common/ScreenPositionHelper';
interface IssueTabViewerProps extends CurrentUserContextInterface {
activityTabContent?: React.ReactNode;
+ aiSuggestionAvailable: boolean;
codeTabContent?: React.ReactNode;
currentUser: CurrentUser;
cve?: Cve;
extendedDescription?: string;
- hasFeature: (feature: string) => boolean;
issue: Issue;
location: Location;
onIssueChange: (issue: Issue) => void;
issue,
selectedFlowIndex,
selectedLocationIndex,
+ aiSuggestionAvailable,
} = this.props;
const { selectedTab } = this.state;
prevProps.issue !== issue ||
prevProps.selectedFlowIndex !== selectedFlowIndex ||
(prevProps.selectedLocationIndex ?? -1) !== (selectedLocationIndex ?? -1) ||
- prevProps.currentUser !== currentUser
+ prevProps.currentUser !== currentUser ||
+ prevProps.aiSuggestionAvailable !== aiSuggestionAvailable
) {
this.setState((pState) =>
this.computeState(
computeTabs = (displayEducationalPrinciplesNotification: boolean) => {
const {
codeTabContent,
- currentUser: { isLoggedIn },
ruleDetails: { descriptionSections, educationPrinciples, lang: ruleLanguage, type: ruleType },
ruleDescriptionContextKey,
extendedDescription,
cve,
issue,
suggestionTabContent,
- hasFeature,
+ aiSuggestionAvailable,
} = this.props;
// As we might tamper with the description later on, we clone to avoid any side effect
/>
),
},
- ...(hasFeature(Feature.FixSuggestions) && isLoggedIn
+ ...(aiSuggestionAvailable
? [
{
value: TabKeys.CodeFix,
}
}
-export default withCurrentUserContext(withLocation(withAvailableFeatures(IssueTabViewer)));
+export default withCurrentUserContext(
+ withLocation(withUseGetFixSuggestionsIssues<IssueTabViewerProps>(IssueTabViewer)),
+);
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2024 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import { useQuery, useQueryClient } from '@tanstack/react-query';
-import { some } from 'lodash';
-import { getSuggestions } from '../api/fix-suggestions';
-import { Issue } from '../types/types';
-import { useRawSourceQuery } from './sources';
-
-const UNKNOWN = -1;
-
-export enum LineTypeEnum {
- CODE = 'code',
- ADDED = 'added',
- REMOVED = 'removed',
-}
-
-export type DisplayedLine = {
- code: string;
- copy?: string;
- lineAfter: number;
- lineBefore: number;
- type: LineTypeEnum;
-};
-
-export type CodeSuggestion = {
- changes: Array<{ endLine: number; newCode: string; startLine: number }>;
- explanation: string;
- suggestionId: string;
- unifiedLines: DisplayedLine[];
-};
-
-export function usePrefetchSuggestion(issueKey: string) {
- const queryClient = useQueryClient();
- return () => {
- queryClient.prefetchQuery({ queryKey: ['code-suggestions', issueKey] });
- };
-}
-
-export function useUnifiedSuggestionsQuery(issue: Issue, enabled = true) {
- const branchLikeParam = issue.pullRequest
- ? { pullRequest: issue.pullRequest }
- : issue.branch
- ? { branch: issue.branch }
- : {};
-
- const { data: code } = useRawSourceQuery(
- { ...branchLikeParam, key: issue.component },
- { enabled },
- );
-
- return useQuery({
- queryKey: ['code-suggestions', issue.key],
- queryFn: ({ queryKey: [_1, issueId] }) => getSuggestions({ issueId }),
- enabled: enabled && code !== undefined,
- refetchOnMount: false,
- refetchOnWindowFocus: false,
- staleTime: Infinity,
- retry: false,
- select: (suggestedCode) => {
- if (code !== undefined && suggestedCode.changes) {
- const originalCodes = code.split(/\r?\n|\r|\n/g).map((line, index) => {
- const lineNumber = index + 1;
- const isRemoved = some(
- suggestedCode.changes,
- ({ startLine, endLine }) => startLine <= lineNumber && lineNumber <= endLine,
- );
- return {
- code: line,
- lineNumber,
- type: isRemoved ? LineTypeEnum.REMOVED : LineTypeEnum.CODE,
- };
- });
-
- const unifiedLines = originalCodes.flatMap<DisplayedLine>((line) => {
- const change = suggestedCode.changes.find(
- ({ endLine }) => endLine === line.lineNumber - 1,
- );
- if (change) {
- return [
- ...change.newCode.split(/\r?\n|\r|\n/g).map((newLine, index) => ({
- code: newLine,
- type: LineTypeEnum.ADDED,
- lineBefore: UNKNOWN,
- lineAfter: UNKNOWN,
- copy: index === 0 ? change.newCode : undefined,
- })),
- { code: line.code, type: line.type, lineBefore: line.lineNumber, lineAfter: UNKNOWN },
- ];
- }
-
- return [
- { code: line.code, type: line.type, lineBefore: line.lineNumber, lineAfter: UNKNOWN },
- ];
- });
- let lineAfterCount = 1;
- unifiedLines.forEach((line) => {
- if (line.type !== LineTypeEnum.REMOVED) {
- line.lineAfter = lineAfterCount;
- lineAfterCount += 1;
- }
- });
- return {
- unifiedLines,
- explanation: suggestedCode.explanation,
- changes: suggestedCode.changes,
- suggestionId: suggestedCode.id,
- };
- }
- return {
- unifiedLines: [],
- explanation: suggestedCode.explanation,
- changes: [],
- suggestionId: suggestedCode.id,
- };
- },
- });
-}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { some } from 'lodash';
+import React, { useContext } from 'react';
+import { getFixSuggestionsIssues, getSuggestions } 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';
+import { Issue } from '../types/types';
+import { isLoggedIn } from '../types/users';
+import { useRawSourceQuery } from './sources';
+
+const UNKNOWN = -1;
+
+export enum LineTypeEnum {
+ CODE = 'code',
+ ADDED = 'added',
+ REMOVED = 'removed',
+}
+
+export type DisplayedLine = {
+ code: string;
+ copy?: string;
+ lineAfter: number;
+ lineBefore: number;
+ type: LineTypeEnum;
+};
+
+export type CodeSuggestion = {
+ changes: Array<{ endLine: number; newCode: string; startLine: number }>;
+ explanation: string;
+ suggestionId: string;
+ unifiedLines: DisplayedLine[];
+};
+
+export function usePrefetchSuggestion(issueKey: string) {
+ const queryClient = useQueryClient();
+ return () => {
+ queryClient.prefetchQuery({ queryKey: ['code-suggestions', issueKey] });
+ };
+}
+
+export function useUnifiedSuggestionsQuery(issue: Issue, enabled = true) {
+ const branchLikeParam = issue.pullRequest
+ ? { pullRequest: issue.pullRequest }
+ : issue.branch
+ ? { branch: issue.branch }
+ : {};
+
+ const { data: code } = useRawSourceQuery(
+ { ...branchLikeParam, key: issue.component },
+ { enabled },
+ );
+
+ return useQuery({
+ queryKey: ['code-suggestions', issue.key],
+ queryFn: ({ queryKey: [_1, issueId] }) => getSuggestions({ issueId }),
+ enabled: enabled && code !== undefined,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ staleTime: Infinity,
+ retry: false,
+ select: (suggestedCode) => {
+ if (code !== undefined && suggestedCode.changes) {
+ const originalCodes = code.split(/\r?\n|\r|\n/g).map((line, index) => {
+ const lineNumber = index + 1;
+ const isRemoved = some(
+ suggestedCode.changes,
+ ({ startLine, endLine }) => startLine <= lineNumber && lineNumber <= endLine,
+ );
+ return {
+ code: line,
+ lineNumber,
+ type: isRemoved ? LineTypeEnum.REMOVED : LineTypeEnum.CODE,
+ };
+ });
+
+ const unifiedLines = originalCodes.flatMap<DisplayedLine>((line) => {
+ const change = suggestedCode.changes.find(
+ ({ endLine }) => endLine === line.lineNumber - 1,
+ );
+ if (change) {
+ return [
+ ...change.newCode.split(/\r?\n|\r|\n/g).map((newLine, index) => ({
+ code: newLine,
+ type: LineTypeEnum.ADDED,
+ lineBefore: UNKNOWN,
+ lineAfter: UNKNOWN,
+ copy: index === 0 ? change.newCode : undefined,
+ })),
+ { code: line.code, type: line.type, lineBefore: line.lineNumber, lineAfter: UNKNOWN },
+ ];
+ }
+
+ return [
+ { code: line.code, type: line.type, lineBefore: line.lineNumber, lineAfter: UNKNOWN },
+ ];
+ });
+ let lineAfterCount = 1;
+ unifiedLines.forEach((line) => {
+ if (line.type !== LineTypeEnum.REMOVED) {
+ line.lineAfter = lineAfterCount;
+ lineAfterCount += 1;
+ }
+ });
+ return {
+ unifiedLines,
+ explanation: suggestedCode.explanation,
+ changes: suggestedCode.changes,
+ suggestionId: suggestedCode.id,
+ };
+ }
+ return {
+ unifiedLines: [],
+ explanation: suggestedCode.explanation,
+ changes: [],
+ suggestionId: suggestedCode.id,
+ };
+ },
+ });
+}
+
+export function useGetFixSuggestionsIssuesQuery(issue: Issue) {
+ const { currentUser } = useContext(CurrentUserContext);
+ const { hasFeature } = useAvailableFeatures();
+
+ return useQuery({
+ queryKey: ['code-suggestions', 'issues', 'details', issue.key],
+ queryFn: () =>
+ getFixSuggestionsIssues({
+ issueId: issue.key,
+ }),
+ enabled: hasFeature(Feature.FixSuggestions) && isLoggedIn(currentUser),
+ });
+}
+
+export function withUseGetFixSuggestionsIssues<P extends { issue: Issue }>(
+ Component: React.ComponentType<
+ Omit<P, 'aiSuggestionAvailable'> & { aiSuggestionAvailable: boolean }
+ >,
+) {
+ return function WithGetFixSuggestion(props: Omit<P, 'aiSuggestionAvailable'>) {
+ const { data } = useGetFixSuggestionsIssuesQuery(props.issue);
+ return <Component aiSuggestionAvailable={data?.aiSuggestion === 'AVAILABLE'} {...props} />;
+ };
+}