From 42f4e8990eb0a9ee865d336ca066024910823ba5 Mon Sep 17 00:00:00 2001 From: Rohit Vinnakota <148245014+rohitvinnakota-codecov@users.noreply.github.com> Date: Mon, 23 Sep 2024 11:01:05 -0400 Subject: [PATCH] [feat] Add AI features tab at the org level (#3185) --- src/App.tsx | 6 + src/config.js | 1 + .../{Header.test.jsx => Header.test.tsx} | 43 +++++- .../shared/Header/{Header.jsx => Header.tsx} | 18 +++ src/pages/AnalyticsPage/Tabs/Tabs.jsx | 29 ---- .../Tabs/{Tabs.spec.jsx => Tabs.spec.tsx} | 53 +++++-- src/pages/AnalyticsPage/Tabs/Tabs.tsx | 41 +++++ .../CodecovAICommands/CodecovAICommands.tsx | 55 +++++++ .../CodecovAIPage/CodecovAIPage.spec.tsx | 140 ++++++++++++++++++ src/pages/CodecovAIPage/CodecovAIPage.tsx | 47 ++++++ .../InstallCodecovAI/InstallCodecovAI.tsx | 58 ++++++++ .../LearnMoreBlurb/LearnMoreBlurb.tsx | 27 ++++ .../Tabs.jsx => CodecovAIPage/Tabs/Tabs.tsx} | 9 ++ src/pages/CodecovAIPage/index.tsx | 1 + src/pages/MembersPage/Tabs/Tabs.jsx | 29 ---- .../Tabs/Tabs.spec.tsx} | 47 ++++-- src/pages/MembersPage/Tabs/Tabs.tsx | 41 +++++ .../Tabs/{Tabs.spec.jsx => Tabs.spec.tsx} | 53 +++++-- .../OwnerPage/Tabs/{Tabs.jsx => Tabs.tsx} | 18 +++ .../Tabs/Tabs.spec.tsx} | 59 ++++++-- src/pages/PlanPage/Tabs/Tabs.tsx | 41 +++++ .../navigation/useNavLinks/useNavLinks.js | 5 + .../useNavLinks/useStaticNavLinks.js | 6 + 23 files changed, 706 insertions(+), 121 deletions(-) rename src/pages/AccountSettings/shared/Header/{Header.test.jsx => Header.test.tsx} (72%) rename src/pages/AccountSettings/shared/Header/{Header.jsx => Header.tsx} (52%) delete mode 100644 src/pages/AnalyticsPage/Tabs/Tabs.jsx rename src/pages/AnalyticsPage/Tabs/{Tabs.spec.jsx => Tabs.spec.tsx} (72%) create mode 100644 src/pages/AnalyticsPage/Tabs/Tabs.tsx create mode 100644 src/pages/CodecovAIPage/CodecovAICommands/CodecovAICommands.tsx create mode 100644 src/pages/CodecovAIPage/CodecovAIPage.spec.tsx create mode 100644 src/pages/CodecovAIPage/CodecovAIPage.tsx create mode 100644 src/pages/CodecovAIPage/InstallCodecovAI/InstallCodecovAI.tsx create mode 100644 src/pages/CodecovAIPage/LearnMoreBlurb/LearnMoreBlurb.tsx rename src/pages/{PlanPage/Tabs/Tabs.jsx => CodecovAIPage/Tabs/Tabs.tsx} (73%) create mode 100644 src/pages/CodecovAIPage/index.tsx delete mode 100644 src/pages/MembersPage/Tabs/Tabs.jsx rename src/pages/{PlanPage/Tabs/Tabs.spec.jsx => MembersPage/Tabs/Tabs.spec.tsx} (70%) create mode 100644 src/pages/MembersPage/Tabs/Tabs.tsx rename src/pages/OwnerPage/Tabs/{Tabs.spec.jsx => Tabs.spec.tsx} (74%) rename src/pages/OwnerPage/Tabs/{Tabs.jsx => Tabs.tsx} (66%) rename src/pages/{MembersPage/Tabs/Tabs.spec.jsx => PlanPage/Tabs/Tabs.spec.tsx} (67%) create mode 100644 src/pages/PlanPage/Tabs/Tabs.tsx diff --git a/src/App.tsx b/src/App.tsx index b6dfa02933..99898bbcdf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import { ThemeContextProvider } from 'shared/ThemeContext' import AccountSettings from './pages/AccountSettings' import AdminSettings from './pages/AdminSettings' const AnalyticsPage = lazy(() => import('./pages/AnalyticsPage')) +const CodecovAIPage = lazy(() => import('./pages/CodecovAIPage')) const CommitDetailPage = lazy(() => import('./pages/CommitDetailPage')) const EnterpriseLandingPage = lazy(() => import('pages/EnterpriseLandingPage')) const LoginPage = lazy(() => import('./pages/LoginPage')) @@ -120,6 +121,11 @@ const MainAppRoutes = () => ( + + + + + diff --git a/src/config.js b/src/config.js index a8b6b86b24..37d61da30d 100644 --- a/src/config.js +++ b/src/config.js @@ -9,6 +9,7 @@ const defaultConfig = { SENTRY_SESSION_SAMPLE_RATE: 0.1, SENTRY_ERROR_SAMPLE_RATE: 1.0, GH_APP: 'codecov', + GH_APP_AI: 'codecov', // TODO: Update to proper GH app name once it is live } // To be removed after we're satisfied session_expiry cookie cleanup is complete diff --git a/src/pages/AccountSettings/shared/Header/Header.test.jsx b/src/pages/AccountSettings/shared/Header/Header.test.tsx similarity index 72% rename from src/pages/AccountSettings/shared/Header/Header.test.jsx rename to src/pages/AccountSettings/shared/Header/Header.test.tsx index 480752fc23..39d5fbdc1e 100644 --- a/src/pages/AccountSettings/shared/Header/Header.test.jsx +++ b/src/pages/AccountSettings/shared/Header/Header.test.tsx @@ -5,14 +5,20 @@ import { MemoryRouter, Route } from 'react-router-dom' import config from 'config' +import { useFlags } from 'shared/featureFlags' + import Header from './Header' vi.mock('config') +vi.mock('layouts/MyContextSwitcher', () => () => 'MyContextSwitcher') +vi.mock('shared/featureFlags') + +const mockedUseFlags = useFlags as jest.Mock const queryClient = new QueryClient() const server = setupServer() -const wrapper = ({ children }) => ( +const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( {children} @@ -33,12 +39,9 @@ afterAll(() => { }) describe('Header', () => { - function setup( - { isSelfHosted = false } = { - isSelfHosted: false, - } - ) { + function setup(isSelfHosted: boolean = false) { config.IS_SELF_HOSTED = isSelfHosted + mockedUseFlags.mockReturnValue({ codecovAiFeaturesTab: true }) } describe('when users is part of the org', () => { @@ -100,7 +103,7 @@ describe('Header', () => { describe('when rendered with enterprise account', () => { it('does not render link to members page', () => { - setup({ isSelfHosted: true }) + setup(true) render(
, { wrapper }) expect( @@ -111,7 +114,7 @@ describe('Header', () => { }) it('does not render link to plan page', () => { - setup({ isSelfHosted: true }) + setup(true) render(
, { wrapper }) expect( @@ -121,4 +124,28 @@ describe('Header', () => { ).not.toBeInTheDocument() }) }) + + describe('ai features tab', () => { + it('does not render tab when flag is off', () => { + mockedUseFlags.mockReturnValue({ codecovAiFeaturesTab: false }) + render(
, { wrapper }) + + expect( + screen.queryByRole('link', { + name: /Codecov AI beta/i, + }) + ).not.toBeInTheDocument() + }) + + it('renders tab when flag is on', () => { + setup() + render(
, { wrapper }) + + expect( + screen.getByRole('link', { + name: /Codecov AI beta/i, + }) + ).toBeInTheDocument() + }) + }) }) diff --git a/src/pages/AccountSettings/shared/Header/Header.jsx b/src/pages/AccountSettings/shared/Header/Header.tsx similarity index 52% rename from src/pages/AccountSettings/shared/Header/Header.jsx rename to src/pages/AccountSettings/shared/Header/Header.tsx index b4470f964a..8a5ca809fb 100644 --- a/src/pages/AccountSettings/shared/Header/Header.jsx +++ b/src/pages/AccountSettings/shared/Header/Header.tsx @@ -1,13 +1,31 @@ import config from 'config' +import { useFlags } from 'shared/featureFlags' +import Badge from 'ui/Badge' import TabNavigation from 'ui/TabNavigation' function Header() { + const { codecovAiFeaturesTab } = useFlags({ + codecovAiFeaturesTab: false, + }) + return ( + Codecov AI beta{' '} + + ), + }, + ] + : []), ...(config.IS_SELF_HOSTED ? [] : [{ pageName: 'membersTab' }, { pageName: 'planTab' }]), diff --git a/src/pages/AnalyticsPage/Tabs/Tabs.jsx b/src/pages/AnalyticsPage/Tabs/Tabs.jsx deleted file mode 100644 index 4ed4e0f5e6..0000000000 --- a/src/pages/AnalyticsPage/Tabs/Tabs.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import config from 'config' - -import TabNavigation from 'ui/TabNavigation' - -function Tabs() { - return ( - - ) -} - -export default Tabs diff --git a/src/pages/AnalyticsPage/Tabs/Tabs.spec.jsx b/src/pages/AnalyticsPage/Tabs/Tabs.spec.tsx similarity index 72% rename from src/pages/AnalyticsPage/Tabs/Tabs.spec.jsx rename to src/pages/AnalyticsPage/Tabs/Tabs.spec.tsx index 184c92af18..3dac61a3a0 100644 --- a/src/pages/AnalyticsPage/Tabs/Tabs.spec.jsx +++ b/src/pages/AnalyticsPage/Tabs/Tabs.spec.tsx @@ -5,14 +5,20 @@ import { MemoryRouter, Route } from 'react-router-dom' import config from 'config' +import { useFlags } from 'shared/featureFlags' + import Tabs from './Tabs' jest.mock('config') +jest.mock('shared/featureFlags') + +const mockedUseFlags = useFlags as jest.Mock + const queryClient = new QueryClient() const server = setupServer() -const wrapper = ({ children }) => ( +const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( {children} @@ -33,17 +39,14 @@ afterAll(() => { }) describe('Tabs', () => { - function setup( - { isSelfHosted = false } = { - isSelfHosted: false, - } - ) { + function setup(isSelfHosted: boolean = false) { config.IS_SELF_HOSTED = isSelfHosted + mockedUseFlags.mockReturnValue({ codecovAiFeaturesTab: true }) } describe('when user is part of the org', () => { it('renders links to the home page', () => { - setup({}) + setup() render(, { wrapper }) expect( @@ -54,7 +57,7 @@ describe('Tabs', () => { }) it('renders links to the analytics page', () => { - setup({}) + setup() render(, { wrapper }) expect( @@ -65,7 +68,7 @@ describe('Tabs', () => { }) it('renders links to the settings page', () => { - setup({}) + setup() render(, { wrapper }) expect( @@ -76,7 +79,7 @@ describe('Tabs', () => { }) it('renders link to plan page', () => { - setup({}) + setup() render(, { wrapper }) expect( @@ -87,7 +90,7 @@ describe('Tabs', () => { }) it('renders link to members page', () => { - setup({}) + setup() render(, { wrapper }) expect( @@ -100,7 +103,7 @@ describe('Tabs', () => { describe('when should render tabs is false', () => { it('does not render link to members page', () => { - setup({ isSelfHosted: true }) + setup(true) render(, { wrapper }) expect( @@ -111,7 +114,7 @@ describe('Tabs', () => { }) it('does not render link to plan page', () => { - setup({ isSelfHosted: true }) + setup(true) render(, { wrapper }) expect( @@ -121,4 +124,28 @@ describe('Tabs', () => { ).not.toBeInTheDocument() }) }) + + describe('ai features tab', () => { + it('does not render tab when flag is off', () => { + mockedUseFlags.mockReturnValue({ codecovAiFeaturesTab: false }) + render(, { wrapper }) + + expect( + screen.queryByRole('link', { + name: /Codecov AI beta/i, + }) + ).not.toBeInTheDocument() + }) + + it('renders tab when flag is on', () => { + setup() + render(, { wrapper }) + + expect( + screen.getByRole('link', { + name: /Codecov AI beta/i, + }) + ).toBeInTheDocument() + }) + }) }) diff --git a/src/pages/AnalyticsPage/Tabs/Tabs.tsx b/src/pages/AnalyticsPage/Tabs/Tabs.tsx new file mode 100644 index 0000000000..c36e711a14 --- /dev/null +++ b/src/pages/AnalyticsPage/Tabs/Tabs.tsx @@ -0,0 +1,41 @@ +import config from 'config' + +import { useFlags } from 'shared/featureFlags' +import Badge from 'ui/Badge' +import TabNavigation from 'ui/TabNavigation' + +function Tabs() { + const { codecovAiFeaturesTab } = useFlags({ + codecovAiFeaturesTab: false, + }) + + return ( + + Codecov AI beta{' '} + + ), + }, + ] + : []), + ...(config.IS_SELF_HOSTED + ? [] + : [{ pageName: 'membersTab' }, { pageName: 'planTab' }]), + { + pageName: 'accountAdmin', + children: 'Settings', + }, + ]} + /> + ) +} + +export default Tabs diff --git a/src/pages/CodecovAIPage/CodecovAICommands/CodecovAICommands.tsx b/src/pages/CodecovAIPage/CodecovAICommands/CodecovAICommands.tsx new file mode 100644 index 0000000000..301a139c5d --- /dev/null +++ b/src/pages/CodecovAIPage/CodecovAICommands/CodecovAICommands.tsx @@ -0,0 +1,55 @@ +import { Card } from 'ui/Card' +import { ExpandableSection } from 'ui/ExpandableSection' + +const CodecovAICommands: React.FC = () => { + return ( +
+ + + Codecov AI Commands + + + After installing the app, use these commands in your PR comments: +
    +
  • + + @codecov-ai-reviewer test + {' '} + --the assistant will generate tests for the PR. +
  • +
  • + + @codecov-ai-reviewer review + {' '} + --the assistant will review the PR and make suggestions. +
  • +
+
+
+ + +

+ Here is an example of Codecov AI Reviewer in PR comments. Comment + generation may take time. +

+
+ + Screenshot goes here + +
+ + +

+ Here is an example of Codecov AI Test Generator in PR comments. + Comment generation may take time. +

+
+ + Screenshot goes here + +
+
+ ) +} + +export default CodecovAICommands diff --git a/src/pages/CodecovAIPage/CodecovAIPage.spec.tsx b/src/pages/CodecovAIPage/CodecovAIPage.spec.tsx new file mode 100644 index 0000000000..7ef597cf11 --- /dev/null +++ b/src/pages/CodecovAIPage/CodecovAIPage.spec.tsx @@ -0,0 +1,140 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import { setupServer } from 'msw/node' +import { MemoryRouter, Route } from 'react-router-dom' + +import { useFlags } from 'shared/featureFlags' +import { ThemeContextProvider } from 'shared/ThemeContext' + +import CodecovAIPage from './CodecovAIPage' + +jest.mock('shared/featureFlags') +const mockedUseFlags = useFlags as jest.Mock + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + suspense: true, + retry: false, + }, + }, +}) +const server = setupServer() + +const wrapper: React.FC = ({ children }) => ( + + + + {children} + + + +) + +beforeAll(() => { + server.listen() +}) + +afterEach(() => { + queryClient.clear() + server.resetHandlers() +}) + +afterAll(() => { + server.close() +}) + +describe('CodecovAIPage', () => { + beforeEach(() => { + mockedUseFlags.mockReturnValue({ codecovAiFeaturesTab: true }) + }) + + it('renders top section', async () => { + render(, { wrapper }) + + const topSection = await screen.findByText(/Codecov AI is a/) + expect(topSection).toBeInTheDocument() + }) + + it('renders the install card', async () => { + render(, { wrapper }) + + const installHeader = await screen.findByText(/Install the Codecov AI/) + expect(installHeader).toBeInTheDocument() + }) + + it('renders install instructions', async () => { + render(, { wrapper }) + + const installHeader = await screen.findByText( + /To enable the Codecov AI assistant/ + ) + expect(installHeader).toBeInTheDocument() + }) + + it('renders the install button', async () => { + render(, { wrapper }) + const buttonEl = screen.getByRole('link', { name: /Install Codecov AI/i }) + expect(buttonEl).toBeInTheDocument() + }) + + it('renders approve install text', async () => { + render(, { wrapper }) + + const linkApproveText = await screen.findByText( + /Hello, could you help approve/ + ) + expect(linkApproveText).toBeInTheDocument() + }) + + it('renders codecov ai commands', async () => { + render(, { wrapper }) + + const commandText = await screen.findByText(/Codecov AI Commands/) + expect(commandText).toBeInTheDocument() + + const commandOneText = await screen.findByText( + /the assistant will generate tests/ + ) + expect(commandOneText).toBeInTheDocument() + + const commandTwoText = await screen.findByText( + /the assistant will review the PR/ + ) + expect(commandTwoText).toBeInTheDocument() + }) + + it('renders examples', async () => { + render(, { wrapper }) + + const reviewExample = await screen.findByText( + /Here is an example of Codecov AI Reviewer in PR comments/ + ) + expect(reviewExample).toBeInTheDocument() + + const testGenerator = await screen.findByText( + /Here is an example of Codecov AI Test Generator/ + ) + expect(testGenerator).toBeInTheDocument() + }) + + //TODO: Once we have screenshots, test that they are visible + + it('renders a link to the docs', async () => { + render(, { wrapper }) + + const docLink = await screen.findByText(/Visit our guide/) + expect(docLink).toBeInTheDocument() + }) +}) + +describe('flag is off', () => { + it('does not render page', async () => { + mockedUseFlags.mockReturnValue({ codecovAiFeaturesTab: false }) + + render(, { wrapper }) + + const topSection = screen.queryByText(/Codecov AI is a/) + expect(topSection).not.toBeInTheDocument() + }) +}) diff --git a/src/pages/CodecovAIPage/CodecovAIPage.tsx b/src/pages/CodecovAIPage/CodecovAIPage.tsx new file mode 100644 index 0000000000..25b163c531 --- /dev/null +++ b/src/pages/CodecovAIPage/CodecovAIPage.tsx @@ -0,0 +1,47 @@ +import { Redirect, useParams } from 'react-router-dom' + +import { useFlags } from 'shared/featureFlags' + +import CodecovAICommands from './CodecovAICommands/CodecovAICommands' +import InstallCodecovAI from './InstallCodecovAI/InstallCodecovAI' +import LearnMoreBlurb from './LearnMoreBlurb/LearnMoreBlurb' +import Tabs from './Tabs/Tabs' + +interface URLParams { + provider: string + owner: string +} + +const CodecovAIPage: React.FC = () => { + const { provider, owner } = useParams() + + const { codecovAiFeaturesTab } = useFlags({ + codecovAiFeaturesTab: false, + }) + + if (!codecovAiFeaturesTab) { + return + } + + return ( + <> + +

Codecov AI

+
+

+ Codecov AI is a generative AI assistant developed by Codecov at + Sentry. It helps you with generating new tests for uncovered code and + reviews your code changes, offering suggestions for improvement before + merging pull requests. +

+
+
+ + + +
+ + ) +} + +export default CodecovAIPage diff --git a/src/pages/CodecovAIPage/InstallCodecovAI/InstallCodecovAI.tsx b/src/pages/CodecovAIPage/InstallCodecovAI/InstallCodecovAI.tsx new file mode 100644 index 0000000000..055015b772 --- /dev/null +++ b/src/pages/CodecovAIPage/InstallCodecovAI/InstallCodecovAI.tsx @@ -0,0 +1,58 @@ +import { Theme, useThemeContext } from 'shared/ThemeContext' +import { loginProviderImage } from 'shared/utils/loginProviders' +import Button from 'ui/Button' +import { Card } from 'ui/Card' +import { CodeSnippet } from 'ui/CodeSnippet' + +const COPY_APP_INSTALL_STRING = + "Hello, could you help approve the installation of the Codecov AI Reviewer app on GitHub for our organization? Here's the link: [Codecov AI Installation](https://github.com/apps/codecov-ai)" + +const InstallCodecovAI: React.FC = () => { + const { theme } = useThemeContext() + + const isDarkMode = theme === Theme.DARK + const githubImage = loginProviderImage('Github', !isDarkMode) + + return ( +
+ + + + Install the Codecov AI app on Github + + + + To enable the Codecov AI assistant in your GitHub organization, or on + specific repositories, you need to install the Codecov AI GitHub App + Integration. This will allow the assistant to analyze pull requests, + provide insights, and generate new tests to help increase your code + coverage. +
+ +
+

+ If you're not an admin, copy the link below and share it with + your organization's admin or owner to install: +

+ +
+ {COPY_APP_INSTALL_STRING} +
+
+
+
+
+ ) +} + +export default InstallCodecovAI diff --git a/src/pages/CodecovAIPage/LearnMoreBlurb/LearnMoreBlurb.tsx b/src/pages/CodecovAIPage/LearnMoreBlurb/LearnMoreBlurb.tsx new file mode 100644 index 0000000000..111346cc20 --- /dev/null +++ b/src/pages/CodecovAIPage/LearnMoreBlurb/LearnMoreBlurb.tsx @@ -0,0 +1,27 @@ +import A from 'ui/A' +import { Card } from 'ui/Card' + +// TODO: Update link to docs once they are available +function LearnMoreBlurb() { + return ( +
+ + +

+ Visit our guide to{' '} + + learn more + {' '} + about Codecov AI. +

+
+
+
+ ) +} + +export default LearnMoreBlurb diff --git a/src/pages/PlanPage/Tabs/Tabs.jsx b/src/pages/CodecovAIPage/Tabs/Tabs.tsx similarity index 73% rename from src/pages/PlanPage/Tabs/Tabs.jsx rename to src/pages/CodecovAIPage/Tabs/Tabs.tsx index 4ed4e0f5e6..d5185079c1 100644 --- a/src/pages/PlanPage/Tabs/Tabs.jsx +++ b/src/pages/CodecovAIPage/Tabs/Tabs.tsx @@ -1,5 +1,6 @@ import config from 'config' +import Badge from 'ui/Badge' import TabNavigation from 'ui/TabNavigation' function Tabs() { @@ -14,6 +15,14 @@ function Tabs() { pageName: 'analytics', children: 'Analytics', }, + { + pageName: 'codecovAI', + children: ( + <> + Codecov AI beta{' '} + + ), + }, ...(config.IS_SELF_HOSTED ? [] : [{ pageName: 'membersTab' }, { pageName: 'planTab' }]), diff --git a/src/pages/CodecovAIPage/index.tsx b/src/pages/CodecovAIPage/index.tsx new file mode 100644 index 0000000000..c8374549c5 --- /dev/null +++ b/src/pages/CodecovAIPage/index.tsx @@ -0,0 +1 @@ +export { default } from './CodecovAIPage' diff --git a/src/pages/MembersPage/Tabs/Tabs.jsx b/src/pages/MembersPage/Tabs/Tabs.jsx deleted file mode 100644 index 4ed4e0f5e6..0000000000 --- a/src/pages/MembersPage/Tabs/Tabs.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import config from 'config' - -import TabNavigation from 'ui/TabNavigation' - -function Tabs() { - return ( - - ) -} - -export default Tabs diff --git a/src/pages/PlanPage/Tabs/Tabs.spec.jsx b/src/pages/MembersPage/Tabs/Tabs.spec.tsx similarity index 70% rename from src/pages/PlanPage/Tabs/Tabs.spec.jsx rename to src/pages/MembersPage/Tabs/Tabs.spec.tsx index 9362546345..3dac61a3a0 100644 --- a/src/pages/PlanPage/Tabs/Tabs.spec.jsx +++ b/src/pages/MembersPage/Tabs/Tabs.spec.tsx @@ -5,17 +5,23 @@ import { MemoryRouter, Route } from 'react-router-dom' import config from 'config' +import { useFlags } from 'shared/featureFlags' + import Tabs from './Tabs' jest.mock('config') +jest.mock('shared/featureFlags') + +const mockedUseFlags = useFlags as jest.Mock + const queryClient = new QueryClient() const server = setupServer() -const wrapper = ({ children }) => ( +const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - {children} + {children} ) @@ -33,12 +39,9 @@ afterAll(() => { }) describe('Tabs', () => { - function setup( - { isSelfHosted = false } = { - isSelfHosted: false, - } - ) { + function setup(isSelfHosted: boolean = false) { config.IS_SELF_HOSTED = isSelfHosted + mockedUseFlags.mockReturnValue({ codecovAiFeaturesTab: true }) } describe('when user is part of the org', () => { @@ -98,9 +101,9 @@ describe('Tabs', () => { }) }) - describe('when rendered with enterprise account', () => { + describe('when should render tabs is false', () => { it('does not render link to members page', () => { - setup({ isSelfHosted: true }) + setup(true) render(, { wrapper }) expect( @@ -111,7 +114,7 @@ describe('Tabs', () => { }) it('does not render link to plan page', () => { - setup({ isSelfHosted: true }) + setup(true) render(, { wrapper }) expect( @@ -121,4 +124,28 @@ describe('Tabs', () => { ).not.toBeInTheDocument() }) }) + + describe('ai features tab', () => { + it('does not render tab when flag is off', () => { + mockedUseFlags.mockReturnValue({ codecovAiFeaturesTab: false }) + render(, { wrapper }) + + expect( + screen.queryByRole('link', { + name: /Codecov AI beta/i, + }) + ).not.toBeInTheDocument() + }) + + it('renders tab when flag is on', () => { + setup() + render(, { wrapper }) + + expect( + screen.getByRole('link', { + name: /Codecov AI beta/i, + }) + ).toBeInTheDocument() + }) + }) }) diff --git a/src/pages/MembersPage/Tabs/Tabs.tsx b/src/pages/MembersPage/Tabs/Tabs.tsx new file mode 100644 index 0000000000..c36e711a14 --- /dev/null +++ b/src/pages/MembersPage/Tabs/Tabs.tsx @@ -0,0 +1,41 @@ +import config from 'config' + +import { useFlags } from 'shared/featureFlags' +import Badge from 'ui/Badge' +import TabNavigation from 'ui/TabNavigation' + +function Tabs() { + const { codecovAiFeaturesTab } = useFlags({ + codecovAiFeaturesTab: false, + }) + + return ( + + Codecov AI beta{' '} + + ), + }, + ] + : []), + ...(config.IS_SELF_HOSTED + ? [] + : [{ pageName: 'membersTab' }, { pageName: 'planTab' }]), + { + pageName: 'accountAdmin', + children: 'Settings', + }, + ]} + /> + ) +} + +export default Tabs diff --git a/src/pages/OwnerPage/Tabs/Tabs.spec.jsx b/src/pages/OwnerPage/Tabs/Tabs.spec.tsx similarity index 74% rename from src/pages/OwnerPage/Tabs/Tabs.spec.jsx rename to src/pages/OwnerPage/Tabs/Tabs.spec.tsx index 148919b483..d8b11035fd 100644 --- a/src/pages/OwnerPage/Tabs/Tabs.spec.jsx +++ b/src/pages/OwnerPage/Tabs/Tabs.spec.tsx @@ -5,15 +5,19 @@ import { MemoryRouter, Route } from 'react-router-dom' import config from 'config' +import { useFlags } from 'shared/featureFlags' + import Tabs from './Tabs' jest.mock('./TrialReminder', () => () => 'TrialReminder') jest.mock('config') +jest.mock('shared/featureFlags') +const mockedUseFlags = useFlags as jest.Mock const queryClient = new QueryClient() const server = setupServer() -const wrapper = ({ children }) => ( +const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( {children} @@ -34,17 +38,14 @@ afterAll(() => { }) describe('Tabs', () => { - function setup( - { isSelfHosted = false } = { - isSelfHosted: false, - } - ) { + function setup(isSelfHosted: boolean = false) { config.IS_SELF_HOSTED = isSelfHosted + mockedUseFlags.mockReturnValue({ codecovAiFeaturesTab: true }) } describe('when user is part of the org', () => { it('renders links to the owner settings', () => { - setup({}) + setup() render(, { wrapper }) @@ -56,7 +57,7 @@ describe('Tabs', () => { }) it('renders link to plan', () => { - setup({}) + setup() render(, { wrapper }) @@ -68,7 +69,7 @@ describe('Tabs', () => { }) it('renders link to members page', () => { - setup({}) + setup() render(, { wrapper }) @@ -82,7 +83,7 @@ describe('Tabs', () => { describe('when user is enterprise account', () => { it('does not render link to plan', () => { - setup({ isSelfHosted: true }) + setup(true) render(, { wrapper }) @@ -93,7 +94,7 @@ describe('Tabs', () => { }) it('does not render link to members page', () => { - setup({ isSelfHosted: true }) + setup(true) render(, { wrapper }) @@ -105,12 +106,8 @@ describe('Tabs', () => { }) describe('rendering TrialReminder', () => { - beforeEach(() => { - setup({ props: { owner: { username: 'kelly' }, provider: 'gh' } }) - }) - it('displays trial reminder', async () => { - setup({}) + setup() render(, { wrapper }) @@ -118,4 +115,28 @@ describe('Tabs', () => { expect(trialReminder).toBeInTheDocument() }) }) + + describe('ai features tab', () => { + it('does not render tab when flag is off', () => { + mockedUseFlags.mockReturnValue({ codecovAiFeaturesTab: false }) + render(, { wrapper }) + + expect( + screen.queryByRole('link', { + name: /Codecov AI beta/i, + }) + ).not.toBeInTheDocument() + }) + + it('renders tab when flag is on', () => { + setup() + render(, { wrapper }) + + expect( + screen.getByRole('link', { + name: /Codecov AI beta/i, + }) + ).toBeInTheDocument() + }) + }) }) diff --git a/src/pages/OwnerPage/Tabs/Tabs.jsx b/src/pages/OwnerPage/Tabs/Tabs.tsx similarity index 66% rename from src/pages/OwnerPage/Tabs/Tabs.jsx rename to src/pages/OwnerPage/Tabs/Tabs.tsx index e3f1883654..a4c62d7be8 100644 --- a/src/pages/OwnerPage/Tabs/Tabs.jsx +++ b/src/pages/OwnerPage/Tabs/Tabs.tsx @@ -3,11 +3,17 @@ import { lazy, Suspense } from 'react' import config from 'config' +import { useFlags } from 'shared/featureFlags' +import Badge from 'ui/Badge' import TabNavigation from 'ui/TabNavigation' const TrialReminder = lazy(() => import('./TrialReminder')) function Tabs() { + const { codecovAiFeaturesTab } = useFlags({ + codecovAiFeaturesTab: false, + }) + return ( + Codecov AI beta{' '} + + ), + }, + ] + : []), ...(config.IS_SELF_HOSTED ? [] : [{ pageName: 'membersTab' }, { pageName: 'planTab' }]), diff --git a/src/pages/MembersPage/Tabs/Tabs.spec.jsx b/src/pages/PlanPage/Tabs/Tabs.spec.tsx similarity index 67% rename from src/pages/MembersPage/Tabs/Tabs.spec.jsx rename to src/pages/PlanPage/Tabs/Tabs.spec.tsx index 137b31b462..3dac61a3a0 100644 --- a/src/pages/MembersPage/Tabs/Tabs.spec.jsx +++ b/src/pages/PlanPage/Tabs/Tabs.spec.tsx @@ -5,17 +5,23 @@ import { MemoryRouter, Route } from 'react-router-dom' import config from 'config' +import { useFlags } from 'shared/featureFlags' + import Tabs from './Tabs' jest.mock('config') +jest.mock('shared/featureFlags') + +const mockedUseFlags = useFlags as jest.Mock + const queryClient = new QueryClient() const server = setupServer() -const wrapper = ({ children }) => ( +const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - - {children} + + {children} ) @@ -33,17 +39,14 @@ afterAll(() => { }) describe('Tabs', () => { - function setup( - { isSelfHosted = false } = { - isSelfHosted: false, - } - ) { + function setup(isSelfHosted: boolean = false) { config.IS_SELF_HOSTED = isSelfHosted + mockedUseFlags.mockReturnValue({ codecovAiFeaturesTab: true }) } describe('when user is part of the org', () => { it('renders links to the home page', () => { - setup({}) + setup() render(, { wrapper }) expect( @@ -54,7 +57,7 @@ describe('Tabs', () => { }) it('renders links to the analytics page', () => { - setup({}) + setup() render(, { wrapper }) expect( @@ -65,7 +68,7 @@ describe('Tabs', () => { }) it('renders links to the settings page', () => { - setup({}) + setup() render(, { wrapper }) expect( @@ -76,7 +79,7 @@ describe('Tabs', () => { }) it('renders link to plan page', () => { - setup({}) + setup() render(, { wrapper }) expect( @@ -87,7 +90,7 @@ describe('Tabs', () => { }) it('renders link to members page', () => { - setup({}) + setup() render(, { wrapper }) expect( @@ -98,9 +101,9 @@ describe('Tabs', () => { }) }) - describe('when user is enterprise account', () => { + describe('when should render tabs is false', () => { it('does not render link to members page', () => { - setup({ isSelfHosted: true }) + setup(true) render(, { wrapper }) expect( @@ -111,7 +114,7 @@ describe('Tabs', () => { }) it('does not render link to plan page', () => { - setup({ isSelfHosted: true }) + setup(true) render(, { wrapper }) expect( @@ -121,4 +124,28 @@ describe('Tabs', () => { ).not.toBeInTheDocument() }) }) + + describe('ai features tab', () => { + it('does not render tab when flag is off', () => { + mockedUseFlags.mockReturnValue({ codecovAiFeaturesTab: false }) + render(, { wrapper }) + + expect( + screen.queryByRole('link', { + name: /Codecov AI beta/i, + }) + ).not.toBeInTheDocument() + }) + + it('renders tab when flag is on', () => { + setup() + render(, { wrapper }) + + expect( + screen.getByRole('link', { + name: /Codecov AI beta/i, + }) + ).toBeInTheDocument() + }) + }) }) diff --git a/src/pages/PlanPage/Tabs/Tabs.tsx b/src/pages/PlanPage/Tabs/Tabs.tsx new file mode 100644 index 0000000000..c36e711a14 --- /dev/null +++ b/src/pages/PlanPage/Tabs/Tabs.tsx @@ -0,0 +1,41 @@ +import config from 'config' + +import { useFlags } from 'shared/featureFlags' +import Badge from 'ui/Badge' +import TabNavigation from 'ui/TabNavigation' + +function Tabs() { + const { codecovAiFeaturesTab } = useFlags({ + codecovAiFeaturesTab: false, + }) + + return ( + + Codecov AI beta{' '} + + ), + }, + ] + : []), + ...(config.IS_SELF_HOSTED + ? [] + : [{ pageName: 'membersTab' }, { pageName: 'planTab' }]), + { + pageName: 'accountAdmin', + children: 'Settings', + }, + ]} + /> + ) +} + +export default Tabs diff --git a/src/services/navigation/useNavLinks/useNavLinks.js b/src/services/navigation/useNavLinks/useNavLinks.js index c881dbec3d..34cecd4a7d 100644 --- a/src/services/navigation/useNavLinks/useNavLinks.js +++ b/src/services/navigation/useNavLinks/useNavLinks.js @@ -60,6 +60,11 @@ export function useNavLinks() { `/analytics/${provider}/${owner}`, isExternalLink: false, }, + codecovAI: { + path: ({ provider = p, owner = o } = { provider: p, owner: o }) => + `/codecovai/${provider}/${owner}`, + isExternalLink: false, + }, repo: { path: ( { provider = p, owner = o, repo = r } = { diff --git a/src/services/navigation/useNavLinks/useStaticNavLinks.js b/src/services/navigation/useNavLinks/useStaticNavLinks.js index 6ea1df2555..a0430dedad 100644 --- a/src/services/navigation/useNavLinks/useStaticNavLinks.js +++ b/src/services/navigation/useNavLinks/useStaticNavLinks.js @@ -116,6 +116,12 @@ export function useStaticNavLinks() { isExternalLink: true, openNewTab: true, }, + codecovAIAppInstallation: { + text: 'Install the Codecov AI app for an org', + path: () => `https://github.com/apps/${config.GH_APP}/installations/new`, + isExternalLink: true, + openNewTab: true, + }, userAppManagePage: { text: 'User App Manage/Access Page', path: () =>