]> source.dussan.org Git - sonarqube.git/commitdiff
CODEFIX-15 Add view fix in ide button
authorMathieu Suen <mathieu.suen@sonarsource.com>
Fri, 6 Sep 2024 09:44:06 +0000 (11:44 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 11 Sep 2024 20:03:48 +0000 (20:03 +0000)
12 files changed:
server/sonar-web/src/main/js/apps/issues/components/IssueOpenInIdeButton.tsx
server/sonar-web/src/main/js/apps/issues/components/OpenFixInIde.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/components/__tests__/IssueOpenInIdeButton-test.tsx
server/sonar-web/src/main/js/apps/issues/components/__tests__/OpenFixInIde-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/IssueSourceViewerHeader.tsx
server/sonar-web/src/main/js/components/rules/IssueSuggestionFileSnippet.tsx
server/sonar-web/src/main/js/helpers/__tests__/sonarlint-test.ts
server/sonar-web/src/main/js/helpers/sonarlint.ts
server/sonar-web/src/main/js/queries/component.ts
server/sonar-web/src/main/js/queries/sonarlint.ts [new file with mode: 0644]
server/sonar-web/src/main/js/types/sonarlint.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index bfa6befdcdd4b85328a8060ba77be8f046b2397f..f336cc20ec2fc9bf1127b34ac18726f86e854423 100644 (file)
@@ -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<Props>) {
+export function IssueOpenInIdeButton({ branchLike, issueKey, login, projectKey }: Readonly<Props>) {
   const [isDisabled, setIsDisabled] = React.useState(false);
   const [ides, setIdes] = React.useState<Ide[] | undefined>(undefined);
   const ref = React.useRef<HTMLButtonElement>(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 (file)
index 0000000..92c1c52
--- /dev/null
@@ -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<Props>) {
+  const [ides, setIdes] = useState<Ide[]>([]);
+  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 (
+    <DropdownMenu.Root
+      items={ides.map((ide) => {
+        const { ideName, description } = ide;
+
+        const label = ideName + (description ? ` - ${description}` : '');
+
+        return (
+          <DropdownMenu.ItemButton
+            key={ide.port}
+            onClick={() => {
+              openFix(ide);
+            }}
+          >
+            {label}
+          </DropdownMenu.ItemButton>
+        );
+      })}
+      onClose={() => {
+        setIdes([]);
+      }}
+      onOpen={onClick}
+    >
+      <Button
+        className="sw-whitespace-nowrap"
+        isDisabled={isPending}
+        onClick={onClick}
+        variety={ButtonVariety.Default}
+      >
+        {translate('view_fix_in_ide')}
+      </Button>
+    </DropdownMenu.Root>
+  );
+}
index 7fe54312832d59f707c40078951e2225460cb324..550f9e79ccf40ac6c6299461ffe46d04e5a04920 100644 (file)
@@ -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 (file)
index 0000000..0b7ddd1
--- /dev/null
@@ -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<ComponentProps<typeof OpenFixInIde>> = {},
+) {
+  const mockedIssue = mockIssue(false, {
+    key: MOCK_ISSUE_KEY,
+    projectKey: MOCK_PROJECT_KEY,
+  });
+
+  function Wrapper() {
+    const queryClient = useQueryClient();
+    queryClient.setQueryData(['branches', 'mycomponent', 'details'], { branchLike: {} });
+    return <OpenFixInIde aiSuggestion={AI_SUGGESTION} issue={mockedIssue} {...props} />;
+  }
+
+  return renderComponent(<Wrapper />, '/?id=mycomponent', { currentUser: mockLoggedInUser() });
+}
index 8c04b2ce59099fa5197eb12420aa91d053862b38..58ce845ca3e65e396bfd6bba355ce985298015ec 100644 (file)
@@ -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<Props>) {
     sourceViewerFile,
     shouldShowOpenInIde = true,
     shouldShowViewAllIssues = true,
+    secondaryActions,
   } = props;
 
   const { measures, path, project, projectName, q } = sourceViewerFile;
@@ -98,18 +100,6 @@ export function IssueSourceViewerHeader(props: Readonly<Props>) {
     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 (
     <IssueSourceViewerStyle
       aria-label={sourceViewerFile.path}
@@ -152,14 +142,15 @@ export function IssueSourceViewerHeader(props: Readonly<Props>) {
 
       {!isProjectRoot && shouldShowOpenInIde && isLoggedIn(currentUser) && !isLoadingBranches && (
         <IssueOpenInIdeButton
-          branchName={branchName}
+          branchLike={branchLike}
           issueKey={issueKey}
           login={currentUser.login}
           projectKey={project}
-          pullRequestID={pullRequestID}
         />
       )}
 
+      {secondaryActions && <div>{secondaryActions}</div>}
+
       {!isProjectRoot && shouldShowViewAllIssues && measures.issues !== undefined && (
         <div
           className={classNames('sw-ml-4', {
index 731e854c239345e0a072d361368582c076a32788..232695b9e613f0d4a00892f7979fcd5aa26b4ae9 100644 (file)
@@ -29,6 +29,7 @@ import {
   SonarCodeColorizer,
   themeColor,
 } from 'design-system';
+import { OpenFixInIde } from '../../apps/issues/components/OpenFixInIde';
 import { IssueSourceViewerHeader } from '../../apps/issues/crossComponentSourceViewer/IssueSourceViewerHeader';
 import { translate } from '../../helpers/l10n';
 import { useComponentForSourceViewer } from '../../queries/component';
@@ -100,6 +101,7 @@ export function IssueSuggestionFileSnippet({ branchLike, issue, language }: Read
           sourceViewerFile={sourceViewerFile}
           shouldShowOpenInIde={false}
           shouldShowViewAllIssues={false}
+          secondaryActions={<OpenFixInIde aiSuggestion={suggestion} issue={issue} />}
         />
       )}
       <SourceFileWrapper className="js-source-file sw-mb-4">
index ef2234a25a8d08de0ff9a99f969997e0a74d8e26..6f77ba8e46156682226a30af9d8f38adb0d5b4dd 100644 (file)
  * 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<typeof openIssue>[0];
+    type OpenIssueParams = Parameters<typeof openFixOrIssueInSonarLint>[0];
     type PartialOpenIssueParams = Partial<OpenIssueParams>;
     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 });
   });
 });
 
index d187ae35b7a6f145374450f6316a4b8b067d111a..5fa922fbb54e7796edf4b4b5d7e94db78cde7cf7 100644 (file)
  * 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<Array<Ide>> {
     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));
 }
 
index e60bec4f6da50ff9deb6217d87320ea0f386a64f..8cb47d8dfd1b81cde2153c9ac902aed8264ff3a8 100644 (file)
@@ -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 (file)
index 0000000..2512bea
--- /dev/null
@@ -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'));
+      }
+    },
+  });
+}
index 3f24bb1399a4121146770aaebb8ac93198c806f0..37fcc43b7b4ddb54ca86626a1c0652a5b0117387 100644 (file)
  * 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;
+}
index ce345ecd8f4b596871d9da822e999caa0d79c511..8c12f3c2dd3cc7423715a7b957bbf521f34d77ad 100644 (file)
@@ -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