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..b050d0842 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,34 @@ vi.mock('@/hooks/source-control', () => ({ useRepositories: mockUseRepositories, })); +vi.mock('@/components/github/CreateGitHubRepoDialog', () => ({ + CreateGitHubRepoDialog: ({ + open, + onRepositoryDetected, + }: { + open: boolean; + onRepositoryDetected?: (repository: { + id: string; + fullName: string; + isEmpty?: boolean; + }) => void; + }) => + open ? ( + + ) : null, +})); + vi.mock('@/hooks/task-models/useLaunchTaskModels', () => ({ useLaunchTaskModels: () => ({ data: { @@ -218,6 +246,7 @@ vi.mock('@/components/system', () => ({ Input: (props: InputHTMLAttributes) => , ArrowRight: (props: SVGProps) => , Loader2: (props: SVGProps) => , + Plus: (props: SVGProps) => , RefreshCcw: (props: SVGProps) => , RotateCw: (props: SVGProps) => , Search: (props: SVGProps) => , @@ -633,7 +662,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 +690,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 +723,58 @@ 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' }), + ); + fireEvent.click( + screen.getByRole('button', { name: 'Detect created repository' }), + ); + + expect(screen.getByLabelText('acme/created')).toBeChecked(); + }); + + it('keeps the create-repository item visible when the list is filtered to no matches', async () => { + mockRepositories.push( + ...Array.from({ length: 4 }, (_, index) => ({ + id: `repo-extra-${index}`, + fullName: `acme/extra-${index}`, + private: true, + defaultBranch: 'main', + })), + ); + + await renderStepRepoSelection(); + + fireEvent.change(screen.getByLabelText('Filter repositories'), { + target: { value: 'does-not-exist' }, + }); + + expect( + screen.getByRole('button', { name: 'Create a new repository' }), + ).toBeInTheDocument(); + expect( + screen.getByText('No repositories match that filter.'), + ).toBeInTheDocument(); + }); + 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..15a7acb55 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 { + CreateGitHubRepoDialog, + type DetectedRepository, +} from '@/components/github/CreateGitHubRepoDialog'; import { EnvironmentRepositorySelector } from '@/components/settings/environments/EnvironmentRepositorySelector'; import { ModelSelect } from '@/components/tasks'; import { SetupFooter } from './SetupFooter'; @@ -106,6 +110,7 @@ export function StepRepoSelection({ initialSelectedModelId ?? undefined, ); const [repositoryFilter, setRepositoryFilter] = useState(''); + const [createRepoDialogOpen, setCreateRepoDialogOpen] = useState(false); const [setupGuidance, setSetupGuidance] = useState(initialSetupGuidance); const [isRefreshPending, setIsRefreshPending] = useState(false); const refreshPromiseRef = useRef | null>(null); @@ -205,6 +210,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]); @@ -282,57 +298,72 @@ export function StepRepoSelection({ if (sortedRepositories.length === 0) { return ( -
-
- -
-
-

No repositories available yet.

-

- {PRODUCT_NAME} is connected to GitHub, but this deployment does not - currently have any accessible repositories to use for setup. -

-
- - - + <> +
+
+ +
+
+

No repositories available yet.

+

+ {PRODUCT_NAME} is connected to GitHub, but this deployment does + not currently have any accessible repositories to use for setup. +

+ setCreateRepoDialogOpen(true)} + inputPrefix="setup-repository" + heightClassName="h-auto" + /> +
+ + + +
+

Not recommended

-

Not recommended

-
+ + ); } @@ -349,245 +380,257 @@ export function StepRepoSelection({ .join(', '); return ( -
-
- -
-
- {!showForm && ( -
- -

- - {PRODUCT_NAME} needs environments to verify its work. - -
- That lets it run your app locally, click around, make API calls, - take screenshots. -

-
- )} -

- Pick the repo(s) needed for the first env to set up. -
- Roomote will install dependencies and figure it all out on its own. -

-
+ <> +
+
+ +
+
+ {!showForm && ( +
+ +

+ + {PRODUCT_NAME} needs environments to verify its work. + +
+ That lets it run your app locally, click around, make API calls, + take screenshots. +

+
+ )} +

+ Pick the repo(s) needed for the first env to set up. +
+ Roomote will install dependencies and figure it all out on its own. +

+
- {retryCopy ? ( - - - - {/* Single child: AlertDescription lays out its children with + {retryCopy ? ( + + + + {/* Single child: AlertDescription lays out its children with flex, which would split loose text and the button apart. */} -

- {retryCopy} - {retryReason === 'task-failed' && onReviewComputeProvider ? ( - <> - {' '} - If the run failed before doing any work, you can also{' '} - - . - - ) : null} -

-
-
- ) : null} - - {computeProvisioningError ? ( - - - -

- Sandbox provider provisioning failed: {computeProvisioningError}{' '} - {onRetryComputeProvisioning ? ( - - ) : null} -

-
-
- ) : null} - - - -
- {showRepositoryFilter ? ( -
- - - setRepositoryFilter(event.currentTarget.value) - } - placeholder="Filter repositories" - aria-label="Filter repositories" - className="pr-9 pl-9" - /> - {repositoryFilter ? ( +

+ {retryCopy} + {retryReason === 'task-failed' && onReviewComputeProvider ? ( + <> + {' '} + If the run failed before doing any work, you can also{' '} + + . + + ) : null} +

+ + + ) : null} + + {computeProvisioningError ? ( + + + +

+ Sandbox provider provisioning failed: {computeProvisioningError}{' '} + {onRetryComputeProvisioning ? ( ) : null} -

- ) : null} +

+ + + ) : null} + + + +
+ {showRepositoryFilter ? ( +
+ + + setRepositoryFilter(event.currentTarget.value) + } + placeholder="Filter repositories" + aria-label="Filter repositories" + className="pr-9 pl-9" + /> + {repositoryFilter ? ( + + ) : null} +
+ ) : null} - {filteredRepositories.length > 0 ? ( setCreateRepoDialogOpen(true)} inputPrefix="setup-repository" heightClassName="max-h-[calc(var(--effective-viewport-height)-40rem)] md:h-[18.75rem]" /> - ) : ( -
- No repositories match that filter. -
- )} -
- {allSelectedRepositoriesAreEmpty ? ( - - - -

- {emptyRepositoryWarningCopy} Push an initial commit before - continuing, or choose different repositories. -

-

- {selectedEmptyRepositoryNames} -

- -
-
- ) : null} - {showForm ? ( -
-