diff --git a/apps/api/src/handlers/github/__tests__/handleInstallationRepositoriesChange.test.ts b/apps/api/src/handlers/github/__tests__/handleInstallationRepositoriesChange.test.ts new file mode 100644 index 000000000..5fa5fac32 --- /dev/null +++ b/apps/api/src/handlers/github/__tests__/handleInstallationRepositoriesChange.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +const { mockFindFirst, mockSyncGitHubInstallation } = vi.hoisted(() => ({ + mockFindFirst: vi.fn(), + mockSyncGitHubInstallation: vi.fn(), +})); + +vi.mock('@roomote/db/server', () => ({ + db: { + query: { + githubInstallations: { + findFirst: mockFindFirst, + }, + }, + }, + githubInstallations: { + installationId: 'installation_id', + }, + eq: (column: unknown, value: unknown) => ({ eq: [column, value] }), +})); + +vi.mock('@roomote/github', () => ({ + syncGitHubInstallation: mockSyncGitHubInstallation, +})); + +import { handleInstallationRepositoriesChange } from '../handleInstallationRepositoriesChange'; + +describe('handleInstallationRepositoriesChange', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFindFirst.mockResolvedValue({ installedByUserId: 'user-1' }); + mockSyncGitHubInstallation.mockResolvedValue({ + success: true, + githubInstallation: {}, + repositories: [{ id: 'repo-1' }, { id: 'repo-2' }], + }); + }); + + it('resyncs the installation attributed to the installing user', async () => { + const response = await handleInstallationRepositoriesChange({ + installation: { id: 42 }, + }); + + expect(mockSyncGitHubInstallation).toHaveBeenCalledWith({ + userId: 'user-1', + installationId: 42, + }); + expect(response.status).toBe('ok'); + expect(response.metadata).toEqual({ repositoryCount: 2 }); + }); + + it('short-circuits when the payload has no installation id', async () => { + const response = await handleInstallationRepositoriesChange({}); + + expect(response).toEqual({ status: 'ok', message: 'missing_installation' }); + expect(mockFindFirst).not.toHaveBeenCalled(); + expect(mockSyncGitHubInstallation).not.toHaveBeenCalled(); + }); + + it('short-circuits for installations this deployment has not synced', async () => { + mockFindFirst.mockResolvedValue(undefined); + + const response = await handleInstallationRepositoriesChange({ + installation: { id: 42 }, + }); + + expect(response).toEqual({ status: 'ok', message: 'unknown_installation' }); + expect(mockSyncGitHubInstallation).not.toHaveBeenCalled(); + }); + + it('reports an error when the resync fails', async () => { + mockSyncGitHubInstallation.mockResolvedValue({ + success: false, + error: 'boom', + }); + + const response = await handleInstallationRepositoriesChange({ + installation: { id: 42 }, + }); + + expect(response.status).toBe('error'); + expect(response.message).toContain('boom'); + }); +}); diff --git a/apps/api/src/handlers/github/__tests__/isFromKnownInstallation.test.ts b/apps/api/src/handlers/github/__tests__/isFromKnownInstallation.test.ts index b67111a9e..d4fe060ea 100644 --- a/apps/api/src/handlers/github/__tests__/isFromKnownInstallation.test.ts +++ b/apps/api/src/handlers/github/__tests__/isFromKnownInstallation.test.ts @@ -97,6 +97,34 @@ describe('isFromKnownInstallation', () => { ).resolves.toBe(false); }); + it('allows installation_repositories events from a known installation', async () => { + mockFindFirst.mockResolvedValue({ id: 'installation-row' }); + + const payload = JSON.stringify({ + action: 'added', + installation: { id: 456 }, + repositories_added: [{ id: 1, full_name: 'acme/new-repo' }], + }); + + await expect( + isFromKnownInstallation('installation_repositories', payload), + ).resolves.toBe(true); + }); + + it('rejects repository.created events from an unknown installation', async () => { + mockFindFirst.mockResolvedValue(undefined); + + const payload = JSON.stringify({ + action: 'created', + installation: { id: 789 }, + repository: { id: 1, full_name: 'acme/new-repo' }, + }); + + await expect(isFromKnownInstallation('repository', payload)).resolves.toBe( + false, + ); + }); + it('rejects non-created installation events from an unknown installation', async () => { mockFindFirst.mockResolvedValue(undefined); diff --git a/apps/api/src/handlers/github/handleInstallationRepositoriesChange.ts b/apps/api/src/handlers/github/handleInstallationRepositoriesChange.ts new file mode 100644 index 000000000..aa51b2ea3 --- /dev/null +++ b/apps/api/src/handlers/github/handleInstallationRepositoriesChange.ts @@ -0,0 +1,53 @@ +import { db, eq, githubInstallations } from '@roomote/db/server'; +import * as GitHub from '@roomote/github'; + +import type { WebhookResponse } from '../../types'; + +interface InstallationRepositoriesChangePayload { + installation?: { id?: number } | null; +} + +/** + * Resync an installation's repository list when its accessible repositories + * change on GitHub: `installation_repositories.added/removed` (selected-repos + * installs) and `repository.created/deleted/renamed` (all-repos installs emit + * these instead). A full resync is used rather than a narrow upsert because + * these payloads omit fields the `repositories` row needs (default branch, + * clone URL), and `syncRepositories` already handles upsert + deactivation. + */ +export async function handleInstallationRepositoriesChange( + payload: InstallationRepositoriesChangePayload, +): Promise { + const installationId = payload.installation?.id; + + if (typeof installationId !== 'number') { + return { status: 'ok', message: 'missing_installation' }; + } + + const installation = await db.query.githubInstallations.findFirst({ + where: eq(githubInstallations.installationId, installationId), + columns: { installedByUserId: true }, + }); + + if (!installation) { + return { status: 'ok', message: 'unknown_installation' }; + } + + const result = await GitHub.syncGitHubInstallation({ + userId: installation.installedByUserId, + installationId, + }); + + if (!result.success) { + return { + status: 'error', + message: `Failed to resync installation ${installationId}: ${result.error}`, + }; + } + + return { + status: 'ok', + message: `Resynced installation ${installationId}`, + metadata: { repositoryCount: result.repositories.length }, + }; +} diff --git a/apps/api/src/handlers/github/index.ts b/apps/api/src/handlers/github/index.ts index da069691b..838e4e10a 100644 --- a/apps/api/src/handlers/github/index.ts +++ b/apps/api/src/handlers/github/index.ts @@ -38,6 +38,7 @@ import { handleWorkflowRunCompleted } from './handleWorkflowRunCompleted'; // Repository metadata sync: import { handleRepositoryEdited } from './handleRepositoryEdited'; +import { handleInstallationRepositoriesChange } from './handleInstallationRepositoriesChange'; // Utilities: import { isFromKnownInstallation } from './isFromKnownInstallation'; @@ -462,6 +463,26 @@ github.post('/', async (c) => { ), ); + // Keep the stored repository list in sync as repos appear, disappear, or + // change access. Selected-repos installs emit `installation_repositories`; + // all-repos installs emit `repository.created/deleted` instead. Not gated + // on isRepoSkipped: the row must exist even for skipped repos. + webhooks.on( + ['installation_repositories.added', 'installation_repositories.removed'], + ({ id, name, payload }) => + recordWebhook(id, `${name}.${payload.action}`, payload, () => + handleInstallationRepositoriesChange(payload), + ), + ); + + webhooks.on( + ['repository.created', 'repository.deleted', 'repository.renamed'], + ({ id, name, payload }) => + recordWebhook(id, `${name}.${payload.action}`, payload, () => + handleInstallationRepositoriesChange(payload), + ), + ); + webhooks.on('workflow_run.completed', ({ id, name, payload }) => recordWebhook(id, `${name}.${payload.action}`, payload, async () => { if (isRepoSkipped(payload.repository.full_name)) { diff --git a/apps/docs/environments.mdx b/apps/docs/environments.mdx index e029c9e53..cffe40df0 100644 --- a/apps/docs/environments.mdx +++ b/apps/docs/environments.mdx @@ -41,6 +41,21 @@ The setup task is meant to produce a working environment Roomote can reuse. If it cannot finish, adjust the input and try again from **Settings > Environments**. +## Start from a brand-new repository + +You do not need an existing codebase to set up an environment. From +**Settings > Environments > New** (or the onboarding repo-selection step), +choose **Create a new repository** to open github.com with the right owner +pre-filled — either a brand-new repository or a fork of an existing one by +URL. Once you create it on GitHub, it appears in the repository list +automatically; if the GitHub App only has access to selected repositories, +grant it access to the new repository first. + +An empty repository is fine. When you start setup against a repository with +no commits, Roomote pushes a minimal initial commit (a README and a +.gitignore) to the default branch and creates a basic environment. Building +the actual project is then just your first task in that environment. + ## What to include Add enough context for Roomote to start productively: diff --git a/apps/docs/providers/source-control/github.mdx b/apps/docs/providers/source-control/github.mdx index 9fc69c78e..9de88e193 100644 --- a/apps/docs/providers/source-control/github.mdx +++ b/apps/docs/providers/source-control/github.mdx @@ -127,6 +127,12 @@ Subscribe to these events: GitHub Apps also receive `installation` and `installation_repositories` events automatically; you do not need to subscribe to them in the app form. +Roomote uses the **Repository** and `installation_repositories` events to +pick up newly created or newly granted repositories without a manual refresh. +If your app was created before the **Repository** event was part of the +manifest, confirm it is enabled under the app's **Permissions & events** page +— GitHub has no API to update an app's event subscriptions. + ## Save credentials Copy these values into the `/setup` manual form, deployment env vars, or your diff --git a/apps/web/src/app/(onboarding)/setup/StepRepoSelection.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepRepoSelection.client.test.tsx index 6e69d4ee1..05c72e051 100644 --- a/apps/web/src/app/(onboarding)/setup/StepRepoSelection.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepRepoSelection.client.test.tsx @@ -112,6 +112,31 @@ vi.mock('@/hooks/source-control', () => ({ useRepositories: mockUseRepositories, })); +vi.mock('@/components/github/CreateGitHubRepoDialog', () => ({ + CreateGitHubRepoButton: ({ + onRepositoryDetected, + }: { + onRepositoryDetected?: (repository: { + id: string; + fullName: string; + isEmpty?: boolean; + }) => void; + }) => ( + + ), +})); + vi.mock('@/hooks/task-models/useLaunchTaskModels', () => ({ useLaunchTaskModels: () => ({ data: { @@ -633,7 +658,7 @@ describe('StepRepoSelection', () => { expect(onReviewComputeProvider).toHaveBeenCalled(); }); - it('shows a warning and disables Continue only when all selected repositories are empty', async () => { + it('explains the bootstrap and keeps Continue enabled when all selected repositories are empty', async () => { mockRepositories.splice( 0, mockRepositories.length, @@ -661,9 +686,11 @@ describe('StepRepoSelection', () => { screen.getByText(/all selected repositories have no commits yet/i), ).toBeInTheDocument(); expect( - screen.getByText(/push an initial commit before continuing/i), + screen.getByText( + /will push an initial commit and set up a basic environment/i, + ), ).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Continue' })).toBeEnabled(); }); it('keeps Continue enabled and hides the warning for mixed empty and non-empty selections', async () => { @@ -692,11 +719,31 @@ describe('StepRepoSelection', () => { fireEvent.click(screen.getByLabelText(/acme\/empty/i)); expect( - screen.queryByText(/push an initial commit before continuing/i), + screen.queryByText( + /will push an initial commit and set up a basic environment/i, + ), ).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Continue' })).toBeEnabled(); }); + it('offers the create-repo affordance and selects the repository it detects', async () => { + mockRepositories.splice(0, mockRepositories.length, { + id: 'repo-created', + fullName: 'acme/created', + private: true, + defaultBranch: 'main', + isEmpty: true, + }); + + await renderStepRepoSelection(); + + fireEvent.click( + screen.getByRole('button', { name: 'Create a new repository' }), + ); + + expect(screen.getByLabelText('acme/created')).toBeChecked(); + }); + it('renders the empty-repository warning below the repository list', async () => { mockRepositories.splice( 0, diff --git a/apps/web/src/app/(onboarding)/setup/StepRepoSelection.tsx b/apps/web/src/app/(onboarding)/setup/StepRepoSelection.tsx index 7a38e4571..ff5f1caf1 100644 --- a/apps/web/src/app/(onboarding)/setup/StepRepoSelection.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepRepoSelection.tsx @@ -39,6 +39,10 @@ import { Info, X, } from '@/components/system'; +import { + CreateGitHubRepoButton, + type DetectedRepository, +} from '@/components/github/CreateGitHubRepoDialog'; import { EnvironmentRepositorySelector } from '@/components/settings/environments/EnvironmentRepositorySelector'; import { ModelSelect } from '@/components/tasks'; import { SetupFooter } from './SetupFooter'; @@ -205,6 +209,17 @@ export function StepRepoSelection({ ); }, []); + const selectDetectedRepository = useCallback( + (repository: DetectedRepository) => { + setSelectedRepositoryIds((currentSelection) => + currentSelection.includes(repository.id) + ? currentSelection + : [...currentSelection, repository.id], + ); + }, + [], + ); + const handleManageGitHubAccess = useCallback(() => { connectGitHub.mutate(`${pathname}?step=repo-selection`); }, [connectGitHub, pathname]); @@ -321,6 +336,11 @@ export function StepRepoSelection({ )} Edit GitHub Access + + diff --git a/apps/web/src/components/github/CreateGitHubRepoDialog.client.test.tsx b/apps/web/src/components/github/CreateGitHubRepoDialog.client.test.tsx new file mode 100644 index 000000000..898827912 --- /dev/null +++ b/apps/web/src/components/github/CreateGitHubRepoDialog.client.test.tsx @@ -0,0 +1,297 @@ +import type { + ButtonHTMLAttributes, + InputHTMLAttributes, + ReactNode, + SVGProps, +} from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; + +type RepositoryRow = { + id: string; + fullName: string; + sourceControlProvider: string; + isEmpty?: boolean; +}; + +const { + mockEnableGitHubApp, + mockSyncGitHubInstallations, + mockUserState, + mockInstallationsState, + mockRepositoriesState, + mockToast, +} = vi.hoisted(() => ({ + mockEnableGitHubApp: vi.fn(), + mockSyncGitHubInstallations: vi.fn(), + mockUserState: { isAdmin: true as boolean }, + mockInstallationsState: { + data: [] as Array<{ + id: string; + accountLogin: string; + accountType: string; + suspendedAt: string | null; + }>, + }, + mockRepositoriesState: { data: [] as RepositoryRow[] }, + mockToast: { error: vi.fn(), success: vi.fn() }, +})); + +vi.mock('next/navigation', () => ({ + usePathname: () => '/settings/environments/new', + useSearchParams: () => new URLSearchParams(''), +})); + +vi.mock('sonner', () => ({ toast: mockToast })); + +vi.mock('@/hooks/github', () => ({ + useEnableGitHubApp: () => ({ + mutate: mockEnableGitHubApp, + isPending: false, + }), + useGitHubInstallations: () => ({ data: mockInstallationsState.data }), + useSyncGitHubInstallations: () => ({ + mutate: mockSyncGitHubInstallations, + isPending: false, + }), +})); + +vi.mock('@/hooks/source-control', () => ({ + useRepositories: () => ({ data: mockRepositoriesState.data }), +})); + +vi.mock('@/hooks/useRealtimePolling', () => ({ + useRealtimePolling: () => ({ + refetchInterval: false, + refetchIntervalInBackground: false, + retry: 3, + isVisible: true, + }), +})); + +vi.mock('@/hooks/useUser', () => ({ + useAuthorizedUser: () => ({ isAdmin: mockUserState.isAdmin }), +})); + +vi.mock('@/components/system', () => { + const passthrough = (Tag: 'div' | 'p' | 'span' = 'div') => { + const Passthrough = ({ children, ...props }: { children?: ReactNode }) => ( + {children} + ); + Passthrough.displayName = `Passthrough(${Tag})`; + return Passthrough; + }; + + return { + Alert: passthrough(), + AlertDescription: passthrough(), + AlertTitle: passthrough(), + Button: ({ + children, + asChild: _asChild, + ...props + }: { + children: ReactNode; + asChild?: boolean; + } & ButtonHTMLAttributes) => + _asChild ? ( + <>{children} + ) : ( + + ), + CircleCheck: (props: SVGProps) => , + Dialog: ({ children, open }: { children: ReactNode; open: boolean }) => + open ?
{children}
: null, + DialogContent: passthrough(), + DialogDescription: passthrough('p'), + DialogFooter: passthrough(), + DialogHeader: passthrough(), + DialogTitle: passthrough('p'), + ExternalLink: (props: SVGProps) => , + Input: (props: InputHTMLAttributes) => ( + + ), + Label: passthrough('span'), + Loader2: (props: SVGProps) => , + Pencil: (props: SVGProps) => , + Plus: (props: SVGProps) => , + Select: passthrough(), + SelectContent: passthrough(), + SelectItem: passthrough(), + SelectTrigger: passthrough(), + SelectValue: passthrough('span'), + Tabs: passthrough(), + TabsContent: passthrough(), + TabsList: passthrough(), + TabsTrigger: ({ children }: { children: ReactNode }) => ( + + ), + }; +}); + +import { CreateGitHubRepoDialog } from './CreateGitHubRepoDialog'; + +function findLinkByText(text: RegExp): HTMLAnchorElement { + const link = screen + .getAllByRole('link') + .find((element) => text.test(element.textContent ?? '')); + + if (!link) { + throw new Error(`No link matching ${text}`); + } + + return link as HTMLAnchorElement; +} + +describe('CreateGitHubRepoDialog', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUserState.isAdmin = true; + mockInstallationsState.data = [ + { + id: 'install-1', + accountLogin: 'acme', + accountType: 'Organization', + suspendedAt: null, + }, + ]; + mockRepositoriesState.data = [ + { + id: 'repo-1', + fullName: 'acme/existing', + sourceControlProvider: 'github', + }, + ]; + }); + + it('prefills the github.com/new link with the installation owner', () => { + render( + undefined} />, + ); + + expect(findLinkByText(/Open GitHub$/).getAttribute('href')).toBe( + 'https://github.com/new?owner=acme', + ); + }); + + it('falls back to a bare github.com/new link without installations', () => { + mockInstallationsState.data = []; + + render( + undefined} />, + ); + + expect(findLinkByText(/Open GitHub$/).getAttribute('href')).toBe( + 'https://github.com/new', + ); + }); + + it('builds the fork link from a pasted repository URL', () => { + render( + undefined} />, + ); + + fireEvent.change(screen.getByLabelText('Repository to fork'), { + target: { value: 'https://github.com/RooCodeInc/Roomote' }, + }); + + expect(findLinkByText(/Open GitHub fork page/).getAttribute('href')).toBe( + 'https://github.com/RooCodeInc/Roomote/fork', + ); + }); + + it('shows a validation hint for unparseable fork references', () => { + render( + undefined} />, + ); + + fireEvent.change(screen.getByLabelText('Repository to fork'), { + target: { value: 'https://gitlab.com/acme/repo' }, + }); + + expect( + screen.getByText('Enter a GitHub repository URL or owner/repo.'), + ).toBeInTheDocument(); + }); + + it('surfaces repositories that appear after the dialog opened', () => { + const onRepositoryDetected = vi.fn(); + const { rerender } = render( + undefined} + onRepositoryDetected={onRepositoryDetected} + />, + ); + + expect(screen.queryByText(/Found /)).not.toBeInTheDocument(); + + mockRepositoriesState.data = [ + ...mockRepositoriesState.data, + { + id: 'repo-2', + fullName: 'acme/new-repo', + sourceControlProvider: 'github', + isEmpty: true, + }, + ]; + + rerender( + undefined} + onRepositoryDetected={onRepositoryDetected} + />, + ); + + expect(screen.getByText('Found acme/new-repo')).toBeInTheDocument(); + + fireEvent.click( + screen.getByRole('button', { name: 'Use this repository' }), + ); + + expect(onRepositoryDetected).toHaveBeenCalledWith({ + id: 'repo-2', + fullName: 'acme/new-repo', + isEmpty: true, + }); + }); + + it('triggers a manual GitHub sync from the refresh link', () => { + render( + undefined} />, + ); + + fireEvent.click(screen.getByRole('button', { name: 'Refresh now' })); + + expect(mockSyncGitHubInstallations).toHaveBeenCalledTimes(1); + }); + + it('offers the Update GitHub action to admins only', () => { + const { unmount } = render( + undefined} />, + ); + + fireEvent.click(screen.getByRole('button', { name: /Update GitHub/i })); + expect(mockEnableGitHubApp).toHaveBeenCalledWith( + { + redirect: '/settings/environments/new', + callbackBackground: 'background', + }, + expect.any(Object), + ); + + unmount(); + mockUserState.isAdmin = false; + + render( + undefined} />, + ); + + expect(screen.getByText(/Ask an admin/i)).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /Update GitHub/i }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/github/CreateGitHubRepoDialog.tsx b/apps/web/src/components/github/CreateGitHubRepoDialog.tsx new file mode 100644 index 000000000..87840c6f1 --- /dev/null +++ b/apps/web/src/components/github/CreateGitHubRepoDialog.tsx @@ -0,0 +1,418 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { usePathname, useSearchParams } from 'next/navigation'; +import { toast } from 'sonner'; + +import { + useEnableGitHubApp, + useGitHubInstallations, + useSyncGitHubInstallations, +} from '@/hooks/github'; +import { useRepositories } from '@/hooks/source-control'; +import { useRealtimePolling } from '@/hooks/useRealtimePolling'; +import { useAuthorizedUser } from '@/hooks/useUser'; +import { parseGitHubRepoReference } from '@/lib/github-urls'; + +import { + Alert, + AlertDescription, + AlertTitle, + Button, + CircleCheck, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + ExternalLink, + Input, + Label, + Loader2, + Pencil, + Plus, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@/components/system'; + +export type DetectedRepository = { + id: string; + fullName: string; + isEmpty?: boolean; +}; + +const REPOSITORY_POLL_INTERVAL_MS = 5000; + +export function CreateGitHubRepoDialog({ + open, + onOpenChange, + onRepositoryDetected, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onRepositoryDetected?: (repository: DetectedRepository) => void; +}) { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const { isAdmin } = useAuthorizedUser(); + + const [selectedOwner, setSelectedOwner] = useState(null); + const [forkReference, setForkReference] = useState(''); + + const installations = useGitHubInstallations(); + const enableGitHubApp = useEnableGitHubApp(); + const syncGitHubInstallations = useSyncGitHubInstallations({ + onError: () => toast.error('Failed to refresh GitHub. Please try again.'), + }); + + const polling = useRealtimePolling({ + enabled: open, + interval: REPOSITORY_POLL_INTERVAL_MS, + }); + const repositories = useRepositories( + { includeEmptyState: true }, + { + refetchInterval: polling.refetchInterval, + refetchIntervalInBackground: polling.refetchIntervalInBackground, + }, + ); + + const githubRepositories = useMemo( + () => + (repositories.data ?? []) + .filter((repository) => repository.sourceControlProvider === 'github') + .map((repository) => ({ + id: repository.id, + fullName: repository.fullName, + // `isEmpty` is present because the query passes includeEmptyState, + // but the router output type does not narrow on that flag. + isEmpty: (repository as { isEmpty?: boolean }).isEmpty, + })), + [repositories.data], + ); + + // Snapshot the repos that existed when the dialog opened; anything beyond + // that baseline was just created (or just granted) and is offered back. + const baselineRepositoryIdsRef = useRef | null>(null); + + useEffect(() => { + if (!open) { + baselineRepositoryIdsRef.current = null; + return; + } + + if ( + baselineRepositoryIdsRef.current === null && + repositories.data !== undefined + ) { + baselineRepositoryIdsRef.current = new Set( + githubRepositories.map((repository) => repository.id), + ); + } + }, [open, repositories.data, githubRepositories]); + + const detectedRepositories = useMemo(() => { + const baseline = baselineRepositoryIdsRef.current; + + if (!open || baseline === null) { + return []; + } + + // The baseline ref is intentionally read during render: it only changes + // together with `githubRepositories`, which is a dependency. + return githubRepositories.filter( + (repository) => !baseline.has(repository.id), + ); + }, [open, githubRepositories]); + + // Returning from github.com is the most likely moment the new repo exists, + // so trigger one authoritative GitHub sync per return to the tab instead of + // waiting for a webhook or the next poll. + const visibilitySyncPendingRef = useRef(false); + + useEffect(() => { + if (!open) { + visibilitySyncPendingRef.current = false; + return; + } + + const handleVisibilityChange = () => { + if (document.hidden) { + visibilitySyncPendingRef.current = true; + return; + } + + if ( + visibilitySyncPendingRef.current && + !syncGitHubInstallations.isPending + ) { + visibilitySyncPendingRef.current = false; + syncGitHubInstallations.mutate(); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [open, syncGitHubInstallations]); + + const activeInstallations = useMemo( + () => + (installations.data ?? []).filter( + (installation) => !installation.suspendedAt, + ), + [installations.data], + ); + + const defaultOwner = + activeInstallations.find( + (installation) => installation.accountType === 'Organization', + )?.accountLogin ?? + activeInstallations[0]?.accountLogin ?? + null; + const owner = selectedOwner ?? defaultOwner; + + const newRepoUrl = owner + ? `https://github.com/new?owner=${encodeURIComponent(owner)}` + : 'https://github.com/new'; + + const forkTarget = parseGitHubRepoReference(forkReference); + const forkUrl = forkTarget + ? `https://github.com/${forkTarget.owner}/${forkTarget.repo}/fork` + : null; + + const search = searchParams.toString(); + const redirectTarget = search ? `${pathname}?${search}` : pathname; + + const updateGitHubAccess = () => { + enableGitHubApp.mutate( + { + redirect: redirectTarget, + callbackBackground: 'background', + }, + { + onSuccess: (result) => { + if (!result.success) { + toast.error(result.error); + return; + } + + if (result.mode === 'redirect') { + window.location.href = result.url; + return; + } + + toast.success('GitHub access updated.'); + }, + onError: () => + toast.error('Failed to update GitHub. Please try again.'), + }, + ); + }; + + return ( + + + + Add a GitHub repository + + Create the repository on GitHub — Roomote picks it up here + automatically. + + + + + + New repository + Fork existing + + + +

+ Create the repository on github.com. An empty repository is fine — + Roomote pushes the initial commit and sets up a basic environment + for it. +

+ {activeInstallations.length > 1 ? ( +
+ + +
+ ) : null} + +
+ + +

+ Paste the repository you want to fork — a GitHub URL or + owner/repo. +

+ setForkReference(event.target.value)} + placeholder="https://github.com/owner/repo" + aria-label="Repository to fork" + /> + {forkReference.trim() && !forkTarget ? ( +

+ Enter a GitHub repository URL or owner/repo. +

+ ) : null} + +
+
+ +
+

+ If the GitHub App only has access to selected repositories, also + grant it access to the new one. +

+ {isAdmin ? ( + + ) : ( +

+ Ask an admin to update the connected GitHub repositories. +

+ )} +
+ + {detectedRepositories.length > 0 ? ( +
+ {detectedRepositories.map((repository) => ( + + + Found {repository.fullName} + + + {repository.isEmpty + ? 'Brand new — Roomote will initialize it during setup.' + : 'Ready to use.'} + + + + + ))} +
+ ) : ( +
+ + Watching for new repositories… + +
+ )} + + + + +
+
+ ); +} + +export function CreateGitHubRepoButton({ + onRepositoryDetected, + size = 'sm', + variant = 'outline', + className, +}: { + onRepositoryDetected?: (repository: DetectedRepository) => void; + size?: 'xs' | 'sm' | 'default'; + variant?: 'outline' | 'ghost' | 'link' | 'default'; + className?: string; +}) { + const [open, setOpen] = useState(false); + + return ( + <> + + { + setOpen(false); + onRepositoryDetected?.(repository); + }} + /> + + ); +} diff --git a/apps/web/src/components/settings/environments/CreateEnvironmentPage.client.test.tsx b/apps/web/src/components/settings/environments/CreateEnvironmentPage.client.test.tsx index 60c755e6b..ee6778249 100644 --- a/apps/web/src/components/settings/environments/CreateEnvironmentPage.client.test.tsx +++ b/apps/web/src/components/settings/environments/CreateEnvironmentPage.client.test.tsx @@ -48,11 +48,23 @@ const { }; }); +const { mockNavigationState, mockRepositoriesState, mockCreateRepoDialog } = + vi.hoisted(() => ({ + mockNavigationState: { search: '' as string }, + mockRepositoriesState: { + data: [ + { id: 'repo-1', fullName: 'acme/api' }, + { id: 'repo-2', fullName: 'acme/web' }, + ] as Array<{ id: string; fullName: string; isEmpty?: boolean }>, + }, + mockCreateRepoDialog: vi.fn(), + })); + vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mockRouterPush, }), - useSearchParams: () => new URLSearchParams(''), + useSearchParams: () => new URLSearchParams(mockNavigationState.search), })); vi.mock('sonner', () => ({ @@ -74,20 +86,26 @@ vi.mock('@/hooks/environments', () => ({ vi.mock('@/hooks/source-control', () => ({ useRepositories: () => ({ - data: [ - { - id: 'repo-1', - fullName: 'acme/api', - }, - { - id: 'repo-2', - fullName: 'acme/web', - }, - ], + data: mockRepositoriesState.data, isPending: false, }), })); +vi.mock('@/components/github/CreateGitHubRepoDialog', () => ({ + CreateGitHubRepoDialog: (props: { + open: boolean; + onOpenChange: (open: boolean) => void; + onRepositoryDetected?: (repository: { + id: string; + fullName: string; + isEmpty?: boolean; + }) => void; + }) => { + mockCreateRepoDialog(props); + return props.open ?
: null; + }, +})); + vi.mock('@/trpc/client', () => ({ useTRPC: () => ({ environments: { @@ -143,8 +161,8 @@ vi.mock('@/components/system', () => ({ AlertDescription: ({ children, ...props - }: { children: ReactNode } & HTMLAttributes) => ( -

{children}

+ }: { children: ReactNode } & HTMLAttributes) => ( +
{children}
), ArrowLeft: (props: SVGProps) => , ArrowRight: (props: SVGProps) => , @@ -217,7 +235,9 @@ vi.mock('@/components/system', () => ({

{children}

), HandMetal: (props: SVGProps) => , + Info: (props: SVGProps) => , Loader2: (props: SVGProps) => , + Plus: (props: SVGProps) => , ScrollArea: ({ children, ...props @@ -235,6 +255,78 @@ describe('CreateEnvironmentPage', () => { beforeEach(() => { vi.clearAllMocks(); mockYamlEditorState.reset(); + mockNavigationState.search = ''; + mockRepositoriesState.data = [ + { id: 'repo-1', fullName: 'acme/api' }, + { id: 'repo-2', fullName: 'acme/web' }, + ]; + }); + + it('renders the create-repo affordance and opens the dialog', () => { + const queryClient = new QueryClient(); + + render( + + + , + ); + + expect(screen.queryByTestId('create-repo-dialog')).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByRole('button', { name: /Create a new repository/i }), + ); + + expect(screen.getByTestId('create-repo-dialog')).toBeInTheDocument(); + }); + + it('opens the create-repo dialog from the create-repo search param', () => { + mockNavigationState.search = 'create-repo=1'; + const queryClient = new QueryClient(); + + render( + + + , + ); + + expect(screen.getByTestId('create-repo-dialog')).toBeInTheDocument(); + }); + + it('selects a detected repository and explains the empty-repo bootstrap', async () => { + mockRepositoriesState.data = [ + { id: 'repo-1', fullName: 'acme/api' }, + { id: 'repo-new', fullName: 'acme/new-repo', isEmpty: true }, + ]; + const queryClient = new QueryClient(); + + render( + + + , + ); + + const dialogProps = mockCreateRepoDialog.mock.calls.at(-1)?.[0] as { + onRepositoryDetected?: (repository: { + id: string; + fullName: string; + isEmpty?: boolean; + }) => void; + }; + + dialogProps.onRepositoryDetected?.({ + id: 'repo-new', + fullName: 'acme/new-repo', + isEmpty: true, + }); + + await waitFor(() => { + expect(screen.getByLabelText(/acme\/new-repo/i)).toBeChecked(); + }); + expect( + screen.getByText(/will push an initial commit and set up a basic/i), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Start Agent' })).toBeEnabled(); }); it('starts an environment definition task and opens it in the task view', async () => { diff --git a/apps/web/src/components/settings/environments/CreateEnvironmentPage.tsx b/apps/web/src/components/settings/environments/CreateEnvironmentPage.tsx index 6b2b5df82..ca594d3d7 100644 --- a/apps/web/src/components/settings/environments/CreateEnvironmentPage.tsx +++ b/apps/web/src/components/settings/environments/CreateEnvironmentPage.tsx @@ -15,6 +15,10 @@ import { useValidateEnvironmentConfig, } from '@/hooks/environments'; import { useRepositories } from '@/hooks/source-control'; +import { + areAllRepositoriesEmpty, + getEmptyRepositories, +} from '@/lib/repositories'; import { useTRPC } from '@/trpc/client'; import { @@ -26,10 +30,16 @@ import { Card, CardContent, HandMetal, + Info, Loader2, + Plus, Textarea, } from '@/components/system'; +import { + CreateGitHubRepoDialog, + type DetectedRepository, +} from '@/components/github/CreateGitHubRepoDialog'; import { EnvironmentRepositorySelector } from './EnvironmentRepositorySelector'; import { UpdateGitHubReposHint } from './UpdateGitHubReposHint'; import { @@ -59,9 +69,12 @@ export function CreateEnvironmentPage({ const trpc = useTRPC(); const editorRef = useRef(null); - const repositories = useRepositories(); + const repositories = useRepositories({ includeEmptyState: true }); const createEnvironment = useCreateEnvironment(); const validateConfig = useValidateEnvironmentConfig(); + const [createRepoDialogOpen, setCreateRepoDialogOpen] = useState( + searchParams.get('create-repo') === '1', + ); const [activeView, setActiveView] = useState( suggestedMcpId ? 'yaml' : 'agent', @@ -176,8 +189,15 @@ export function CreateEnvironmentPage({ ) : null} {activeView === 'agent' ? ( ({ + id: repository.id, + fullName: repository.fullName, + // includeEmptyState is set on the query, but the router + // output type does not narrow on that flag. + isEmpty: (repository as { isEmpty?: boolean }).isEmpty, + }))} repositoriesLoading={repositories.isPending} + onOpenCreateRepo={() => setCreateRepoDialogOpen(true)} selectedRepositoryIds={selectedRepositoryIds} onToggleRepository={(repositoryId) => { setSelectedRepositoryIds((currentSelection) => @@ -212,10 +232,28 @@ export function CreateEnvironmentPage({
+ { + setCreateRepoDialogOpen(false); + setSelectedRepositoryIds((currentSelection) => + currentSelection.includes(repository.id) + ? currentSelection + : [...currentSelection, repository.id], + ); + }} + /> ); } +type AgentViewRepository = { + id: string; + fullName: string; + isEmpty?: boolean | null; +}; + function AgentMasterView({ repositories, repositoriesLoading, @@ -225,10 +263,11 @@ function AgentMasterView({ setupGuidance, onSetupGuidanceChange, onSwitchToYaml, + onOpenCreateRepo, isStartAgentPending, isBusy, }: { - repositories: Array<{ id: string; fullName: string }>; + repositories: AgentViewRepository[]; repositoriesLoading: boolean; selectedRepositoryIds: string[]; onToggleRepository: (repositoryId: string) => void; @@ -236,6 +275,7 @@ function AgentMasterView({ setupGuidance: string; onSetupGuidanceChange: (value: string) => void; onSwitchToYaml: () => void; + onOpenCreateRepo: () => void; isStartAgentPending: boolean; isBusy: boolean; }) { @@ -249,6 +289,7 @@ function AgentMasterView({ setupGuidance={setupGuidance} onSetupGuidanceChange={onSetupGuidanceChange} onSwitchToYaml={onSwitchToYaml} + onOpenCreateRepo={onOpenCreateRepo} isStartAgentPending={isStartAgentPending} isBusy={isBusy} /> @@ -264,10 +305,11 @@ function AgentRepositorySelectionSubview({ setupGuidance, onSetupGuidanceChange, onSwitchToYaml, + onOpenCreateRepo, isStartAgentPending, isBusy, }: { - repositories: Array<{ id: string; fullName: string }>; + repositories: AgentViewRepository[]; repositoriesLoading: boolean; selectedRepositoryIds: string[]; onToggleRepository: (repositoryId: string) => void; @@ -275,9 +317,23 @@ function AgentRepositorySelectionSubview({ setupGuidance: string; onSetupGuidanceChange: (value: string) => void; onSwitchToYaml: () => void; + onOpenCreateRepo: () => void; isStartAgentPending: boolean; isBusy: boolean; }) { + const selectedEmptyRepositories = getEmptyRepositories( + repositories.filter((repository) => + selectedRepositoryIds.includes(repository.id), + ), + ); + const allSelectedRepositoriesAreEmpty = + selectedRepositoryIds.length > 0 && + areAllRepositoriesEmpty( + repositories.filter((repository) => + selectedRepositoryIds.includes(repository.id), + ), + ); + return ( <>

@@ -299,6 +355,15 @@ function AgentRepositorySelectionSubview({ + ) : (

@@ -310,7 +375,39 @@ function AgentRepositorySelectionSubview({ heightClassName="max-h-[calc(var(--effective-viewport-height)-17rem)] overflow-auto" /> - +
+ + +
+ + {allSelectedRepositoriesAreEmpty ? ( + + + +

+ {selectedEmptyRepositories.length === 1 + ? 'The selected repository has no commits yet.' + : 'All selected repositories have no commits yet.'}{' '} + Roomote will push an initial commit and set up a basic + environment — then you can start building with your first + task. +

+

+ {selectedEmptyRepositories + .map((repository) => repository.fullName) + .join(', ')} +

+
+
+ ) : null}