From 40e8bc4f43b0cb790d29aff8fb6c9816738231a8 Mon Sep 17 00:00:00 2001 From: Suejung Shin Date: Tue, 28 Oct 2025 17:27:57 -0700 Subject: [PATCH 1/7] feat(prevent): Update repo selector for single app --- .../hooks/useInfiniteRepositories.tsx | 142 +++++++++ .../hooks/usePreventAIOrgRepos.spec.tsx | 97 ------ .../preventAI/hooks/usePreventAIOrgRepos.tsx | 33 --- static/app/views/prevent/preventAI/index.tsx | 27 +- .../views/prevent/preventAI/manageRepos.tsx | 211 ++++++++++--- .../prevent/preventAI/manageReposPanel.tsx | 18 +- .../prevent/preventAI/manageReposToolbar.tsx | 277 +++++++++++++++++- 7 files changed, 606 insertions(+), 199 deletions(-) create mode 100644 static/app/views/prevent/preventAI/hooks/useInfiniteRepositories.tsx delete mode 100644 static/app/views/prevent/preventAI/hooks/usePreventAIOrgRepos.spec.tsx delete mode 100644 static/app/views/prevent/preventAI/hooks/usePreventAIOrgRepos.tsx diff --git a/static/app/views/prevent/preventAI/hooks/useInfiniteRepositories.tsx b/static/app/views/prevent/preventAI/hooks/useInfiniteRepositories.tsx new file mode 100644 index 00000000000000..9fbf7223625b25 --- /dev/null +++ b/static/app/views/prevent/preventAI/hooks/useInfiniteRepositories.tsx @@ -0,0 +1,142 @@ +import {useMemo} from 'react'; + +import type {ApiResult} from 'sentry/api'; +import type {Repository} from 'sentry/types/integrations'; +import { + fetchDataQuery, + useInfiniteQuery, + type InfiniteData, + type QueryKeyEndpointOptions, +} from 'sentry/utils/queryClient'; +import useOrganization from 'sentry/utils/useOrganization'; + +type QueryKey = [url: string, endpointOptions: QueryKeyEndpointOptions]; + +type Props = { + integrationId: string; + term?: string; +}; + +export function useInfiniteRepositories({integrationId, term}: Props) { + const organization = useOrganization(); + + const {data, ...rest} = useInfiniteQuery< + ApiResult, + Error, + InfiniteData>, + QueryKey + >({ + queryKey: [ + `/organizations/${organization.slug}/repos/`, + { + query: { + integration_id: integrationId || undefined, + status: 'active', + query: term || undefined, + }, + }, + ], + queryFn: async ({ + queryKey: [url, {query}], + pageParam, + client, + signal, + meta, + }): Promise> => { + // eslint-disable-next-line no-console + console.log('Fetching page with cursor:', pageParam); + const result = await fetchDataQuery({ + queryKey: [ + url, + { + query: { + ...query, + cursor: pageParam ?? undefined, + }, + }, + ], + client, + signal, + meta, + }); + + // eslint-disable-next-line no-console + console.log('Fetched page, result length:', (result as any)[0]?.length); + return result as ApiResult; + }, + getNextPageParam: _lastPage => { + // The /repos/ endpoint uses Link header pagination + const [, , responseMeta] = _lastPage; + const linkHeader = responseMeta?.getResponseHeader('Link'); + // eslint-disable-next-line no-console + console.log('getNextPageParam - Link header:', linkHeader); + if (!linkHeader) { + return undefined; + } + + // Parse Link header for next page cursor and check if results="true" + const nextMatch = linkHeader.match( + /<[^>]*[?&]cursor=([^&>]+)[^>]*>;\s*rel="next";\s*results="([^"]+)"/ + ); + if (!nextMatch) { + return undefined; + } + + const nextCursor = nextMatch[1]; + const hasResults = nextMatch[2] === 'true'; + + // eslint-disable-next-line no-console + console.log( + 'getNextPageParam - next cursor:', + nextCursor, + 'hasResults:', + hasResults + ); + + // Only return cursor if there are actually results + return hasResults ? nextCursor : undefined; + }, + getPreviousPageParam: _lastPage => { + // The /repos/ endpoint uses Link header pagination + const [, , responseMeta] = _lastPage; + const linkHeader = responseMeta?.getResponseHeader('Link'); + if (!linkHeader) { + return undefined; + } + + // Parse Link header for previous page cursor and check if results="true" + const prevMatch = linkHeader.match( + /<[^>]*[?&]cursor=([^&>]+)[^>]*>;\s*rel="previous";\s*results="([^"]+)"/ + ); + if (!prevMatch) { + return undefined; + } + + const prevCursor = prevMatch[1]; + const hasResults = prevMatch[2] === 'true'; + + // Only return cursor if there are actually results + return hasResults ? prevCursor : undefined; + }, + initialPageParam: undefined, + enabled: Boolean(integrationId), + staleTime: 0, + }); + + const memoizedData = useMemo(() => { + const flattened = data?.pages?.flatMap(([pageData]) => pageData) ?? []; + // eslint-disable-next-line no-console + console.log( + 'memoizedData - pages:', + data?.pages?.length, + 'total repos:', + flattened.length + ); + return flattened; + }, [data]); + + return { + data: memoizedData, + ...rest, + }; +} diff --git a/static/app/views/prevent/preventAI/hooks/usePreventAIOrgRepos.spec.tsx b/static/app/views/prevent/preventAI/hooks/usePreventAIOrgRepos.spec.tsx deleted file mode 100644 index f93d8c85eae732..00000000000000 --- a/static/app/views/prevent/preventAI/hooks/usePreventAIOrgRepos.spec.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import {OrganizationFixture} from 'sentry-fixture/organization'; -import {PreventAIConfigFixture} from 'sentry-fixture/prevent'; - -import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; - -import { - usePreventAIOrgRepos, - type PreventAIOrgReposResponse, -} 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'}], - }, - ], - }; - - beforeEach(() => { - MockApiClient.clearMockResponses(); - }); - - it('returns data on success', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${mockOrg.slug}/prevent/github/repos/`, - body: mockResponse, - }); - - const {result} = renderHookWithProviders(() => usePreventAIOrgRepos(), { - organization: mockOrg, - }); - - await waitFor(() => expect(result.current.data).toEqual(mockResponse)); - expect(result.current.isError).toBe(false); - expect(result.current.isPending).toBe(false); - }); - - it('returns error on failure', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${mockOrg.slug}/prevent/github/repos/`, - statusCode: 500, - body: {error: 'Internal Server Error'}, - }); - - const {result} = renderHookWithProviders(() => usePreventAIOrgRepos(), { - organization: mockOrg, - }); - - await waitFor(() => expect(result.current.isError).toBe(true)); - }); - - it('refetches data', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${mockOrg.slug}/prevent/github/repos/`, - body: mockResponse, - }); - - const {result} = renderHookWithProviders(() => usePreventAIOrgRepos(), { - 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'}], - }, - ], - }; - MockApiClient.addMockResponse({ - url: `/organizations/${mockOrg.slug}/prevent/github/repos/`, - body: newResponse, - }); - - result.current.refetch(); - await waitFor(() => expect(result.current.data?.orgRepos?.[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 deleted file mode 100644 index ef45597f983554..00000000000000 --- a/static/app/views/prevent/preventAI/hooks/usePreventAIOrgRepos.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type {PreventAIOrg} from 'sentry/types/prevent'; -import {useApiQuery} from 'sentry/utils/queryClient'; -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 { - const organization = useOrganization(); - - const {data, isPending, isError, refetch} = useApiQuery( - [`/organizations/${organization.slug}/prevent/github/repos/`], - { - 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..106d77f8703ed3 100644 --- a/static/app/views/prevent/preventAI/index.tsx +++ b/static/app/views/prevent/preventAI/index.tsx @@ -2,14 +2,29 @@ import Feature from 'sentry/components/acl/feature'; import LoadingError from 'sentry/components/loadingError'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import {t} from 'sentry/locale'; +import type {OrganizationIntegration} from 'sentry/types/integrations'; +import {useApiQuery} from 'sentry/utils/queryClient'; +import useOrganization from 'sentry/utils/useOrganization'; import PreventAIManageRepos from 'sentry/views/prevent/preventAI/manageRepos'; import PreventAIOnboarding from 'sentry/views/prevent/preventAI/onboarding'; -import {usePreventAIOrgRepos} from './hooks/usePreventAIOrgRepos'; - function PreventAIContent() { - const {data, isPending, isError} = usePreventAIOrgRepos(); - const orgRepos = data?.orgRepos ?? []; + const organization = useOrganization(); + + // Check if there are any GitHub integrations installed + const { + data: githubIntegrations = [], + isPending, + isError, + } = useApiQuery( + [ + `/organizations/${organization.slug}/integrations/`, + {query: {includeConfig: 0, provider_key: 'github'}}, + ], + { + staleTime: 0, + } + ); if (isPending) { return ; @@ -22,8 +37,8 @@ function PreventAIContent() { /> ); } - if (orgRepos.length > 0) { - return ; + if (githubIntegrations.length > 0) { + return ; } return ; } diff --git a/static/app/views/prevent/preventAI/manageRepos.tsx b/static/app/views/prevent/preventAI/manageRepos.tsx index 8c3f3405e3bb28..e3d9ab1701582f 100644 --- a/static/app/views/prevent/preventAI/manageRepos.tsx +++ b/static/app/views/prevent/preventAI/manageRepos.tsx @@ -1,4 +1,5 @@ -import {useCallback, useMemo, useState} from 'react'; +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useSearchParams} from 'react-router-dom'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; @@ -12,7 +13,11 @@ 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 {OrganizationIntegration} from 'sentry/types/integrations'; import type {PreventAIOrg} from 'sentry/types/prevent'; +import {useApiQuery} from 'sentry/utils/queryClient'; +import useOrganization from 'sentry/utils/useOrganization'; +import {useInfiniteRepositories} from 'sentry/views/prevent/preventAI/hooks/useInfiniteRepositories'; import ManageReposPanel from 'sentry/views/prevent/preventAI/manageReposPanel'; import ManageReposToolbar, { ALL_REPOS_VALUE, @@ -20,61 +25,157 @@ import ManageReposToolbar, { import {FeatureOverview} from './onboarding'; -function ManageReposPage({installedOrgs}: {installedOrgs: PreventAIOrg[]}) { +function ManageReposPage(_props: {installedOrgs: PreventAIOrg[]}) { const theme = useTheme(); + const organization = useOrganization(); const [isPanelOpen, setIsPanelOpen] = useState(false); + const [searchParams, setSearchParams] = useSearchParams(); - const [selectedOrgId, setSelectedOrgId] = useState( - () => installedOrgs[0]?.githubOrganizationId ?? '' - ); + // Track if we've initialized from URL params to avoid overriding user selections + const hasInitializedFromUrlRef = useRef({org: false, repo: false}); + + // selectedOrgId now stores the Sentry integration ID (not GitHub org ID) + const [selectedOrgId, setSelectedOrgId] = useState(''); 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]); + // Fetch GitHub integrations to get organization names + const {data: githubIntegrations = []} = useApiQuery( + [ + `/organizations/${organization.slug}/integrations/`, + {query: {includeConfig: 0, provider_key: 'github'}}, + ], + { + staleTime: 0, + } + ); + + // Fetch repositories for the selected organization using infinite scroll + const {data: reposData = []} = useInfiniteRepositories({ + integrationId: selectedOrgId, + term: undefined, // No search term for the panel + }); + + // Find the selected org and repo data + const selectedOrgData = useMemo( + () => githubIntegrations.find(integration => integration.id === selectedOrgId), + [githubIntegrations, selectedOrgId] + ); + + const selectedRepoData = useMemo( + () => reposData.find(repo => repo.id === selectedRepoId), + [reposData, selectedRepoId] + ); - // Ditto for repos - const selectedRepo = useMemo(() => { - if (selectedRepoId === ALL_REPOS_VALUE) { - return null; + // Initialize from URL params when data is loaded, or auto-select first org + useEffect(() => { + const org = searchParams.get('org'); + const repo = searchParams.get('repo'); + + // Find org by name if specified in URL + if ( + org && + githubIntegrations.length > 0 && + !selectedOrgId && + !hasInitializedFromUrlRef.current.org + ) { + const matchedOrg = githubIntegrations.find( + integration => integration.name.toLowerCase() === org.toLowerCase() + ); + if (matchedOrg) { + setSelectedOrgId(matchedOrg.id); + hasInitializedFromUrlRef.current.org = true; + } } - const found = selectedOrg?.repos?.find(repo => repo.id === selectedRepoId); - return found ?? selectedOrg?.repos?.[0]; - }, [selectedOrg, selectedRepoId]); + // Auto-select first org if no URL param and not yet initialized + else if ( + !org && + githubIntegrations.length > 0 && + !selectedOrgId && + !hasInitializedFromUrlRef.current.org && + githubIntegrations[0] + ) { + setSelectedOrgId(githubIntegrations[0].id); + hasInitializedFromUrlRef.current.org = true; + } + + // Find repo by name if specified in URL (only after org is selected and repos are loaded) + if ( + repo && + selectedOrgId && + reposData.length > 0 && + !hasInitializedFromUrlRef.current.repo + ) { + const matchedRepo = reposData.find(r => { + const repoNameWithoutOrg = r.name.includes('/') + ? r.name.split('/').pop() || r.name + : r.name; + return repoNameWithoutOrg.toLowerCase() === repo.toLowerCase(); + }); + if (matchedRepo) { + setSelectedRepoId(matchedRepo.id); + hasInitializedFromUrlRef.current.repo = true; + } + } + }, [searchParams, githubIntegrations, reposData, selectedOrgId]); - // When the org changes, if the selected repo is not present in the new org, - // reset to "All Repos" + // When the org changes, reset to "All Repos" to avoid stale repo selection const setSelectedOrgIdWithCascadeRepoId = useCallback( - (orgId: string) => { - setSelectedOrgId(orgId); - const newSelectedOrgData = installedOrgs.find( - org => org.githubOrganizationId === orgId + (integrationId: string) => { + setSelectedOrgId(integrationId); + // Reset to All Repos when changing organizations + setSelectedRepoId(ALL_REPOS_VALUE); + // Reset initialization flags when user manually changes org + hasInitializedFromUrlRef.current = {org: true, repo: false}; + + // Update URL params with org name + const selectedOrg = githubIntegrations.find( + integration => integration.id === integrationId ); - if ( - newSelectedOrgData && - selectedRepoId !== ALL_REPOS_VALUE && - !newSelectedOrgData.repos.some(repo => repo.id === selectedRepoId) - ) { - setSelectedRepoId(ALL_REPOS_VALUE); + if (selectedOrg) { + const newParams = new URLSearchParams(searchParams); + newParams.set('org', selectedOrg.name); + newParams.delete('repo'); // Clear repo when changing org + setSearchParams(newParams); } }, - [installedOrgs, selectedRepoId] + [githubIntegrations, searchParams, setSearchParams] ); - const isOrgSelected = !!selectedOrg; - const isRepoSelected = selectedRepoId === ALL_REPOS_VALUE || !!selectedRepo; + // Update URL params when repo changes + const setSelectedRepoIdWithUrlUpdate = useCallback( + (repoId: string) => { + setSelectedRepoId(repoId); + // Mark repo as initialized when user manually changes it + hasInitializedFromUrlRef.current.repo = true; + + const newParams = new URLSearchParams(searchParams); + if (repoId === ALL_REPOS_VALUE) { + newParams.delete('repo'); + } else { + const selectedRepo = reposData.find(r => r.id === repoId); + if (selectedRepo) { + const repoNameWithoutOrg = selectedRepo.name.includes('/') + ? selectedRepo.name.split('/').pop() || selectedRepo.name + : selectedRepo.name; + newParams.set('repo', repoNameWithoutOrg); + } + } + setSearchParams(newParams); + }, + [reposData, searchParams, setSearchParams] + ); + + const isOrgSelected = !!selectedOrgId; + const isRepoSelected = !!selectedRepoId; return ( - {selectedOrg && (selectedRepoId === ALL_REPOS_VALUE || selectedRepo) && ( + {isPanelOpen && selectedOrgData && ( setIsPanelOpen(false)} - org={selectedOrg} - repo={selectedRepo} - allRepos={selectedOrg.repos} + org={{ + githubOrganizationId: selectedOrgData.externalId || selectedOrgId, + name: selectedOrgData.name, + provider: 'github' as const, + repos: reposData.map(repo => { + // Extract just the repo name without org prefix + const repoNameWithoutOrg = repo.name.includes('/') + ? repo.name.split('/').pop() || repo.name + : repo.name; + return { + id: repo.id, + name: repoNameWithoutOrg, + fullName: repo.name, + }; + }), + }} + repo={ + selectedRepoId === ALL_REPOS_VALUE || !selectedRepoData + ? null + : { + id: selectedRepoData.id, + name: selectedRepoData.name.includes('/') + ? selectedRepoData.name.split('/').pop() || selectedRepoData.name + : selectedRepoData.name, + fullName: selectedRepoData.name, + } + } + allRepos={reposData.map(repo => { + const repoNameWithoutOrg = repo.name.includes('/') + ? repo.name.split('/').pop() || repo.name + : repo.name; + return { + id: repo.id, + name: repoNameWithoutOrg, + fullName: repo.name, + }; + })} isEditingOrgDefaults={selectedRepoId === ALL_REPOS_VALUE} /> )} diff --git a/static/app/views/prevent/preventAI/manageReposPanel.tsx b/static/app/views/prevent/preventAI/manageReposPanel.tsx index 4e0ef675285a7c..4e3ef05e92ff27 100644 --- a/static/app/views/prevent/preventAI/manageReposPanel.tsx +++ b/static/app/views/prevent/preventAI/manageReposPanel.tsx @@ -86,7 +86,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 @@ -190,7 +190,7 @@ function ManageReposPanel({ onChange={async () => { await enableFeature({ feature: 'use_org_defaults', - orgId: org.githubOrganizationId, + orgId: org.name, repoId: repo?.id, enabled: !doesUseOrgDefaults, }); @@ -230,7 +230,7 @@ function ManageReposPanel({ await enableFeature({ feature: 'vanilla', enabled: newValue, - orgId: org.githubOrganizationId, + orgId: org.name, repoId: repo?.id, }); }} @@ -262,7 +262,7 @@ function ManageReposPanel({ await enableFeature({ feature: 'vanilla', enabled: true, - orgId: org.githubOrganizationId, + orgId: org.name, repoId: repo?.id, sensitivity: option.value, }) @@ -307,7 +307,7 @@ function ManageReposPanel({ await enableFeature({ feature: 'test_generation', enabled: newValue, - orgId: org.githubOrganizationId, + orgId: org.name, repoId: repo?.id, }); }} @@ -345,7 +345,7 @@ function ManageReposPanel({ await enableFeature({ feature: 'bug_prediction', enabled: newValue, - orgId: org.githubOrganizationId, + orgId: org.name, repoId: repo?.id, }); }} @@ -377,7 +377,7 @@ function ManageReposPanel({ await enableFeature({ feature: 'bug_prediction', enabled: true, - orgId: org.githubOrganizationId, + orgId: org.name, repoId: repo?.id, sensitivity: option.value, }) @@ -415,7 +415,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 +447,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.tsx b/static/app/views/prevent/preventAI/manageReposToolbar.tsx index 94df19aaa5961d..540c2510edbcd9 100644 --- a/static/app/views/prevent/preventAI/manageReposToolbar.tsx +++ b/static/app/views/prevent/preventAI/manageReposToolbar.tsx @@ -1,43 +1,154 @@ -import {Fragment, useMemo} from 'react'; +import {Fragment, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import debounce from 'lodash/debounce'; 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} from 'sentry/types/integrations'; +import {useApiQuery} from 'sentry/utils/queryClient'; +import useOrganization from 'sentry/utils/useOrganization'; + +import {useInfiniteRepositories} from './hooks/useInfiniteRepositories'; export const ALL_REPOS_VALUE = '__$ALL_REPOS__'; function ManageReposToolbar({ - installedOrgs, onOrgChange, onRepoChange, selectedOrg, selectedRepo, }: { - installedOrgs: PreventAIOrg[]; onOrgChange: (orgId: string) => void; onRepoChange: (repoId: string) => void; selectedOrg: string; selectedRepo: string; }) { + const organization = useOrganization(); + + // Search state for the repo search box - following RepoSelector pattern + const [searchValue, setSearchValue] = useState(); + + // Debounced search handler - following RepoSelector pattern + const handleOnSearch = useMemo( + () => + debounce((value: string) => { + setSearchValue(value); + }, 300), + [] + ); + + // Cleanup debounced function on unmount + useEffect(() => { + return () => { + handleOnSearch.cancel(); + }; + }, [handleOnSearch]); + + // Fetch GitHub integrations to power the organization dropdown + const {data: githubIntegrations = [], isLoading: isLoadingIntegrations} = useApiQuery< + OrganizationIntegration[] + >( + [ + `/organizations/${organization.slug}/integrations/`, + {query: {includeConfig: 0, provider_key: 'github'}}, + ], + { + staleTime: 0, + } + ); + + // Options for organization selector - use integration ID as value const organizationOptions = useMemo( () => - installedOrgs.map(org => ({ - value: org.githubOrganizationId, - label: org.name, + githubIntegrations.map(integration => ({ + value: integration.id, // Use integration ID as the value + label: integration.name, // Display the GitHub org name })), - [installedOrgs] + [githubIntegrations] ); + // Fetch repos for the selected integration with infinite scroll + // Filter results to only show matches in the repo name (after the slash) + const { + data: allReposData = [], + isLoading: reposLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteRepositories({ + integrationId: selectedOrg, + term: searchValue, + }); + + // Filter out repos where search only matches org name, not repo name + const reposData = useMemo(() => { + if (!searchValue) { + return allReposData; + } + + return allReposData.filter(repo => { + const parts = repo.name.split('/'); + if (parts.length !== 2) { + return true; + } + + const repoName = parts[1]; + if (!repoName) { + return true; + } + + return repoName.toLowerCase().includes(searchValue.toLowerCase()); + }); + }, [allReposData, searchValue]); + + // Auto-fetch more pages if filtering reduced visible results below threshold + const MIN_VISIBLE_RESULTS = 50; + useEffect(() => { + // Only auto-fetch if: + // 1. We have a search filter active (otherwise all results are visible) + // 2. We have fewer than MIN_VISIBLE_RESULTS after filtering + // 3. There are more pages available + // 4. Not currently fetching + if ( + searchValue && + reposData.length < MIN_VISIBLE_RESULTS && + hasNextPage && + !isFetchingNextPage && + !reposLoading + ) { + fetchNextPage(); + } + }, [ + searchValue, + reposData.length, + hasNextPage, + isFetchingNextPage, + reposLoading, + fetchNextPage, + ]); + + // Displayed repos - hide during initial load only (not during refetch when switching orgs) + const displayedRepos = useMemo( + () => (reposLoading ? [] : reposData), + [reposData, reposLoading] + ); + + // Compose repo options for CompactSelect, add 'All Repos' const repositoryOptions = useMemo(() => { - const org = installedOrgs.find(o => o.githubOrganizationId === selectedOrg); const repoOptions = - org?.repos.map(repo => ({ - value: repo.id, - label: repo.name, - })) ?? []; + displayedRepos?.map(repo => { + // Extract just the repo name without the org prefix (e.g., "suejung-sentry/tools" → "tools") + const repoNameWithoutOrg = repo.name.includes('/') + ? repo.name.split('/').pop() || repo.name + : repo.name; + + return { + value: repo.id, + label: repoNameWithoutOrg, + }; + }) ?? []; return [ { @@ -46,7 +157,111 @@ function ManageReposToolbar({ }, ...repoOptions, ]; - }, [installedOrgs, selectedOrg]); + }, [displayedRepos]); + + // Empty message handler - following RepoSelector pattern + function getEmptyMessage() { + if (reposLoading) { + return t('Loading repositories...'); + } + + if (!displayedRepos?.length) { + if (searchValue?.length) { + return t('No repositories found. Please enter a different search term.'); + } + + return t('No repositories found'); + } + + return undefined; + } + + // Infinite scroll: Attach scroll listener to the list + const scrollListenerRef = useRef(null); + const scrollListenerIdRef = useRef(0); + + // Use refs to avoid stale closures + 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 listElement = scrollListenerRef.current; + + if (!listElement) { + return; + } + + // Check if user has scrolled near the bottom + const scrollTop = listElement.scrollTop; + const scrollHeight = listElement.scrollHeight; + const clientHeight = listElement.clientHeight; + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + + if (!hasNextPageRef.current || isFetchingNextPageRef.current) { + return; + } + + // Trigger when within 100px of bottom + if (distanceFromBottom < 100) { + fetchNextPageRef.current(); + } + }, []); + + // Set up scroll listener when menu opens + const handleMenuOpenChange = useCallback( + (isOpen: boolean) => { + if (isOpen) { + // Increment ID to track this specific open instance + scrollListenerIdRef.current += 1; + const currentId = scrollListenerIdRef.current; + + // Try multiple times to find the list element as it may take time to render + const tryAttachListener = (attempts = 0) => { + // Stop if menu was closed (ID changed) or too many attempts + if (scrollListenerIdRef.current !== currentId || attempts > 10) { + return; + } + + // Find all listbox elements and get the last one (most recently opened) + const listElements = document.querySelectorAll('ul[role="listbox"]'); + const listElement = listElements[listElements.length - 1]; + + if (listElement instanceof HTMLElement) { + scrollListenerRef.current = listElement; + listElement.addEventListener('scroll', handleScroll, {passive: true}); + } else { + // Retry after a short delay + setTimeout(() => tryAttachListener(attempts + 1), 20); + } + }; + + tryAttachListener(); + } else { + // Clean up listener when menu closes + if (scrollListenerRef.current) { + scrollListenerRef.current.removeEventListener('scroll', handleScroll); + scrollListenerRef.current = null; + } + } + }, + [handleScroll] + ); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (scrollListenerRef.current) { + scrollListenerRef.current.removeEventListener('scroll', handleScroll); + } + }; + }, [handleScroll]); return ( @@ -54,6 +269,7 @@ function ManageReposToolbar({ onOrgChange(option?.value ?? '')} triggerProps={{ icon: , @@ -69,16 +285,45 @@ function ManageReposToolbar({ onRepoChange(option?.value ?? '')} + searchable + disableSearchFilter + onSearch={handleOnSearch} + searchPlaceholder={t('Filter by repository name')} + onOpenChange={isOpen => { + setSearchValue(undefined); + handleMenuOpenChange(isOpen); + }} + emptyMessage={getEmptyMessage()} triggerProps={{ icon: , children: ( - {repositoryOptions.find(opt => opt.value === selectedRepo)?.label || - t('Select repository')} + {reposLoading + ? t('Loading...') + : repositoryOptions.find(opt => opt.value === selectedRepo)?.label || + t('Select repository')} ), }} + menuFooter={ + hasNextPage || isFetchingNextPage ? ( +
+ {isFetchingNextPage ? t('Loading...') : '\u00A0'} +
+ ) : null + } />
From e784be0e2d44b04426d122ffba6ab257a349ca78 Mon Sep 17 00:00:00 2001 From: Suejung Shin Date: Tue, 28 Oct 2025 20:17:34 -0700 Subject: [PATCH 2/7] wip --- .../hooks/useInfiniteRepositories.tsx | 142 ------------ .../usePreventAIInfiniteRepositories.spec.tsx | 0 .../usePreventAIInfiniteRepositories.tsx | 79 +++++++ .../preventAI/hooks/usePreventAIOrgs.spec.tsx | 96 ++++++++ .../preventAI/hooks/usePreventAIOrgs.tsx | 22 ++ static/app/views/prevent/preventAI/index.tsx | 27 +-- .../views/prevent/preventAI/manageRepos.tsx | 208 +++--------------- .../prevent/preventAI/manageReposToolbar.tsx | 164 ++++---------- .../app/views/prevent/preventAI/utils.test.ts | 27 +++ static/app/views/prevent/preventAI/utils.ts | 3 + 10 files changed, 313 insertions(+), 455 deletions(-) delete mode 100644 static/app/views/prevent/preventAI/hooks/useInfiniteRepositories.tsx create mode 100644 static/app/views/prevent/preventAI/hooks/usePreventAIInfiniteRepositories.spec.tsx create mode 100644 static/app/views/prevent/preventAI/hooks/usePreventAIInfiniteRepositories.tsx create mode 100644 static/app/views/prevent/preventAI/hooks/usePreventAIOrgs.spec.tsx create mode 100644 static/app/views/prevent/preventAI/hooks/usePreventAIOrgs.tsx create mode 100644 static/app/views/prevent/preventAI/utils.test.ts create mode 100644 static/app/views/prevent/preventAI/utils.ts diff --git a/static/app/views/prevent/preventAI/hooks/useInfiniteRepositories.tsx b/static/app/views/prevent/preventAI/hooks/useInfiniteRepositories.tsx deleted file mode 100644 index 9fbf7223625b25..00000000000000 --- a/static/app/views/prevent/preventAI/hooks/useInfiniteRepositories.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import {useMemo} from 'react'; - -import type {ApiResult} from 'sentry/api'; -import type {Repository} from 'sentry/types/integrations'; -import { - fetchDataQuery, - useInfiniteQuery, - type InfiniteData, - type QueryKeyEndpointOptions, -} from 'sentry/utils/queryClient'; -import useOrganization from 'sentry/utils/useOrganization'; - -type QueryKey = [url: string, endpointOptions: QueryKeyEndpointOptions]; - -type Props = { - integrationId: string; - term?: string; -}; - -export function useInfiniteRepositories({integrationId, term}: Props) { - const organization = useOrganization(); - - const {data, ...rest} = useInfiniteQuery< - ApiResult, - Error, - InfiniteData>, - QueryKey - >({ - queryKey: [ - `/organizations/${organization.slug}/repos/`, - { - query: { - integration_id: integrationId || undefined, - status: 'active', - query: term || undefined, - }, - }, - ], - queryFn: async ({ - queryKey: [url, {query}], - pageParam, - client, - signal, - meta, - }): Promise> => { - // eslint-disable-next-line no-console - console.log('Fetching page with cursor:', pageParam); - const result = await fetchDataQuery({ - queryKey: [ - url, - { - query: { - ...query, - cursor: pageParam ?? undefined, - }, - }, - ], - client, - signal, - meta, - }); - - // eslint-disable-next-line no-console - console.log('Fetched page, result length:', (result as any)[0]?.length); - return result as ApiResult; - }, - getNextPageParam: _lastPage => { - // The /repos/ endpoint uses Link header pagination - const [, , responseMeta] = _lastPage; - const linkHeader = responseMeta?.getResponseHeader('Link'); - // eslint-disable-next-line no-console - console.log('getNextPageParam - Link header:', linkHeader); - if (!linkHeader) { - return undefined; - } - - // Parse Link header for next page cursor and check if results="true" - const nextMatch = linkHeader.match( - /<[^>]*[?&]cursor=([^&>]+)[^>]*>;\s*rel="next";\s*results="([^"]+)"/ - ); - if (!nextMatch) { - return undefined; - } - - const nextCursor = nextMatch[1]; - const hasResults = nextMatch[2] === 'true'; - - // eslint-disable-next-line no-console - console.log( - 'getNextPageParam - next cursor:', - nextCursor, - 'hasResults:', - hasResults - ); - - // Only return cursor if there are actually results - return hasResults ? nextCursor : undefined; - }, - getPreviousPageParam: _lastPage => { - // The /repos/ endpoint uses Link header pagination - const [, , responseMeta] = _lastPage; - const linkHeader = responseMeta?.getResponseHeader('Link'); - if (!linkHeader) { - return undefined; - } - - // Parse Link header for previous page cursor and check if results="true" - const prevMatch = linkHeader.match( - /<[^>]*[?&]cursor=([^&>]+)[^>]*>;\s*rel="previous";\s*results="([^"]+)"/ - ); - if (!prevMatch) { - return undefined; - } - - const prevCursor = prevMatch[1]; - const hasResults = prevMatch[2] === 'true'; - - // Only return cursor if there are actually results - return hasResults ? prevCursor : undefined; - }, - initialPageParam: undefined, - enabled: Boolean(integrationId), - staleTime: 0, - }); - - const memoizedData = useMemo(() => { - const flattened = data?.pages?.flatMap(([pageData]) => pageData) ?? []; - // eslint-disable-next-line no-console - console.log( - 'memoizedData - pages:', - data?.pages?.length, - 'total repos:', - flattened.length - ); - return flattened; - }, [data]); - - return { - data: memoizedData, - ...rest, - }; -} 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..e69de29bb2d1d6 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..e5d8d7cb08c604 --- /dev/null +++ b/static/app/views/prevent/preventAI/hooks/usePreventAIInfiniteRepositories.tsx @@ -0,0 +1,79 @@ +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]; + +export type UseInfiniteRepositoriesOptions = { + integrationId: string; + searchTerm?: string; +}; + +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> => { + return fetchDataQuery({ + queryKey: [ + url, + { + query: { + ...query, + cursor: pageParam ?? undefined, + }, + }, + ], + client, + signal, + meta, + }); + }, + 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/usePreventAIOrgs.spec.tsx b/static/app/views/prevent/preventAI/hooks/usePreventAIOrgs.spec.tsx new file mode 100644 index 00000000000000..260d562e2d7252 --- /dev/null +++ b/static/app/views/prevent/preventAI/hooks/usePreventAIOrgs.spec.tsx @@ -0,0 +1,96 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {PreventAIConfigFixture} from 'sentry-fixture/prevent'; + +import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {usePreventAIOrgRepos, type PreventAIOrgReposResponse} from './usePreventAIOrgs'; + +describe('usePreventAIOrgRepos', () => { + const mockOrg = OrganizationFixture({ + preventAiConfigGithub: PreventAIConfigFixture(), + }); + + const mockResponse: PreventAIOrgReposResponse = { + integratedOrgs: [ + { + 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'}], + }, + ], + }; + + beforeEach(() => { + MockApiClient.clearMockResponses(); + }); + + it('returns data on success', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${mockOrg.slug}/prevent/github/repos/`, + body: mockResponse, + }); + + const {result} = renderHookWithProviders(() => usePreventAIOrgRepos(), { + organization: mockOrg, + }); + + await waitFor(() => expect(result.current.data).toEqual(mockResponse)); + expect(result.current.isError).toBe(false); + expect(result.current.isPending).toBe(false); + }); + + it('returns error on failure', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${mockOrg.slug}/prevent/github/repos/`, + statusCode: 500, + body: {error: 'Internal Server Error'}, + }); + + const {result} = renderHookWithProviders(() => usePreventAIOrgRepos(), { + organization: mockOrg, + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); + + it('refetches data', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${mockOrg.slug}/prevent/github/repos/`, + body: mockResponse, + }); + + const {result} = renderHookWithProviders(() => usePreventAIOrgRepos(), { + organization: mockOrg, + }); + + await waitFor(() => expect(result.current.data).toEqual(mockResponse)); + + const newResponse: PreventAIOrgReposResponse = { + integratedOrgs: [ + { + githubOrganizationId: '3', + name: 'repo3', + provider: 'github', + repos: [{id: '3', name: 'repo3', fullName: 'org-3/repo3'}], + }, + ], + }; + MockApiClient.addMockResponse({ + url: `/organizations/${mockOrg.slug}/prevent/github/repos/`, + body: newResponse, + }); + + result.current.refetch(); + await waitFor(() => + expect(result.current.data?.integratedOrgs?.[0]?.name).toBe('repo3') + ); + expect(result.current.data).toEqual(newResponse); + }); +}); diff --git a/static/app/views/prevent/preventAI/hooks/usePreventAIOrgs.tsx b/static/app/views/prevent/preventAI/hooks/usePreventAIOrgs.tsx new file mode 100644 index 00000000000000..74826690ef4c3e --- /dev/null +++ b/static/app/views/prevent/preventAI/hooks/usePreventAIOrgs.tsx @@ -0,0 +1,22 @@ +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 function usePreventAIOrgRepos(): UseApiQueryResult< + OrganizationIntegration[], + RequestError +> { + const organization = useOrganization(); + + return useApiQuery( + [ + `/organizations/${organization.slug}/integrations/`, + {query: {includeConfig: 0, provider_key: 'github'}}, + ], + { + staleTime: 0, + retry: false, + } + ); +} diff --git a/static/app/views/prevent/preventAI/index.tsx b/static/app/views/prevent/preventAI/index.tsx index 106d77f8703ed3..68cb38abb41bfb 100644 --- a/static/app/views/prevent/preventAI/index.tsx +++ b/static/app/views/prevent/preventAI/index.tsx @@ -2,29 +2,14 @@ import Feature from 'sentry/components/acl/feature'; import LoadingError from 'sentry/components/loadingError'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import {t} from 'sentry/locale'; -import type {OrganizationIntegration} from 'sentry/types/integrations'; -import {useApiQuery} from 'sentry/utils/queryClient'; -import useOrganization from 'sentry/utils/useOrganization'; import PreventAIManageRepos from 'sentry/views/prevent/preventAI/manageRepos'; import PreventAIOnboarding from 'sentry/views/prevent/preventAI/onboarding'; -function PreventAIContent() { - const organization = useOrganization(); +import {usePreventAIOrgRepos} from './hooks/usePreventAIOrgs'; - // Check if there are any GitHub integrations installed - const { - data: githubIntegrations = [], - isPending, - isError, - } = useApiQuery( - [ - `/organizations/${organization.slug}/integrations/`, - {query: {includeConfig: 0, provider_key: 'github'}}, - ], - { - staleTime: 0, - } - ); +function PreventAIContent() { + const {data, isPending, isError} = usePreventAIOrgRepos(); + const integratedOrgs = data ?? []; if (isPending) { return ; @@ -37,8 +22,8 @@ function PreventAIContent() { /> ); } - if (githubIntegrations.length > 0) { - return ; + if (integratedOrgs.length > 0) { + return ; } return ; } diff --git a/static/app/views/prevent/preventAI/manageRepos.tsx b/static/app/views/prevent/preventAI/manageRepos.tsx index e3d9ab1701582f..49f3915f4a8196 100644 --- a/static/app/views/prevent/preventAI/manageRepos.tsx +++ b/static/app/views/prevent/preventAI/manageRepos.tsx @@ -1,7 +1,7 @@ -import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {useSearchParams} from 'react-router-dom'; +import {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'; @@ -13,160 +13,47 @@ 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 {OrganizationIntegration} from 'sentry/types/integrations'; -import type {PreventAIOrg} from 'sentry/types/prevent'; -import {useApiQuery} from 'sentry/utils/queryClient'; -import useOrganization from 'sentry/utils/useOrganization'; -import {useInfiniteRepositories} from 'sentry/views/prevent/preventAI/hooks/useInfiniteRepositories'; +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 {getRepoNameWithoutOrg} from 'sentry/views/prevent/preventAI/utils'; import {FeatureOverview} from './onboarding'; -function ManageReposPage(_props: {installedOrgs: PreventAIOrg[]}) { +function ManageReposPage({integratedOrgs}: {integratedOrgs: OrganizationIntegration[]}) { const theme = useTheme(); - const organization = useOrganization(); const [isPanelOpen, setIsPanelOpen] = useState(false); - const [searchParams, setSearchParams] = useSearchParams(); + const [selectedOrgId, setSelectedOrgId] = useState(integratedOrgs[0]?.id || ''); + const [selectedRepoId, setSelectedRepoId] = useState(ALL_REPOS_VALUE); - // Track if we've initialized from URL params to avoid overriding user selections - const hasInitializedFromUrlRef = useRef({org: false, repo: false}); - - // selectedOrgId now stores the Sentry integration ID (not GitHub org ID) - const [selectedOrgId, setSelectedOrgId] = useState(''); - const [selectedRepoId, setSelectedRepoId] = useState(() => ALL_REPOS_VALUE); - - // Fetch GitHub integrations to get organization names - const {data: githubIntegrations = []} = useApiQuery( - [ - `/organizations/${organization.slug}/integrations/`, - {query: {includeConfig: 0, provider_key: 'github'}}, - ], - { - staleTime: 0, - } - ); - - // Fetch repositories for the selected organization using infinite scroll - const {data: reposData = []} = useInfiniteRepositories({ + const queryResult = useInfiniteRepositories({ integrationId: selectedOrgId, - term: undefined, // No search term for the panel + searchTerm: undefined, }); - // Find the selected org and repo data - const selectedOrgData = useMemo( - () => githubIntegrations.find(integration => integration.id === selectedOrgId), - [githubIntegrations, selectedOrgId] - ); - - const selectedRepoData = useMemo( - () => reposData.find(repo => repo.id === selectedRepoId), - [reposData, selectedRepoId] - ); - - // Initialize from URL params when data is loaded, or auto-select first org - useEffect(() => { - const org = searchParams.get('org'); - const repo = searchParams.get('repo'); - - // Find org by name if specified in URL - if ( - org && - githubIntegrations.length > 0 && - !selectedOrgId && - !hasInitializedFromUrlRef.current.org - ) { - const matchedOrg = githubIntegrations.find( - integration => integration.name.toLowerCase() === org.toLowerCase() - ); - if (matchedOrg) { - setSelectedOrgId(matchedOrg.id); - hasInitializedFromUrlRef.current.org = true; - } - } - // Auto-select first org if no URL param and not yet initialized - else if ( - !org && - githubIntegrations.length > 0 && - !selectedOrgId && - !hasInitializedFromUrlRef.current.org && - githubIntegrations[0] - ) { - setSelectedOrgId(githubIntegrations[0].id); - hasInitializedFromUrlRef.current.org = true; - } - - // Find repo by name if specified in URL (only after org is selected and repos are loaded) - if ( - repo && - selectedOrgId && - reposData.length > 0 && - !hasInitializedFromUrlRef.current.repo - ) { - const matchedRepo = reposData.find(r => { - const repoNameWithoutOrg = r.name.includes('/') - ? r.name.split('/').pop() || r.name - : r.name; - return repoNameWithoutOrg.toLowerCase() === repo.toLowerCase(); - }); - if (matchedRepo) { - setSelectedRepoId(matchedRepo.id); - hasInitializedFromUrlRef.current.repo = true; - } - } - }, [searchParams, githubIntegrations, reposData, selectedOrgId]); - - // When the org changes, reset to "All Repos" to avoid stale repo selection - const setSelectedOrgIdWithCascadeRepoId = useCallback( - (integrationId: string) => { - setSelectedOrgId(integrationId); - // Reset to All Repos when changing organizations - setSelectedRepoId(ALL_REPOS_VALUE); - // Reset initialization flags when user manually changes org - hasInitializedFromUrlRef.current = {org: true, repo: false}; - - // Update URL params with org name - const selectedOrg = githubIntegrations.find( - integration => integration.id === integrationId - ); - if (selectedOrg) { - const newParams = new URLSearchParams(searchParams); - newParams.set('org', selectedOrg.name); - newParams.delete('repo'); // Clear repo when changing org - setSearchParams(newParams); - } - }, - [githubIntegrations, searchParams, setSearchParams] + const reposData = useMemo( + () => uniqBy(queryResult.data?.pages.flatMap(result => result[0]) ?? [], 'id'), + [queryResult.data?.pages] ); - // Update URL params when repo changes - const setSelectedRepoIdWithUrlUpdate = useCallback( - (repoId: string) => { - setSelectedRepoId(repoId); - // Mark repo as initialized when user manually changes it - hasInitializedFromUrlRef.current.repo = true; + const selectedOrgData = integratedOrgs.find(org => org.id === selectedOrgId); + const selectedRepoData = reposData.find(repo => repo.id === selectedRepoId); - const newParams = new URLSearchParams(searchParams); - if (repoId === ALL_REPOS_VALUE) { - newParams.delete('repo'); - } else { - const selectedRepo = reposData.find(r => r.id === repoId); - if (selectedRepo) { - const repoNameWithoutOrg = selectedRepo.name.includes('/') - ? selectedRepo.name.split('/').pop() || selectedRepo.name - : selectedRepo.name; - newParams.set('repo', repoNameWithoutOrg); - } - } - setSearchParams(newParams); - }, - [reposData, searchParams, setSearchParams] - ); + function handleOrgChange(integrationId: string) { + setSelectedOrgId(integrationId); + setSelectedRepoId(ALL_REPOS_VALUE); + } - const isOrgSelected = !!selectedOrgId; - const isRepoSelected = !!selectedRepoId; + function formatRepoForPanel(repo: Repository) { + return { + id: repo.id, + name: getRepoNameWithoutOrg(repo.name), + fullName: repo.name, + }; + } return ( @@ -174,13 +61,13 @@ function ManageReposPage(_props: {installedOrgs: PreventAIOrg[]}) {