diff --git a/apps/web/src/components/auto-fix/AutoFixConfigForm.tsx b/apps/web/src/components/auto-fix/AutoFixConfigForm.tsx index 9df8a14ac4..ea854a02cd 100644 --- a/apps/web/src/components/auto-fix/AutoFixConfigForm.tsx +++ b/apps/web/src/components/auto-fix/AutoFixConfigForm.tsx @@ -356,6 +356,7 @@ export function AutoFixConfigForm({ organizationId }: AutoFixConfigFormProps) { name: repo.name, full_name: repo.fullName, private: repo.private, + fork: repo.fork, })) as Repository[] } selectedIds={selectedRepositoryIds} diff --git a/apps/web/src/components/auto-triage/AutoTriageConfigForm.tsx b/apps/web/src/components/auto-triage/AutoTriageConfigForm.tsx index c6f93f5b6d..d93c7f81d7 100644 --- a/apps/web/src/components/auto-triage/AutoTriageConfigForm.tsx +++ b/apps/web/src/components/auto-triage/AutoTriageConfigForm.tsx @@ -317,6 +317,7 @@ export function AutoTriageConfigForm({ organizationId }: AutoTriageConfigFormPro name: repo.name, full_name: repo.fullName, private: repo.private, + fork: repo.fork, })) as Repository[] } selectedIds={selectedRepositoryIds} diff --git a/apps/web/src/components/code-reviews/RepositoryMultiSelect.tsx b/apps/web/src/components/code-reviews/RepositoryMultiSelect.tsx index da28c794ac..b5a7043e63 100644 --- a/apps/web/src/components/code-reviews/RepositoryMultiSelect.tsx +++ b/apps/web/src/components/code-reviews/RepositoryMultiSelect.tsx @@ -6,6 +6,12 @@ import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Lock, Unlock, Search } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { useLocalStorage } from '@/hooks/useLocalStorage'; +import { + visibleRepositories, + withVisibleSelected, + withoutVisibleSelected, +} from './repository-multi-select-selection'; export type RepositoryId = string | number; @@ -14,6 +20,7 @@ export type Repository = { name: string; full_name: string; private: boolean; + fork?: boolean; }; export type RepositoryMultiSelectProps = { @@ -30,13 +37,22 @@ export function RepositoryMultiSelect({ renderRepositoryAccessory, }: RepositoryMultiSelectProps) { const [searchQuery, setSearchQuery] = useState(''); + const [hideForks, setHideForks] = useLocalStorage('repo-picker:hide-forks', false, { + initializeWithValue: false, + }); + + const forkCount = useMemo(() => repositories.filter(repo => repo.fork).length, [repositories]); + const visible = useMemo( + () => visibleRepositories(repositories, hideForks), + [repositories, hideForks] + ); const filteredRepositories = useMemo(() => { - if (!searchQuery.trim()) return repositories; + if (!searchQuery.trim()) return visible; const query = searchQuery.toLowerCase(); - return repositories.filter(repo => repo.full_name.toLowerCase().includes(query)); - }, [repositories, searchQuery]); + return visible.filter(repo => repo.full_name.toLowerCase().includes(query)); + }, [visible, searchQuery]); const handleToggle = (repoId: TId) => { const newSelection = selectedIds.includes(repoId) @@ -47,15 +63,26 @@ export function RepositoryMultiSelect({ }; const handleSelectAll = () => { - onSelectionChange(repositories.map(repo => repo.id)); + onSelectionChange( + withVisibleSelected( + selectedIds, + visible.map(repo => repo.id) + ) + ); }; const handleDeselectAll = () => { - onSelectionChange([]); + onSelectionChange( + withoutVisibleSelected( + selectedIds, + visible.map(repo => repo.id) + ) + ); }; - const isAllSelected = selectedIds.length === repositories.length && repositories.length > 0; - const isNoneSelected = selectedIds.length === 0; + const visibleSelectedCount = visible.filter(repo => selectedIds.includes(repo.id)).length; + const isAllSelected = visible.length > 0 && visibleSelectedCount === visible.length; + const isNoneSelected = visibleSelectedCount === 0; return (
@@ -91,6 +118,18 @@ export function RepositoryMultiSelect({ > Deselect All + {forkCount > 0 && ( +
+ setHideForks(checked === true)} + /> + +
+ )}
@@ -136,7 +175,10 @@ export function RepositoryMultiSelect({
- {selectedIds.length} of {repositories.length} repositories selected + {visibleSelectedCount} of {visible.length} repositories selected + {hideForks && forkCount > 0 + ? ` · ${forkCount} ${forkCount === 1 ? 'fork' : 'forks'} hidden` + : ''}
); diff --git a/apps/web/src/components/code-reviews/repository-multi-select-selection.test.ts b/apps/web/src/components/code-reviews/repository-multi-select-selection.test.ts new file mode 100644 index 0000000000..c422a503cd --- /dev/null +++ b/apps/web/src/components/code-reviews/repository-multi-select-selection.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from '@jest/globals'; +import { + visibleRepositories, + withVisibleSelected, + withoutVisibleSelected, +} from './repository-multi-select-selection'; + +// `fork: undefined` is a repo cached before the flag existed — it must stay listed. +const repositories = [ + { id: 1, fork: false }, + { id: 2, fork: true }, + { id: 3, fork: undefined }, +]; + +describe('visibleRepositories', () => { + it('lists every repository when forks are not hidden', () => { + expect(visibleRepositories(repositories, false).map(repo => repo.id)).toEqual([1, 2, 3]); + }); + + it('drops only forks when forks are hidden', () => { + expect(visibleRepositories(repositories, true).map(repo => repo.id)).toEqual([1, 3]); + }); +}); + +describe('withVisibleSelected', () => { + it('adds the visible repositories without duplicating a selected one', () => { + expect(withVisibleSelected([1, 5], [1, 3])).toEqual([1, 5, 3]); + }); +}); + +describe('withoutVisibleSelected', () => { + it('clears the visible repositories and keeps a hidden selection', () => { + expect(withoutVisibleSelected([1, 2, 3], [1, 3])).toEqual([2]); + }); +}); diff --git a/apps/web/src/components/code-reviews/repository-multi-select-selection.ts b/apps/web/src/components/code-reviews/repository-multi-select-selection.ts new file mode 100644 index 0000000000..e3e01ff8c8 --- /dev/null +++ b/apps/web/src/components/code-reviews/repository-multi-select-selection.ts @@ -0,0 +1,24 @@ +/** The repositories the picker lists. Forks drop out only when the user hides them. */ +export function visibleRepositories( + repositories: readonly T[], + hideForks: boolean +): T[] { + return hideForks ? repositories.filter(repository => !repository.fork) : [...repositories]; +} + +/** Select-all adds the listed repositories and keeps selections the fork filter hides. */ +export function withVisibleSelected( + selectedIds: readonly TId[], + visibleIds: readonly TId[] +): TId[] { + return [...new Set([...selectedIds, ...visibleIds])]; +} + +/** Deselect-all clears the listed repositories and keeps selections the fork filter hides. */ +export function withoutVisibleSelected( + selectedIds: readonly TId[], + visibleIds: readonly TId[] +): TId[] { + const visible = new Set(visibleIds); + return selectedIds.filter(id => !visible.has(id)); +} diff --git a/apps/web/src/components/security-agent/security-config-types.ts b/apps/web/src/components/security-agent/security-config-types.ts index 9e7c359530..15b31b2394 100644 --- a/apps/web/src/components/security-agent/security-config-types.ts +++ b/apps/web/src/components/security-agent/security-config-types.ts @@ -25,6 +25,7 @@ export type SecurityRepository = { fullName: string; name: string; private: boolean; + fork?: boolean; dependabotAlerts: DependabotAlertsAvailability; }; @@ -166,5 +167,6 @@ export function toRepositoryOptions(repositories: SecurityRepository[]): Reposit name: repository.name, full_name: repository.fullName, private: repository.private, + fork: repository.fork, })); } diff --git a/apps/web/src/lib/auto-triage/application/routers/shared-router-factory.ts b/apps/web/src/lib/auto-triage/application/routers/shared-router-factory.ts index f57ae52b6e..6b168c1280 100644 --- a/apps/web/src/lib/auto-triage/application/routers/shared-router-factory.ts +++ b/apps/web/src/lib/auto-triage/application/routers/shared-router-factory.ts @@ -45,6 +45,7 @@ type GitHubRepositoriesResult = { name: string; fullName: string; private: boolean; + fork?: boolean; }[]; errorMessage?: string; }; diff --git a/apps/web/src/lib/cloud-agent/github-integration-helpers.test.ts b/apps/web/src/lib/cloud-agent/github-integration-helpers.test.ts index ae735ca83f..e859116cc2 100644 --- a/apps/web/src/lib/cloud-agent/github-integration-helpers.test.ts +++ b/apps/web/src/lib/cloud-agent/github-integration-helpers.test.ts @@ -47,7 +47,9 @@ jest.mock('@/components/cloud-agent/demo-config', () => ({ DEMO_SOURCE_REPO_NAME: 'demo-repo', })); -const cachedRepositories = [{ id: 1, name: 'repo', full_name: 'org/repo', private: false }]; +const cachedRepositories = [ + { id: 1, name: 'repo', full_name: 'org/repo', private: false, fork: false }, +]; const buildIntegration = (overrides: Partial = {}): PlatformIntegration => ({ @@ -78,7 +80,7 @@ describe('github-integration-helpers', () => { expect(result.integrationInstalled).toBe(true); expect(result.repositories).toEqual([ - { id: 1, name: 'repo', fullName: 'org/repo', private: false }, + { id: 1, name: 'repo', fullName: 'org/repo', private: false, fork: false }, ]); expect(mockFetchGitHubRepositories).not.toHaveBeenCalled(); }); @@ -137,6 +139,28 @@ describe('github-integration-helpers', () => { expect(mockUpdateRepositoriesForIntegration).not.toHaveBeenCalled(); }); + it('refetches when the cached repositories predate the fork flag', async () => { + mockGetIntegrationForOwner.mockResolvedValue( + buildIntegration({ + repositories: [{ id: 1, name: 'repo', full_name: 'org/repo', private: false }], + }) + ); + mockFetchGitHubRepositories.mockResolvedValue([ + { id: 1, name: 'repo', full_name: 'org/repo', private: false, fork: true }, + ]); + + const { fetchGitHubRepositoriesForUser } = await import('./github-integration-helpers'); + const result = await fetchGitHubRepositoriesForUser('user-123'); + + expect(mockFetchGitHubRepositories).toHaveBeenCalled(); + expect(mockUpdateRepositoriesForIntegration).toHaveBeenCalledWith('integration-1', [ + { id: 1, name: 'repo', full_name: 'org/repo', private: false, fork: true }, + ]); + expect(result.repositories).toEqual([ + { id: 1, name: 'repo', fullName: 'org/repo', private: false, fork: true }, + ]); + }); + it('fetches fresh repositories when forceRefresh is true', async () => { mockGetIntegrationForOwner.mockResolvedValue(buildIntegration()); mockFetchGitHubRepositories.mockResolvedValue([ @@ -171,6 +195,7 @@ describe('github-integration-helpers', () => { name: 'repo', fullName: 'org/repo', private: false, + fork: false, platformIntegrationId: 'integration-1', }, ]); @@ -182,14 +207,22 @@ describe('github-integration-helpers', () => { buildIntegration({ id: 'integration-1', platform_account_login: 'acme-core', - repositories: [{ id: 1, name: 'api', full_name: 'acme-core/api', private: true }], + repositories: [ + { id: 1, name: 'api', full_name: 'acme-core/api', private: true, fork: false }, + ], }), buildIntegration({ id: 'integration-2', platform_installation_id: 'installation-2', platform_account_login: 'acme-security', repositories: [ - { id: 2, name: 'scanner', full_name: 'acme-security/scanner', private: true }, + { + id: 2, + name: 'scanner', + full_name: 'acme-security/scanner', + private: true, + fork: false, + }, ], }), ]); @@ -216,7 +249,9 @@ describe('github-integration-helpers', () => { mockGetIntegrationsByOrganization.mockResolvedValue([ buildIntegration({ id: 'integration-1', - repositories: [{ id: 1, name: 'api', full_name: 'acme-core/api', private: true }], + repositories: [ + { id: 1, name: 'api', full_name: 'acme-core/api', private: true, fork: false }, + ], }), buildIntegration({ id: 'integration-2', diff --git a/apps/web/src/lib/cloud-agent/github-integration-helpers.ts b/apps/web/src/lib/cloud-agent/github-integration-helpers.ts index 91329a3f82..f38cb6797e 100644 --- a/apps/web/src/lib/cloud-agent/github-integration-helpers.ts +++ b/apps/web/src/lib/cloud-agent/github-integration-helpers.ts @@ -18,6 +18,7 @@ import { isPlatformIntegrationSuspended, } from '@/lib/integrations/core/health'; import { + needsForkFlagBackfill, requireNumericPlatformRepositories, type PlatformRepository, } from '@/lib/integrations/core/types'; @@ -29,6 +30,7 @@ type GitHubRepositoriesResult = { name: string; fullName: string; private: boolean; + fork?: boolean; platformIntegrationId?: string; platformAccountLogin?: string; }[]; @@ -45,6 +47,7 @@ const mapRepositories = ( name: repo.name, fullName: repo.full_name, private: repo.private, + fork: repo.fork, ...(integration ? { platformIntegrationId: integration.id, @@ -162,7 +165,7 @@ export async function fetchGitHubRepositoriesForOrganization( try { const cachedRepositories = requireNumericPlatformRepositories(integration.repositories); - if (forceRefresh || !cachedRepositories?.length) { + if (forceRefresh || !cachedRepositories?.length || needsForkFlagBackfill(cachedRepositories)) { const repositories = await fetchGitHubRepositories( integration.platform_installation_id, integration.github_app_type || 'standard' @@ -210,7 +213,11 @@ async function fetchRepositoriesForIntegrations( integrations.map(async integration => { if (!integration.platform_installation_id) return { repositories: [], syncedAt: null }; const cachedRepositories = requireNumericPlatformRepositories(integration.repositories); - if (forceRefresh || !cachedRepositories?.length) { + if ( + forceRefresh || + !cachedRepositories?.length || + needsForkFlagBackfill(cachedRepositories) + ) { const repositories = await fetchGitHubRepositories( integration.platform_installation_id, integration.github_app_type || 'standard' @@ -271,7 +278,7 @@ export async function fetchGitHubRepositoriesForUser( try { const cachedRepositories = requireNumericPlatformRepositories(integration.repositories); // If forceRefresh or no cached repos, fetch from GitHub and update cache - if (forceRefresh || !cachedRepositories?.length) { + if (forceRefresh || !cachedRepositories?.length || needsForkFlagBackfill(cachedRepositories)) { const appType = integration.github_app_type || 'standard'; const repositories = await fetchGitHubRepositories( integration.platform_installation_id, diff --git a/apps/web/src/lib/code-reviews/core/selectable-repositories.ts b/apps/web/src/lib/code-reviews/core/selectable-repositories.ts index f437b7ae50..435d88ab9f 100644 --- a/apps/web/src/lib/code-reviews/core/selectable-repositories.ts +++ b/apps/web/src/lib/code-reviews/core/selectable-repositories.ts @@ -18,6 +18,7 @@ export type FetchedRepository = { name: string; fullName: string; private: boolean; + fork?: boolean; }; export type ManuallyAddedRepository = { @@ -32,6 +33,7 @@ export type SelectableRepository = { name: string; full_name: string; private: boolean; + fork?: boolean; }; /** The deduped repository list offered for review config / conversion (fetched + legacy manual). */ @@ -44,6 +46,7 @@ export function buildSelectableRepositories( name: repo.name, full_name: repo.fullName, private: repo.private, + fork: repo.fork, })); const seenIds = new Set(canonical.map(repo => repo.id)); const legacy = manuallyAdded.filter(repo => !seenIds.has(repo.id)); diff --git a/apps/web/src/lib/integrations/core/types.ts b/apps/web/src/lib/integrations/core/types.ts index bab97daf8e..ae675cb9ca 100644 --- a/apps/web/src/lib/integrations/core/types.ts +++ b/apps/web/src/lib/integrations/core/types.ts @@ -17,6 +17,11 @@ export function requireNumericPlatformRepositories( return repositories; } +/** True when a cached GitHub repository list predates the `fork` flag and must be refetched. */ +export function needsForkFlagBackfill(repositories: PlatformRepository[] | null): boolean { + return repositories?.some(repository => repository.fork === undefined) ?? false; +} + /** * Represents ownership of an integration * Can be either a user or an organization diff --git a/apps/web/src/lib/integrations/platforms/github/adapter.ts b/apps/web/src/lib/integrations/platforms/github/adapter.ts index b1d7d93f76..4e699ecde9 100644 --- a/apps/web/src/lib/integrations/platforms/github/adapter.ts +++ b/apps/web/src/lib/integrations/platforms/github/adapter.ts @@ -123,6 +123,7 @@ type GitHubRepository = { full_name: string; private: boolean; created_at: string; + fork: boolean; }; type GitHubBranch = { @@ -162,6 +163,7 @@ export async function fetchGitHubRepositories( full_name: repo.full_name, private: repo.private, created_at: repo.created_at ?? new Date().toISOString(), + fork: repo.fork, })) ); diff --git a/apps/web/src/lib/security-agent/router/shared-handlers.test.ts b/apps/web/src/lib/security-agent/router/shared-handlers.test.ts index 2882d13911..595bf69cb3 100644 --- a/apps/web/src/lib/security-agent/router/shared-handlers.test.ts +++ b/apps/web/src/lib/security-agent/router/shared-handlers.test.ts @@ -203,7 +203,7 @@ function createHandlers() { id: 'integration-123', integration_status: 'active', platform_installation_id: 'installation-123', - repositories: [{ id: 1, full_name: 'kilo/repo', name: 'repo', private: true }], + repositories: [{ id: 1, full_name: 'kilo/repo', name: 'repo', private: true, fork: false }], }) as never, trackingExtras: () => ({}), }); @@ -365,6 +365,7 @@ describe('getRepositories', () => { fullName: 'kilo/repo', name: 'repo', private: true, + fork: false, dependabotAlerts: 'disabled', }, ]); @@ -378,6 +379,7 @@ describe('getRepositories', () => { fullName: 'kilo/repo', name: 'repo', private: true, + fork: false, }, ] ); diff --git a/apps/web/src/lib/security-agent/router/shared-handlers.ts b/apps/web/src/lib/security-agent/router/shared-handlers.ts index 00a444ba46..c3655d7af5 100644 --- a/apps/web/src/lib/security-agent/router/shared-handlers.ts +++ b/apps/web/src/lib/security-agent/router/shared-handlers.ts @@ -10,7 +10,10 @@ import { updateRepositoriesForIntegration, } from '@/lib/integrations/db/platform-integrations'; import { fetchGitHubRepositories } from '@/lib/integrations/platforms/github/adapter'; -import { requireNumericPlatformRepositories } from '@/lib/integrations/core/types'; +import { + needsForkFlagBackfill, + requireNumericPlatformRepositories, +} from '@/lib/integrations/core/types'; import { getSecurityAgentConfigWithStatus, upsertSecurityAgentConfig, @@ -1304,7 +1307,10 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps // Auto-fetch repositories from GitHub if not cached let repos = requireNumericPlatformRepositories(integration.repositories) ?? []; - if (repos.length === 0 && integration.platform_installation_id) { + if ( + (repos.length === 0 || needsForkFlagBackfill(repos)) && + integration.platform_installation_id + ) { const appType = integration.github_app_type || 'standard'; const fetchedRepos = await fetchGitHubRepositories( integration.platform_installation_id, @@ -1319,6 +1325,7 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps fullName: repo.full_name, name: repo.name, private: repo.private, + fork: repo.fork, })); const installationId = integration.platform_installation_id; if (!installationId || !hasSecurityReviewPermissions(integration)) { diff --git a/packages/db/src/schema-types.ts b/packages/db/src/schema-types.ts index cc22107fde..6921bdd3ba 100644 --- a/packages/db/src/schema-types.ts +++ b/packages/db/src/schema-types.ts @@ -1265,6 +1265,7 @@ export type PlatformRepository = { full_name: string; private: boolean; default_branch?: string; + fork?: boolean; }; export const REVIEW_MEMORY_PLATFORMS = ['github'] as const;