From 20709cb92df91c0a6c20aa010c5dff53e65f9c74 Mon Sep 17 00:00:00 2001 From: Mathieu Suen Date: Mon, 23 Sep 2024 16:56:17 +0200 Subject: [PATCH] SONAR-23064 Add unsolved finding on overall code warning --- server/sonar-web/src/main/js/api/messages.ts | 1 + .../js/api/mocks/AiCodeAssuredServiceMock.ts | 42 ++++++++ .../js/api/mocks/ProjectBadgesServiceMock.tsx | 10 -- .../branches/AiCodeAssuranceWarrning.tsx | 97 +++++++++++++++++++ .../branches/BranchOverviewRenderer.tsx | 19 ++++ .../branches/__tests__/BranchOverview-it.tsx | 27 +++++- .../js/apps/overview/branches/test-utils.ts | 4 + .../src/main/js/apps/overview/utils.tsx | 3 + .../__tests__/ProjectInformationApp-it.tsx | 3 + .../src/main/js/queries/dismissed-messages.ts | 47 +++++++++ .../resources/org/sonar/l10n/core.properties | 4 + 11 files changed, 245 insertions(+), 12 deletions(-) create mode 100644 server/sonar-web/src/main/js/api/mocks/AiCodeAssuredServiceMock.ts create mode 100644 server/sonar-web/src/main/js/apps/overview/branches/AiCodeAssuranceWarrning.tsx create mode 100644 server/sonar-web/src/main/js/queries/dismissed-messages.ts diff --git a/server/sonar-web/src/main/js/api/messages.ts b/server/sonar-web/src/main/js/api/messages.ts index a5081180e20..983ae9a2715 100644 --- a/server/sonar-web/src/main/js/api/messages.ts +++ b/server/sonar-web/src/main/js/api/messages.ts @@ -27,6 +27,7 @@ export enum MessageTypes { ProjectNcd90 = 'PROJECT_NCD_90', ProjectNcdPage90 = 'PROJECT_NCD_PAGE_90', BranchNcd90 = 'BRANCH_NCD_90', + UnresolvedFindingsInAIGeneratedCode = 'UNRESOLVED_FINDINGS_IN_AI_GENERATED_CODE', } export interface MessageDismissParams { diff --git a/server/sonar-web/src/main/js/api/mocks/AiCodeAssuredServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/AiCodeAssuredServiceMock.ts new file mode 100644 index 00000000000..da0a0668abe --- /dev/null +++ b/server/sonar-web/src/main/js/api/mocks/AiCodeAssuredServiceMock.ts @@ -0,0 +1,42 @@ +/* + * 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 { isProjectAiCodeAssured } from '../ai-code-assurance'; + +jest.mock('../ai-code-assurance'); + +export class AiCodeAssuredServiceMock { + noAiProject = 'no-ai'; + + constructor() { + jest.mocked(isProjectAiCodeAssured).mockImplementation(this.handleProjectAiGeneratedCode); + } + + handleProjectAiGeneratedCode = (project: string) => { + if (project === this.noAiProject) { + return Promise.resolve(false); + } + return Promise.resolve(true); + }; + + reset() { + this.noAiProject = 'no-ai'; + } +} diff --git a/server/sonar-web/src/main/js/api/mocks/ProjectBadgesServiceMock.tsx b/server/sonar-web/src/main/js/api/mocks/ProjectBadgesServiceMock.tsx index 0c2de2bffa3..0e1839aa8b9 100644 --- a/server/sonar-web/src/main/js/api/mocks/ProjectBadgesServiceMock.tsx +++ b/server/sonar-web/src/main/js/api/mocks/ProjectBadgesServiceMock.tsx @@ -17,12 +17,10 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { isProjectAiCodeAssured } from '../ai-code-assurance'; import { getProjectBadgesToken, renewProjectBadgesToken } from '../project-badges'; jest.mock('../project-badges'); jest.mock('../project-badges'); -jest.mock('../ai-code-assurance'); const defaultToken = 'sqb_2b5052cef8eac91a921ac71be9227a27f6b6b38b'; @@ -34,20 +32,12 @@ export class ProjectBadgesServiceMock { jest.mocked(getProjectBadgesToken).mockImplementation(this.handleGetProjectBadgesToken); jest.mocked(renewProjectBadgesToken).mockImplementation(this.handleRenewProjectBadgesToken); - jest.mocked(isProjectAiCodeAssured).mockImplementation(this.handleProjectAiGeneratedCode); } handleGetProjectBadgesToken = () => { return Promise.resolve(this.token); }; - handleProjectAiGeneratedCode = (project: string) => { - if (project === 'no-ai') { - return Promise.resolve(false); - } - return Promise.resolve(true); - }; - handleRenewProjectBadgesToken = () => { const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; this.token = diff --git a/server/sonar-web/src/main/js/apps/overview/branches/AiCodeAssuranceWarrning.tsx b/server/sonar-web/src/main/js/apps/overview/branches/AiCodeAssuranceWarrning.tsx new file mode 100644 index 00000000000..289a6d9eb5c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/branches/AiCodeAssuranceWarrning.tsx @@ -0,0 +1,97 @@ +/* + * 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 { ButtonIcon, ButtonVariety, IconX } from '@sonarsource/echoes-react'; +import { themeBorder } from 'design-system'; +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { MessageTypes } from '../../../api/messages'; +import { translate } from '../../../helpers/l10n'; +import { + useMessageDismissedMutation, + useMessageDismissedQuery, +} from '../../../queries/dismissed-messages'; + +export function AiCodeAssuranceWarrning({ projectKey }: Readonly<{ projectKey: string }>) { + const messageType = MessageTypes.UnresolvedFindingsInAIGeneratedCode; + const { data: isDismissed } = useMessageDismissedQuery( + { messageType, projectKey }, + { select: (r) => r.dismissed }, + ); + + const { mutate: dismiss } = useMessageDismissedMutation(); + + if (isDismissed !== false) { + return null; + } + + return ( + + + + + + + + dismiss({ projectKey, messageType })} + variety={ButtonVariety.DefaultGhost} + /> + + + ); +} + +const StyleSnowflakesContent = styled.div` + display: flex; + padding: var(--echoes-dimension-space-100) 0px; + + flex-direction: column; + align-items: flex-start; + gap: var(--echoes-dimension-space-200); + flex: 1 0 0; +`; + +const StyleSnowflakesInner = styled.div` + display: flex; + padding: var(--echoes-dimension-space-100) 0px; + + align-items: flex-start; + gap: var(--echoes-dimension-space-300); + flex: 1 0 0; +`; + +const StyledSnowflakes = styled.div` + display: flex; + justify-content: space-between; + width: 641px; + padding: 0 var(--echoes-dimension-space-100) 0 var(--echoes-dimension-space-300); + + align-items: flex-start; + gap: 12px; + + border-radius: 8px; + border: ${themeBorder('default', 'projectCardBorder')}; + + background: var(--echoes-color-background-warning-weak); +`; diff --git a/server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx b/server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx index 7d83ed60083..a2004e6a902 100644 --- a/server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx @@ -24,15 +24,20 @@ import A11ySkipTarget from '~sonar-aligned/components/a11y/A11ySkipTarget'; import { useLocation, useRouter } from '~sonar-aligned/components/hoc/withRouter'; import { isPortfolioLike } from '~sonar-aligned/helpers/component'; import { ComponentQualifier } from '~sonar-aligned/types/component'; +import { useAvailableFeatures } from '../../../app/components/available-features/withAvailableFeatures'; import { CurrentUserContext } from '../../../app/components/current-user/CurrentUserContext'; import AnalysisMissingInfoMessage from '../../../components/shared/AnalysisMissingInfoMessage'; import { parseDate } from '../../../helpers/dates'; import { translate } from '../../../helpers/l10n'; import { areCCTMeasuresComputed, isDiffMetric } from '../../../helpers/measures'; import { CodeScope } from '../../../helpers/urls'; +import { useProjectAiCodeAssuredQuery } from '../../../queries/ai-code-assurance'; import { useDismissNoticeMutation } from '../../../queries/users'; +import { MetricKey } from '../../../sonar-aligned/types/metrics'; import { ApplicationPeriod } from '../../../types/application'; import { Branch } from '../../../types/branch-like'; +import { isProject } from '../../../types/component'; +import { Feature } from '../../../types/features'; import { Analysis, GraphType, MeasureHistory } from '../../../types/project-activity'; import { QualityGateStatus } from '../../../types/quality-gates'; import { Component, MeasureEnhanced, Metric, Period, QualityGate } from '../../../types/types'; @@ -41,6 +46,7 @@ import { AnalysisStatus } from '../components/AnalysisStatus'; import LastAnalysisLabel from '../components/LastAnalysisLabel'; import { Status } from '../utils'; import ActivityPanel from './ActivityPanel'; +import { AiCodeAssuranceWarrning } from './AiCodeAssuranceWarrning'; import BranchMetaTopBar from './BranchMetaTopBar'; import CaycPromotionGuide from './CaycPromotionGuide'; import DismissablePromotedSection from './DismissablePromotedSection'; @@ -98,6 +104,7 @@ export default function BranchOverviewRenderer(props: BranchOverviewRendererProp const router = useRouter(); const { currentUser } = React.useContext(CurrentUserContext); + const { hasFeature } = useAvailableFeatures(); const { mutateAsync: dismissNotice } = useDismissNoticeMutation(); @@ -108,11 +115,20 @@ export default function BranchOverviewRenderer(props: BranchOverviewRendererProp currentUser.isLoggedIn && !!currentUser.dismissedNotices[NoticeType.ONBOARDING_CAYC_BRANCH_SUMMARY_GUIDE], ); + const { data: isAiCodeAssured } = useProjectAiCodeAssuredQuery( + { project: component.key }, + { enabled: isProject(component.qualifier) && hasFeature(Feature.AiCodeAssurance) }, + ); const tab = query.codeScope === CodeScope.Overall ? CodeScope.Overall : CodeScope.New; const leakPeriod = component.qualifier === ComponentQualifier.Application ? appLeak : period; const isNewCodeTab = tab === CodeScope.New; const hasNewCodeMeasures = measures.some((m) => isDiffMetric(m.metric.key)); + const hasOverallFindings = measures.some( + (m) => + [MetricKey.violations, MetricKey.security_hotspots].includes(m.metric.key as MetricKey) && + m.value !== '0', + ); // Check if any potentially missing uncomputed measure is not present // const isMissingMeasures = @@ -231,6 +247,9 @@ export default function BranchOverviewRenderer(props: BranchOverviewRendererProp + {hasOverallFindings && isAiCodeAssured && ( + + )}
({ getAnalysisStatus: jest.fn().mockResolvedValue({ component: { warnings: [] } }), @@ -120,6 +125,8 @@ beforeAll(() => { }, ], }); + aiCodeAssuranceHandler = new AiCodeAssuredServiceMock(); + messageshandler = new MessagesServiceMock(); }); afterEach(() => { @@ -133,6 +140,8 @@ afterEach(() => { qualityGatesHandler.reset(); almHandler.reset(); settingsHandler.reset(); + aiCodeAssuranceHandler.reset(); + messageshandler.reset(); }); describe('project overview', () => { @@ -321,6 +330,15 @@ describe('project overview', () => { ); }); + it('should show unsolved message when project is AI assured', async () => { + const { user, ui } = getPageObjects(); + renderBranchOverview({ branch: undefined }, { featureList: [Feature.AiCodeAssurance] }); + expect(await ui.unsolvedOverallMessage.find()).toBeInTheDocument(); + await user.click(ui.dismissUnsolvedButton.get()); + + expect(ui.unsolvedOverallMessage.query()).not.toBeInTheDocument(); + }); + // eslint-disable-next-line jest/expect-expect it('should render overall tab without branch specified', async () => { const { user, ui } = getPageObjects(); @@ -843,7 +861,10 @@ it.each([ }, ); -function renderBranchOverview(props: Partial> = {}) { +function renderBranchOverview( + props: Partial> = {}, + context: RenderContext = {}, +) { const user = mockLoggedInUser(); const component = mockComponent({ breadcrumbs: [mockComponent({ key: 'foo' })], @@ -856,5 +877,7 @@ function renderBranchOverview(props: Partial , + '/', + context, ); } diff --git a/server/sonar-web/src/main/js/apps/overview/branches/test-utils.ts b/server/sonar-web/src/main/js/apps/overview/branches/test-utils.ts index 9367882d9a1..eef3c9f2c65 100644 --- a/server/sonar-web/src/main/js/apps/overview/branches/test-utils.ts +++ b/server/sonar-web/src/main/js/apps/overview/branches/test-utils.ts @@ -24,6 +24,10 @@ import { SoftwareImpactSeverity, SoftwareQuality } from '../../../types/clean-co export const getPageObjects = () => { const user = userEvent.setup(); const selectors = { + unsolvedOverallMessage: byText(/overview.ai_assurance.unsolved_overall.title/), + dismissUnsolvedButton: byRole('button', { + name: 'overview.ai_assurance.unsolved_overall.dismiss', + }), overallCodeButton: byRole('tab', { name: /overview.overall_code/ }), softwareImpactMeasureCard: (softwareQuality: SoftwareQuality) => byTestId(`overview__software-impact-card-${softwareQuality}`), diff --git a/server/sonar-web/src/main/js/apps/overview/utils.tsx b/server/sonar-web/src/main/js/apps/overview/utils.tsx index 0d94c172778..23134935448 100644 --- a/server/sonar-web/src/main/js/apps/overview/utils.tsx +++ b/server/sonar-web/src/main/js/apps/overview/utils.tsx @@ -98,6 +98,9 @@ export const BRANCH_OVERVIEW_METRICS: string[] = [ MetricKey.projects, MetricKey.lines, MetricKey.new_lines, + + // others + MetricKey.violations, ]; export const PR_METRICS: string[] = [ diff --git a/server/sonar-web/src/main/js/apps/projectInformation/__tests__/ProjectInformationApp-it.tsx b/server/sonar-web/src/main/js/apps/projectInformation/__tests__/ProjectInformationApp-it.tsx index 07884aa5838..856e28c9c67 100644 --- a/server/sonar-web/src/main/js/apps/projectInformation/__tests__/ProjectInformationApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/projectInformation/__tests__/ProjectInformationApp-it.tsx @@ -21,6 +21,7 @@ import { screen } from '@testing-library/react'; import { byRole } from '~sonar-aligned/helpers/testSelector'; import { ComponentQualifier, Visibility } from '~sonar-aligned/types/component'; import { MetricKey } from '~sonar-aligned/types/metrics'; +import { AiCodeAssuredServiceMock } from '../../../api/mocks/AiCodeAssuredServiceMock'; import BranchesServiceMock from '../../../api/mocks/BranchesServiceMock'; import CodingRulesServiceMock from '../../../api/mocks/CodingRulesServiceMock'; import ComponentsServiceMock from '../../../api/mocks/ComponentsServiceMock'; @@ -53,6 +54,7 @@ const rulesHandler = new CodingRulesServiceMock(); const badgesHandler = new ProjectBadgesServiceMock(); const notificationsHandler = new NotificationsMock(); const branchesHandler = new BranchesServiceMock(); +const aiCodeAssurance = new AiCodeAssuredServiceMock(); const ui = { projectPageTitle: byRole('heading', { name: 'project.info.title' }), @@ -76,6 +78,7 @@ afterEach(() => { badgesHandler.reset(); notificationsHandler.reset(); branchesHandler.reset(); + aiCodeAssurance.reset(); }); it('should show fields for project', async () => { diff --git a/server/sonar-web/src/main/js/queries/dismissed-messages.ts b/server/sonar-web/src/main/js/queries/dismissed-messages.ts new file mode 100644 index 00000000000..471824fde75 --- /dev/null +++ b/server/sonar-web/src/main/js/queries/dismissed-messages.ts @@ -0,0 +1,47 @@ +/* + * 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 { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'; +import { checkMessageDismissed, MessageDismissParams, setMessageDismissed } from '../api/messages'; +import { useCurrentUser } from '../app/components/current-user/CurrentUserContext'; +import { isLoggedIn } from '../types/users'; +import { createQueryHook } from './common'; + +export const useMessageDismissedQuery = createQueryHook( + ({ messageType, projectKey }: MessageDismissParams) => { + const { currentUser } = useCurrentUser(); + return queryOptions({ + queryKey: ['message-dismissed', projectKey, messageType], + queryFn: () => checkMessageDismissed({ projectKey, messageType }), + enabled: isLoggedIn(currentUser), + }); + }, +); + +export function useMessageDismissedMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: setMessageDismissed, + onSuccess: (_, { messageType, projectKey }) => { + queryClient.setQueryData(['message-dismissed', projectKey, messageType], { dismiss: true }); + queryClient.invalidateQueries({ queryKey: ['message-dismissed', projectKey, messageType] }); + }, + }); +} 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 314dc966426..de88613b2c2 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -4391,6 +4391,10 @@ overview.promoted_section.button_primary=Take the Tour overview.promoted_section.button_secondary=Not now overview.promoted_section.button_tooltip=Learn how to improve your code base by cleaning only new code. +overview.ai_assurance.unsolved_overall.title=Unresolved findings in overall code +overview.ai_assurance.unsolved_overall.description=Some unresolved findings in this branch’s overall code may represent a risk. We recommend reviewing these findings before releasing the branch. +overview.ai_assurance.unsolved_overall.dismiss=Dismiss unresolved overall code findings + guiding.cayc_promotion.1.title=The power of new code guiding.cayc_promotion.1.content.1=Cleaning only new code is easier and guarantees no debt will be added. As you change old code, it also gets cleaner over time. We call this ‘Clean as You Code’. guiding.cayc_promotion.2.title=Define your new code -- 2.39.5