diff --git a/frontend/src/app/git-projects/repositories/page.tsx b/frontend/src/app/git-projects/repositories/page.tsx
index 43aaef5..2037792 100644
--- a/frontend/src/app/git-projects/repositories/page.tsx
+++ b/frontend/src/app/git-projects/repositories/page.tsx
@@ -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 (
+
+ {notice.tone === "success" ? (
+
+ ) : (
+
+ )}
+
{notice.message}
+
+ );
+}
+
+function StatCard({
+ icon,
+ label,
+ value,
+ caption,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ value: string;
+ caption: string;
+}) {
+ return (
+
+
+
+
+ {label}
+
+
{value}
+
+
+ {icon}
+
+
+
{caption}
+
+ );
+}
+
+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 (
+
+ );
+}
+
+export default function ForgejoRepositoriesPage() {
+ const auth = useAuth();
const [repositories, setRepositories] = useState([]);
const [isLoading, setIsLoading] = useState(true);
+ const [isBulkSyncing, setIsBulkSyncing] = useState(false);
const [error, setError] = useState(null);
- const [notice, setNotice] = useState<{
- tone: "success" | "error";
- message: string;
- } | null>(null);
+ const [notice, setNotice] = useState(null);
+ const [search, setSearch] = useState("");
+ const [filter, setFilter] = useState("all");
+ const [selectedRepository, setSelectedRepository] =
+ useState(null);
const [deleteTarget, setDeleteTarget] = useState(
null,
);
const [deleteError, setDeleteError] = useState(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
- >
-
- {notice ? (
-
- {notice.tone === "success" ? (
-
- ) : (
-
- )}
-
{notice.message}
-
- ) : null}
-
-
-
Repositories
+ headerActions={
+
+
+
+
+
+
+
+ }
+ >
+
+ {notice ?
: null}
+
+
+
+
+
+ {attentionRepositories.length
+ ? `${attentionRepositories.length} need review`
+ : "Healthy"}
+
+
+ Repository Operations
+
+
+ Track provider reachability, webhook coverage, sync freshness,
+ and board usage from one management surface.
+
+
+
+
+
+
+
+
+
+
+ }
+ label="Repositories"
+ value={`${activeRepositories}/${repositories.length}`}
+ caption="Active tracked repositories."
+ />
+ }
+ label="Open Issues"
+ value={formatCompactNumber(totalOpenIssues)}
+ caption="Reported by Forgejo."
+ />
+ }
+ label="Webhooks"
+ value={`${webhookReady}/${activeRepositories}`}
+ caption="Active repositories with secrets."
+ />
+ }
+ label="Latest Sync"
+ value={formatTimestamp(latestSync?.toISOString() ?? null)}
+ caption={`${syncErrors} errors, ${archivedRepositories} archived, ${linkedBoardCount} board links.`}
+ />
+
+
+
+
+
+
+
+ setSearch(event.target.value)}
+ placeholder="Search repositories, connections, topics, boards..."
+ className="pl-9"
+ />
+
+
+ {filterOptions.map((option) => (
+
+ ))}
+
+
+
+
{error ? (
@@ -175,9 +711,10 @@ export default function ForgejoRepositoriesPage() {
) : (
@@ -185,6 +722,13 @@ export default function ForgejoRepositoriesPage() {
+
{
+ if (!open) setSelectedRepository(null);
+ }}
+ />
{