feat: implement git repository management
- Add backend API for git repository CRUD (create, list, delete) - Support bare repository initialization and mirror cloning - Add cascade delete for repositories when project is deleted - Add frontend page for repository management per project - Update project page with link to repositories - Add repo_base_path to config - Quality gates: ruff, mypy, typecheck, lint, build all pass
This commit is contained in:
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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<GitRepository[]> {
|
||||
const response = await apiClient.get(`/projects/${projectId}/repositories`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createRepository(
|
||||
projectId: string,
|
||||
data: GitRepositoryCreate
|
||||
): Promise<GitRepository> {
|
||||
const response = await apiClient.post(`/projects/${projectId}/repositories`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deleteRepository(projectId: string, repoId: string): Promise<void> {
|
||||
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
||||
}
|
||||
@@ -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" }
|
||||
];
|
||||
|
||||
@@ -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<RepoStatus>("loading");
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [formName, setFormName] = useState("");
|
||||
const [formRemoteUrl, setFormRemoteUrl] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(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 (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<h1>Repositories</h1>
|
||||
<button className="primary-button" onClick={() => setShowCreate(true)} type="button">
|
||||
New Repository
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status === "loading" && <p className="muted">Loading repositories...</p>}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Failed to load repositories</p>
|
||||
<button className="secondary-button" onClick={() => void loadRepositories()} type="button">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && <p className="muted">No repositories yet. Create your first repository above.</p>}
|
||||
|
||||
{status === "ready" && repositories.length > 0 && (
|
||||
<div className="repository-list">
|
||||
{repositories.map((repo) => (
|
||||
<article className="card repository-card" key={repo.id}>
|
||||
<div className="repository-info">
|
||||
<h3>{repo.name}</h3>
|
||||
{repo.is_mirror && repo.remote_url && (
|
||||
<p className="muted">Mirror of {repo.remote_url}</p>
|
||||
)}
|
||||
<p className="muted">{repo.path}</p>
|
||||
</div>
|
||||
<div className="repository-actions">
|
||||
{deleteConfirmId === repo.id ? (
|
||||
<div className="delete-confirm">
|
||||
<span>Are you sure?</span>
|
||||
<button
|
||||
className="danger-button"
|
||||
onClick={() => void handleDelete(repo.id)}
|
||||
type="button"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button danger-text"
|
||||
onClick={() => setDeleteConfirmId(repo.id)}
|
||||
type="button"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h2>Create Repository</h2>
|
||||
<form onSubmit={handleSubmit} className="stack">
|
||||
<label className="form-field">
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
placeholder="repository-name"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Remote URL (optional)
|
||||
<input
|
||||
type="text"
|
||||
value={formRemoteUrl}
|
||||
onChange={(e) => setFormRemoteUrl(e.target.value)}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
/>
|
||||
</label>
|
||||
{formError && <p className="error-text">{formError}</p>}
|
||||
<div className="dialog-actions">
|
||||
<button className="secondary-button" onClick={() => setShowCreate(false)} type="button">
|
||||
Cancel
|
||||
</button>
|
||||
<button className="primary-button" type="submit">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -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 && <p className="muted">{project.description}</p>}
|
||||
</div>
|
||||
<div className="project-actions">
|
||||
<Link className="ghost-button" to={`/projects/${project.id}/repositories`}>
|
||||
Repositories
|
||||
</Link>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => openEdit(project)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DashboardPage } from "./pages/dashboard";
|
||||
import { LoginRedirectPage, NotFoundPage, PlaceholderPage } from "./pages/placeholder";
|
||||
import { ProfilePage } from "./pages/profile";
|
||||
import { ProjectsPage } from "./pages/projects";
|
||||
import { GitRepositoriesPage } from "./pages/git-repositories";
|
||||
import { SSHKeysPage } from "./pages/ssh-keys";
|
||||
|
||||
export const AppRouter = () => {
|
||||
@@ -22,7 +23,7 @@ export const AppRouter = () => {
|
||||
>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="projects" element={<ProjectsPage />} />
|
||||
<Route path="repositories" element={<PlaceholderPage title="Repositories" />} />
|
||||
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} />
|
||||
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
<Route path="settings" element={<PlaceholderPage title="Settings" />} />
|
||||
|
||||
Reference in New Issue
Block a user