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 (
}
aria-label="Settings"
onClick={() => setIsPanelOpen(true)}
- disabled={!isOrgSelected || !isRepoSelected}
- tabIndex={!isOrgSelected || !isRepoSelected ? -1 : 0}
+ disabled={!isOrgSelected}
+ tabIndex={isOrgSelected ? 0 : -1}
data-test-id="manage-repos-settings-button"
/>
@@ -142,15 +128,15 @@ function ManageReposPage({installedOrgs}: {installedOrgs: PreventAIOrg[]}) {
/>
- {selectedOrg && (selectedRepoId === ALL_REPOS_VALUE || selectedRepo) && (
+ {selectedOrg && (
setIsPanelOpen(false)}
org={selectedOrg}
repo={selectedRepo}
- allRepos={selectedOrg.repos}
- isEditingOrgDefaults={selectedRepoId === ALL_REPOS_VALUE}
+ allRepos={reposData}
+ isEditingOrgDefaults={selectedRepo === null}
/>
)}
diff --git a/static/app/views/prevent/preventAI/manageReposPanel.spec.tsx b/static/app/views/prevent/preventAI/manageReposPanel.spec.tsx
index 2b3f55aeeec300..b312328d69ea1f 100644
--- a/static/app/views/prevent/preventAI/manageReposPanel.spec.tsx
+++ b/static/app/views/prevent/preventAI/manageReposPanel.spec.tsx
@@ -2,12 +2,13 @@ import {OrganizationFixture} from 'sentry-fixture/organization';
import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
-import type {PreventAIOrg, PreventAIOrgConfig, PreventAIRepo} from 'sentry/types/prevent';
+import {RepositoryStatus} from 'sentry/types/integrations';
+import type {OrganizationIntegration, Repository} from 'sentry/types/integrations';
+import type {PreventAIOrgConfig} from 'sentry/types/prevent';
import ManageReposPanel, {
getRepoConfig,
type ManageReposPanelProps,
} from 'sentry/views/prevent/preventAI/manageReposPanel';
-import {ALL_REPOS_VALUE} from 'sentry/views/prevent/preventAI/manageReposToolbar';
let mockUpdatePreventAIFeatureReturn: any = {};
jest.mock('sentry/views/prevent/preventAI/hooks/useUpdatePreventAIFeature', () => ({
@@ -15,24 +16,56 @@ jest.mock('sentry/views/prevent/preventAI/hooks/useUpdatePreventAIFeature', () =
}));
describe('ManageReposPanel', () => {
- const mockOrg: PreventAIOrg = {
- githubOrganizationId: 'org-1',
+ const mockOrg: OrganizationIntegration = {
+ id: '1',
name: 'org-1',
- provider: 'github',
- repos: [],
+ externalId: 'org-1',
+ provider: {
+ key: 'github',
+ name: 'GitHub',
+ slug: 'github',
+ aspects: {},
+ canAdd: true,
+ canDisable: false,
+ features: [],
+ },
+ organizationId: '1',
+ status: 'active',
+ domainName: null,
+ accountType: null,
+ configData: null,
+ configOrganization: [],
+ gracePeriodEnd: null,
+ icon: null,
+ organizationIntegrationStatus: 'active',
};
- const mockRepo: PreventAIRepo = {
+ const mockRepo: Repository = {
id: 'repo-1',
- name: 'repo-1',
- fullName: 'org-1/repo-1',
+ name: 'org-1/repo-1',
+ url: 'https://github.com/org-1/repo-1',
+ provider: {
+ id: 'integrations:github',
+ name: 'GitHub',
+ },
+ status: RepositoryStatus.ACTIVE,
+ externalSlug: 'org-1/repo-1',
+ integrationId: '1',
+ externalId: 'ext-1',
+ dateCreated: '2024-01-01T00:00:00Z',
};
+ const mockAllRepos = [
+ {id: 'repo-1', name: 'org-1/repo-1'},
+ {id: 'repo-2', name: 'org-1/repo-2'},
+ ];
+
const defaultProps: ManageReposPanelProps = {
collapsed: false,
onClose: jest.fn(),
repo: mockRepo,
org: mockOrg,
+ allRepos: mockAllRepos,
isEditingOrgDefaults: false,
};
@@ -88,7 +121,7 @@ describe('ManageReposPanel', () => {
it('renders the panel header and description with org defaults when "All Repos" is selected', async () => {
const props = {...defaultProps};
- props.repo = {id: ALL_REPOS_VALUE, name: 'All Repos', fullName: ''};
+ props.repo = null;
props.isEditingOrgDefaults = true;
render(, {organization: mockOrganization});
expect(
@@ -101,7 +134,7 @@ describe('ManageReposPanel', () => {
it('renders [none] when no repos have overrides when editing org defaults', async () => {
const props = {...defaultProps};
- props.repo = {id: ALL_REPOS_VALUE, name: 'All Repos', fullName: ''};
+ props.repo = null;
props.isEditingOrgDefaults = true;
const mockOrgNoOverrides = structuredClone(mockOrganization);
mockOrgNoOverrides.preventAiConfigGithub!.default_org_config.repo_overrides = {};
diff --git a/static/app/views/prevent/preventAI/manageReposPanel.tsx b/static/app/views/prevent/preventAI/manageReposPanel.tsx
index 4e0ef675285a7c..0798f631a6dbf2 100644
--- a/static/app/views/prevent/preventAI/manageReposPanel.tsx
+++ b/static/app/views/prevent/preventAI/manageReposPanel.tsx
@@ -11,11 +11,10 @@ import FieldGroup from 'sentry/components/forms/fieldGroup';
import SlideOverPanel from 'sentry/components/slideOverPanel';
import {IconClose} from 'sentry/icons';
import {t, tct} from 'sentry/locale';
+import type {OrganizationIntegration, Repository} from 'sentry/types/integrations';
import type {
PreventAIFeatureConfigsByName,
- PreventAIOrg,
PreventAIOrgConfig,
- PreventAIRepo,
Sensitivity,
} from 'sentry/types/prevent';
import useOrganization from 'sentry/utils/useOrganization';
@@ -25,10 +24,10 @@ export type ManageReposPanelProps = {
collapsed: boolean;
isEditingOrgDefaults: boolean;
onClose: () => void;
- org: PreventAIOrg;
+ org: OrganizationIntegration;
allRepos?: Array<{id: string; name: string}>;
onFocusRepoSelector?: () => void;
- repo?: PreventAIRepo | null;
+ repo?: Repository | null;
};
interface SensitivityOption {
@@ -86,7 +85,7 @@ function ManageReposPanel({
}
const orgConfig =
- organization.preventAiConfigGithub.github_organizations[org.githubOrganizationId] ??
+ organization.preventAiConfigGithub.github_organizations[org.name] ??
organization.preventAiConfigGithub.default_org_config;
const {doesUseOrgDefaults, repoConfig} = isEditingOrgDefaults
@@ -139,9 +138,7 @@ function ManageReposPanel({
'These settings apply to the selected [repoLink] repository. To switch, use the repository selector in the page header.',
{
repoLink: (
-
+
{repo?.name}
),
@@ -190,7 +187,7 @@ function ManageReposPanel({
onChange={async () => {
await enableFeature({
feature: 'use_org_defaults',
- orgId: org.githubOrganizationId,
+ orgId: org.name,
repoId: repo?.id,
enabled: !doesUseOrgDefaults,
});
@@ -230,7 +227,7 @@ function ManageReposPanel({
await enableFeature({
feature: 'vanilla',
enabled: newValue,
- orgId: org.githubOrganizationId,
+ orgId: org.name,
repoId: repo?.id,
});
}}
@@ -262,7 +259,7 @@ function ManageReposPanel({
await enableFeature({
feature: 'vanilla',
enabled: true,
- orgId: org.githubOrganizationId,
+ orgId: org.name,
repoId: repo?.id,
sensitivity: option.value,
})
@@ -307,7 +304,7 @@ function ManageReposPanel({
await enableFeature({
feature: 'test_generation',
enabled: newValue,
- orgId: org.githubOrganizationId,
+ orgId: org.name,
repoId: repo?.id,
});
}}
@@ -345,7 +342,7 @@ function ManageReposPanel({
await enableFeature({
feature: 'bug_prediction',
enabled: newValue,
- orgId: org.githubOrganizationId,
+ orgId: org.name,
repoId: repo?.id,
});
}}
@@ -377,7 +374,7 @@ function ManageReposPanel({
await enableFeature({
feature: 'bug_prediction',
enabled: true,
- orgId: org.githubOrganizationId,
+ orgId: org.name,
repoId: repo?.id,
sensitivity: option.value,
})
@@ -415,7 +412,7 @@ function ManageReposPanel({
feature: 'bug_prediction',
trigger: {on_ready_for_review: newValue},
enabled: true,
- orgId: org.githubOrganizationId,
+ orgId: org.name,
repoId: repo?.id,
});
}}
@@ -447,7 +444,7 @@ function ManageReposPanel({
feature: 'bug_prediction',
trigger: {on_command_phrase: newValue},
enabled: true,
- orgId: org.githubOrganizationId,
+ orgId: org.name,
repoId: repo?.id,
});
}}
diff --git a/static/app/views/prevent/preventAI/manageReposToolbar.spec.tsx b/static/app/views/prevent/preventAI/manageReposToolbar.spec.tsx
index 3948fefe21f54f..b1945da7b1491a 100644
--- a/static/app/views/prevent/preventAI/manageReposToolbar.spec.tsx
+++ b/static/app/views/prevent/preventAI/manageReposToolbar.spec.tsx
@@ -1,57 +1,138 @@
-import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+import {OrganizationFixture} from 'sentry-fixture/organization';
-import type {PreventAIOrg, PreventAIProvider} from 'sentry/types/prevent';
+import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import {RepositoryStatus} from 'sentry/types/integrations';
+import type {OrganizationIntegration, Repository} from 'sentry/types/integrations';
import ManageReposToolbar from 'sentry/views/prevent/preventAI/manageReposToolbar';
describe('ManageReposToolbar', () => {
- const github: PreventAIProvider = 'github';
- const installedOrgs: PreventAIOrg[] = [
+ const mockIntegratedOrgs: OrganizationIntegration[] = [
{
- githubOrganizationId: '1',
+ id: '1',
name: 'org-1',
- provider: github,
- repos: [
- {
- id: '1',
- name: 'repo-1',
- fullName: 'org-1/repo-1',
- },
- {
- id: '2',
- name: 'repo-2',
- fullName: 'org-1/repo-2',
- },
- ],
+ externalId: 'ext-1',
+ provider: {
+ key: 'github',
+ name: 'GitHub',
+ slug: 'github',
+ aspects: {},
+ canAdd: true,
+ canDisable: false,
+ features: [],
+ },
+ organizationId: '1',
+ status: 'active',
+ domainName: null,
+ accountType: null,
+ configData: null,
+ configOrganization: [],
+ gracePeriodEnd: null,
+ icon: null,
+ organizationIntegrationStatus: 'active',
},
{
- githubOrganizationId: '2',
+ id: '2',
name: 'org-2',
- provider: github,
- repos: [
- {
- id: '3',
- name: 'repo-3',
- fullName: 'org-2/repo-3',
- },
- ],
+ externalId: 'ext-2',
+ provider: {
+ key: 'github',
+ name: 'GitHub',
+ slug: 'github',
+ aspects: {},
+ canAdd: true,
+ canDisable: false,
+ features: [],
+ },
+ organizationId: '1',
+ status: 'active',
+ domainName: null,
+ accountType: null,
+ configData: null,
+ configOrganization: [],
+ gracePeriodEnd: null,
+ icon: null,
+ organizationIntegrationStatus: 'active',
+ },
+ ];
+
+ const mockRepositories: Repository[] = [
+ {
+ id: '1',
+ name: 'org-1/repo-1',
+ url: 'https://github.com/org-1/repo-1',
+ provider: {
+ id: 'integrations:github',
+ name: 'GitHub',
+ },
+ status: RepositoryStatus.ACTIVE,
+ externalSlug: 'org-1/repo-1',
+ integrationId: '1',
+ externalId: 'ext-1',
+ dateCreated: '2024-01-01T00:00:00Z',
+ },
+ {
+ id: '2',
+ name: 'org-1/repo-2',
+ url: 'https://github.com/org-1/repo-2',
+ provider: {
+ id: 'integrations:github',
+ name: 'GitHub',
+ },
+ status: RepositoryStatus.ACTIVE,
+ externalSlug: 'org-1/repo-2',
+ integrationId: '1',
+ externalId: 'ext-2',
+ dateCreated: '2024-01-01T00:00:00Z',
+ },
+ ];
+
+ const mockOrg2Repositories: Repository[] = [
+ {
+ id: '3',
+ name: 'org-2/repo-3',
+ url: 'https://github.com/org-2/repo-3',
+ provider: {
+ id: 'integrations:github',
+ name: 'GitHub',
+ },
+ status: RepositoryStatus.ACTIVE,
+ externalSlug: 'org-2/repo-3',
+ integrationId: '2',
+ externalId: 'ext-3',
+ dateCreated: '2024-01-01T00:00:00Z',
},
];
const defaultProps = {
- installedOrgs,
+ integratedOrgs: mockIntegratedOrgs,
selectedOrg: '1',
- selectedRepo: '1',
+ selectedRepo: mockRepositories[0] ?? null,
onOrgChange: jest.fn(),
onRepoChange: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
+ MockApiClient.clearMockResponses();
});
it('renders organization and repository selectors', async () => {
- render();
- // Find the org and repo selects by their visible labels
+ MockApiClient.addMockResponse({
+ url: '/organizations/org-slug/repos/',
+ body: mockRepositories,
+ match: [
+ MockApiClient.matchQuery({
+ integration_id: '1',
+ status: 'active',
+ }),
+ ],
+ });
+
+ render(, {
+ organization: OrganizationFixture({slug: 'org-slug'}),
+ });
+
const orgSelect = await screen.findByRole('button', {name: /org-1/i});
const repoSelect = await screen.findByRole('button', {name: /repo-1/i});
expect(orgSelect).toBeInTheDocument();
@@ -59,8 +140,21 @@ describe('ManageReposToolbar', () => {
});
it('shows the correct selected org and repo', async () => {
- render();
- // The triggers should show the selected org and repo names
+ MockApiClient.addMockResponse({
+ url: '/organizations/org-slug/repos/',
+ body: mockRepositories,
+ match: [
+ MockApiClient.matchQuery({
+ integration_id: '1',
+ status: 'active',
+ }),
+ ],
+ });
+
+ render(, {
+ organization: OrganizationFixture({slug: 'org-slug'}),
+ });
+
const orgTrigger = await screen.findByRole('button', {name: /org-1/i});
const repoTrigger = await screen.findByRole('button', {name: /repo-1/i});
expect(orgTrigger).toHaveTextContent('org-1');
@@ -68,12 +162,24 @@ describe('ManageReposToolbar', () => {
});
it('calls onOrgChange when organization is changed', async () => {
- render();
- // Open the org select dropdown
+ MockApiClient.addMockResponse({
+ url: '/organizations/org-slug/repos/',
+ body: mockRepositories,
+ match: [
+ MockApiClient.matchQuery({
+ integration_id: '1',
+ status: 'active',
+ }),
+ ],
+ });
+
+ render(, {
+ organization: OrganizationFixture({slug: 'org-slug'}),
+ });
+
const orgTrigger = await screen.findByRole('button', {name: /org-1/i});
await userEvent.click(orgTrigger);
- // Select the new org option
const orgOption = await screen.findByText('org-2');
await userEvent.click(orgOption);
@@ -81,21 +187,57 @@ describe('ManageReposToolbar', () => {
});
it('calls onRepoChange when repository is changed', async () => {
- render();
- // Open the repo select dropdown
+ MockApiClient.addMockResponse({
+ url: '/organizations/org-slug/repos/',
+ body: mockRepositories,
+ match: [
+ MockApiClient.matchQuery({
+ integration_id: '1',
+ status: 'active',
+ }),
+ ],
+ });
+
+ render(, {
+ organization: OrganizationFixture({slug: 'org-slug'}),
+ });
+
const repoTrigger = await screen.findByRole('button', {name: /repo-1/i});
await userEvent.click(repoTrigger);
- // Select the new repo option
- const repoOption = await screen.findByText('repo-2');
+ await waitFor(() => {
+ expect(screen.getByText('repo-2')).toBeInTheDocument();
+ });
+
+ const repoOption = screen.getByText('repo-2');
await userEvent.click(repoOption);
- expect(defaultProps.onRepoChange).toHaveBeenCalledWith('2');
+ expect(defaultProps.onRepoChange).toHaveBeenCalledWith(mockRepositories[1]);
});
it('shows only repos for the selected org', async () => {
- render();
- // Open the repo select dropdown
+ MockApiClient.addMockResponse({
+ url: '/organizations/org-slug/repos/',
+ body: mockOrg2Repositories,
+ match: [
+ MockApiClient.matchQuery({
+ integration_id: '2',
+ status: 'active',
+ }),
+ ],
+ });
+
+ render(
+ ,
+ {
+ organization: OrganizationFixture({slug: 'org-slug'}),
+ }
+ );
+
const repoTrigger = await screen.findByRole('button', {name: /repo-3/i});
await userEvent.click(repoTrigger);
@@ -108,21 +250,49 @@ describe('ManageReposToolbar', () => {
});
it('shows "All Repos" option at the top of repository dropdown', async () => {
- render();
+ MockApiClient.addMockResponse({
+ url: '/organizations/org-slug/repos/',
+ body: mockRepositories,
+ match: [
+ MockApiClient.matchQuery({
+ integration_id: '1',
+ status: 'active',
+ }),
+ ],
+ });
+
+ render(, {
+ organization: OrganizationFixture({slug: 'org-slug'}),
+ });
+
const repoTrigger = await screen.findByRole('button', {name: /repo-1/i});
await userEvent.click(repoTrigger);
expect(await screen.findByText('All Repos')).toBeInTheDocument();
});
- it('calls onRepoChange with "__$ALL_REPOS__" when "All Repos" is selected', async () => {
- render();
+ it('calls onRepoChange with null when "All Repos" is selected', async () => {
+ MockApiClient.addMockResponse({
+ url: '/organizations/org-slug/repos/',
+ body: mockRepositories,
+ match: [
+ MockApiClient.matchQuery({
+ integration_id: '1',
+ status: 'active',
+ }),
+ ],
+ });
+
+ render(, {
+ organization: OrganizationFixture({slug: 'org-slug'}),
+ });
+
const repoTrigger = await screen.findByRole('button', {name: /repo-1/i});
await userEvent.click(repoTrigger);
const allReposOption = await screen.findByText('All Repos');
await userEvent.click(allReposOption);
- expect(defaultProps.onRepoChange).toHaveBeenCalledWith('__$ALL_REPOS__');
+ expect(defaultProps.onRepoChange).toHaveBeenCalledWith(null);
});
});
diff --git a/static/app/views/prevent/preventAI/manageReposToolbar.tsx b/static/app/views/prevent/preventAI/manageReposToolbar.tsx
index 94df19aaa5961d..8243d2a1f38812 100644
--- a/static/app/views/prevent/preventAI/manageReposToolbar.tsx
+++ b/static/app/views/prevent/preventAI/manageReposToolbar.tsx
@@ -1,52 +1,145 @@
-import {Fragment, useMemo} from 'react';
+import {Fragment, useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import uniqBy from 'lodash/uniqBy';
import {CompactSelect} from 'sentry/components/core/compactSelect';
import {TriggerLabel} from 'sentry/components/core/compactSelect/control';
import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
import {IconBuilding, IconRepository} from 'sentry/icons';
import {t} from 'sentry/locale';
-import type {PreventAIOrg} from 'sentry/types/prevent';
+import type {OrganizationIntegration, Repository} from 'sentry/types/integrations';
+import {useDebouncedValue} from 'sentry/utils/useDebouncedValue';
+import {useInfiniteRepositories} from 'sentry/views/prevent/preventAI/hooks/usePreventAIInfiniteRepositories';
+import {getRepoNameWithoutOrg} from 'sentry/views/prevent/preventAI/utils';
-export const ALL_REPOS_VALUE = '__$ALL_REPOS__';
+const ALL_REPOS_VALUE = '__$ALL_REPOS__';
function ManageReposToolbar({
- installedOrgs,
+ integratedOrgs,
onOrgChange,
onRepoChange,
selectedOrg,
selectedRepo,
}: {
- installedOrgs: PreventAIOrg[];
+ integratedOrgs: OrganizationIntegration[];
onOrgChange: (orgId: string) => void;
- onRepoChange: (repoId: string) => void;
+ onRepoChange: (repo: Repository | null) => void;
selectedOrg: string;
- selectedRepo: string;
+ selectedRepo: Repository | null;
}) {
+ const [searchValue, setSearchValue] = useState();
+ const debouncedSearch = useDebouncedValue(searchValue, 300);
+
+ const {data, hasNextPage, isFetchingNextPage, isLoading, fetchNextPage} =
+ useInfiniteRepositories({
+ integrationId: selectedOrg,
+ searchTerm: debouncedSearch,
+ });
+
+ const scrollParentRef = useRef(null);
+ const scrollListenerIdRef = useRef(0);
+ const hasNextPageRef = useRef(hasNextPage);
+ const isFetchingNextPageRef = useRef(isFetchingNextPage);
+ const fetchNextPageRef = useRef(fetchNextPage);
+
+ useEffect(() => {
+ hasNextPageRef.current = hasNextPage;
+ isFetchingNextPageRef.current = isFetchingNextPage;
+ fetchNextPageRef.current = fetchNextPage;
+ }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
+
+ const handleScroll = useCallback(() => {
+ const el = scrollParentRef.current;
+ if (!el) return;
+ if (!hasNextPageRef.current || isFetchingNextPageRef.current) return;
+ const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
+ if (distanceFromBottom < 100) fetchNextPageRef.current();
+ }, []);
+
+ const handleRepoDropdownOpenChange = useCallback(
+ (isOpen: boolean) => {
+ if (isOpen) {
+ scrollListenerIdRef.current += 1;
+ const currentId = scrollListenerIdRef.current;
+
+ const attachListener = () => {
+ if (scrollListenerIdRef.current !== currentId) return;
+ const dropdownLists = document.querySelectorAll('ul[role="listbox"]');
+ const lastList = dropdownLists[dropdownLists.length - 1];
+ if (lastList instanceof HTMLElement) {
+ scrollParentRef.current = lastList;
+ lastList.addEventListener('scroll', handleScroll, {passive: true});
+ } else {
+ setTimeout(attachListener, 20);
+ }
+ };
+ attachListener();
+ } else if (scrollParentRef.current) {
+ scrollParentRef.current.removeEventListener('scroll', handleScroll);
+ scrollParentRef.current = null;
+ }
+ },
+ [handleScroll]
+ );
+
+ useEffect(
+ () => () => {
+ if (scrollParentRef.current) {
+ scrollParentRef.current.removeEventListener('scroll', handleScroll);
+ }
+ },
+ [handleScroll]
+ );
+
const organizationOptions = useMemo(
() =>
- installedOrgs.map(org => ({
- value: org.githubOrganizationId,
+ (integratedOrgs ?? []).map(org => ({
+ value: org.id,
label: org.name,
})),
- [installedOrgs]
+ [integratedOrgs]
+ );
+
+ const allReposData = useMemo(
+ () => uniqBy(data?.pages.flatMap(result => result[0]) ?? [], 'id'),
+ [data?.pages]
);
+ const filteredReposData = useMemo(() => {
+ if (!debouncedSearch) return allReposData;
+ const search = debouncedSearch.toLowerCase();
+ return allReposData.filter(repo =>
+ getRepoNameWithoutOrg(repo.name).toLowerCase().includes(search)
+ );
+ }, [allReposData, debouncedSearch]);
const repositoryOptions = useMemo(() => {
- const org = installedOrgs.find(o => o.githubOrganizationId === selectedOrg);
- const repoOptions =
- org?.repos.map(repo => ({
- value: repo.id,
- label: repo.name,
- })) ?? [];
-
- return [
- {
- value: ALL_REPOS_VALUE,
- label: t('All Repos'),
- },
- ...repoOptions,
- ];
- }, [installedOrgs, selectedOrg]);
+ let repoOptions = filteredReposData.map(repo => ({
+ value: repo.externalId,
+ label: getRepoNameWithoutOrg(repo.name),
+ }));
+
+ if (selectedRepo) {
+ repoOptions = [
+ {
+ value: selectedRepo.externalId,
+ label: getRepoNameWithoutOrg(selectedRepo.name),
+ },
+ ...repoOptions,
+ ];
+ }
+
+ const dedupedRepoOptions = uniqBy(repoOptions, 'value');
+ return [{value: ALL_REPOS_VALUE, label: t('All Repos')}, ...dedupedRepoOptions];
+ }, [filteredReposData, selectedRepo]);
+
+ const getRepoEmptyMessage = () => {
+ if (isLoading) return t('Loading repositories...');
+ if (filteredReposData.length === 0) {
+ return debouncedSearch
+ ? t('No repositories found. Please enter a different search term.')
+ : t('No repositories found');
+ }
+ return undefined;
+ };
return (
@@ -67,15 +160,38 @@ function ManageReposToolbar({
/>
onRepoChange(option?.value ?? '')}
+ loading={isLoading}
+ disabled={!selectedOrg || isLoading}
+ onChange={option => {
+ const repoExternalId = option?.value;
+ if (repoExternalId === ALL_REPOS_VALUE) {
+ onRepoChange(null);
+ } else {
+ const foundRepo = allReposData.find(
+ repo => repo.externalId === repoExternalId
+ );
+ onRepoChange(foundRepo ?? null);
+ }
+ }}
+ searchable
+ disableSearchFilter
+ onSearch={setSearchValue}
+ searchPlaceholder={t('search by repository name')}
+ onOpenChange={isOpen => {
+ handleRepoDropdownOpenChange(isOpen);
+ if (!isOpen) setSearchValue(undefined);
+ }}
+ emptyMessage={getRepoEmptyMessage()}
+ menuWidth="250px"
triggerProps={{
icon: ,
children: (
- {repositoryOptions.find(opt => opt.value === selectedRepo)?.label ||
- t('Select repository')}
+ {repositoryOptions.find(
+ opt => opt.value === (selectedRepo?.externalId ?? ALL_REPOS_VALUE)
+ )?.label || t('Select repository')}
),
}}
diff --git a/static/app/views/prevent/preventAI/utils.test.ts b/static/app/views/prevent/preventAI/utils.test.ts
new file mode 100644
index 00000000000000..7d6e0891428631
--- /dev/null
+++ b/static/app/views/prevent/preventAI/utils.test.ts
@@ -0,0 +1,22 @@
+import {getRepoNameWithoutOrg} from 'sentry/views/prevent/preventAI/utils';
+
+describe('getRepoNameWithoutOrg', () => {
+ it('returns the repo name when given a full name with org/repo', () => {
+ expect(getRepoNameWithoutOrg('org1/repo1')).toBe('repo1');
+ expect(getRepoNameWithoutOrg('my-org/another-repo')).toBe('another-repo');
+ });
+
+ it('returns the input string when there is no slash', () => {
+ expect(getRepoNameWithoutOrg('justrepo')).toBe('justrepo');
+ expect(getRepoNameWithoutOrg('repoOnlyName')).toBe('repoOnlyName');
+ });
+
+ it('handles empty strings', () => {
+ expect(getRepoNameWithoutOrg('')).toBe('');
+ });
+
+ it('returns the last portion if there are multiple slashes', () => {
+ expect(getRepoNameWithoutOrg('org/foo/bar/repo-test')).toBe('repo-test');
+ expect(getRepoNameWithoutOrg('////repo')).toBe('repo');
+ });
+});
diff --git a/static/app/views/prevent/preventAI/utils.ts b/static/app/views/prevent/preventAI/utils.ts
new file mode 100644
index 00000000000000..613ad8e9b6be4f
--- /dev/null
+++ b/static/app/views/prevent/preventAI/utils.ts
@@ -0,0 +1,3 @@
+export function getRepoNameWithoutOrg(fullName: string): string {
+ return fullName.includes('/') ? fullName.split('/').pop() || fullName : fullName;
+}