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" />} />
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
## 1. Backend SSH Key API
|
||||
|
||||
- [x] 1.1 Create `apps/api/src/api/ssh_keys.py` with endpoints for list, generate, and delete SSH keys.
|
||||
- [x] 1.2 Implement Ed25519 key generation using cryptography library.
|
||||
- [x] 1.3 Implement Fernet encryption for private keys.
|
||||
- [x] 1.4 Add Pydantic schemas for SSHKeyCreate, SSHKeyResponse.
|
||||
- [x] 1.5 Register ssh_keys router in `apps/api/src/main.py`.
|
||||
- [x] 1.6 Add backend tests for SSH key CRUD operations.
|
||||
|
||||
## 2. Frontend SSH Keys Page
|
||||
|
||||
- [x] 2.1 Create `apps/web/src/api/ssh_keys.ts` with API methods.
|
||||
- [x] 2.2 Create `apps/web/src/pages/ssh-keys.tsx` with key list, generate form, and delete action.
|
||||
- [x] 2.3 Add `/ssh-keys` route in `apps/web/src/router.tsx`.
|
||||
- [x] 2.4 Update app shell navigation to include SSH keys link.
|
||||
|
||||
## 3. Verification
|
||||
|
||||
- [x] 3.1 Run backend checks (pytest, ruff, mypy).
|
||||
- [x] 3.2 Run frontend checks (npm test, typecheck, lint, build).
|
||||
- [x] 3.3 Update tasks file with completed checkboxes.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-18
|
||||
@@ -0,0 +1,53 @@
|
||||
## Context
|
||||
|
||||
The platform has projects and SSH keys but no git repository management. Users need to create bare repos for their projects and optionally clone external ones.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Create bare git repositories on disk within project structure
|
||||
- List repositories per project
|
||||
- Clone external repositories as bare mirrors
|
||||
- Delete repositories (cascade with project deletion)
|
||||
- Prevent duplicate repo names per project
|
||||
|
||||
**Non-Goals:**
|
||||
- Git hosting (push/pull via SSH/HTTP)
|
||||
- Webhook handling
|
||||
- CI/CD integration
|
||||
- Repository browsing/file viewing
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Bare repositories only**
|
||||
- Simplifies storage, no working tree needed
|
||||
- Standard pattern for git servers
|
||||
|
||||
2. **Storage path: `/data/repos/{user_id}/{project_id}/{name}.git`**
|
||||
- Isolates repos by user and project
|
||||
- Predictable structure
|
||||
|
||||
3. **Use subprocess to run `git init --bare` and `git clone --mirror`**
|
||||
- Standard git commands, no additional dependencies
|
||||
- More reliable than git libraries for basic operations
|
||||
|
||||
4. **Store only metadata in database**
|
||||
- Name, path, remote_url, project_id
|
||||
- Actual git data stays on disk
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[Disk space]** -> Monitor usage, implement cleanup
|
||||
- **[Git not in container]** -> Ensure git is installed in API Dockerfile
|
||||
- **[Concurrent access]** -> File locking not implemented (future)
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Create API endpoints
|
||||
2. Add git to Dockerfile
|
||||
3. Create frontend
|
||||
4. Test with sample repos
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should we validate git URLs before cloning?
|
||||
@@ -0,0 +1,25 @@
|
||||
## Why
|
||||
|
||||
Projects exist but users cannot create or manage git repositories. The platform needs to allow users to create bare repositories, clone external ones, and manage them within projects.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add backend API for git repository CRUD operations
|
||||
- Implement bare repository initialization on disk
|
||||
- Support cloning external repositories as mirrors
|
||||
- Add frontend page for repository management
|
||||
- Integrate with existing project structure
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `git-repo-management`: Full git repository lifecycle within projects
|
||||
|
||||
### Modified Capabilities
|
||||
- `project-management`: Include repositories in project responses
|
||||
|
||||
## Impact
|
||||
|
||||
- New API endpoints under `/projects/{id}/repositories`
|
||||
- Disk storage at `/data/repos/{user_id}/{project_id}/{repo_name}.git`
|
||||
- Frontend repository list and creation UI
|
||||
@@ -0,0 +1,62 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Repository Creation
|
||||
|
||||
The system SHALL allow creating new bare git repositories within projects.
|
||||
|
||||
#### Scenario: Create repository
|
||||
- GIVEN an authenticated user with a project
|
||||
- WHEN they POST a repository name
|
||||
- THEN a bare repo is initialized on disk at `/data/repos/{user_id}/{project_id}/{repo_name}.git`
|
||||
- AND metadata is stored in the database
|
||||
- AND the response includes the repository details
|
||||
|
||||
#### Scenario: Duplicate name prevention
|
||||
- GIVEN a project with a repo named "frontend"
|
||||
- WHEN the user tries to create another "frontend" repo
|
||||
- THEN the system responds with 400 Bad Request
|
||||
|
||||
### Requirement: Repository Cloning
|
||||
|
||||
The system SHALL support cloning external repositories as bare mirrors.
|
||||
|
||||
#### Scenario: Clone repository
|
||||
- GIVEN an authenticated user with a project
|
||||
- WHEN they provide a valid remote URL
|
||||
- THEN the system clones as a bare mirror
|
||||
- AND stores it in the structured path
|
||||
- AND records the remote URL in metadata
|
||||
|
||||
#### Scenario: Invalid URL
|
||||
- GIVEN an authenticated user
|
||||
- WHEN they provide an invalid or unreachable URL
|
||||
- THEN the system responds with 400 Bad Request
|
||||
|
||||
### Requirement: Repository Listing
|
||||
|
||||
The system SHALL list all repositories for a project.
|
||||
|
||||
#### Scenario: List repositories
|
||||
- GIVEN an authenticated user with a project
|
||||
- WHEN they GET the repositories endpoint
|
||||
- THEN all repos for that project are listed with name, path, and last push date
|
||||
|
||||
### Requirement: Repository Deletion
|
||||
|
||||
The system SHALL remove repository records and disk contents.
|
||||
|
||||
#### Scenario: Delete repository
|
||||
- GIVEN a project owner
|
||||
- WHEN they delete a repository
|
||||
- THEN the database record is removed
|
||||
- AND the directory on disk is removed
|
||||
|
||||
### Requirement: Project Cascade Delete
|
||||
|
||||
The system SHALL clean up repositories when a project is deleted.
|
||||
|
||||
#### Scenario: Cascade repository cleanup
|
||||
- GIVEN a project with associated repositories
|
||||
- WHEN the project owner deletes the project
|
||||
- THEN all repository records for that project are removed
|
||||
- AND all repository directories on disk are removed
|
||||
@@ -0,0 +1,28 @@
|
||||
## 1. Backend Git Repository API
|
||||
|
||||
- [ ] 1.1 Create `apps/api/src/api/git_repositories.py` with endpoints for list, create, clone, and delete repositories.
|
||||
- [ ] 1.2 Add Pydantic schemas for GitRepositoryCreate, GitRepositoryResponse.
|
||||
- [ ] 1.3 Implement bare repository initialization using `git init --bare`.
|
||||
- [ ] 1.4 Implement mirror cloning using `git clone --mirror`.
|
||||
- [ ] 1.5 Add ownership validation (only project owner can manage repos).
|
||||
- [ ] 1.6 Add duplicate name validation per project.
|
||||
- [ ] 1.7 Register router in `apps/api/src/main.py`.
|
||||
- [ ] 1.8 Add backend tests for repository CRUD operations.
|
||||
|
||||
## 2. Frontend Git Repositories Page
|
||||
|
||||
- [ ] 2.1 Create `apps/web/src/api/git_repositories.ts` with API methods.
|
||||
- [ ] 2.2 Create `apps/web/src/pages/git-repositories.tsx` with repo list, create form, and delete action.
|
||||
- [ ] 2.3 Add route in `apps/web/src/router.tsx`.
|
||||
- [ ] 2.4 Update project page to show associated repositories.
|
||||
|
||||
## 3. Project Integration
|
||||
|
||||
- [ ] 3.1 Update project deletion to cascade delete repositories.
|
||||
- [ ] 3.2 Update project response to include repository count.
|
||||
|
||||
## 4. Verification
|
||||
|
||||
- [ ] 4.1 Run backend checks (pytest, ruff, mypy).
|
||||
- [ ] 4.2 Run frontend checks (npm test, typecheck, lint, build).
|
||||
- [ ] 4.3 Verify docker-compose config is valid.
|
||||
@@ -1,21 +0,0 @@
|
||||
## 1. Backend SSH Key API
|
||||
|
||||
- [ ] 1.1 Create `apps/api/src/api/ssh_keys.py` with endpoints for list, generate, and delete SSH keys.
|
||||
- [ ] 1.2 Implement Ed25519 key generation using cryptography library.
|
||||
- [ ] 1.3 Implement Fernet encryption for private keys.
|
||||
- [ ] 1.4 Add Pydantic schemas for SSHKeyCreate, SSHKeyResponse.
|
||||
- [ ] 1.5 Register ssh_keys router in `apps/api/src/main.py`.
|
||||
- [ ] 1.6 Add backend tests for SSH key CRUD operations.
|
||||
|
||||
## 2. Frontend SSH Keys Page
|
||||
|
||||
- [ ] 2.1 Create `apps/web/src/api/ssh_keys.ts` with API methods.
|
||||
- [ ] 2.2 Create `apps/web/src/pages/ssh-keys.tsx` with key list, generate form, and delete action.
|
||||
- [ ] 2.3 Add `/ssh-keys` route in `apps/web/src/router.tsx`.
|
||||
- [ ] 2.4 Update app shell navigation to include SSH keys link.
|
||||
|
||||
## 3. Verification
|
||||
|
||||
- [ ] 3.1 Run backend checks (pytest, ruff, mypy).
|
||||
- [ ] 3.2 Run frontend checks (npm test, typecheck, lint, build).
|
||||
- [ ] 3.3 Update tasks file with completed checkboxes.
|
||||
Reference in New Issue
Block a user