From 695c1260eb8442d021865258de4c50b58abd9273 Mon Sep 17 00:00:00 2001 From: Mathieu Suen Date: Fri, 6 Sep 2024 11:44:06 +0200 Subject: [PATCH] CODEFIX-15 Add view fix in ide button --- .../components/IssueOpenInIdeButton.tsx | 24 +-- .../apps/issues/components/OpenFixInIde.tsx | 152 ++++++++++++++++ .../__tests__/IssueOpenInIdeButton-test.tsx | 25 ++- .../__tests__/OpenFixInIde-test.tsx | 163 ++++++++++++++++++ .../IssueSourceViewerHeader.tsx | 21 +-- .../rules/IssueSuggestionFileSnippet.tsx | 2 + .../js/helpers/__tests__/sonarlint-test.ts | 25 ++- .../src/main/js/helpers/sonarlint.ts | 49 +++--- .../src/main/js/queries/component.ts | 7 +- .../src/main/js/queries/sonarlint.ts | 74 ++++++++ .../sonar-web/src/main/js/types/sonarlint.ts | 27 +++ .../resources/org/sonar/l10n/core.properties | 11 ++ 12 files changed, 507 insertions(+), 73 deletions(-) create mode 100644 server/sonar-web/src/main/js/apps/issues/components/OpenFixInIde.tsx create mode 100644 server/sonar-web/src/main/js/apps/issues/components/__tests__/OpenFixInIde-test.tsx create mode 100644 server/sonar-web/src/main/js/queries/sonarlint.ts diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssueOpenInIdeButton.tsx b/server/sonar-web/src/main/js/apps/issues/components/IssueOpenInIdeButton.tsx index bfa6befdcdd..f336cc20ec2 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/IssueOpenInIdeButton.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/IssueOpenInIdeButton.tsx @@ -27,14 +27,16 @@ import { DocLink } from '../../../helpers/doc-links'; import { translate } from '../../../helpers/l10n'; import { generateSonarLintUserToken, - openIssue as openSonarLintIssue, + openFixOrIssueInSonarLint, probeSonarLintServers, } from '../../../helpers/sonarlint'; +import { BranchLike } from '../../../types/branch-like'; import { Ide } from '../../../types/sonarlint'; +import { NewUserToken } from '../../../types/token'; import { UserBase } from '../../../types/users'; export interface Props { - branchName?: string; + branchLike?: BranchLike; issueKey: string; login: UserBase['login']; projectKey: string; @@ -59,13 +61,7 @@ const showSuccess = () => addGlobalSuccessMessage(translate('issues.open_in_ide. const DELAY_AFTER_TOKEN_CREATION = 3000; -export function IssueOpenInIdeButton({ - branchName, - issueKey, - login, - projectKey, - pullRequestID, -}: Readonly) { +export function IssueOpenInIdeButton({ branchLike, issueKey, login, projectKey }: Readonly) { const [isDisabled, setIsDisabled] = React.useState(false); const [ides, setIdes] = React.useState(undefined); const ref = React.useRef(null); @@ -80,21 +76,19 @@ export function IssueOpenInIdeButton({ const openIssue = async (ide: Ide) => { setIsDisabled(true); - let token: { name?: string; token?: string } = {}; + let token: NewUserToken | undefined = undefined; try { if (ide.needsToken) { token = await generateSonarLintUserToken({ ideName: ide.ideName, login }); } - await openSonarLintIssue({ - branchName, + await openFixOrIssueInSonarLint({ + branchLike, calledPort: ide.port, issueKey, projectKey, - pullRequestID, - tokenName: token.name, - tokenValue: token.token, + token, }); showSuccess(); diff --git a/server/sonar-web/src/main/js/apps/issues/components/OpenFixInIde.tsx b/server/sonar-web/src/main/js/apps/issues/components/OpenFixInIde.tsx new file mode 100644 index 00000000000..92c1c52fced --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/components/OpenFixInIde.tsx @@ -0,0 +1,152 @@ +/* + * 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, DropdownMenu } from '@sonarsource/echoes-react'; +import { addGlobalErrorMessage } from 'design-system/lib'; +import React, { useCallback, useState } from 'react'; +import { useCurrentUser } from '../../../app/components/current-user/CurrentUserContext'; +import { translate } from '../../../helpers/l10n'; +import { probeSonarLintServers } from '../../../helpers/sonarlint'; +import { useBranchesQuery } from '../../../queries/branch'; +import { useComponentForSourceViewer } from '../../../queries/component'; +import { CodeSuggestion } from '../../../queries/fix-suggestions'; +import { useOpenFixOrIssueInIdeMutation } from '../../../queries/sonarlint'; +import { Fix, Ide } from '../../../types/sonarlint'; +import { Issue } from '../../../types/types'; + +export interface Props { + aiSuggestion: CodeSuggestion; + issue: Issue; +} + +const DELAY_AFTER_TOKEN_CREATION = 3000; + +export function OpenFixInIde({ aiSuggestion, issue }: Readonly) { + const [ides, setIdes] = useState([]); + const { data, isLoading: isBranchLoading } = useBranchesQuery(); + + const { + currentUser: { isLoggedIn }, + } = useCurrentUser(); + + const { data: sourceViewerFile } = useComponentForSourceViewer( + issue.component, + data?.branchLike, + !isBranchLoading, + ); + const { mutateAsync: openFixInIde, isPending } = useOpenFixOrIssueInIdeMutation(); + + const closeDropdown = () => { + setIdes([]); + }; + + const openFix = useCallback( + async (ide: Ide) => { + closeDropdown(); + + const fix: Fix = { + explanation: aiSuggestion.explanation, + fileEdit: { + changes: aiSuggestion.changes.map((change) => ({ + after: change.newCode, + before: aiSuggestion.unifiedLines + .filter( + (line) => line.lineBefore >= change.startLine && line.lineBefore <= change.endLine, + ) + .map((line) => line.code) + .join('\n'), + beforeLineRange: { + startLine: change.startLine, + endLine: change.endLine, + }, + })), + path: sourceViewerFile?.path ?? '', + }, + suggestionId: aiSuggestion.suggestionId, + }; + + await openFixInIde({ + branchLike: data?.branchLike, + ide, + fix, + issue, + }); + + setTimeout( + () => { + closeDropdown(); + }, + ide.needsToken ? DELAY_AFTER_TOKEN_CREATION : 0, + ); + }, + [aiSuggestion, issue, sourceViewerFile, data, openFixInIde], + ); + + const onClick = async () => { + let IDEs = (await probeSonarLintServers()) ?? []; + + IDEs = IDEs.filter((ide) => ide.capabilities?.canOpenFixSuggestion); + + if (IDEs.length === 0) { + addGlobalErrorMessage(translate('unable_to_find_ide_with_fix.error')); + } else if (IDEs.length === 1) { + openFix(IDEs[0]); + } else { + setIdes(IDEs); + } + }; + + if (!isLoggedIn || data?.branchLike === undefined || sourceViewerFile === undefined) { + return null; + } + + return ( + { + const { ideName, description } = ide; + + const label = ideName + (description ? ` - ${description}` : ''); + + return ( + { + openFix(ide); + }} + > + {label} + + ); + })} + onClose={() => { + setIdes([]); + }} + onOpen={onClick} + > + + + ); +} diff --git a/server/sonar-web/src/main/js/apps/issues/components/__tests__/IssueOpenInIdeButton-test.tsx b/server/sonar-web/src/main/js/apps/issues/components/__tests__/IssueOpenInIdeButton-test.tsx index 7fe54312832..550f9e79ccf 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/__tests__/IssueOpenInIdeButton-test.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/__tests__/IssueOpenInIdeButton-test.tsx @@ -26,10 +26,7 @@ import { FormattedMessage } from 'react-intl'; import UserTokensMock from '../../../../api/mocks/UserTokensMock'; import DocumentationLink from '../../../../components/common/DocumentationLink'; import { DocLink } from '../../../../helpers/doc-links'; -import { - openIssue as openSonarLintIssue, - probeSonarLintServers, -} from '../../../../helpers/sonarlint'; +import { openFixOrIssueInSonarLint, probeSonarLintServers } from '../../../../helpers/sonarlint'; import { renderComponent } from '../../../../helpers/testReactTestingUtils'; import { Ide } from '../../../../types/sonarlint'; import { IssueOpenInIdeButton, Props } from '../IssueOpenInIdeButton'; @@ -38,7 +35,7 @@ jest.mock('../../../../helpers/sonarlint', () => ({ generateSonarLintUserToken: jest .fn() .mockResolvedValue({ name: 'token name', token: 'token value' }), - openIssue: jest.fn().mockResolvedValue(undefined), + openFixOrIssueInSonarLint: jest.fn().mockResolvedValue(undefined), probeSonarLintServers: jest.fn(), })); @@ -77,7 +74,7 @@ it('renders properly', () => { expect(addGlobalErrorMessage).not.toHaveBeenCalled(); expect(addGlobalSuccessMessage).not.toHaveBeenCalled(); - expect(openSonarLintIssue).not.toHaveBeenCalled(); + expect(openFixOrIssueInSonarLint).not.toHaveBeenCalled(); expect(probeSonarLintServers).not.toHaveBeenCalled(); }); @@ -107,7 +104,7 @@ it('handles button click with no ide found', async () => { />, ); - expect(openSonarLintIssue).not.toHaveBeenCalled(); + expect(openFixOrIssueInSonarLint).not.toHaveBeenCalled(); expect(addGlobalSuccessMessage).not.toHaveBeenCalled(); }); @@ -126,7 +123,7 @@ it('handles button click with one ide found', async () => { expect(probeSonarLintServers).toHaveBeenCalledWith(); - expect(openSonarLintIssue).toHaveBeenCalledWith({ + expect(openFixOrIssueInSonarLint).toHaveBeenCalledWith({ branchName: undefined, calledPort: MOCK_IDES[0].port, issueKey: MOCK_ISSUE_KEY, @@ -156,7 +153,7 @@ it('handles button click with several ides found', async () => { expect(probeSonarLintServers).toHaveBeenCalledWith(); - expect(openSonarLintIssue).not.toHaveBeenCalled(); + expect(openFixOrIssueInSonarLint).not.toHaveBeenCalled(); expect(addGlobalSuccessMessage).not.toHaveBeenCalled(); expect(addGlobalErrorMessage).not.toHaveBeenCalled(); @@ -170,14 +167,16 @@ it('handles button click with several ides found', async () => { await user.click(secondIde); - expect(openSonarLintIssue).toHaveBeenCalledWith({ - branchName: undefined, + expect(openFixOrIssueInSonarLint).toHaveBeenCalledWith({ + branchLike: undefined, calledPort: MOCK_IDES[1].port, issueKey: MOCK_ISSUE_KEY, projectKey: MOCK_PROJECT_KEY, pullRequestID: undefined, - tokenName: 'token name', - tokenValue: 'token value', + token: { + name: 'token name', + token: 'token value', + }, }); expect(addGlobalSuccessMessage).toHaveBeenCalledWith('issues.open_in_ide.success'); diff --git a/server/sonar-web/src/main/js/apps/issues/components/__tests__/OpenFixInIde-test.tsx b/server/sonar-web/src/main/js/apps/issues/components/__tests__/OpenFixInIde-test.tsx new file mode 100644 index 00000000000..0b7ddd1bcd6 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/components/__tests__/OpenFixInIde-test.tsx @@ -0,0 +1,163 @@ +/* + * 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 { useQueryClient } from '@tanstack/react-query'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React, { ComponentProps } from 'react'; +import BranchesServiceMock from '../../../../api/mocks/BranchesServiceMock'; +import { openFixOrIssueInSonarLint, probeSonarLintServers } from '../../../../helpers/sonarlint'; +import { mockIssue, mockLoggedInUser } from '../../../../helpers/testMocks'; +import { renderComponent } from '../../../../helpers/testReactTestingUtils'; +import { CodeSuggestion, LineTypeEnum } from '../../../../queries/fix-suggestions'; +import { Fix, Ide } from '../../../../types/sonarlint'; +import { OpenFixInIde } from '../OpenFixInIde'; + +jest.mock('../../../../api/components', () => ({ + getComponentForSourceViewer: jest.fn().mockReturnValue({}), +})); +jest.mock('../../../../helpers/sonarlint', () => ({ + generateSonarLintUserToken: jest + .fn() + .mockResolvedValue({ name: 'token name', token: 'token value' }), + openFixOrIssueInSonarLint: jest.fn().mockResolvedValue(undefined), + probeSonarLintServers: jest.fn(), +})); + +const handler = new BranchesServiceMock(); + +const MOCK_TOKEN: any = { + name: 'token name', + token: 'token value', +}; + +const FIX_DATA: Fix = { + explanation: 'explanation', + fileEdit: { + changes: [ + { + after: 'var p = 2;', + before: 'var t = 1;', + beforeLineRange: { + startLine: 1, + endLine: 2, + }, + }, + ], + path: '', + }, + suggestionId: 'suggestionId', +}; + +const AI_SUGGESTION: CodeSuggestion = { + changes: [{ endLine: 2, newCode: 'var p = 2;', startLine: 1 }], + explanation: 'explanation', + suggestionId: 'suggestionId', + unifiedLines: [ + { + code: 'var t = 1;', + lineAfter: -1, + lineBefore: 1, + type: LineTypeEnum.REMOVED, + }, + { + code: 'var p = 2;', + lineAfter: 1, + lineBefore: -1, + type: LineTypeEnum.ADDED, + }, + ], +}; + +const MOCK_IDES_OPEN_FIX: Ide[] = [ + { + description: 'IDE description', + ideName: 'Some IDE', + port: 1234, + capabilities: { canOpenFixSuggestion: true }, + needsToken: false, + }, + { + description: '', + ideName: 'Some other IDE', + needsToken: true, + port: 42000, + capabilities: { canOpenFixSuggestion: true }, + }, + { description: '', ideName: 'Some other IDE 2', needsToken: true, port: 43000 }, +]; +const MOCK_ISSUE_KEY = 'issue-key'; +const MOCK_PROJECT_KEY = 'project-key'; + +beforeEach(() => { + handler.reset(); +}); + +it('handles open in ide button click with several ides found when there is fix suggestion', async () => { + const user = userEvent.setup(); + + jest.mocked(probeSonarLintServers).mockResolvedValueOnce(MOCK_IDES_OPEN_FIX); + + renderComponentOpenIssueInIdeButton(); + + await user.click( + await screen.findByRole('button', { + name: 'view_fix_in_ide', + }), + ); + + expect( + screen.getByRole('menuitem', { + name: `${MOCK_IDES_OPEN_FIX[0].ideName} - ${MOCK_IDES_OPEN_FIX[0].description}`, + }), + ).toBeInTheDocument(); + + const secondIde = screen.getByRole('menuitem', { name: MOCK_IDES_OPEN_FIX[1].ideName }); + + expect(secondIde).toBeInTheDocument(); + + await user.click(secondIde); + + expect(openFixOrIssueInSonarLint).toHaveBeenCalledWith({ + branchLike: {}, + calledPort: MOCK_IDES_OPEN_FIX[1].port, + fix: FIX_DATA, + issueKey: MOCK_ISSUE_KEY, + projectKey: MOCK_PROJECT_KEY, + token: MOCK_TOKEN, + }); +}); + +function renderComponentOpenIssueInIdeButton( + props: Partial> = {}, +) { + const mockedIssue = mockIssue(false, { + key: MOCK_ISSUE_KEY, + projectKey: MOCK_PROJECT_KEY, + }); + + function Wrapper() { + const queryClient = useQueryClient(); + queryClient.setQueryData(['branches', 'mycomponent', 'details'], { branchLike: {} }); + return ; + } + + return renderComponent(, '/?id=mycomponent', { currentUser: mockLoggedInUser() }); +} 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 8c04b2ce590..58ce845ca3e 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 @@ -31,7 +31,7 @@ import { themeColor, } from 'design-system'; import * as React from 'react'; -import { getBranchLikeQuery, isBranch, isPullRequest } from '~sonar-aligned/helpers/branch-like'; +import { getBranchLikeQuery } from '~sonar-aligned/helpers/branch-like'; import { getComponentIssuesUrl } from '~sonar-aligned/helpers/urls'; import { ComponentQualifier } from '~sonar-aligned/types/component'; import { ComponentContext } from '../../../app/components/componentContext/ComponentContext'; @@ -55,6 +55,7 @@ export interface Props { linkToProject?: boolean; loading?: boolean; onExpand?: () => void; + secondaryActions?: React.ReactNode; shouldShowOpenInIde?: boolean; shouldShowViewAllIssues?: boolean; sourceViewerFile: SourceViewerFile; @@ -72,6 +73,7 @@ export function IssueSourceViewerHeader(props: Readonly) { sourceViewerFile, shouldShowOpenInIde = true, shouldShowViewAllIssues = true, + secondaryActions, } = props; const { measures, path, project, projectName, q } = sourceViewerFile; @@ -98,18 +100,6 @@ export function IssueSourceViewerHeader(props: Readonly) { border-bottom: none; `; - const [branchName, pullRequestID] = React.useMemo(() => { - if (isBranch(branchLike)) { - return [branchLike.name, undefined]; - } - - if (isPullRequest(branchLike)) { - return [branchLike.branch, branchLike.key]; - } - - return [undefined, undefined]; // should never end up here, but needed for consistent returns - }, [branchLike]); - return ( ) { {!isProjectRoot && shouldShowOpenInIde && isLoggedIn(currentUser) && !isLoadingBranches && ( )} + {secondaryActions &&
{secondaryActions}
} + {!isProjectRoot && shouldShowViewAllIssues && measures.issues !== undefined && (
} /> )} diff --git a/server/sonar-web/src/main/js/helpers/__tests__/sonarlint-test.ts b/server/sonar-web/src/main/js/helpers/__tests__/sonarlint-test.ts index ef2234a25a8..6f77ba8e461 100644 --- a/server/sonar-web/src/main/js/helpers/__tests__/sonarlint-test.ts +++ b/server/sonar-web/src/main/js/helpers/__tests__/sonarlint-test.ts @@ -17,12 +17,12 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { TokenType } from '../../types/token'; +import { NewUserToken, TokenType } from '../../types/token'; import { HttpStatus } from '../request'; import { buildPortRange, + openFixOrIssueInSonarLint, openHotspot, - openIssue, portIsValid, probeSonarLintServers, sendUserToken, @@ -92,7 +92,7 @@ describe('openHotspot', () => { }); }); -describe('openIssue', () => { +describe('open ide', () => { it('should send the correct request to the IDE to open an issue', async () => { let branchName: string | undefined = undefined; let pullRequestID: string | undefined = undefined; @@ -123,13 +123,13 @@ describe('openIssue', () => { return Promise.resolve(resp); }); - type OpenIssueParams = Parameters[0]; + type OpenIssueParams = Parameters[0]; type PartialOpenIssueParams = Partial; let params: PartialOpenIssueParams = {}; const testWith = async (args: PartialOpenIssueParams) => { params = { ...params, ...args }; - const result = await openIssue(params as OpenIssueParams); + const result = await openFixOrIssueInSonarLint(params as OpenIssueParams); expect(result).toBe(resp); }; @@ -140,14 +140,23 @@ describe('openIssue', () => { }); branchName = 'branch-1'; - await testWith({ branchName }); + await testWith({ branchLike: { name: branchName, isMain: false, excludedFromPurge: false } }); pullRequestID = 'pr-1'; - await testWith({ pullRequestID }); + await testWith({ + branchLike: { + key: pullRequestID, + branch: branchName, + name: branchName, + base: 'foo', + target: 'bar', + title: 'test', + }, + }); tokenName = 'token-name'; tokenValue = 'token-value'; - await testWith({ tokenName, tokenValue }); + await testWith({ token: { token: tokenValue, name: tokenName } as NewUserToken }); }); }); diff --git a/server/sonar-web/src/main/js/helpers/sonarlint.ts b/server/sonar-web/src/main/js/helpers/sonarlint.ts index d187ae35b7a..5fa922fbb54 100644 --- a/server/sonar-web/src/main/js/helpers/sonarlint.ts +++ b/server/sonar-web/src/main/js/helpers/sonarlint.ts @@ -17,9 +17,12 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { omit } from 'lodash'; import { generateToken, getTokens } from '../api/user-tokens'; import { getHostUrl } from '../helpers/urls'; -import { Ide } from '../types/sonarlint'; +import { isBranch, isPullRequest } from '../sonar-aligned/helpers/branch-like'; +import { BranchLike } from '../types/branch-like'; +import { Fix, Ide } from '../types/sonarlint'; import { NewUserToken, TokenExpiration } from '../types/token'; import { UserBase } from '../types/users'; import { checkStatus, isSuccessStatus } from './request'; @@ -39,9 +42,7 @@ export async function probeSonarLintServers(): Promise> { fetch(buildSonarLintEndpoint(p, '/status')) .then((r) => r.json()) .then((json) => { - const { description, ideName, needsToken } = json; - - return { description, ideName, needsToken, port: p } as Ide; + return { port: p, ...omit(json, 'p') }; }) .catch(() => undefined), ); @@ -93,42 +94,48 @@ export const generateSonarLintUserToken = async ({ return generateToken({ expirationDate, login, name }); }; -export function openIssue({ - branchName, +export function openFixOrIssueInSonarLint({ + branchLike, calledPort, + fix, issueKey, projectKey, - pullRequestID, - tokenName, - tokenValue, + token, }: { - branchName?: string; + branchLike: BranchLike | undefined; calledPort: number; + fix?: Fix; issueKey: string; projectKey: string; - pullRequestID?: string; - tokenName?: string; - tokenValue?: string; + token?: NewUserToken; }) { - const showUrl = new URL(buildSonarLintEndpoint(calledPort, '/issues/show')); + const showUrl = new URL( + buildSonarLintEndpoint(calledPort, fix === undefined ? '/issues/show' : '/fix/show'), + ); showUrl.searchParams.set('server', getHostUrl()); showUrl.searchParams.set('project', projectKey); showUrl.searchParams.set('issue', issueKey); - if (branchName !== undefined) { - showUrl.searchParams.set('branch', branchName); + if (isBranch(branchLike)) { + showUrl.searchParams.set('branch', branchLike.name); } - if (pullRequestID !== undefined) { - showUrl.searchParams.set('pullRequest', pullRequestID); + if (isPullRequest(branchLike)) { + showUrl.searchParams.set('branch', branchLike.branch); + showUrl.searchParams.set('pullRequest', branchLike.key); } - if (tokenName !== undefined && tokenValue !== undefined) { - showUrl.searchParams.set('tokenName', tokenName); - showUrl.searchParams.set('tokenValue', tokenValue); + if (token !== undefined) { + showUrl.searchParams.set('tokenName', token.name); + showUrl.searchParams.set('tokenValue', token.token); } + if (fix !== undefined) { + return fetch(showUrl.toString(), { method: 'POST', body: JSON.stringify(fix) }).then( + (response: Response) => checkStatus(response, true), + ); + } return fetch(showUrl.toString()).then((response: Response) => checkStatus(response, true)); } diff --git a/server/sonar-web/src/main/js/queries/component.ts b/server/sonar-web/src/main/js/queries/component.ts index e60bec4f6da..8cb47d8dfd1 100644 --- a/server/sonar-web/src/main/js/queries/component.ts +++ b/server/sonar-web/src/main/js/queries/component.ts @@ -102,11 +102,16 @@ export const useComponentDataQuery = createQueryHook( }, ); -export function useComponentForSourceViewer(fileKey: string, branchLike?: BranchLike) { +export function useComponentForSourceViewer( + fileKey: string, + branchLike?: BranchLike, + enabled = true, +) { return useQuery({ queryKey: ['component', 'source-viewer', fileKey, branchLike] as const, queryFn: ({ queryKey: [_1, _2, fileKey, branchLike] }) => getComponentForSourceViewer({ component: fileKey, ...getBranchLikeQuery(branchLike) }), staleTime: Infinity, + enabled, }); } diff --git a/server/sonar-web/src/main/js/queries/sonarlint.ts b/server/sonar-web/src/main/js/queries/sonarlint.ts new file mode 100644 index 00000000000..2512bea0ff1 --- /dev/null +++ b/server/sonar-web/src/main/js/queries/sonarlint.ts @@ -0,0 +1,74 @@ +/* + * 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 { useMutation } from '@tanstack/react-query'; +import { addGlobalErrorMessage, addGlobalSuccessMessage } from 'design-system/lib'; +import { useCurrentUser } from '../app/components/current-user/CurrentUserContext'; +import { translate } from '../helpers/l10n'; +import { generateSonarLintUserToken, openFixOrIssueInSonarLint } from '../helpers/sonarlint'; +import { BranchLike } from '../types/branch-like'; +import { Fix, Ide } from '../types/sonarlint'; +import { Issue } from '../types/types'; +import { isLoggedIn } from '../types/users'; + +export function useOpenFixOrIssueInIdeMutation() { + const { currentUser } = useCurrentUser(); + const login: string | undefined = isLoggedIn(currentUser) ? currentUser.login : undefined; + + return useMutation({ + mutationFn: async (data: { + branchLike: BranchLike | undefined; + fix?: Fix; + ide: Ide; + issue: Issue; + }) => { + const { ide, branchLike, issue, fix } = data; + + const { key: issueKey, projectKey } = issue; + + let token; + if (ide.needsToken && login !== undefined) { + token = await generateSonarLintUserToken({ ideName: ide.ideName, login }); + } + + return openFixOrIssueInSonarLint({ + branchLike, + calledPort: ide.port, + issueKey, + projectKey, + token, + fix, + }); + }, + onSuccess: (_, arg) => { + if (arg.fix) { + addGlobalSuccessMessage(translate('fix_in_ide.report_success')); + } else { + addGlobalSuccessMessage(translate('open_in_ide.report_success')); + } + }, + onError: (_, arg) => { + if (arg.fix) { + addGlobalErrorMessage(translate('fix_in_ide.report_error')); + } else { + addGlobalErrorMessage(translate('open_in_ide.report_error')); + } + }, + }); +} diff --git a/server/sonar-web/src/main/js/types/sonarlint.ts b/server/sonar-web/src/main/js/types/sonarlint.ts index 3f24bb1399a..37fcc43b7b4 100644 --- a/server/sonar-web/src/main/js/types/sonarlint.ts +++ b/server/sonar-web/src/main/js/types/sonarlint.ts @@ -18,8 +18,35 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ export interface Ide { + capabilities?: Capabilities; description: string; ideName: string; needsToken?: boolean; port: number; } + +export interface Capabilities { + canOpenFixSuggestion: boolean; +} + +export interface LineRange { + endLine: number; + startLine: number; +} + +export interface Changes { + after: string; + before: string; + beforeLineRange: LineRange; +} + +export interface EditFile { + changes: Changes[]; + path: string; +} + +export interface Fix { + explanation: string; + fileEdit: EditFile; + suggestionId: string; +} diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index ce345ecd8f4..8c12f3c2dd3 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -365,6 +365,17 @@ work_duration.x_minutes={0}min work_duration.about=~ {0} +#------------------------------------------------------------------------------ +# +# Open Fix in ide +# +#------------------------------------------------------------------------------ +view_fix_in_ide=View fix in IDE +fix_in_ide.report_success=Success. Switch to your IDE to see the fix. +fix_in_ide.report_error=Unable to open the fix in the IDE. +unable_to_find_ide_with_fix.error=Unable to find IDE with capabilities to show fix suggestions + + #------------------------------------------------------------------------------ # # DAY PICKER -- 2.39.5