From: Mathieu Suen Date: Tue, 17 Sep 2024 09:29:45 +0000 (+0200) Subject: CODEFIX-33 Remove the get fix button when feature is not available X-Git-Tag: 10.7.0.96327~57 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=502956b75fa6797e1a82d0d8ddb192e50683e92d;p=sonarqube.git CODEFIX-33 Remove the get fix button when feature is not available --- diff --git a/server/sonar-web/src/main/js/api/fix-suggestions.ts b/server/sonar-web/src/main/js/api/fix-suggestions.ts index 84570ff31e1..f4e582f343f 100644 --- a/server/sonar-web/src/main/js/api/fix-suggestions.ts +++ b/server/sonar-web/src/main/js/api/fix-suggestions.ts @@ -24,6 +24,15 @@ export interface FixParam { issueId: string; } +export interface AiIssue { + aiSuggestion: 'AVAILABLE' | 'NOT_AVAILABLE_FILE_LEVEL_ISSUE' | 'NOT_AVAILABLE_UNSUPPORTED_RULE'; + id: string; +} + export function getSuggestions(data: FixParam): Promise { return axiosToCatch.post('/api/v2/fix-suggestions/ai-suggestions', data); } + +export function getFixSuggestionsIssues(data: FixParam): Promise { + return axiosToCatch.get(`/api/v2/fix-suggestions/issues/${data.issueId}`); +} diff --git a/server/sonar-web/src/main/js/api/mocks/FixIssueServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/FixIssueServiceMock.ts index be6dfcfcf69..ce7da44af52 100644 --- a/server/sonar-web/src/main/js/api/mocks/FixIssueServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/FixIssueServiceMock.ts @@ -18,8 +18,8 @@ * 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'); @@ -40,8 +40,16 @@ export default class FixIssueServiceMock { 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' } }); diff --git a/server/sonar-web/src/main/js/apps/issues/__tests__/IssueApp-it.tsx b/server/sonar-web/src/main/js/apps/issues/__tests__/IssueApp-it.tsx index d341530762b..4082b39f2a3 100644 --- a/server/sonar-web/src/main/js/apps/issues/__tests__/IssueApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/issues/__tests__/IssueApp-it.tsx @@ -22,7 +22,7 @@ import userEvent from '@testing-library/user-event'; 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'; @@ -96,7 +96,7 @@ describe('issue app', () => { [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(); @@ -118,6 +118,18 @@ describe('issue app', () => { 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( diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx index ba696076c9d..e6444f83480 100644 --- a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx +++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx @@ -25,20 +25,18 @@ import * as React from 'react'; 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, @@ -53,7 +51,6 @@ import { 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'; @@ -68,7 +65,6 @@ import { interface Props { branchLike: BranchLike | undefined; - currentUser: CurrentUser; duplications?: Duplication[]; duplicationsByLine?: { [line: number]: number[] }; highlightedLocationMessage: { index: number; text: string | undefined } | undefined; @@ -94,7 +90,10 @@ interface State { snippets: Snippet[]; } -class ComponentSourceSnippetGroupViewer extends React.PureComponent, State> { +export default class ComponentSourceSnippetGroupViewer extends React.PureComponent< + Readonly, + State +> { mounted = false; constructor(props: Readonly) { @@ -229,8 +228,7 @@ class ComponentSourceSnippetGroupViewer extends React.PureComponent { - 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]) @@ -267,9 +265,7 @@ class ComponentSourceSnippetGroupViewer extends React.PureComponent - ) : undefined + isSelectedIssue ? : undefined } /> )} @@ -345,7 +341,8 @@ class ComponentSourceSnippetGroupViewer extends React.PureComponent} /> )} @@ -413,21 +410,17 @@ const FileLevelIssueStyle = styled.div` 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 ( {suggestion !== undefined && ( @@ -455,5 +448,3 @@ function GetFixButton({ ); } - -export default withCurrentUserContext(ComponentSourceSnippetGroupViewer); diff --git a/server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx b/server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx index a77a8c25839..15feceeea3b 100644 --- a/server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx +++ b/server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx @@ -23,7 +23,6 @@ import { cloneDeep, debounce, groupBy } from 'lodash'; 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'; @@ -32,8 +31,8 @@ import IssueHeader from '../../apps/issues/components/IssueHeader'; 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'; @@ -44,11 +43,11 @@ import { TabSelectorContext } from './TabSelectorContext'; 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; @@ -127,6 +126,7 @@ export class IssueTabViewer extends React.PureComponent this.computeState( @@ -194,7 +195,6 @@ export class IssueTabViewer extends React.PureComponent { const { codeTabContent, - currentUser: { isLoggedIn }, ruleDetails: { descriptionSections, educationPrinciples, lang: ruleLanguage, type: ruleType }, ruleDescriptionContextKey, extendedDescription, @@ -202,7 +202,7 @@ export class IssueTabViewer extends React.PureComponent ), }, - ...(hasFeature(Feature.FixSuggestions) && isLoggedIn + ...(aiSuggestionAvailable ? [ { value: TabKeys.CodeFix, @@ -433,4 +433,6 @@ export class IssueTabViewer extends React.PureComponent(IssueTabViewer)), +); diff --git a/server/sonar-web/src/main/js/queries/fix-suggestions.ts b/server/sonar-web/src/main/js/queries/fix-suggestions.ts deleted file mode 100644 index c40d76bf8ec..00000000000 --- a/server/sonar-web/src/main/js/queries/fix-suggestions.ts +++ /dev/null @@ -1,134 +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 { 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((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, - }; - }, - }); -} diff --git a/server/sonar-web/src/main/js/queries/fix-suggestions.tsx b/server/sonar-web/src/main/js/queries/fix-suggestions.tsx new file mode 100644 index 00000000000..a235a9101b6 --- /dev/null +++ b/server/sonar-web/src/main/js/queries/fix-suggestions.tsx @@ -0,0 +1,164 @@ +/* + * 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((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

( + Component: React.ComponentType< + Omit & { aiSuggestionAvailable: boolean } + >, +) { + return function WithGetFixSuggestion(props: Omit) { + const { data } = useGetFixSuggestionsIssuesQuery(props.issue); + return ; + }; +}