]> source.dussan.org Git - sonarqube.git/commitdiff
CODEFIX-33 Remove the get fix button when feature is not available
authorMathieu Suen <mathieu.suen@sonarsource.com>
Tue, 17 Sep 2024 09:29:45 +0000 (11:29 +0200)
committersonartech <sonartech@sonarsource.com>
Tue, 24 Sep 2024 20:03:05 +0000 (20:03 +0000)
server/sonar-web/src/main/js/api/fix-suggestions.ts
server/sonar-web/src/main/js/api/mocks/FixIssueServiceMock.ts
server/sonar-web/src/main/js/apps/issues/__tests__/IssueApp-it.tsx
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx
server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx
server/sonar-web/src/main/js/queries/fix-suggestions.ts [deleted file]
server/sonar-web/src/main/js/queries/fix-suggestions.tsx [new file with mode: 0644]

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