diff options
author | Revanshu Paliwal <revanshu.paliwal@sonarsource.com> | 2024-08-28 16:40:16 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2024-09-04 20:03:11 +0000 |
commit | 0996c6186cfeb8e2c763d2f174b26ab276b232f7 (patch) | |
tree | 6a56285befb26ce1b41d9065ab86379cb91d31ac /server/sonar-web/src/main | |
parent | bed8af05f0c3f2b4c6018634d3d7aaba5b45a83c (diff) | |
download | sonarqube-0996c6186cfeb8e2c763d2f174b26ab276b232f7.tar.gz sonarqube-0996c6186cfeb8e2c763d2f174b26ab276b232f7.zip |
CODEFIX-12 Show new suggestion feature in issues page
Diffstat (limited to 'server/sonar-web/src/main')
16 files changed, 931 insertions, 18 deletions
diff --git a/server/sonar-web/src/main/js/api/fix-suggestions.ts b/server/sonar-web/src/main/js/api/fix-suggestions.ts new file mode 100644 index 00000000000..84570ff31e1 --- /dev/null +++ b/server/sonar-web/src/main/js/api/fix-suggestions.ts @@ -0,0 +1,29 @@ +/* + * 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 { axiosToCatch } from '../helpers/request'; +import { SuggestedFix } from '../types/fix-suggestions'; + +export interface FixParam { + issueId: string; +} + +export function getSuggestions(data: FixParam): Promise<SuggestedFix> { + return axiosToCatch.post<SuggestedFix>('/api/v2/fix-suggestions/ai-suggestions', data); +} diff --git a/server/sonar-web/src/main/js/api/mocks/FixIssueServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/FixIssueServiceMock.ts new file mode 100644 index 00000000000..be6dfcfcf69 --- /dev/null +++ b/server/sonar-web/src/main/js/api/mocks/FixIssueServiceMock.ts @@ -0,0 +1,59 @@ +/* + * 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 { FixParam, getSuggestions } from '../fix-suggestions'; +import { ISSUE_101 } from './data/ids'; + +jest.mock('../fix-suggestions'); + +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';", + }, + ], + }; + + constructor() { + jest.mocked(getSuggestions).mockImplementation(this.handleGetFixSuggestion); + } + + handleGetFixSuggestion = (data: FixParam) => { + if (data.issueId === ISSUE_101) { + return Promise.reject({ error: { msg: 'Invalid issue' } }); + } + return this.reply(this.fixSuggestion); + }; + + reply<T>(response: T): Promise<T> { + return new Promise((resolve) => { + setTimeout(() => { + resolve(cloneDeep(response)); + }, 10); + }); + } +} 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 f83fc7ab920..09cb5e4f844 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 @@ -19,10 +19,13 @@ */ import { screen, within } from '@testing-library/react'; 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 { TabKeys } from '../../../components/rules/RuleTabViewer'; -import { mockLoggedInUser } from '../../../helpers/testMocks'; +import { mockCurrentUser, mockLoggedInUser } from '../../../helpers/testMocks'; +import { Feature } from '../../../types/features'; import { RestUserDetailed } from '../../../types/users'; import { branchHandler, @@ -30,6 +33,7 @@ import { issuesHandler, renderIssueApp, renderProjectIssuesApp, + sourcesHandler, ui, usersHandler, } from '../test-utils'; @@ -76,6 +80,57 @@ describe('issue app', () => { expect(ui.conciseIssueItem2.get()).toBeInTheDocument(); }); + it('should be able to trigger a fix when feature is available', async () => { + sourcesHandler.setSource( + range(0, 20) + .map((n) => `line: ${n}`) + .join('\n'), + ); + const user = userEvent.setup(); + renderProjectIssuesApp( + 'project/issues?issueStatuses=CONFIRMED&open=issue2&id=myproject', + {}, + mockLoggedInUser(), + [Feature.BranchSupport, Feature.FixSuggestions], + ); + + expect(await ui.getFixSuggestion.find()).toBeInTheDocument(); + await user.click(ui.getFixSuggestion.get()); + + expect(await ui.suggestedExplanation.find()).toBeInTheDocument(); + + await user.click(ui.issueCodeTab.get()); + + expect(ui.seeFixSuggestion.get()).toBeInTheDocument(); + }); + + it('should not be able to trigger a fix when user is not logged in', async () => { + renderProjectIssuesApp( + 'project/issues?issueStatuses=CONFIRMED&open=issue2&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( + `project/issues?issueStatuses=CONFIRMED&open=${ISSUE_101}&id=myproject`, + {}, + mockLoggedInUser(), + [Feature.BranchSupport, Feature.FixSuggestions], + ); + + await user.click(await ui.issueCodeFixTab.find()); + await user.click(ui.getAFixSuggestion.get()); + + expect(await ui.noFixAvailable.find()).toBeInTheDocument(); + }); + it('should navigate to Why is this an issue tab', async () => { renderProjectIssuesApp('project/issues?issues=issue2&open=issue2&id=myproject&why=1'); diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx b/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx index 7a7383db6b9..2ce9343bb76 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx @@ -52,6 +52,7 @@ import withIndexationContext, { WithIndexationContextProps, } from '../../../components/hoc/withIndexationContext'; import withIndexationGuard from '../../../components/hoc/withIndexationGuard'; +import { IssueSuggestionCodeTab } from '../../../components/rules/IssueSuggestionCodeTab'; import IssueTabViewer from '../../../components/rules/IssueTabViewer'; import '../../../components/search-navigator.css'; import { DEFAULT_ISSUES_QUERY } from '../../../components/shared/utils'; @@ -1246,6 +1247,13 @@ export class App extends React.PureComponent<Props, State> { selectedLocationIndex={this.state.selectedLocationIndex} /> } + suggestionTabContent={ + <IssueSuggestionCodeTab + branchLike={fillBranchLike(openIssue.branch, openIssue.pullRequest)} + issue={openIssue} + language={openRuleDetails.lang} + /> + } extendedDescription={openRuleDetails.htmlNote} issue={openIssue} onIssueChange={this.handleIssueChange} 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 1ca660d6544..ba696076c9d 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 @@ -18,23 +18,34 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import styled from '@emotion/styled'; +import { Button, ButtonVariety, Spinner } from '@sonarsource/echoes-react'; import classNames from 'classnames'; import { FlagMessage, IssueMessageHighlighting, LineFinding, themeColor } from 'design-system'; 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 { + 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, Duplication, ExpandDirection, FlowLocation, + Issue, IssuesByLine, Snippet, SnippetGroup, @@ -42,6 +53,7 @@ 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'; @@ -56,6 +68,7 @@ import { interface Props { branchLike: BranchLike | undefined; + currentUser: CurrentUser; duplications?: Duplication[]; duplicationsByLine?: { [line: number]: number[] }; highlightedLocationMessage: { index: number; text: string | undefined } | undefined; @@ -81,10 +94,7 @@ interface State { snippets: Snippet[]; } -export default class ComponentSourceSnippetGroupViewer extends React.PureComponent< - Readonly<Props>, - State -> { +class ComponentSourceSnippetGroupViewer extends React.PureComponent<Readonly<Props>, State> { mounted = false; constructor(props: Readonly<Props>) { @@ -219,7 +229,8 @@ export default class ComponentSourceSnippetGroupViewer extends React.PureCompone }; renderIssuesList = (line: SourceLine) => { - const { isLastOccurenceOfPrimaryComponent, issue, issuesByLine, snippetGroup } = this.props; + const { isLastOccurenceOfPrimaryComponent, issue, issuesByLine, snippetGroup, currentUser } = + this.props; const locations = issue.component === snippetGroup.component.key && issue.textRange !== undefined ? locationsByLine([issue]) @@ -243,6 +254,8 @@ export default class ComponentSourceSnippetGroupViewer extends React.PureCompone <IssueSourceViewerScrollContext.Consumer key={issueToDisplay.key}> {(ctx) => ( <LineFinding + as={isSelectedIssue ? 'div' : undefined} + className="sw-justify-between" issueKey={issueToDisplay.key} message={ <IssueMessageHighlighting @@ -253,6 +266,11 @@ export default class ComponentSourceSnippetGroupViewer extends React.PureCompone selected={isSelectedIssue} ref={isSelectedIssue ? ctx?.registerPrimaryLocationRef : undefined} onIssueSelect={this.props.onIssueSelect} + getFixButton={ + isSelectedIssue ? ( + <GetFixButton issue={issueToDisplay} currentUser={currentUser} /> + ) : undefined + } /> )} </IssueSourceViewerScrollContext.Consumer> @@ -394,3 +412,48 @@ function isExpandable(snippets: Snippet[], snippetGroup: SnippetGroup) { const FileLevelIssueStyle = styled.div` border: 1px solid ${themeColor('codeLineBorder')}; `; + +function GetFixButton({ + currentUser, + issue, +}: Readonly<{ currentUser: CurrentUser; 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, + ); + + if (!isLoggedIn(currentUser) || !isSuggestionFeatureEnabled) { + return null; + } + return ( + <Spinner ariaLabel={translate('issues.code_fix.fix_is_being_generated')} isLoading={isLoading}> + {suggestion !== undefined && ( + <Button + className="sw-shrink-0" + onClick={() => { + handler(TabKeys.CodeFix); + }} + > + {translate('issues.code_fix.see_fix_suggestion')} + </Button> + )} + {suggestion === undefined && ( + <Button + className="sw-ml-2 sw-shrink-0" + onClick={() => { + handler(TabKeys.CodeFix); + prefetchSuggestion(); + }} + variety={ButtonVariety.Primary} + > + {translate('issues.code_fix.get_fix_suggestion')} + </Button> + )} + </Spinner> + ); +} + +export default withCurrentUserContext(ComponentSourceSnippetGroupViewer); diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/IssueSourceViewerHeader.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/IssueSourceViewerHeader.tsx index 528712bb163..8c04b2ce590 100644 --- a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/IssueSourceViewerHeader.tsx +++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/IssueSourceViewerHeader.tsx @@ -55,6 +55,8 @@ export interface Props { linkToProject?: boolean; loading?: boolean; onExpand?: () => void; + shouldShowOpenInIde?: boolean; + shouldShowViewAllIssues?: boolean; sourceViewerFile: SourceViewerFile; } @@ -68,6 +70,8 @@ export function IssueSourceViewerHeader(props: Readonly<Props>) { loading, onExpand, sourceViewerFile, + shouldShowOpenInIde = true, + shouldShowViewAllIssues = true, } = props; const { measures, path, project, projectName, q } = sourceViewerFile; @@ -146,7 +150,7 @@ export function IssueSourceViewerHeader(props: Readonly<Props>) { )} </div> - {!isProjectRoot && isLoggedIn(currentUser) && !isLoadingBranches && ( + {!isProjectRoot && shouldShowOpenInIde && isLoggedIn(currentUser) && !isLoadingBranches && ( <IssueOpenInIdeButton branchName={branchName} issueKey={issueKey} @@ -156,7 +160,7 @@ export function IssueSourceViewerHeader(props: Readonly<Props>) { /> )} - {!isProjectRoot && measures.issues !== undefined && ( + {!isProjectRoot && shouldShowViewAllIssues && measures.issues !== undefined && ( <div className={classNames('sw-ml-4', { 'sw-mr-1': (!expandable || loading) ?? isLoadingBranches, diff --git a/server/sonar-web/src/main/js/apps/issues/test-utils.tsx b/server/sonar-web/src/main/js/apps/issues/test-utils.tsx index 3963c7fb4c2..995d63c8259 100644 --- a/server/sonar-web/src/main/js/apps/issues/test-utils.tsx +++ b/server/sonar-web/src/main/js/apps/issues/test-utils.tsx @@ -23,6 +23,7 @@ import { Outlet, Route } from 'react-router-dom'; import { byPlaceholderText, byRole, byTestId, byText } from '~sonar-aligned/helpers/testSelector'; import BranchesServiceMock from '../../api/mocks/BranchesServiceMock'; import ComponentsServiceMock from '../../api/mocks/ComponentsServiceMock'; +import FixIssueServiceMock from '../../api/mocks/FixIssueServiceMock'; import IssuesServiceMock from '../../api/mocks/IssuesServiceMock'; import SourcesServiceMock from '../../api/mocks/SourcesServiceMock'; import UsersServiceMock from '../../api/mocks/UsersServiceMock'; @@ -45,9 +46,13 @@ export const issuesHandler = new IssuesServiceMock(usersHandler); export const componentsHandler = new ComponentsServiceMock(); export const sourcesHandler = new SourcesServiceMock(); export const branchHandler = new BranchesServiceMock(); +export const fixIssueHanlder = new FixIssueServiceMock(); 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), issuePageHeadering: byRole('heading', { level: 1, name: 'issues.page' }), issueItemAction1: byRole('link', { name: 'Issue with no location message' }), issueItemAction2: byRole('link', { name: 'FlowIssue' }), @@ -90,6 +95,10 @@ export const ui = { issueStatusFacet: byRole('button', { name: 'issues.facet.issueStatuses' }), tagFacet: byRole('button', { name: 'issues.facet.tags' }), typeFacet: byRole('button', { name: 'issues.facet.types' }), + getFixSuggestion: byRole('button', { name: 'issues.code_fix.get_fix_suggestion' }), + getAFixSuggestion: byRole('button', { name: 'issues.code_fix.get_a_fix_suggestion' }), + + seeFixSuggestion: byRole('button', { name: 'issues.code_fix.see_fix_suggestion' }), cleanCodeAttributeCategoryFacet: byRole('button', { name: 'issues.facet.cleanCodeAttributeCategories', }), @@ -147,6 +156,8 @@ export const ui = { ruleFacetSearch: byPlaceholderText('search.search_for_rules'), tagFacetSearch: byPlaceholderText('search.search_for_tags'), + issueCodeFixTab: byRole('tab', { name: 'coding_rules.description_section.title.code_fix' }), + issueCodeTab: byRole('tab', { name: 'issue.tabs.code' }), issueActivityTab: byRole('tab', { name: 'coding_rules.description_section.title.activity' }), issueActivityAddComment: byRole('button', { name: `issue.activity.add_comment`, @@ -184,6 +195,7 @@ export function renderProjectIssuesApp( [NoticeType.ISSUE_NEW_STATUS_AND_TRANSITION_GUIDE]: true, }, }), + featureList = [Feature.BranchSupport], ) { renderAppWithComponentContext( 'project/issues', @@ -198,7 +210,7 @@ export function renderProjectIssuesApp( {projectIssuesRoutes()} </Route> ), - { navigateTo, currentUser, featureList: [Feature.BranchSupport] }, + { navigateTo, currentUser, featureList }, { component: mockComponent(overrides) }, ); } diff --git a/server/sonar-web/src/main/js/components/rules/IssueSuggestionCodeTab.tsx b/server/sonar-web/src/main/js/components/rules/IssueSuggestionCodeTab.tsx new file mode 100644 index 00000000000..482ebc1ab19 --- /dev/null +++ b/server/sonar-web/src/main/js/components/rules/IssueSuggestionCodeTab.tsx @@ -0,0 +1,91 @@ +/* + * 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 { Button, ButtonVariety } from '@sonarsource/echoes-react'; +import { InProgressVisual, OverviewQGNotComputedIcon, OverviewQGPassedIcon } from 'design-system'; +import React from 'react'; +import { translate } from '../../helpers/l10n'; +import { usePrefetchSuggestion, useUnifiedSuggestionsQuery } from '../../queries/fix-suggestions'; +import { useRawSourceQuery } from '../../queries/sources'; +import { getBranchLikeQuery } from '../../sonar-aligned/helpers/branch-like'; +import { BranchLike } from '../../types/branch-like'; +import { Issue } from '../../types/types'; +import { IssueSuggestionFileSnippet } from './IssueSuggestionFileSnippet'; + +interface Props { + branchLike?: BranchLike; + issue: Issue; + language?: string; +} + +export function IssueSuggestionCodeTab({ branchLike, issue, language }: Readonly<Props>) { + const prefetchSuggestion = usePrefetchSuggestion(issue.key); + const { isPending, isLoading, isError, refetch } = useUnifiedSuggestionsQuery(issue, false); + const { isError: isIssueRawError } = useRawSourceQuery({ + ...getBranchLikeQuery(branchLike), + key: issue.component, + }); + + return ( + <> + {isPending && !isLoading && !isError && ( + <div className="sw-flex sw-flex-col sw-items-center"> + <OverviewQGPassedIcon className="sw-mt-6" /> + <p className="sw-body-sm-highlight sw-mt-4"> + {translate('issues.code_fix.let_us_suggest_fix')} + </p> + <Button + className="sw-mt-4" + onClick={() => prefetchSuggestion()} + variety={ButtonVariety.Primary} + > + {translate('issues.code_fix.get_a_fix_suggestion')} + </Button> + </div> + )} + {isLoading && ( + <div className="sw-flex sw-pt-6 sw-flex-col sw-items-center"> + <InProgressVisual /> + <p className="sw-body-sm-highlight sw-mt-4"> + {translate('issues.code_fix.fix_is_being_generated')} + </p> + </div> + )} + {isError && ( + <div className="sw-flex sw-flex-col sw-items-center"> + <OverviewQGNotComputedIcon className="sw-mt-6" /> + <p className="sw-body-sm-highlight sw-mt-4"> + {translate('issues.code_fix.something_went_wrong')} + </p> + <p className="sw-my-4">{translate('issues.code_fix.not_able_to_generate_fix')}</p> + {translate('issues.code_fix.check_how_to_fix')} + {!isIssueRawError && ( + <Button className="sw-mt-4" onClick={() => refetch()} variety={ButtonVariety.Primary}> + {translate('issues.code_fix.get_a_fix_suggestion')} + </Button> + )} + </div> + )} + + {!isPending && !isError && ( + <IssueSuggestionFileSnippet branchLike={branchLike} issue={issue} language={language} /> + )} + </> + ); +} diff --git a/server/sonar-web/src/main/js/components/rules/IssueSuggestionFileSnippet.tsx b/server/sonar-web/src/main/js/components/rules/IssueSuggestionFileSnippet.tsx new file mode 100644 index 00000000000..731e854c239 --- /dev/null +++ b/server/sonar-web/src/main/js/components/rules/IssueSuggestionFileSnippet.tsx @@ -0,0 +1,214 @@ +/* + * 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 { max } from 'lodash'; +import React, { Fragment, useCallback, useEffect, useState } from 'react'; + +import { + ClipboardIconButton, + CodeEllipsisDirection, + CodeEllipsisIcon, + LineCodeEllipsisStyled, + SonarCodeColorizer, + themeColor, +} from 'design-system'; +import { IssueSourceViewerHeader } from '../../apps/issues/crossComponentSourceViewer/IssueSourceViewerHeader'; +import { translate } from '../../helpers/l10n'; +import { useComponentForSourceViewer } from '../../queries/component'; +import { + DisplayedLine, + LineTypeEnum, + useUnifiedSuggestionsQuery, +} from '../../queries/fix-suggestions'; +import { BranchLike } from '../../types/branch-like'; +import { Issue } from '../../types/types'; +import { IssueSuggestionLine } from './IssueSuggestionLine'; + +interface Props { + branchLike?: BranchLike; + issue: Issue; + language?: string; +} +const EXPAND_SIZE = 10; +const BUFFER_CODE = 3; + +export function IssueSuggestionFileSnippet({ branchLike, issue, language }: Readonly<Props>) { + const [displayedLine, setDisplayedLine] = useState<DisplayedLine[]>([]); + + const { data: suggestion } = useUnifiedSuggestionsQuery(issue); + + const { data: sourceViewerFile } = useComponentForSourceViewer(issue.component, branchLike); + + useEffect(() => { + if (suggestion !== undefined) { + setDisplayedLine( + suggestion.unifiedLines.filter((line, index) => { + if (line.type !== LineTypeEnum.CODE) { + return true; + } + return suggestion.unifiedLines + .slice(max([index - BUFFER_CODE, 0]), index + BUFFER_CODE + 1) + .some((line) => line.type !== LineTypeEnum.CODE); + }), + ); + } + }, [suggestion]); + + const handleExpand = useCallback( + (index: number | undefined, at: number | undefined, to: number) => { + if (suggestion !== undefined) { + setDisplayedLine((current) => { + return [ + ...current.slice(0, index), + ...suggestion.unifiedLines.filter( + (line) => at !== undefined && at <= line.lineBefore && line.lineBefore < to, + ), + ...current.slice(index), + ]; + }); + } + }, + [suggestion], + ); + + if (suggestion === undefined) { + return null; + } + + return ( + <div> + {sourceViewerFile && ( + <IssueSourceViewerHeader + issueKey={issue.key} + sourceViewerFile={sourceViewerFile} + shouldShowOpenInIde={false} + shouldShowViewAllIssues={false} + /> + )} + <SourceFileWrapper className="js-source-file sw-mb-4"> + <SonarCodeColorizer> + {displayedLine[0]?.lineBefore !== 1 && ( + <LineCodeEllipsisStyled + onClick={() => + handleExpand( + 0, + max([displayedLine[0].lineBefore - EXPAND_SIZE, 0]), + displayedLine[0].lineBefore, + ) + } + style={{ borderTop: 'none' }} + > + <CodeEllipsisIcon direction={CodeEllipsisDirection.Up} /> + </LineCodeEllipsisStyled> + )} + {displayedLine.map((line, index) => ( + <Fragment key={`${line.lineBefore} -> ${line.lineAfter} `}> + {displayedLine[index - 1] !== undefined && + displayedLine[index - 1].lineBefore !== -1 && + line.lineBefore !== -1 && + displayedLine[index - 1].lineBefore !== line.lineBefore - 1 && ( + <> + {line.lineBefore - displayedLine[index - 1].lineBefore > EXPAND_SIZE ? ( + <> + <LineCodeEllipsisStyled + onClick={() => + handleExpand( + index, + displayedLine[index - 1].lineBefore + 1, + displayedLine[index - 1].lineBefore + EXPAND_SIZE + 1, + ) + } + > + <CodeEllipsisIcon direction={CodeEllipsisDirection.Down} /> + </LineCodeEllipsisStyled> + <LineCodeEllipsisStyled + onClick={() => + handleExpand(index, line.lineBefore - EXPAND_SIZE, line.lineBefore) + } + style={{ borderTop: 'none' }} + > + <CodeEllipsisIcon direction={CodeEllipsisDirection.Up} /> + </LineCodeEllipsisStyled> + </> + ) : ( + <LineCodeEllipsisStyled + onClick={() => + handleExpand( + index, + displayedLine[index - 1].lineBefore + 1, + line.lineBefore, + ) + } + > + <CodeEllipsisIcon direction={CodeEllipsisDirection.Middle} /> + </LineCodeEllipsisStyled> + )} + </> + )} + <div className="sw-relative"> + {line.copy !== undefined && ( + <StyledClipboardIconButton + aria-label={translate('component_viewer.copy_path_to_clipboard')} + copyValue={line.copy} + /> + )} + <IssueSuggestionLine + language={language} + line={line.code} + lineAfter={line.lineAfter} + lineBefore={line.lineBefore} + type={line.type} + /> + </div> + </Fragment> + ))} + + {displayedLine[displayedLine.length - 1]?.lineBefore !== + suggestion.unifiedLines[suggestion.unifiedLines.length - 1]?.lineBefore && ( + <LineCodeEllipsisStyled + onClick={() => + handleExpand( + displayedLine.length, + displayedLine[displayedLine.length - 1].lineBefore + 1, + displayedLine[displayedLine.length - 1].lineBefore + EXPAND_SIZE + 1, + ) + } + style={{ borderBottom: 'none' }} + > + <CodeEllipsisIcon direction={CodeEllipsisDirection.Down} /> + </LineCodeEllipsisStyled> + )} + </SonarCodeColorizer> + </SourceFileWrapper> + <p className="sw-mt-4">{suggestion.explanation}</p> + </div> + ); +} + +const StyledClipboardIconButton = styled(ClipboardIconButton)` + position: absolute; + right: 4px; + top: -4px; + z-index: 9; +`; + +const SourceFileWrapper = styled.div` + border: 1px solid ${themeColor('codeLineBorder')}; +`; diff --git a/server/sonar-web/src/main/js/components/rules/IssueSuggestionLine.tsx b/server/sonar-web/src/main/js/components/rules/IssueSuggestionLine.tsx new file mode 100644 index 00000000000..d2cecf9b809 --- /dev/null +++ b/server/sonar-web/src/main/js/components/rules/IssueSuggestionLine.tsx @@ -0,0 +1,145 @@ +/* + * 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 { + CodeSyntaxHighlighter, + LineMeta, + LineStyled, + SuggestedLineWrapper, + themeBorder, + themeColor, +} from 'design-system'; +import React from 'react'; +import { LineTypeEnum } from '../../queries/fix-suggestions'; + +type LineType = 'code' | 'added' | 'removed'; + +export function IssueSuggestionLine({ + language, + line, + lineAfter, + lineBefore, + type = 'code', +}: Readonly<{ + language?: string; + line: string; + lineAfter: number; + lineBefore: number; + type: LineType; +}>) { + return ( + <SuggestedLineWrapper> + <LineMeta as="div"> + {type !== LineTypeEnum.ADDED && ( + <LineNumberStyled className="sw-px-1 sw-inline-block">{lineBefore}</LineNumberStyled> + )} + </LineMeta> + <LineMeta as="div"> + {type !== LineTypeEnum.REMOVED && ( + <LineNumberStyled className="sw-px-1 sw-inline-block">{lineAfter}</LineNumberStyled> + )} + </LineMeta> + <LineDirectionMeta as="div"> + {type === LineTypeEnum.REMOVED && ( + <RemovedLineLayer className="sw-px-2">-</RemovedLineLayer> + )} + {type === LineTypeEnum.ADDED && <AddedLineLayer className="sw-px-2">+</AddedLineLayer>} + </LineDirectionMeta> + <LineCodeLayers> + {type === LineTypeEnum.CODE && ( + <LineCodeLayer className="sw-px-3"> + <CodeSyntaxHighlighter + htmlAsString={`<pre style="white-space: pre-wrap;">${line}</pre>`} + language={language} + escapeDom={false} + /> + </LineCodeLayer> + )} + {type === LineTypeEnum.REMOVED && ( + <RemovedLineLayer className="sw-px-3"> + <LineCodePreFormatted> + <CodeSyntaxHighlighter + htmlAsString={`<pre style="white-space: pre-wrap;">${line}</pre>`} + language={language} + escapeDom={false} + /> + </LineCodePreFormatted> + </RemovedLineLayer> + )} + {type === LineTypeEnum.ADDED && ( + <AddedLineLayer className="sw-px-3"> + <LineCodePreFormatted> + <CodeSyntaxHighlighter + htmlAsString={`<pre style="white-space: pre-wrap;">${line}</pre>`} + language={language} + escapeDom={false} + /> + </LineCodePreFormatted> + </AddedLineLayer> + )} + </LineCodeLayers> + </SuggestedLineWrapper> + ); +} + +const LineNumberStyled = styled.div` + &:hover { + color: ${themeColor('codeLineMetaHover')}; + } + + &:focus-visible { + outline-offset: -1px; + } +`; + +const LineCodeLayers = styled.div` + position: relative; + display: grid; + height: 100%; + background-color: var(--line-background); + + ${LineStyled}:hover & { + background-color: ${themeColor('codeLineHover')}; + } +`; + +const LineDirectionMeta = styled(LineMeta)` + border-left: ${themeBorder('default', 'codeLineBorder')}; +`; + +const LineCodeLayer = styled.div` + grid-row: 1; + grid-column: 1; +`; + +const LineCodePreFormatted = styled.pre` + position: relative; + white-space: pre-wrap; + overflow-wrap: anywhere; + tab-size: 4; +`; + +const AddedLineLayer = styled.div` + background-color: ${themeColor('codeLineCoveredUnderline')}; +`; + +const RemovedLineLayer = styled.div` + background-color: ${themeColor('codeLineUncoveredUnderline')}; +`; 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 c1ccaa506e1..fa4fff30c06 100644 --- a/server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx +++ b/server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx @@ -23,6 +23,7 @@ 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'; @@ -31,17 +32,21 @@ 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 { Feature } from '../../types/features'; import { Issue, RuleDetails } from '../../types/types'; -import { NoticeType } from '../../types/users'; +import { CurrentUser, NoticeType } from '../../types/users'; import ScreenPositionHelper from '../common/ScreenPositionHelper'; import withLocation from '../hoc/withLocation'; import MoreInfoRuleDescription from './MoreInfoRuleDescription'; import RuleDescription from './RuleDescription'; +import { TabSelectorContext } from './TabSelectorContext'; interface IssueTabViewerProps extends CurrentUserContextInterface { activityTabContent?: React.ReactNode; codeTabContent?: React.ReactNode; + currentUser: CurrentUser; extendedDescription?: string; + hasFeature: (feature: string) => boolean; issue: Issue; location: Location; onIssueChange: (issue: Issue) => void; @@ -49,6 +54,7 @@ interface IssueTabViewerProps extends CurrentUserContextInterface { ruleDetails: RuleDetails; selectedFlowIndex?: number; selectedLocationIndex?: number; + suggestionTabContent?: React.ReactNode; } interface State { displayEducationalPrinciplesNotification?: boolean; @@ -70,6 +76,7 @@ export enum TabKeys { WhyIsThisAnIssue = 'why', HowToFixIt = 'how_to_fix', AssessTheIssue = 'assess_the_problem', + CodeFix = 'code_fix', Activity = 'activity', MoreInfo = 'more_info', } @@ -127,7 +134,7 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta prevProps.ruleDescriptionContextKey !== ruleDescriptionContextKey || prevProps.issue !== issue || prevProps.selectedFlowIndex !== selectedFlowIndex || - prevProps.selectedLocationIndex !== selectedLocationIndex || + (prevProps.selectedLocationIndex ?? -1) !== (selectedLocationIndex ?? -1) || prevProps.currentUser !== currentUser ) { this.setState((pState) => @@ -172,9 +179,12 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta const tabs = this.computeTabs(displayEducationalPrinciplesNotification); + const selectedTab = + resetSelectedTab || !prevState.selectedTab ? tabs[0] : prevState.selectedTab; + return { tabs, - selectedTab: resetSelectedTab || !prevState.selectedTab ? tabs[0] : prevState.selectedTab, + selectedTab, displayEducationalPrinciplesNotification, }; }; @@ -182,11 +192,14 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta computeTabs = (displayEducationalPrinciplesNotification: boolean) => { const { codeTabContent, + currentUser: { isLoggedIn }, ruleDetails: { descriptionSections, educationPrinciples, lang: ruleLanguage, type: ruleType }, ruleDescriptionContextKey, extendedDescription, activityTabContent, issue, + suggestionTabContent, + hasFeature, } = this.props; // As we might tamper with the description later on, we clone to avoid any side effect @@ -253,6 +266,16 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta /> ), }, + ...(hasFeature(Feature.FixSuggestions) && isLoggedIn + ? [ + { + value: TabKeys.CodeFix, + key: TabKeys.CodeFix, + label: translate('coding_rules.description_section.title', TabKeys.CodeFix), + content: suggestionTabContent, + }, + ] + : []), { value: TabKeys.Activity, key: TabKeys.Activity, @@ -330,9 +353,11 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta }; handleSelectTabs = (currentTabKey: TabKeys) => { - this.setState(({ tabs }) => ({ - selectedTab: tabs.find((tab) => tab.key === currentTabKey) || tabs[0], - })); + this.setState(({ tabs }) => { + return { + selectedTab: tabs.find((tab) => tab.key === currentTabKey) || tabs[0], + }; + }); }; render() { @@ -390,7 +415,9 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta })} key={tab.key} > - {tab.content} + <TabSelectorContext.Provider value={this.handleSelectTabs}> + {tab.content} + </TabSelectorContext.Provider> </div> )) } @@ -402,4 +429,4 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta } } -export default withCurrentUserContext(withLocation(IssueTabViewer)); +export default withCurrentUserContext(withLocation(withAvailableFeatures(IssueTabViewer))); diff --git a/server/sonar-web/src/main/js/components/rules/TabSelectorContext.ts b/server/sonar-web/src/main/js/components/rules/TabSelectorContext.ts new file mode 100644 index 00000000000..5f1a4d6e90e --- /dev/null +++ b/server/sonar-web/src/main/js/components/rules/TabSelectorContext.ts @@ -0,0 +1,24 @@ +/* + * 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 { noop } from 'lodash'; +import { createContext } from 'react'; +import { TabKeys } from './IssueTabViewer'; + +export const TabSelectorContext = createContext<(selectedTab: TabKeys) => void>(noop); diff --git a/server/sonar-web/src/main/js/queries/component.ts b/server/sonar-web/src/main/js/queries/component.ts index 642564b73d4..e60bec4f6da 100644 --- a/server/sonar-web/src/main/js/queries/component.ts +++ b/server/sonar-web/src/main/js/queries/component.ts @@ -21,7 +21,14 @@ import { queryOptions, useQuery, useQueryClient } from '@tanstack/react-query'; import { groupBy, omit } from 'lodash'; import { BranchParameters } from '~sonar-aligned/types/branch-like'; import { getTasksForComponent } from '../api/ce'; -import { getBreadcrumbs, getComponent, getComponentData } from '../api/components'; +import { + getBreadcrumbs, + getComponent, + getComponentData, + getComponentForSourceViewer, +} from '../api/components'; +import { getBranchLikeQuery } from '../sonar-aligned/helpers/branch-like'; +import { BranchLike } from '../types/branch-like'; import { Component, Measure } from '../types/types'; import { StaleTime, createQueryHook } from './common'; @@ -94,3 +101,12 @@ export const useComponentDataQuery = createQueryHook( }); }, ); + +export function useComponentForSourceViewer(fileKey: string, branchLike?: BranchLike) { + return useQuery({ + queryKey: ['component', 'source-viewer', fileKey, branchLike] as const, + queryFn: ({ queryKey: [_1, _2, fileKey, branchLike] }) => + getComponentForSourceViewer({ component: fileKey, ...getBranchLikeQuery(branchLike) }), + staleTime: Infinity, + }); +} diff --git a/server/sonar-web/src/main/js/queries/fix-suggestions.ts b/server/sonar-web/src/main/js/queries/fix-suggestions.ts new file mode 100644 index 00000000000..c40d76bf8ec --- /dev/null +++ b/server/sonar-web/src/main/js/queries/fix-suggestions.ts @@ -0,0 +1,134 @@ +/* + * 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, + }; + }, + }); +} diff --git a/server/sonar-web/src/main/js/types/features.ts b/server/sonar-web/src/main/js/types/features.ts index 320e0f96300..abcfe8fbb64 100644 --- a/server/sonar-web/src/main/js/types/features.ts +++ b/server/sonar-web/src/main/js/types/features.ts @@ -29,4 +29,5 @@ export enum Feature { GithubProvisioning = 'github-provisioning', GitlabProvisioning = 'gitlab-provisioning', PrioritizedRules = 'prioritized-rules', + FixSuggestions = 'fix-suggestions', } diff --git a/server/sonar-web/src/main/js/types/fix-suggestions.ts b/server/sonar-web/src/main/js/types/fix-suggestions.ts new file mode 100644 index 00000000000..124684ff256 --- /dev/null +++ b/server/sonar-web/src/main/js/types/fix-suggestions.ts @@ -0,0 +1,31 @@ +/* + * 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. + */ +interface SuggestedChange { + endLine: number; + newCode: string; + startLine: number; +} + +export interface SuggestedFix { + changes: SuggestedChange[]; + explanation: string; + id: string; + issueId: string; +} |