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:
Fusion
2026-05-18 15:47:42 +02:00
parent b179319601
commit 4e2edb1d93
27 changed files with 623 additions and 23 deletions
+35
View File
@@ -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}`);
}
-1
View File
@@ -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" }
];
+185
View File
@@ -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>
);
};
+5
View File
@@ -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)}
+2 -1
View File
@@ -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" />} />