diff --git a/static/app/types/prevent.tsx b/static/app/types/prevent.tsx index 5585c6cd8a421d..1687fbc86519b8 100644 --- a/static/app/types/prevent.tsx +++ b/static/app/types/prevent.tsx @@ -1,21 +1,5 @@ -// Add any new providers here e.g., 'github' | 'bitbucket' | 'gitlab' -export type PreventAIProvider = 'github'; - export type Sensitivity = 'low' | 'medium' | 'high' | 'critical'; -export interface PreventAIRepo { - fullName: string; - id: string; - name: string; -} - -export interface PreventAIOrg { - githubOrganizationId: string; - name: string; - provider: PreventAIProvider; - repos: PreventAIRepo[]; -} - interface PreventAIFeatureConfig { enabled: boolean; triggers: PreventAIFeatureTriggers; diff --git a/static/app/views/prevent/preventAI/hooks/usePreventAIInfiniteRepositories.spec.tsx b/static/app/views/prevent/preventAI/hooks/usePreventAIInfiniteRepositories.spec.tsx new file mode 100644 index 00000000000000..da53f71d7a4ede --- /dev/null +++ b/static/app/views/prevent/preventAI/hooks/usePreventAIInfiniteRepositories.spec.tsx @@ -0,0 +1,413 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {RepositoryFixture} from 'sentry-fixture/repository'; + +import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {RepositoryStatus} from 'sentry/types/integrations'; +import type {Repository} from 'sentry/types/integrations'; + +import {useInfiniteRepositories} from './usePreventAIInfiniteRepositories'; + +const mockRepositories: Repository[] = [ + RepositoryFixture({ + id: '1', + name: 'test-org/repo-one', + integrationId: 'integration-123', + externalId: 'ext-1', + status: RepositoryStatus.ACTIVE, + }), + RepositoryFixture({ + id: '2', + name: 'test-org/repo-two', + integrationId: 'integration-123', + externalId: 'ext-2', + status: RepositoryStatus.ACTIVE, + }), +]; + +const mockRepositoriesPage2: Repository[] = [ + RepositoryFixture({ + id: '3', + name: 'test-org/repo-three', + integrationId: 'integration-123', + externalId: 'ext-3', + status: RepositoryStatus.ACTIVE, + }), +]; + +describe('useInfiniteRepositories', () => { + beforeEach(() => { + MockApiClient.clearMockResponses(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('fetches repositories with integration ID', async () => { + const organization = OrganizationFixture({slug: 'test-org'}); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/repos/`, + body: mockRepositories, + match: [ + MockApiClient.matchQuery({ + integration_id: 'integration-123', + status: 'active', + }), + ], + headers: { + Link: '; rel="next"; results="true"; cursor="next-cursor"', + }, + }); + + const {result} = renderHookWithProviders( + () => + useInfiniteRepositories({ + integrationId: 'integration-123', + }), + { + organization, + } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data?.pages).toHaveLength(1); + expect(result.current.data?.pages[0]?.[0]).toEqual(mockRepositories); + expect(result.current.hasNextPage).toBe(true); + }); + + it('fetches repositories with search term', async () => { + const organization = OrganizationFixture({slug: 'test-org'}); + + const filteredRepos = [mockRepositories[0]]; + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/repos/`, + body: filteredRepos, + match: [ + MockApiClient.matchQuery({ + integration_id: 'integration-123', + status: 'active', + query: 'repo-one', + }), + ], + }); + + const {result} = renderHookWithProviders( + () => + useInfiniteRepositories({ + integrationId: 'integration-123', + searchTerm: 'repo-one', + }), + { + organization, + } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data?.pages[0]?.[0]).toEqual(filteredRepos); + }); + + it('fetches next page when fetchNextPage is called', async () => { + const organization = OrganizationFixture({slug: 'test-org'}); + + // First page + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/repos/`, + body: mockRepositories, + match: [ + MockApiClient.matchQuery({ + integration_id: 'integration-123', + status: 'active', + }), + ], + headers: { + Link: '; rel="next"; results="true"; cursor="next-cursor"', + }, + }); + + // Second page + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/repos/`, + body: mockRepositoriesPage2, + match: [ + MockApiClient.matchQuery({ + integration_id: 'integration-123', + status: 'active', + cursor: 'next-cursor', + }), + ], + headers: { + Link: '; rel="previous"; results="true"; cursor="prev-cursor"', + }, + }); + + const {result} = renderHookWithProviders( + () => + useInfiniteRepositories({ + integrationId: 'integration-123', + }), + { + organization, + } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data?.pages).toHaveLength(1); + expect(result.current.hasNextPage).toBe(true); + + // Fetch next page + result.current.fetchNextPage(); + + await waitFor(() => { + expect(result.current.data?.pages).toHaveLength(2); + }); + + expect(result.current.data?.pages[0]?.[0]).toEqual(mockRepositories); + expect(result.current.data?.pages[1]?.[0]).toEqual(mockRepositoriesPage2); + expect(result.current.hasNextPage).toBe(false); + }); + + it('handles empty results', async () => { + const organization = OrganizationFixture({slug: 'test-org'}); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/repos/`, + body: [], + match: [ + MockApiClient.matchQuery({ + integration_id: 'integration-123', + status: 'active', + }), + ], + }); + + const {result} = renderHookWithProviders( + () => + useInfiniteRepositories({ + integrationId: 'integration-123', + }), + { + organization, + } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data?.pages[0]?.[0]).toEqual([]); + expect(result.current.hasNextPage).toBe(false); + }); + + it('handles API errors gracefully', async () => { + const organization = OrganizationFixture({slug: 'test-org'}); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/repos/`, + statusCode: 500, + body: {detail: 'Internal Server Error'}, + match: [ + MockApiClient.matchQuery({ + integration_id: 'integration-123', + status: 'active', + }), + ], + }); + + const {result} = renderHookWithProviders( + () => + useInfiniteRepositories({ + integrationId: 'integration-123', + }), + { + organization, + } + ); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBeDefined(); + }); + + it('is disabled when integration ID is not provided', () => { + const organization = OrganizationFixture({slug: 'test-org'}); + + const mockRequest = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/repos/`, + body: mockRepositories, + }); + + const {result} = renderHookWithProviders( + () => + useInfiniteRepositories({ + integrationId: '', + }), + { + organization, + } + ); + + // Query should not be triggered + expect(result.current.isPending).toBe(true); + expect(result.current.fetchStatus).toBe('idle'); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it('calls API with correct query parameters', async () => { + const organization = OrganizationFixture({slug: 'test-org'}); + + const mockRequest = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/repos/`, + body: mockRepositories, + match: [ + MockApiClient.matchQuery({ + integration_id: 'integration-123', + status: 'active', + }), + ], + }); + + const {result} = renderHookWithProviders( + () => + useInfiniteRepositories({ + integrationId: 'integration-123', + searchTerm: undefined, + }), + { + organization, + } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockRequest).toHaveBeenCalledWith( + '/organizations/test-org/repos/', + expect.objectContaining({ + query: expect.objectContaining({ + integration_id: 'integration-123', + status: 'active', + }), + }) + ); + }); + + it('updates results when search term changes', async () => { + const organization = OrganizationFixture({slug: 'test-org'}); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/repos/`, + body: mockRepositories, + match: [ + MockApiClient.matchQuery({ + integration_id: 'integration-123', + status: 'active', + }), + ], + }); + + const {result, rerender} = renderHookWithProviders( + (props: {searchTerm?: string}) => + useInfiniteRepositories({ + integrationId: 'integration-123', + searchTerm: props.searchTerm, + }), + { + organization, + initialProps: {searchTerm: undefined as string | undefined}, + } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data?.pages[0]?.[0]).toHaveLength(2); + + // Update with search term + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/repos/`, + body: [mockRepositories[0]], + match: [ + MockApiClient.matchQuery({ + integration_id: 'integration-123', + status: 'active', + query: 'repo-one', + }), + ], + }); + + rerender({searchTerm: 'repo-one'}); + + await waitFor(() => { + expect(result.current.data?.pages[0]?.[0]).toHaveLength(1); + }); + }); + + it('filters out repositories with null externalId', async () => { + const organization = OrganizationFixture({slug: 'test-org'}); + const reposResponse: Repository[] = [ + RepositoryFixture({ + id: '1', + name: 'test-org/repo-one', + integrationId: 'integration-123', + externalId: 'ext-1', + status: RepositoryStatus.ACTIVE, + }), + { + ...RepositoryFixture({ + id: '999', + name: 'test-org/repo-hidden', + integrationId: 'integration-123', + // @ts-expect-error test value intentionally violates type + externalId: null, + status: RepositoryStatus.ACTIVE, + }), + }, + ]; + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/repos/`, + body: reposResponse, + match: [ + MockApiClient.matchQuery({ + integration_id: 'integration-123', + status: 'active', + }), + ], + }); + + const {result} = renderHookWithProviders( + () => + useInfiniteRepositories({ + integrationId: 'integration-123', + }), + { + organization, + } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + const data = result.current.data?.pages?.[0]?.[0] ?? []; + expect(data).toHaveLength(1); + expect(data?.[0]?.id).toBe('1'); + expect(data.find(r => r.externalId === null)).toBeUndefined(); + }); +}); diff --git a/static/app/views/prevent/preventAI/hooks/usePreventAIInfiniteRepositories.tsx b/static/app/views/prevent/preventAI/hooks/usePreventAIInfiniteRepositories.tsx new file mode 100644 index 00000000000000..75afcf3c14d72f --- /dev/null +++ b/static/app/views/prevent/preventAI/hooks/usePreventAIInfiniteRepositories.tsx @@ -0,0 +1,94 @@ +import type {ApiResult} from 'sentry/api'; +import type {Repository} from 'sentry/types/integrations'; +import parseLinkHeader from 'sentry/utils/parseLinkHeader'; +import { + fetchDataQuery, + useInfiniteQuery, + type InfiniteData, + type QueryKeyEndpointOptions, +} from 'sentry/utils/queryClient'; +import useOrganization from 'sentry/utils/useOrganization'; + +type QueryKey = [url: string, endpointOptions: QueryKeyEndpointOptions]; + +type UseInfiniteRepositoriesOptions = { + integrationId: string; + searchTerm?: string; +}; + +function removeReposWithNullExternalId( + result: ApiResult +): ApiResult { + // result is [data, status, responseMeta]; data is Repository[] + if (Array.isArray(result) && Array.isArray(result[0])) { + return [ + result[0].filter(repo => repo && repo.externalId !== null), + result[1], + result[2], + ]; + } + return result; +} + +export function useInfiniteRepositories({ + integrationId, + searchTerm, +}: UseInfiniteRepositoriesOptions) { + const organization = useOrganization(); + + return useInfiniteQuery< + ApiResult, + Error, + InfiniteData>, + QueryKey + >({ + queryKey: [ + `/organizations/${organization.slug}/repos/`, + { + query: { + integration_id: integrationId || undefined, + status: 'active', + query: searchTerm || undefined, + }, + }, + ], + queryFn: async ({ + queryKey: [url, {query}], + pageParam, + client, + signal, + meta, + }): Promise> => { + const result = await fetchDataQuery({ + queryKey: [ + url, + { + query: { + ...query, + cursor: pageParam ?? undefined, + }, + }, + ], + client, + signal, + meta, + }); + return removeReposWithNullExternalId(result as ApiResult); + }, + getNextPageParam: _lastPage => { + const [, , responseMeta] = _lastPage; + const linkHeader = responseMeta?.getResponseHeader('Link') ?? null; + const links = parseLinkHeader(linkHeader); + return links.next?.results ? links.next.cursor : undefined; + }, + getPreviousPageParam: _lastPage => { + const [, , responseMeta] = _lastPage; + const linkHeader = responseMeta?.getResponseHeader('Link') ?? null; + const links = parseLinkHeader(linkHeader); + return links.previous?.results ? links.previous.cursor : undefined; + }, + initialPageParam: undefined, + enabled: Boolean(integrationId), + staleTime: 0, + }); +} diff --git a/static/app/views/prevent/preventAI/hooks/usePreventAIOrgRepos.spec.tsx b/static/app/views/prevent/preventAI/hooks/usePreventAIOrgRepos.spec.tsx index f93d8c85eae732..03db46e8b1db0b 100644 --- a/static/app/views/prevent/preventAI/hooks/usePreventAIOrgRepos.spec.tsx +++ b/static/app/views/prevent/preventAI/hooks/usePreventAIOrgRepos.spec.tsx @@ -1,34 +1,30 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; +import {OrganizationIntegrationsFixture} from 'sentry-fixture/organizationIntegrations'; import {PreventAIConfigFixture} from 'sentry-fixture/prevent'; import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; -import { - usePreventAIOrgRepos, - type PreventAIOrgReposResponse, -} from './usePreventAIOrgRepos'; +import type {OrganizationIntegration} from 'sentry/types/integrations'; + +import {usePreventAIOrgs} from './usePreventAIOrgRepos'; describe('usePreventAIOrgRepos', () => { const mockOrg = OrganizationFixture({ preventAiConfigGithub: PreventAIConfigFixture(), }); - const mockResponse: PreventAIOrgReposResponse = { - orgRepos: [ - { - githubOrganizationId: '1', - name: 'repo1', - provider: 'github', - repos: [{id: '1', name: 'repo1', fullName: 'org-1/repo1'}], - }, - { - githubOrganizationId: '2', - name: 'repo2', - provider: 'github', - repos: [{id: '2', name: 'repo2', fullName: 'org-2/repo2'}], - }, - ], - }; + const mockResponse: OrganizationIntegration[] = [ + OrganizationIntegrationsFixture({ + id: '1', + name: 'repo1', + externalId: 'ext-1', + }), + OrganizationIntegrationsFixture({ + id: '2', + name: 'repo2', + externalId: 'ext-2', + }), + ]; beforeEach(() => { MockApiClient.clearMockResponses(); @@ -36,11 +32,11 @@ describe('usePreventAIOrgRepos', () => { it('returns data on success', async () => { MockApiClient.addMockResponse({ - url: `/organizations/${mockOrg.slug}/prevent/github/repos/`, + url: `/organizations/${mockOrg.slug}/integrations/`, body: mockResponse, }); - const {result} = renderHookWithProviders(() => usePreventAIOrgRepos(), { + const {result} = renderHookWithProviders(() => usePreventAIOrgs(), { organization: mockOrg, }); @@ -51,12 +47,12 @@ describe('usePreventAIOrgRepos', () => { it('returns error on failure', async () => { MockApiClient.addMockResponse({ - url: `/organizations/${mockOrg.slug}/prevent/github/repos/`, + url: `/organizations/${mockOrg.slug}/integrations/`, statusCode: 500, body: {error: 'Internal Server Error'}, }); - const {result} = renderHookWithProviders(() => usePreventAIOrgRepos(), { + const {result} = renderHookWithProviders(() => usePreventAIOrgs(), { organization: mockOrg, }); @@ -65,33 +61,30 @@ describe('usePreventAIOrgRepos', () => { it('refetches data', async () => { MockApiClient.addMockResponse({ - url: `/organizations/${mockOrg.slug}/prevent/github/repos/`, + url: `/organizations/${mockOrg.slug}/integrations/`, body: mockResponse, }); - const {result} = renderHookWithProviders(() => usePreventAIOrgRepos(), { + const {result} = renderHookWithProviders(() => usePreventAIOrgs(), { organization: mockOrg, }); await waitFor(() => expect(result.current.data).toEqual(mockResponse)); - const newResponse: PreventAIOrgReposResponse = { - orgRepos: [ - { - githubOrganizationId: '3', - name: 'repo3', - provider: 'github', - repos: [{id: '3', name: 'repo3', fullName: 'org-3/repo3'}], - }, - ], - }; + const newResponse: OrganizationIntegration[] = [ + OrganizationIntegrationsFixture({ + id: '3', + name: 'repo3', + externalId: 'ext-3', + }), + ]; MockApiClient.addMockResponse({ - url: `/organizations/${mockOrg.slug}/prevent/github/repos/`, + url: `/organizations/${mockOrg.slug}/integrations/`, body: newResponse, }); result.current.refetch(); - await waitFor(() => expect(result.current.data?.orgRepos?.[0]?.name).toBe('repo3')); + await waitFor(() => expect(result.current.data?.[0]?.name).toBe('repo3')); expect(result.current.data).toEqual(newResponse); }); }); diff --git a/static/app/views/prevent/preventAI/hooks/usePreventAIOrgRepos.tsx b/static/app/views/prevent/preventAI/hooks/usePreventAIOrgRepos.tsx index ef45597f983554..8bec9c2e310795 100644 --- a/static/app/views/prevent/preventAI/hooks/usePreventAIOrgRepos.tsx +++ b/static/app/views/prevent/preventAI/hooks/usePreventAIOrgRepos.tsx @@ -1,33 +1,22 @@ -import type {PreventAIOrg} from 'sentry/types/prevent'; -import {useApiQuery} from 'sentry/utils/queryClient'; +import type {OrganizationIntegration} from 'sentry/types/integrations'; +import {useApiQuery, type UseApiQueryResult} from 'sentry/utils/queryClient'; +import type RequestError from 'sentry/utils/requestError/requestError'; import useOrganization from 'sentry/utils/useOrganization'; -export interface PreventAIOrgReposResponse { - orgRepos: PreventAIOrg[]; -} - -interface PreventAIOrgsReposResult { - data: PreventAIOrgReposResponse | undefined; - isError: boolean; - isPending: boolean; - refetch: () => void; -} - -export function usePreventAIOrgRepos(): PreventAIOrgsReposResult { +export function usePreventAIOrgs(): UseApiQueryResult< + OrganizationIntegration[], + RequestError +> { const organization = useOrganization(); - const {data, isPending, isError, refetch} = useApiQuery( - [`/organizations/${organization.slug}/prevent/github/repos/`], + return useApiQuery( + [ + `/organizations/${organization.slug}/integrations/`, + {query: {includeConfig: 0, provider_key: 'github'}}, + ], { staleTime: 0, retry: false, } ); - - return { - data, - isPending, - isError, - refetch, - }; } diff --git a/static/app/views/prevent/preventAI/index.tsx b/static/app/views/prevent/preventAI/index.tsx index 3170b5fdbecba4..88e9e983b6878a 100644 --- a/static/app/views/prevent/preventAI/index.tsx +++ b/static/app/views/prevent/preventAI/index.tsx @@ -5,11 +5,11 @@ import {t} from 'sentry/locale'; import PreventAIManageRepos from 'sentry/views/prevent/preventAI/manageRepos'; import PreventAIOnboarding from 'sentry/views/prevent/preventAI/onboarding'; -import {usePreventAIOrgRepos} from './hooks/usePreventAIOrgRepos'; +import {usePreventAIOrgs} from './hooks/usePreventAIOrgRepos'; function PreventAIContent() { - const {data, isPending, isError} = usePreventAIOrgRepos(); - const orgRepos = data?.orgRepos ?? []; + const {data, isPending, isError} = usePreventAIOrgs(); + const integratedOrgs = data ?? []; if (isPending) { return ; @@ -22,8 +22,8 @@ function PreventAIContent() { /> ); } - if (orgRepos.length > 0) { - return ; + if (integratedOrgs.length > 0) { + return ; } return ; } diff --git a/static/app/views/prevent/preventAI/manageRepos.spec.tsx b/static/app/views/prevent/preventAI/manageRepos.spec.tsx index 4e5b398e736d12..4e2166461d514b 100644 --- a/static/app/views/prevent/preventAI/manageRepos.spec.tsx +++ b/static/app/views/prevent/preventAI/manageRepos.spec.tsx @@ -1,61 +1,66 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; +import {OrganizationIntegrationsFixture} from 'sentry-fixture/organizationIntegrations'; import {PreventAIConfigFixture} from 'sentry-fixture/prevent'; import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; -import type {PreventAIProvider} from 'sentry/types/prevent'; - import ManageReposPage from './manageRepos'; describe('PreventAIManageRepos', () => { - const github: PreventAIProvider = 'github'; - const installedOrgs = [ - { - githubOrganizationId: 'org-1', + const integratedOrgs = [ + OrganizationIntegrationsFixture({ + id: 'integration-1', + organizationId: 'org-1', name: 'Org One', - provider: github, - repos: [ + externalId: 'ext-1', + domainName: 'github.com/org-one', + }), + OrganizationIntegrationsFixture({ + id: 'integration-2', + organizationId: 'org-2', + name: 'Org Two', + externalId: 'ext-2', + domainName: 'github.com/org-two', + }), + ]; + + const organization = OrganizationFixture({ + preventAiConfigGithub: PreventAIConfigFixture(), + }); + + beforeEach(() => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/repos/`, + method: 'GET', + body: [ { id: 'repo-1', - name: 'Repo One', - fullName: 'org-1/repo-1', - url: 'https://github.com/org-1/repo-1', + name: 'org-one/repo-one', }, { id: 'repo-2', - name: 'Repo Two', - fullName: 'org-1/repo-2', - url: 'https://github.com/org-1/repo-2', + name: 'org-one/repo-two', }, - ], - }, - { - githubOrganizationId: 'org-2', - name: 'Org Two', - provider: github, - repos: [ { id: 'repo-3', - name: 'Repo Three', - fullName: 'org-2/repo-3', - url: 'https://github.com/org-2/repo-3', + name: 'org-two/repo-three', }, ], - }, - ]; + }); + }); - const organization = OrganizationFixture({ - preventAiConfigGithub: PreventAIConfigFixture(), + afterEach(() => { + MockApiClient.clearMockResponses(); }); it('renders the Manage Repositories title and toolbar', async () => { - render(, {organization}); + render(, {organization}); expect(await screen.findByTestId('manage-repos-title')).toBeInTheDocument(); expect(await screen.findByTestId('manage-repos-settings-button')).toBeInTheDocument(); }); it('opens the settings panel when the settings button is clicked', async () => { - render(, {organization}); + render(, {organization}); expect(screen.queryByTestId('manage-repos-panel')).not.toBeInTheDocument(); const settingsButton = await screen.findByTestId('manage-repos-settings-button'); await userEvent.click(settingsButton); @@ -63,14 +68,14 @@ describe('PreventAIManageRepos', () => { }); it('renders the illustration image', async () => { - render(, {organization}); + render(, {organization}); const img = await screen.findByTestId('manage-repos-illustration-image'); expect(img).toBeInTheDocument(); expect(img.tagName).toBe('IMG'); }); it('starts with "All Repos" selected by default', async () => { - render(, {organization}); + render(, {organization}); const repoButton = await screen.findByRole('button', { name: /All Repos/i, }); diff --git a/static/app/views/prevent/preventAI/manageRepos.tsx b/static/app/views/prevent/preventAI/manageRepos.tsx index 8c3f3405e3bb28..242c2f27530fd5 100644 --- a/static/app/views/prevent/preventAI/manageRepos.tsx +++ b/static/app/views/prevent/preventAI/manageRepos.tsx @@ -1,6 +1,7 @@ import {useCallback, useMemo, useState} from 'react'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; +import uniqBy from 'lodash/uniqBy'; import preventPrCommentsDark from 'sentry-images/features/prevent-pr-comments-dark.svg'; import preventPrCommentsLight from 'sentry-images/features/prevent-pr-comments-light.svg'; @@ -12,74 +13,59 @@ import {Heading, Text} from 'sentry/components/core/text'; import {Tooltip} from 'sentry/components/core/tooltip'; import {IconSettings} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; -import type {PreventAIOrg} from 'sentry/types/prevent'; +import type {OrganizationIntegration, Repository} from 'sentry/types/integrations'; +import {useInfiniteRepositories} from 'sentry/views/prevent/preventAI/hooks/usePreventAIInfiniteRepositories'; import ManageReposPanel from 'sentry/views/prevent/preventAI/manageReposPanel'; -import ManageReposToolbar, { - ALL_REPOS_VALUE, -} from 'sentry/views/prevent/preventAI/manageReposToolbar'; +import ManageReposToolbar from 'sentry/views/prevent/preventAI/manageReposToolbar'; import {FeatureOverview} from './onboarding'; -function ManageReposPage({installedOrgs}: {installedOrgs: PreventAIOrg[]}) { +function ManageReposPage({integratedOrgs}: {integratedOrgs: OrganizationIntegration[]}) { const theme = useTheme(); const [isPanelOpen, setIsPanelOpen] = useState(false); const [selectedOrgId, setSelectedOrgId] = useState( - () => installedOrgs[0]?.githubOrganizationId ?? '' + () => integratedOrgs[0]?.id ?? '' + ); + const [selectedRepo, setSelectedRepo] = useState(() => null); + + const queryResult = useInfiniteRepositories({ + integrationId: selectedOrgId, + searchTerm: undefined, + }); + const reposData = useMemo( + () => uniqBy(queryResult.data?.pages.flatMap(result => result[0]) ?? [], 'id'), + [queryResult.data?.pages] ); - const [selectedRepoId, setSelectedRepoId] = useState(() => ALL_REPOS_VALUE); // If the selected org is not present in the list of orgs, use the first org const selectedOrg = useMemo(() => { - const found = installedOrgs.find(org => org.githubOrganizationId === selectedOrgId); - return found ?? installedOrgs[0]; - }, [installedOrgs, selectedOrgId]); + const found = integratedOrgs.find(org => org.id === selectedOrgId); + return found ?? integratedOrgs[0]; + }, [integratedOrgs, selectedOrgId]); - // Ditto for repos - const selectedRepo = useMemo(() => { - if (selectedRepoId === ALL_REPOS_VALUE) { - return null; - } - const found = selectedOrg?.repos?.find(repo => repo.id === selectedRepoId); - return found ?? selectedOrg?.repos?.[0]; - }, [selectedOrg, selectedRepoId]); - - // When the org changes, if the selected repo is not present in the new org, - // reset to "All Repos" - const setSelectedOrgIdWithCascadeRepoId = useCallback( - (orgId: string) => { - setSelectedOrgId(orgId); - const newSelectedOrgData = installedOrgs.find( - org => org.githubOrganizationId === orgId - ); - if ( - newSelectedOrgData && - selectedRepoId !== ALL_REPOS_VALUE && - !newSelectedOrgData.repos.some(repo => repo.id === selectedRepoId) - ) { - setSelectedRepoId(ALL_REPOS_VALUE); - } - }, - [installedOrgs, selectedRepoId] - ); + // When the org changes, reset to "All Repos" + const setSelectedOrgIdWithCascadeRepoId = useCallback((orgId: string) => { + setSelectedOrgId(orgId); + setSelectedRepo(null); + }, []); const isOrgSelected = !!selectedOrg; - const isRepoSelected = selectedRepoId === ALL_REPOS_VALUE || !!selectedRepo; return (