"use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; import { Activity, AlertTriangle, CircleDot, ExternalLink, GitBranch, GitCommitHorizontal, GitMerge, GitPullRequest, HardDrive, ListPlus, Loader2, Lock, Package, Plus, Radio, RefreshCw, ShieldCheck, Star, Tag, Unlink, } from "lucide-react"; import { CiBadge } from "@/components/CiBadge"; import { Heatmap } from "@/components/Heatmap"; import { RepositoryDialog } from "@/components/RepositoryDialog"; import { WebhookDialog } from "@/components/WebhookDialog"; import { Sparkline } from "@/components/Sparkline"; import { repoTitle } from "@/lib/git/meta"; import { asArray, asCalendar, asRecord } from "@/types"; import type { BranchInfo, CommitInfo, ConnectionSafe, ProjectDetail, ReleaseInfo, RepositoryWithSnapshot, TagInfo, TicketInfo, } from "@/types"; import { cn, formatDateTime, formatNumber, formatRelative } from "@/lib/utils"; const TABS = [ { key: "pulls", label: "PRs", icon: GitPullRequest }, { key: "issues", label: "Issues", icon: CircleDot }, { key: "branches", label: "Branches", icon: GitBranch }, { key: "tags", label: "Tags", icon: Tag }, { key: "commits", label: "Commits", icon: GitCommitHorizontal }, { key: "releases", label: "Releases", icon: Package }, ] as const; type TabKey = (typeof TABS)[number]["key"]; export function GitPanel({ project, connections, webhookRepoIds = [], }: { project: ProjectDetail; connections: ConnectionSafe[]; /** Repositories mit hinterlegtem Webhook. Das Geheimnis selbst bleibt auf dem Server – hier kommt nur an, ob eines existiert. */ webhookRepoIds?: string[]; }) { const router = useRouter(); const [tab, setTab] = useState("pulls"); const [syncing, setSyncing] = useState(false); const [importing, setImporting] = useState(false); const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); const [addOpen, setAddOpen] = useState(false); const [webhookOpen, setWebhookOpen] = useState(false); const repositories = project.repositories; const [selectedId, setSelectedId] = useState(repositories[0]?.id ?? null); // Nach dem Entfernen zeigt die Auswahl ins Leere – dann auf das fuehrende // zurueckfallen, statt eine leere Ansicht zu zeigen. const repository = repositories.find((entry) => entry.id === selectedId) ?? repositories[0] ?? null; const webhookAktiv = repository ? webhookRepoIds.includes(repository.id) : false; /** Offene Issues als Aufgaben anlegen – ohne erneuten Abruf beim Provider. */ async function importIssues() { if (!repository) return; setImporting(true); setMessage(null); try { const response = await fetch(`/api/projects/${project.id}/tasks/from-issues`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ repositoryId: repository.id }), }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error ?? "Uebernahme fehlgeschlagen."); setMessage({ ok: true, text: body.created === 0 ? `Nichts Neues – alle ${body.skipped} Issues sind bereits als Aufgabe vorhanden.` : `${body.created} Aufgabe(n) angelegt${body.skipped ? `, ${body.skipped} uebersprungen` : ""}.`, }); router.refresh(); } catch (caught) { setMessage({ ok: false, text: caught instanceof Error ? caught.message : String(caught) }); } finally { setImporting(false); } } const snapshot = repository?.snapshot ?? null; const branches = asArray(snapshot?.branches); const tags = asArray(snapshot?.tags); const commits = asArray(snapshot?.commits); const releases = asArray(snapshot?.releases); const languages = asRecord(snapshot?.languages); const pulls = asArray(snapshot?.pullRequests); const issues = asArray(snapshot?.issues); const activity = asArray(snapshot?.commitActivity); const calendar = asCalendar(snapshot?.commitCalendar); /** Ein einzelnes Repository oder – ohne Argument – alle. */ async function sync(nurDieses?: string) { setSyncing(true); setMessage(null); try { const url = nurDieses ? `/api/repositories/${nurDieses}/sync` : `/api/projects/${project.id}/sync`; const response = await fetch(url, { method: "POST" }); const payload = await response.json().catch(() => ({})); setMessage({ ok: response.ok, text: payload.message ?? "Unbekannte Antwort." }); router.refresh(); } catch (caught) { setMessage({ ok: false, text: caught instanceof Error ? caught.message : String(caught), }); } finally { setSyncing(false); } } async function removeRepository(entry: RepositoryWithSnapshot) { if ( !window.confirm( `„${repoTitle(entry)}“ vom Projekt loesen? Der geholte Stand geht dabei verloren.`, ) ) { return; } await fetch(`/api/repositories/${entry.id}`, { method: "DELETE" }).catch(() => undefined); setSelectedId(null); router.refresh(); } if (!repository) { return ( <>

Kein Repository verknuepft

Verknuepfe ein Repository, um Branches, Tags, Commits und Releases hier zu sehen. Ein Projekt darf auch mehrere haben – etwa Frontend und Backend.

setAddOpen(false)} projectId={project.id} connections={connections} /> ); } const languageTotal = Object.values(languages).reduce((sum, value) => sum + value, 0); const topLanguages = Object.entries(languages) .sort((a, b) => b[1] - a[1]) .slice(0, 5); return (
{/* Auswahl, sobald es mehr als eines gibt. Bei einem einzigen waere eine Leiste mit genau einem Knopf nur Ballast. */} {repositories.length > 1 ? (
{repositories.map((entry) => { const aktiv = entry.id === repository.id; return ( ); })}
) : null} {/* Kopf mit Sync */}

{snapshot?.fullName ?? repoTitle(repository)}

{snapshot?.visibility ? ( {snapshot.visibility === "private" ? ( ) : ( )} {snapshot.visibility} ) : null} {snapshot?.htmlUrl ? ( ) : null}

{repository.lastSyncedAt ? `Zuletzt synchronisiert ${formatRelative(repository.lastSyncedAt)} · ${formatDateTime(repository.lastSyncedAt)}` : "Noch nie synchronisiert."}

{repositories.length > 1 ? ( ) : ( )}
setAddOpen(false)} projectId={project.id} connections={connections} /> setWebhookOpen(false)} repositoryId={repository.id} repositoryTitle={repoTitle(repository)} provider={repository.connection?.provider ?? null} eingerichtet={webhookAktiv} zuletzt={repository.webhookLastAt} /> {message ? (

{message.text}

) : null} {repository.syncError && !message ? (

{repository.syncError}

) : null} {snapshot ? ( <> {/* Kennzahlen */}
1024 ? `${Math.round(snapshot.sizeKb / 1024)} MB` : `${snapshot.sizeKb} KB`} />
{/* Letzter Commit + Version */}

Letzter Commit

{snapshot.lastCommitSha ? ( <>

{snapshot.lastCommitMessage}

{snapshot.lastCommitSha.slice(0, 7)} {snapshot.lastCommitAuthor ?? "unbekannt"} {formatRelative(snapshot.lastCommitAt)}

) : (

Keine Commit-Daten.

)}

Aktuelle Version

{snapshot.latestTag ?? snapshot.latestRelease ?? "–"} {snapshot.latestRelease && snapshot.latestRelease !== snapshot.latestTag ? ( {snapshot.latestRelease} ) : null}

{snapshot.latestReleaseAt ? `Release vom ${formatDateTime(snapshot.latestReleaseAt)}` : `${snapshot.tagCount} Tag${snapshot.tagCount === 1 ? "" : "s"} im Repository`}

{/* Commit-Aktivitaet */} {activity.some((value) => value > 0) ? (

{/* Der Zeitraum richtet sich danach, wie weit die geholten Commits zurueckreichen – hoechstens 30 Tage. */} Commits der letzten {activity.length} Tage

{activity.reduce((sum, value) => sum + value, 0)}
) : null} {/* Der Jahresueberblick, sobald er einmal geholt wurde. */} {calendar ? : null} {/* Sprachen */} {languageTotal > 0 ? (

Sprachen

{topLanguages.map(([name, bytes], index) => ( ))}
{topLanguages.map(([name, bytes], index) => ( {name} {Math.round((bytes / languageTotal) * 100)}% ))}
) : null} {/* Listen */}
{TABS.map(({ key, label, icon: Icon }) => { const count = { pulls: pulls.length, issues: issues.length, branches: branches.length, tags: tags.length, commits: commits.length, releases: releases.length, }[key]; return ( ); })}
{tab === "pulls" ? ( ) : null} {tab === "issues" ? ( <> {issues.length > 0 ? (

Aus offenen Issues Aufgaben machen. Bereits uebernommene werden uebersprungen.

) : null} ) : null} {tab === "branches" ? : null} {tab === "tags" ? : null} {tab === "commits" ? : null} {tab === "releases" ? : null}
) : (
Noch keine Daten geladen. Mit „Jetzt synchronisieren“ den ersten Snapshot holen.
)}
); } const LANGUAGE_COLORS = ["#a78bfa", "#22d3ee", "#34d399", "#fbbf24", "#f472b6"]; function Metric({ icon: Icon, label, value, }: { icon: typeof Star; label: string; value: string; }) { return (

{label}

{value}

); } function Row({ children }: { children: React.ReactNode }) { return (
{children}
); } function Empty({ text }: { text: string }) { return

{text}

; } function TicketList({ tickets, empty, icon: Icon, }: { tickets: TicketInfo[]; empty: string; icon: typeof GitPullRequest; }) { if (tickets.length === 0) return ; return (
{tickets.map((ticket) => (

{ticket.title} {ticket.draft ? ( Entwurf ) : null}

#{ticket.number} {ticket.author ?? "unbekannt"} {ticket.labels.slice(0, 3).map((label) => ( {label} ))}

{formatRelative(ticket.createdAt)} {ticket.url ? ( ) : null}
))}
); } function BranchList({ branches }: { branches: BranchInfo[] }) { if (branches.length === 0) return ; return (
{branches.map((branch) => (
{branch.name} {branch.isDefault ? ( default ) : null} {branch.protected ? ( ) : null}
{branch.lastCommitAt ? ( {formatRelative(branch.lastCommitAt)} ) : null} {branch.sha ? ( {branch.sha.slice(0, 7)} ) : null}
))}
); } function TagList({ tags }: { tags: TagInfo[] }) { if (tags.length === 0) return ; return (
{tags.map((tag) => (
{tag.name} {tag.message ? ( {tag.message} ) : null}
{tag.date ? {formatRelative(tag.date)} : null} {tag.sha ? ( {tag.sha.slice(0, 7)} ) : null}
))}
); } function CommitList({ commits }: { commits: CommitInfo[] }) { if (commits.length === 0) return ; return (
{commits.map((commit) => (

{commit.message}

{commit.shortSha} {commit.author ?? "unbekannt"}

{formatRelative(commit.date)} {commit.url ? ( ) : null}
))}
); } function ReleaseList({ releases }: { releases: ReleaseInfo[] }) { if (releases.length === 0) return ; return (
{releases.map((release) => (
{release.name} {release.tag} {release.prerelease ? ( pre ) : null} {release.draft ? ( draft ) : null}
{formatRelative(release.publishedAt)} {release.url ? ( ) : null}
))}
); }