{virtualItems.map(virtualRow => (
({
+ identifier: `${prefix}-${i}`,
+ name: `${prefix.split('/')[1]}-${i}`,
+ defaultBranch: 'main',
+ isInstalled: false,
+ }));
+}
+
+describe('useScmRepoSearch', () => {
+ const organization = OrganizationFixture();
+ const reposUrl = `/organizations/${organization.slug}/integrations/1/repos/`;
+
+ afterEach(() => {
+ MockApiClient.clearMockResponses();
+ });
+
+ it('fires browse request on mount without requiring search', async () => {
+ const browseRequest = MockApiClient.addMockResponse({
+ url: reposUrl,
+ body: {repos: makeRepos(3), searchable: true},
+ match: [MockApiClient.matchQuery({accessibleOnly: true, paginate: true})],
+ });
+
+ const {result} = renderHookWithProviders(() => useScmRepoSearch('1'), {
+ organization,
+ });
+
+ await waitFor(() => expect(result.current.dropdownItems).toHaveLength(3));
+ expect(browseRequest).toHaveBeenCalled();
+ expect(result.current.dropdownItems[0]!.value).toBe('org/repo-0');
+ });
+
+ it('uses server-side search when user types', async () => {
+ MockApiClient.addMockResponse({
+ url: reposUrl,
+ body: {repos: makeRepos(5), searchable: true},
+ match: [MockApiClient.matchQuery({accessibleOnly: true, paginate: true})],
+ });
+
+ const searchRequest = MockApiClient.addMockResponse({
+ url: reposUrl,
+ body: {
+ repos: [{identifier: 'org/match', name: 'match', isInstalled: false}],
+ searchable: true,
+ },
+ match: [MockApiClient.matchQuery({search: 'match', accessibleOnly: true})],
+ });
+
+ const {result} = renderHookWithProviders(() => useScmRepoSearch('1'), {
+ organization,
+ });
+
+ // Wait for browse results first
+ await waitFor(() => expect(result.current.dropdownItems).toHaveLength(5));
+
+ // Type a search query
+ act(() => {
+ result.current.setSearch('match');
+ });
+
+ await waitFor(() => expect(searchRequest).toHaveBeenCalled());
+ await waitFor(() => expect(result.current.dropdownItems).toHaveLength(1));
+ expect(result.current.dropdownItems[0]!.value).toBe('org/match');
+ });
+
+ it('returns to browse results when search is cleared', async () => {
+ MockApiClient.addMockResponse({
+ url: reposUrl,
+ body: {repos: makeRepos(3), searchable: true},
+ match: [MockApiClient.matchQuery({accessibleOnly: true, paginate: true})],
+ });
+
+ MockApiClient.addMockResponse({
+ url: reposUrl,
+ body: {
+ repos: [{identifier: 'org/x', name: 'x', isInstalled: false}],
+ searchable: true,
+ },
+ match: [MockApiClient.matchQuery({search: 'x', accessibleOnly: true})],
+ });
+
+ const {result} = renderHookWithProviders(() => useScmRepoSearch('1'), {
+ organization,
+ });
+
+ await waitFor(() => expect(result.current.dropdownItems).toHaveLength(3));
+
+ // Search
+ act(() => {
+ result.current.setSearch('x');
+ });
+ await waitFor(() => expect(result.current.dropdownItems).toHaveLength(1));
+
+ // Clear search -- should return to browse results
+ act(() => {
+ result.current.setSearch('');
+ });
+ await waitFor(() => expect(result.current.dropdownItems).toHaveLength(3));
+ });
+
+ it('marks the selected repo as disabled in dropdown items', async () => {
+ MockApiClient.addMockResponse({
+ url: reposUrl,
+ body: {
+ repos: [
+ {identifier: 'org/selected', name: 'selected', isInstalled: false},
+ {identifier: 'org/other', name: 'other', isInstalled: false},
+ ],
+ searchable: true,
+ },
+ match: [MockApiClient.matchQuery({accessibleOnly: true, paginate: true})],
+ });
+
+ const selectedRepo = RepositoryFixture({
+ name: 'org/selected',
+ externalSlug: 'org/selected',
+ });
+
+ const {result} = renderHookWithProviders(() => useScmRepoSearch('1', selectedRepo), {
+ organization,
+ });
+
+ await waitFor(() => expect(result.current.dropdownItems).toHaveLength(2));
+
+ const selectedItem = result.current.dropdownItems.find(
+ item => item.value === 'org/selected'
+ );
+ const otherItem = result.current.dropdownItems.find(
+ item => item.value === 'org/other'
+ );
+ expect(selectedItem!.disabled).toBe(true);
+ expect(otherItem!.disabled).toBe(false);
+ });
+});
diff --git a/static/app/views/onboarding/components/useScmRepoSearch.ts b/static/app/views/onboarding/components/useScmRepoSearch.ts
index 378c20be131467..721d7cf24badb1 100644
--- a/static/app/views/onboarding/components/useScmRepoSearch.ts
+++ b/static/app/views/onboarding/components/useScmRepoSearch.ts
@@ -2,7 +2,7 @@ import {useMemo, useState} from 'react';
import type {IntegrationRepository, Repository} from 'sentry/types/integrations';
import {getApiUrl} from 'sentry/utils/api/getApiUrl';
-import {fetchDataQuery, useQuery} from 'sentry/utils/queryClient';
+import {fetchDataQuery, useInfiniteApiQuery, useQuery} from 'sentry/utils/queryClient';
import {useDebouncedValue} from 'sentry/utils/useDebouncedValue';
import {useOrganization} from 'sentry/utils/useOrganization';
@@ -15,17 +15,32 @@ export function useScmRepoSearch(integrationId: string, selectedRepo?: Repositor
const [search, setSearch] = useState('');
const debouncedSearch = useDebouncedValue(search, 200);
- const searchQuery = useQuery({
+ const reposUrl = getApiUrl(
+ `/organizations/$organizationIdOrSlug/integrations/$integrationId/repos/`,
+ {
+ path: {
+ organizationIdOrSlug: organization.slug,
+ integrationId,
+ },
+ }
+ );
+
+ // Browse: paginated, fires on mount. Additional pages are fetched on
+ // demand when the user scrolls near the bottom of the dropdown.
+ const browseResult = useInfiniteApiQuery({
queryKey: [
- getApiUrl(
- `/organizations/$organizationIdOrSlug/integrations/$integrationId/repos/`,
- {
- path: {
- organizationIdOrSlug: organization.slug,
- integrationId,
- },
- }
- ),
+ {infinite: true, version: 'v1' as const},
+ reposUrl,
+ {method: 'GET', query: {accessibleOnly: true, paginate: true, per_page: 50}},
+ ],
+ staleTime: 20_000,
+ });
+
+ // Search: non-paginated, fires when user types. Uses server-side filtering
+ // with accessibleOnly=true to guarantee accurate results.
+ const searchResult = useQuery({
+ queryKey: [
+ reposUrl,
{method: 'GET', query: {search: debouncedSearch, accessibleOnly: true}},
] as const,
queryFn: async context => {
@@ -33,15 +48,24 @@ export function useScmRepoSearch(integrationId: string, selectedRepo?: Repositor
},
retry: 0,
staleTime: 20_000,
- placeholderData: previousData => (debouncedSearch ? previousData : undefined),
+ placeholderData: previousData => previousData,
enabled: !!debouncedSearch,
});
+ const isSearching = !!debouncedSearch;
+
+ const repos = useMemo(() => {
+ if (isSearching) {
+ return searchResult.data?.[0]?.repos ?? [];
+ }
+ return browseResult.data?.pages.flatMap(([data]) => data?.repos ?? []) ?? [];
+ }, [isSearching, searchResult.data, browseResult.data]);
+
const selectedRepoSlug = selectedRepo?.externalSlug;
const {reposByIdentifier, dropdownItems} = useMemo(
() =>
- (searchQuery.data?.[0]?.repos ?? []).reduce<{
+ repos.reduce<{
dropdownItems: Array<{
disabled: boolean;
label: string;
@@ -63,14 +87,17 @@ export function useScmRepoSearch(integrationId: string, selectedRepo?: Repositor
dropdownItems: [],
}
),
- [searchQuery.data, selectedRepoSlug]
+ [repos, selectedRepoSlug]
);
return {
reposByIdentifier,
dropdownItems,
- isFetching: searchQuery.isFetching,
- isError: searchQuery.isError,
+ isFetching: isSearching ? searchResult.isFetching : browseResult.isFetching,
+ isFetchingNextPage: browseResult.isFetchingNextPage,
+ isError: isSearching ? searchResult.isError : browseResult.isError,
+ hasNextPage: browseResult.hasNextPage,
+ fetchNextPage: browseResult.fetchNextPage,
debouncedSearch,
setSearch,
};