Modernized git-projects/repositories/

This commit is contained in:
null 2026-05-24 22:22:18 -05:00
parent 1c1bade3ca
commit 19a6b8fda8
1 changed files with 596 additions and 52 deletions

View File

@ -1,14 +1,37 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { AlertCircle, CheckCircle2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import {
AlertCircle,
CheckCircle2,
CircleDot,
Clock,
Copy,
ExternalLink,
GitBranch,
Loader2,
RefreshCw,
Search,
Settings,
Tags,
Webhook,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/auth/clerk";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { ForgejoRepositoriesTable } from "@/components/git/ForgejoRepositoriesTable";
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { getApiBaseUrl } from "@/lib/api-base";
import {
getForgejoRepositories,
deleteForgejoRepository,
@ -17,46 +40,420 @@ import {
type ForgejoRepository,
} from "@/lib/api-forgejo";
export default function ForgejoRepositoriesPage() {
const router = useRouter();
const auth = useAuth();
type Notice = {
tone: "success" | "error";
message: string;
};
type RepositoryFilter =
| "all"
| "attention"
| "active"
| "webhooks"
| "archived";
const repositoryName = (repository: ForgejoRepository) =>
repository.display_name || `${repository.owner}/${repository.repo}`;
const formatTimestamp = (value: string | null) => {
if (!value) return "Never";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
};
const formatCompactNumber = (value: number) =>
new Intl.NumberFormat(undefined, { notation: "compact" }).format(value);
const labelColor = (color: string) =>
color.startsWith("#") ? color : `#${color}`;
function NoticeBanner({ notice }: { notice: Notice }) {
return (
<div
className={`flex items-start gap-3 rounded-xl border p-3 text-sm ${
notice.tone === "success"
? "border-[color:rgba(52,211,153,0.35)] bg-[color:rgba(52,211,153,0.08)] text-[color:var(--success)]"
: "border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] text-[color:var(--danger)]"
}`}
>
{notice.tone === "success" ? (
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
) : (
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
)}
<span>{notice.message}</span>
</div>
);
}
function StatCard({
icon,
label,
value,
caption,
}: {
icon: React.ReactNode;
label: string;
value: string;
caption: string;
}) {
return (
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wide text-muted">
{label}
</p>
<p className="mt-2 text-2xl font-semibold text-strong">{value}</p>
</div>
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-2 text-[color:var(--accent)]">
{icon}
</div>
</div>
<p className="mt-3 text-sm text-muted">{caption}</p>
</div>
);
}
function RepositoryDetailsDialog({
repository,
webhookBaseUrl,
onOpenChange,
}: {
repository: ForgejoRepository | null;
webhookBaseUrl: string;
onOpenChange: (open: boolean) => void;
}) {
const [copied, setCopied] = useState(false);
const webhookUrl = repository
? webhookBaseUrl
? `${webhookBaseUrl}/api/v1/forgejo/webhooks/${repository.id}`
: `.../api/v1/forgejo/webhooks/${repository.id}`
: "";
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(webhookUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard support is optional.
}
};
return (
<Dialog open={Boolean(repository)} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>
{repository ? repositoryName(repository) : "Repository details"}
</DialogTitle>
</DialogHeader>
{repository ? (
<div className="space-y-5">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={<CircleDot className="h-4 w-4" />}
label="Open Issues"
value={String(repository.open_issues_count)}
caption="Reported upstream."
/>
<StatCard
icon={<GitBranch className="h-4 w-4" />}
label="Branch"
value={repository.default_branch || "Unknown"}
caption="Default branch."
/>
<StatCard
icon={<Webhook className="h-4 w-4" />}
label="Webhook"
value={repository.has_webhook_secret ? "Ready" : "Missing"}
caption="Stored secret status."
/>
<StatCard
icon={<Clock className="h-4 w-4" />}
label="Synced"
value={formatTimestamp(repository.last_sync_at)}
caption="Last sync timestamp."
/>
</div>
{repository.last_sync_error ? (
<div className="flex items-start gap-3 rounded-lg border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] p-3 text-sm text-[color:var(--danger)]">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{repository.last_sync_error}</span>
</div>
) : null}
{repository.description ? (
<p className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-sm text-muted">
{repository.description}
</p>
) : null}
<div className="grid gap-4 lg:grid-cols-2">
<div>
<h3 className="text-sm font-semibold text-strong">
Linked Boards
</h3>
<div className="mt-2 flex flex-wrap gap-2">
{repository.linked_boards.length ? (
repository.linked_boards.map((board) => (
<Badge
key={board.id}
variant="outline"
className="normal-case"
>
{board.name}
</Badge>
))
) : (
<span className="text-sm text-muted">
No linked boards.
</span>
)}
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-strong">Topics</h3>
<div className="mt-2 flex flex-wrap gap-2">
{repository.topics.length ? (
repository.topics.map((topic) => (
<Badge
key={topic}
variant="accent"
className="gap-1 normal-case"
>
<Tags className="h-3 w-3" />
{topic}
</Badge>
))
) : (
<span className="text-sm text-muted">No topics.</span>
)}
</div>
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-strong">Labels</h3>
<div className="mt-2 flex flex-wrap gap-2">
{repository.labels.length ? (
repository.labels.map((label) => (
<span
key={`${label.id ?? label.name}-${label.name}`}
className="inline-flex items-center gap-1 rounded-full border border-[color:var(--border)] px-3 py-1 text-xs text-strong"
title={label.description || label.name}
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: labelColor(label.color) }}
/>
{label.name}
</span>
))
) : (
<span className="text-sm text-muted">No labels synced.</span>
)}
</div>
</div>
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-sm font-semibold text-strong">
Webhook URL
</p>
<code className="mt-1 block truncate font-mono text-xs text-muted">
{webhookUrl}
</code>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleCopy}
className="h-8"
>
{copied ? (
<CheckCircle2 className="h-4 w-4 text-[color:var(--success)]" />
) : (
<Copy className="h-4 w-4" />
)}
{copied ? "Copied" : "Copy"}
</Button>
</div>
</div>
</div>
) : null}
</DialogContent>
</Dialog>
);
}
export default function ForgejoRepositoriesPage() {
const auth = useAuth();
const [repositories, setRepositories] = useState<ForgejoRepository[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isBulkSyncing, setIsBulkSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<{
tone: "success" | "error";
message: string;
} | null>(null);
const [notice, setNotice] = useState<Notice | null>(null);
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<RepositoryFilter>("all");
const [selectedRepository, setSelectedRepository] =
useState<ForgejoRepository | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ForgejoRepository | null>(
null,
);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
const fetchRepositories = async () => {
try {
setIsLoading(true);
const data = await getForgejoRepositories();
setRepositories(data);
setError(null);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to load repositories",
);
} finally {
setIsLoading(false);
}
};
const webhookBaseUrl = useMemo(() => {
try {
return getApiBaseUrl();
} catch {
return "";
}
}, []);
const fetchRepositories = useCallback(async () => {
try {
setIsLoading(true);
const data = await getForgejoRepositories();
setRepositories(data);
setError(null);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to load repositories",
);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
if (auth.isSignedIn) {
fetchRepositories();
}
}, [auth.isSignedIn]);
}, [auth.isSignedIn, fetchRepositories]);
const repositoryName = (repository: ForgejoRepository) =>
repository.display_name || `${repository.owner}/${repository.repo}`;
useEffect(() => {
if (!notice) return;
const id = setTimeout(() => setNotice(null), 8000);
return () => clearTimeout(id);
}, [notice]);
const activeRepositories = useMemo(
() => repositories.filter((repo) => repo.active).length,
[repositories],
);
const totalOpenIssues = useMemo(
() =>
repositories.reduce((total, repo) => total + repo.open_issues_count, 0),
[repositories],
);
const webhookReady = useMemo(
() =>
repositories.filter((repo) => repo.active && repo.has_webhook_secret)
.length,
[repositories],
);
const syncErrors = useMemo(
() => repositories.filter((repo) => repo.last_sync_error).length,
[repositories],
);
const attentionRepositories = useMemo(
() =>
repositories.filter(
(repo) =>
repo.last_sync_error ||
(repo.active && !repo.has_webhook_secret) ||
(repo.active && !repo.last_sync_at),
),
[repositories],
);
const archivedRepositories = useMemo(
() => repositories.filter((repo) => repo.is_archived).length,
[repositories],
);
const linkedBoardCount = useMemo(
() =>
repositories.reduce(
(total, repo) => total + repo.linked_boards.length,
0,
),
[repositories],
);
const latestSync = useMemo(() => {
const dates = repositories
.map((repo) => repo.last_sync_at)
.filter((value): value is string => Boolean(value))
.map((value) => new Date(value))
.filter((date) => !Number.isNaN(date.getTime()));
if (!dates.length) return null;
return dates.reduce((latest, date) =>
date.getTime() > latest.getTime() ? date : latest,
);
}, [repositories]);
const filteredRepositories = useMemo(() => {
const query = search.trim().toLowerCase();
return repositories.filter((repo) => {
const matchesQuery =
!query ||
[
repositoryName(repo),
repo.owner,
repo.repo,
repo.connection?.name ?? "",
repo.connection?.base_url ?? "",
repo.default_branch,
...repo.topics,
...repo.linked_boards.map((board) => board.name),
]
.join(" ")
.toLowerCase()
.includes(query);
if (!matchesQuery) return false;
if (filter === "active") return repo.active;
if (filter === "webhooks") return repo.active && !repo.has_webhook_secret;
if (filter === "archived") return repo.is_archived;
if (filter === "attention") return attentionRepositories.includes(repo);
return true;
});
}, [attentionRepositories, filter, repositories, search]);
const filterOptions = useMemo(
() => [
{ key: "all" as const, label: "All", count: repositories.length },
{
key: "attention" as const,
label: "Attention",
count: attentionRepositories.length,
},
{ key: "active" as const, label: "Active", count: activeRepositories },
{
key: "webhooks" as const,
label: "Missing Webhooks",
count: repositories.filter(
(repo) => repo.active && !repo.has_webhook_secret,
).length,
},
{
key: "archived" as const,
label: "Archived",
count: archivedRepositories,
},
],
[
activeRepositories,
archivedRepositories,
attentionRepositories.length,
repositories,
],
);
const handleDelete = (repository: ForgejoRepository) => {
setDeleteError(null);
@ -105,6 +502,38 @@ export default function ForgejoRepositoriesPage() {
}
};
const handleSyncAttention = async () => {
const targets = attentionRepositories.filter((repo) => repo.active);
if (!targets.length) return;
setIsBulkSyncing(true);
let succeeded = 0;
let failed = 0;
await Promise.allSettled(
targets.map(async (repo) => {
try {
await syncRepository(repo.id);
succeeded++;
} catch {
failed++;
}
}),
);
const data = await getForgejoRepositories();
setRepositories(data);
setIsBulkSyncing(false);
setNotice(
failed === 0
? {
tone: "success",
message: `${succeeded} repositor${succeeded === 1 ? "y" : "ies"} synced successfully.`,
}
: {
tone: "error",
message: `Sync completed: ${succeeded} succeeded, ${failed} failed.`,
},
);
};
const handleValidateRepository = async (repository: ForgejoRepository) => {
try {
const result = await validateRepository(repository.id);
@ -141,33 +570,140 @@ export default function ForgejoRepositoriesPage() {
title="Git Project Repositories"
description={`${repositories.length} repositor${repositories.length === 1 ? "y" : "ies"} tracked by Pipeline.`}
stickyHeader
>
<div className="flex flex-col gap-4">
{notice ? (
<div
className={`flex items-start gap-3 rounded-xl border p-3 text-sm ${
notice.tone === "success"
? "border-[color:rgba(52,211,153,0.35)] bg-[color:rgba(52,211,153,0.08)] text-[color:var(--success)]"
: "border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] text-[color:var(--danger)]"
}`}
>
{notice.tone === "success" ? (
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
) : (
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
)}
<span>{notice.message}</span>
</div>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-sm font-medium text-muted">Repositories</h2>
headerActions={
<div className="flex flex-wrap gap-2">
<Link href="/settings/git-projects">
<Button variant="outline" size="sm">
<Settings className="h-4 w-4" />
Settings
</Button>
</Link>
<Button
onClick={() => router.push("/git-projects/repositories/new")}
type="button"
variant="outline"
size="sm"
onClick={fetchRepositories}
disabled={isLoading}
>
Add Repository
<RefreshCw
className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
<Link href="/git-projects/repositories/new">
<Button size="sm">
<GitBranch className="h-4 w-4" />
Add Repository
</Button>
</Link>
</div>
}
>
<div className="space-y-6">
{notice ? <NoticeBanner notice={notice} /> : null}
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<Badge
variant={attentionRepositories.length ? "warning" : "success"}
className="w-fit"
>
{attentionRepositories.length
? `${attentionRepositories.length} need review`
: "Healthy"}
</Badge>
<h2 className="mt-3 font-heading text-2xl font-semibold text-strong">
Repository Operations
</h2>
<p className="mt-2 max-w-3xl text-sm text-muted">
Track provider reachability, webhook coverage, sync freshness,
and board usage from one management surface.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={handleSyncAttention}
disabled={
isBulkSyncing ||
attentionRepositories.filter((repo) => repo.active)
.length === 0
}
>
{isBulkSyncing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
Sync Attention
</Button>
<Link href="/git-projects">
<Button type="button" variant="outline">
<ExternalLink className="h-4 w-4" />
Git Projects
</Button>
</Link>
</div>
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
icon={<GitBranch className="h-4 w-4" />}
label="Repositories"
value={`${activeRepositories}/${repositories.length}`}
caption="Active tracked repositories."
/>
<StatCard
icon={<CircleDot className="h-4 w-4" />}
label="Open Issues"
value={formatCompactNumber(totalOpenIssues)}
caption="Reported by Forgejo."
/>
<StatCard
icon={<Webhook className="h-4 w-4" />}
label="Webhooks"
value={`${webhookReady}/${activeRepositories}`}
caption="Active repositories with secrets."
/>
<StatCard
icon={<Clock className="h-4 w-4" />}
label="Latest Sync"
value={formatTimestamp(latestSync?.toISOString() ?? null)}
caption={`${syncErrors} errors, ${archivedRepositories} archived, ${linkedBoardCount} board links.`}
/>
</div>
</section>
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5">
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search repositories, connections, topics, boards..."
className="pl-9"
/>
</div>
<div className="flex flex-wrap gap-2">
{filterOptions.map((option) => (
<Button
key={option.key}
type="button"
variant={filter === option.key ? "secondary" : "outline"}
size="sm"
onClick={() => setFilter(option.key)}
className="gap-1.5"
>
{option.label}
<span className="text-xs text-muted">{option.count}</span>
</Button>
))}
</div>
</div>
</section>
<div className="overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-lush">
{error ? (
<div className="p-8 text-center">
@ -175,9 +711,10 @@ export default function ForgejoRepositoriesPage() {
</div>
) : (
<ForgejoRepositoriesTable
repositories={repositories}
repositories={filteredRepositories}
isLoading={isLoading}
onDelete={handleDelete}
onViewDetails={setSelectedRepository}
onSync={handleSync}
onValidate={handleValidateRepository}
/>
@ -185,6 +722,13 @@ export default function ForgejoRepositoriesPage() {
</div>
</div>
</DashboardPageLayout>
<RepositoryDetailsDialog
repository={selectedRepository}
webhookBaseUrl={webhookBaseUrl}
onOpenChange={(open) => {
if (!open) setSelectedRepository(null);
}}
/>
<ConfirmActionDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => {