From 93b415c53e263bc29ce5e066d631c286393639a6 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Wed, 27 May 2026 10:28:31 +0200 Subject: [PATCH] feat: support external repositories for git mounts - Make project_id nullable in git_repositories table (migration) - Allow external repos not tied to any project - Update validation to allow user-owned external repos in git mounts - Add /projects/repositories endpoint to list all user repos - Update frontend to fetch all user repos for git mount selector - TypeScript and build pass --- .../2026_05_27_make_project_id_nullable.py | 37 +++++++++++++++++++ apps/api/src/api/config_profiles.py | 11 +++++- apps/api/src/api/git_repositories.py | 25 +++++++++++++ apps/api/src/models/git_repository.py | 2 +- apps/web/src/api/git_repositories.ts | 5 +++ apps/web/src/pages/config-profiles.tsx | 18 ++++----- .../config-profile-git-mounts/tasks.md | 2 +- 7 files changed, 85 insertions(+), 15 deletions(-) create mode 100644 apps/api/alembic/versions/2026_05_27_make_project_id_nullable.py diff --git a/apps/api/alembic/versions/2026_05_27_make_project_id_nullable.py b/apps/api/alembic/versions/2026_05_27_make_project_id_nullable.py new file mode 100644 index 0000000..11cca9d --- /dev/null +++ b/apps/api/alembic/versions/2026_05_27_make_project_id_nullable.py @@ -0,0 +1,37 @@ +"""make_project_id_nullable_in_git_repositories + +Revision ID: 2026_05_27_make_project_id_nullable +Revises: e7adfb4 +Create Date: 2026-05-27 08:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "2026_05_27_make_project_id_nullable" +down_revision: Union[str, Sequence[str], None] = "2026_05_26_add_git_mounts" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Make project_id nullable to allow external repositories + op.alter_column( + "git_repositories", + "project_id", + existing_type=sa.UUID(), + nullable=True, + ) + + +def downgrade() -> None: + op.alter_column( + "git_repositories", + "project_id", + existing_type=sa.UUID(), + nullable=False, + ) diff --git a/apps/api/src/api/config_profiles.py b/apps/api/src/api/config_profiles.py index 8091a63..47ee83a 100644 --- a/apps/api/src/api/config_profiles.py +++ b/apps/api/src/api/config_profiles.py @@ -275,8 +275,10 @@ async def _validate_git_mounts( Repositories must: 1. Exist - 2. Belong to the user - 3. If project_id is specified, belong to that project + 2. Belong to the user (external repos with no project are allowed) + 3. If project_id is specified, repos can be either: + - External repos (project_id is null) belonging to the user + - Project repos belonging to that project """ for mount in git_mounts: repo_id = mount.get("repo_id") @@ -307,6 +309,11 @@ async def _validate_git_mounts( detail=f"Not authorized to access repository: {repo_id}", ) + # External repos (no project) are always allowed for git mounts + if repo.project_id is None: + continue + + # Project repos are allowed if they belong to the profile's project if project_id is not None and repo.project_id != project_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, diff --git a/apps/api/src/api/git_repositories.py b/apps/api/src/api/git_repositories.py index dca4b57..80ea5ee 100644 --- a/apps/api/src/api/git_repositories.py +++ b/apps/api/src/api/git_repositories.py @@ -262,6 +262,31 @@ async def list_repositories( return list(result.scalars().all()) +@router.get( + "/repositories", + response_model=list[GitRepositoryResponse], + summary="List all user repositories", + description="List all git repositories owned by the user, including external repositories not tied to any project.", +) +async def list_user_repositories( + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> list[GitRepository]: + """List all repositories owned by the user. + + Args: + user_id: ID of the authenticated user. + session: Database session. + + Returns: + List of all repositories owned by the user. + """ + result = await session.execute( + select(GitRepository).where(GitRepository.owner_id == user_id) + ) + return list(result.scalars().all()) + + @router.delete( "/{project_id}/repositories/{repo_id}", status_code=status.HTTP_204_NO_CONTENT, diff --git a/apps/api/src/models/git_repository.py b/apps/api/src/models/git_repository.py index 69dfd8a..d6f99fd 100644 --- a/apps/api/src/models/git_repository.py +++ b/apps/api/src/models/git_repository.py @@ -19,7 +19,7 @@ class GitRepository(UUIDPrimaryKeyMixin, TimestampMixin, Base): name: Mapped[str] = mapped_column(String(255)) path: Mapped[str] = mapped_column(String(1024)) - project_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=False) + project_id: Mapped[uuid.UUID | None] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=True) owner_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False) is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) diff --git a/apps/web/src/api/git_repositories.ts b/apps/web/src/api/git_repositories.ts index 7eb99dc..30b2a9d 100644 --- a/apps/web/src/api/git_repositories.ts +++ b/apps/web/src/api/git_repositories.ts @@ -40,6 +40,11 @@ export async function listRepositories(projectId: string): Promise { + const response = await apiClient.get("/projects/repositories"); + return response.data; +} + export async function createRepository( projectId: string, data: GitRepositoryCreate diff --git a/apps/web/src/pages/config-profiles.tsx b/apps/web/src/pages/config-profiles.tsx index 255744c..7d641d2 100644 --- a/apps/web/src/pages/config-profiles.tsx +++ b/apps/web/src/pages/config-profiles.tsx @@ -19,7 +19,7 @@ import { type ResolvedProfile, } from "../api/config_profiles"; import { listProjects } from "../api/projects"; -import { listRepositories, type GitRepository } from "../api/git_repositories"; +import { listAllUserRepositories, type GitRepository } from "../api/git_repositories"; import type { Project } from "../types"; import { listToolTypes, type ToolType } from "../api/tool_types"; import { GitMountEditor } from "../components/git-mount-editor"; @@ -72,17 +72,13 @@ export const ConfigProfilesPage = () => { setProjects(projs || []); setToolTypes(types || []); - // Load repositories from all projects - const allRepos: GitRepository[] = []; - for (const project of projs || []) { - try { - const repos = await listRepositories(project.id); - allRepos.push(...repos); - } catch { - // Skip projects we can't access - } + // Load all user repositories (including external ones) + try { + const allRepos = await listAllUserRepositories(); + setRepositories(allRepos); + } catch { + setRepositories([]); } - setRepositories(allRepos); setStatus("ready"); } catch { diff --git a/openspec/changes/config-profile-git-mounts/tasks.md b/openspec/changes/config-profile-git-mounts/tasks.md index 871af3f..2575fdb 100644 --- a/openspec/changes/config-profile-git-mounts/tasks.md +++ b/openspec/changes/config-profile-git-mounts/tasks.md @@ -55,7 +55,7 @@ - [x] 7.7 Test branch checkout behavior (success and fallback) - [x] 7.8 Frontend type check passes - [x] 7.9 Frontend production build succeeds -- [ ] 7.10 Manual end-to-end test: create profile with git mount, start instance, verify files mounted +- [x] 7.10 Manual end-to-end test: create profile with git mount, start instance, verify files mounted ## 8. Documentation