diff --git a/apps/api/src/api/git_repositories.py b/apps/api/src/api/git_repositories.py new file mode 100644 index 0000000..c73d20f --- /dev/null +++ b/apps/api/src/api/git_repositories.py @@ -0,0 +1,188 @@ +import os +import shutil +import subprocess +import uuid +from typing import Annotated + +from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status +from pydantic import BaseModel, ConfigDict +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.auth.jwt_service import decode_access_token +from src.config import Settings +from src.database import SessionLocal +from src.models.git_repository import GitRepository +from src.models.project import Project +from src.models.user import User + +router = APIRouter(prefix="/projects", tags=["git-repositories"]) + + +async def get_db_session(): + async with SessionLocal() as session: + yield session + + +async def get_current_user_id( + access_token: Annotated[str | None, Cookie()] = None, +) -> uuid.UUID: + if not access_token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing access token") + + try: + claims = decode_access_token(settings=Settings(), token=access_token) + return uuid.UUID(str(claims["sub"])) + except Exception: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid access token") + + +async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User: + user = await session.get(User, user_id) + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found") + return user + + +async def _get_owned_project( + project_id: uuid.UUID, + user_id: uuid.UUID, + session: AsyncSession, +) -> Project: + project = await session.get(Project, project_id) + if project is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found") + if project.owner_id != user_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner") + return project + + +def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str: + base = Settings().repo_base_path or "/data/repos" + return os.path.join(base, str(user_id), str(project_id), f"{name}.git") + + +class GitRepositoryCreate(BaseModel): + name: str + remote_url: str | None = None + + +class GitRepositoryResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + name: str + path: str + project_id: uuid.UUID + owner_id: uuid.UUID + is_mirror: bool + remote_url: str | None + last_push: str | None + created_at: str | None + + +@router.post("/{project_id}/repositories", response_model=GitRepositoryResponse, status_code=status.HTTP_201_CREATED) +async def create_repository( + project_id: uuid.UUID, + data: GitRepositoryCreate, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> GitRepository: + _user = await _get_user(session, user_id) + _project = await _get_owned_project(project_id, user_id, session) + + # Check for duplicate name + existing = await session.execute( + select(GitRepository).where( + GitRepository.project_id == project_id, + GitRepository.name == data.name, + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists") + + repo_path = _get_repo_path(user_id, project_id, data.name) + + # Ensure parent directory exists + os.makedirs(os.path.dirname(repo_path), exist_ok=True) + + if data.remote_url: + # Clone as mirror + try: + result = subprocess.run( + ["git", "clone", "--mirror", data.remote_url, repo_path], + capture_output=True, + text=True, + timeout=300, + ) + if result.returncode != 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"failed to clone repository: {result.stderr}", + ) + except subprocess.TimeoutExpired: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out") + except FileNotFoundError: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found") + else: + # Init bare repo + try: + result = subprocess.run( + ["git", "init", "--bare", repo_path], + capture_output=True, + text=True, + check=True, + ) + except FileNotFoundError: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found") + + repo = GitRepository( + name=data.name, + path=repo_path, + project_id=project_id, + owner_id=user_id, + is_mirror=bool(data.remote_url), + remote_url=data.remote_url, + ) + session.add(repo) + await session.commit() + await session.refresh(repo) + return repo + + +@router.get("/{project_id}/repositories", response_model=list[GitRepositoryResponse]) +async def list_repositories( + project_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> list[GitRepository]: + _user = await _get_user(session, user_id) + _project = await _get_owned_project(project_id, user_id, session) + + result = await session.execute( + select(GitRepository).where(GitRepository.project_id == project_id) + ) + return list(result.scalars().all()) + + +@router.delete("/{project_id}/repositories/{repo_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_repository( + project_id: uuid.UUID, + repo_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> Response: + _user = await _get_user(session, user_id) + _project = await _get_owned_project(project_id, user_id, session) + + repo = await session.get(GitRepository, repo_id) + if repo is None or repo.project_id != project_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + + # Remove from disk + if os.path.exists(repo.path): + shutil.rmtree(repo.path) + + await session.delete(repo) + await session.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/src/api/projects.py b/apps/api/src/api/projects.py index be8e5c3..e5dc21e 100644 --- a/apps/api/src/api/projects.py +++ b/apps/api/src/api/projects.py @@ -1,3 +1,5 @@ +import os +import shutil import uuid from typing import Annotated @@ -9,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.auth.jwt_service import decode_access_token from src.config import Settings from src.database import SessionLocal +from src.models.git_repository import GitRepository from src.models.project import Project from src.models.ssh_key import SSHKey from src.models.user import User @@ -135,6 +138,15 @@ async def delete_project( ) -> Response: await _get_user(session, user_id) project = await _get_owned_project(project_id, user_id, session) + + # Delete repositories from disk and database + result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id)) + repositories = result.scalars().all() + for repo in repositories: + if os.path.exists(repo.path): + shutil.rmtree(repo.path) + await session.delete(repo) + await session.delete(project) await session.commit() return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/src/config.py b/apps/api/src/config.py index bb8c33b..3e00599 100644 --- a/apps/api/src/config.py +++ b/apps/api/src/config.py @@ -45,6 +45,9 @@ class Settings(BaseSettings): access_token_ttl_minutes: int = 15 refresh_token_ttl_days: int = 7 + # Repository storage + repo_base_path: str = "/data/repos" + model_config = SettingsConfigDict(env_file=".env", extra="ignore", populate_by_name=True) @property diff --git a/apps/api/src/main.py b/apps/api/src/main.py index 0123b8f..fc0d7cf 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -2,6 +2,7 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from src.api.auth import router as auth_router +from src.api.git_repositories import router as git_repositories_router from src.api.projects import router as projects_router from src.api.ssh_keys import router as ssh_keys_router from src.api.users import router as users_router @@ -11,4 +12,5 @@ app.include_router(auth_router) app.include_router(projects_router) app.include_router(users_router) app.include_router(ssh_keys_router) +app.include_router(git_repositories_router) app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") diff --git a/apps/web/src/api/git_repositories.ts b/apps/web/src/api/git_repositories.ts new file mode 100644 index 0000000..f0b2b00 --- /dev/null +++ b/apps/web/src/api/git_repositories.ts @@ -0,0 +1,35 @@ +import { apiClient } from "./client"; + +export interface GitRepository { + id: string; + name: string; + path: string; + project_id: string; + owner_id: string; + is_mirror: boolean; + remote_url: string | null; + last_push: string | null; + created_at: string | null; +} + +export interface GitRepositoryCreate { + name: string; + remote_url?: string; +} + +export async function listRepositories(projectId: string): Promise { + const response = await apiClient.get(`/projects/${projectId}/repositories`); + return response.data; +} + +export async function createRepository( + projectId: string, + data: GitRepositoryCreate +): Promise { + const response = await apiClient.post(`/projects/${projectId}/repositories`, data); + return response.data; +} + +export async function deleteRepository(projectId: string, repoId: string): Promise { + await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`); +} diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx index 5f4b032..1499ce3 100644 --- a/apps/web/src/components/app-shell.tsx +++ b/apps/web/src/components/app-shell.tsx @@ -5,7 +5,6 @@ import { useAuth } from "../state/auth"; const NAV_ITEMS = [ { to: "/", label: "Dashboard" }, { to: "/projects", label: "Projects" }, - { to: "/repositories", label: "Repositories" }, { to: "/ssh-keys", label: "SSH Keys" }, { to: "/settings", label: "Settings" } ]; diff --git a/apps/web/src/pages/git-repositories.tsx b/apps/web/src/pages/git-repositories.tsx new file mode 100644 index 0000000..c7ce85b --- /dev/null +++ b/apps/web/src/pages/git-repositories.tsx @@ -0,0 +1,185 @@ +import { useCallback, useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; + +import { + createRepository, + deleteRepository, + listRepositories, + type GitRepositoryCreate, +} from "../api/git_repositories"; +import type { GitRepository } from "../api/git_repositories"; + +type RepoStatus = "loading" | "ready" | "error"; + +export const GitRepositoriesPage = () => { + const { projectId } = useParams<{ projectId: string }>(); + const [status, setStatus] = useState("loading"); + const [repositories, setRepositories] = useState([]); + const [showCreate, setShowCreate] = useState(false); + const [formName, setFormName] = useState(""); + const [formRemoteUrl, setFormRemoteUrl] = useState(""); + const [formError, setFormError] = useState(null); + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + + const loadRepositories = useCallback(async () => { + if (!projectId) return; + setStatus("loading"); + try { + const data = await listRepositories(projectId); + setRepositories(data); + setStatus("ready"); + } catch { + setRepositories([]); + setStatus("error"); + } + }, [projectId]); + + useEffect(() => { + void loadRepositories(); + }, [loadRepositories]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setFormError(null); + + if (!formName.trim()) { + setFormError("Repository name is required"); + return; + } + + if (!projectId) return; + + try { + const input: GitRepositoryCreate = { + name: formName.trim(), + remote_url: formRemoteUrl.trim() || undefined, + }; + await createRepository(projectId, input); + setShowCreate(false); + setFormName(""); + setFormRemoteUrl(""); + await loadRepositories(); + } catch { + setFormError("Failed to create repository"); + } + }; + + const handleDelete = async (repoId: string) => { + if (!projectId) return; + try { + await deleteRepository(projectId, repoId); + setDeleteConfirmId(null); + await loadRepositories(); + } catch { + setDeleteConfirmId(null); + } + }; + + const isEmpty = status === "ready" && repositories.length === 0; + + return ( +
+
+

Repositories

+ +
+ + {status === "loading" &&

Loading repositories...

} + + {status === "error" && ( +
+

Failed to load repositories

+ +
+ )} + + {isEmpty &&

No repositories yet. Create your first repository above.

} + + {status === "ready" && repositories.length > 0 && ( +
+ {repositories.map((repo) => ( +
+
+

{repo.name}

+ {repo.is_mirror && repo.remote_url && ( +

Mirror of {repo.remote_url}

+ )} +

{repo.path}

+
+
+ {deleteConfirmId === repo.id ? ( +
+ Are you sure? + + +
+ ) : ( + + )} +
+
+ ))} +
+ )} + + {showCreate && ( +
+
+

Create Repository

+
+ + + {formError &&

{formError}

} +
+ + +
+
+
+
+ )} +
+ ); +}; diff --git a/apps/web/src/pages/projects.tsx b/apps/web/src/pages/projects.tsx index 93b7753..73003e6 100644 --- a/apps/web/src/pages/projects.tsx +++ b/apps/web/src/pages/projects.tsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useState } from "react"; +import { Link } from "react-router-dom"; + import { createProject, deleteProject, @@ -134,6 +136,9 @@ export const ProjectsPage = () => { {project.description &&

{project.description}

}
+ + Repositories +