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
This commit is contained in:
@@ -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,
|
||||||
|
)
|
||||||
@@ -275,8 +275,10 @@ async def _validate_git_mounts(
|
|||||||
|
|
||||||
Repositories must:
|
Repositories must:
|
||||||
1. Exist
|
1. Exist
|
||||||
2. Belong to the user
|
2. Belong to the user (external repos with no project are allowed)
|
||||||
3. If project_id is specified, belong to that project
|
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:
|
for mount in git_mounts:
|
||||||
repo_id = mount.get("repo_id")
|
repo_id = mount.get("repo_id")
|
||||||
@@ -307,6 +309,11 @@ async def _validate_git_mounts(
|
|||||||
detail=f"Not authorized to access repository: {repo_id}",
|
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:
|
if project_id is not None and repo.project_id != project_id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
|||||||
@@ -262,6 +262,31 @@ async def list_repositories(
|
|||||||
return list(result.scalars().all())
|
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(
|
@router.delete(
|
||||||
"/{project_id}/repositories/{repo_id}",
|
"/{project_id}/repositories/{repo_id}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class GitRepository(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
|
|
||||||
name: Mapped[str] = mapped_column(String(255))
|
name: Mapped[str] = mapped_column(String(255))
|
||||||
path: Mapped[str] = mapped_column(String(1024))
|
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)
|
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False)
|
||||||
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ export async function listRepositories(projectId: string): Promise<GitRepository
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listAllUserRepositories(): Promise<GitRepository[]> {
|
||||||
|
const response = await apiClient.get<GitRepository[]>("/projects/repositories");
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createRepository(
|
export async function createRepository(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
data: GitRepositoryCreate
|
data: GitRepositoryCreate
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
type ResolvedProfile,
|
type ResolvedProfile,
|
||||||
} from "../api/config_profiles";
|
} from "../api/config_profiles";
|
||||||
import { listProjects } from "../api/projects";
|
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 type { Project } from "../types";
|
||||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
import { GitMountEditor } from "../components/git-mount-editor";
|
import { GitMountEditor } from "../components/git-mount-editor";
|
||||||
@@ -72,17 +72,13 @@ export const ConfigProfilesPage = () => {
|
|||||||
setProjects(projs || []);
|
setProjects(projs || []);
|
||||||
setToolTypes(types || []);
|
setToolTypes(types || []);
|
||||||
|
|
||||||
// Load repositories from all projects
|
// Load all user repositories (including external ones)
|
||||||
const allRepos: GitRepository[] = [];
|
try {
|
||||||
for (const project of projs || []) {
|
const allRepos = await listAllUserRepositories();
|
||||||
try {
|
setRepositories(allRepos);
|
||||||
const repos = await listRepositories(project.id);
|
} catch {
|
||||||
allRepos.push(...repos);
|
setRepositories([]);
|
||||||
} catch {
|
|
||||||
// Skip projects we can't access
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setRepositories(allRepos);
|
|
||||||
|
|
||||||
setStatus("ready");
|
setStatus("ready");
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
- [x] 7.7 Test branch checkout behavior (success and fallback)
|
- [x] 7.7 Test branch checkout behavior (success and fallback)
|
||||||
- [x] 7.8 Frontend type check passes
|
- [x] 7.8 Frontend type check passes
|
||||||
- [x] 7.9 Frontend production build succeeds
|
- [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
|
## 8. Documentation
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user