From 03ce74faa2e2261fce1900c9791c033315d64b60 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 26 Jul 2026 01:04:19 +0200 Subject: [PATCH] feat(git): offene PRs und Issues, CI-Status und Commit-Aktivitaet Bisher standen nur Zahlen da. Jetzt kommen die Inhalte mit. Pull Requests und Issues: bis zu 50 offene je Liste, mit Nummer, Titel, Autor, Alter, Labels und Link. Bei GitHub und Gitea liefert der Issue-Endpunkt auch Pull Requests mit - die werden herausgefiltert. CI-Status des letzten Laufs auf dem Default-Branch: - GitHub ueber actions/runs; dort sind Lauf-Status und Ergebnis getrennt und werden auf einen Wert zusammengefuehrt - GitLab ueber pipelines - Gitea ueber den kombinierten Commit-Status. Das deckt Gitea Actions und externe Systeme wie Woodpecker gleichermassen ab - ein reiner Actions-Endpunkt existiert auf aelteren Instanzen nicht. Commit-Aktivitaet als Balkenreihe. Berechnet aus den ohnehin geholten Commits (jetzt 100 statt 15, angezeigt bleiben 15) statt ueber eigene Statistik-Endpunkte: die gibt es nicht bei allen drei Systemen, und bei GitHub antworten sie beim ersten Aufruf mit 202 und ohne Daten. Beim Testen gegen gitlab-runner fiel auf, dass alle 100 Commits in die letzten 30 Tage fielen - die frueheren Tage haetten faelschlich Null gezeigt, obwohl wir ueber sie nichts wissen. Die Kurve wird deshalb auf den tatsaechlich belegten Zeitraum gekuerzt. Co-Authored-By: Claude Opus 5 (1M context) --- .../20260726100000_pr_issues_ci/migration.sql | 9 ++ prisma/schema.prisma | 22 +++- src/components/CiBadge.tsx | 90 +++++++++++++ src/components/GitPanel.tsx | 118 +++++++++++++++-- src/components/ProjectCard.tsx | 14 +- src/components/Sparkline.tsx | 57 ++++++++ src/lib/git/gitea.ts | 121 +++++++++++++++-- src/lib/git/github.ts | 122 ++++++++++++++++-- src/lib/git/gitlab.ts | 111 ++++++++++++++-- src/lib/git/parse.test.ts | 109 ++++++++++++++++ src/lib/git/types.ts | 70 ++++++++++ src/lib/sync/index.ts | 8 ++ src/types/index.ts | 21 ++- 13 files changed, 823 insertions(+), 49 deletions(-) create mode 100644 prisma/migrations/20260726100000_pr_issues_ci/migration.sql create mode 100644 src/components/CiBadge.tsx create mode 100644 src/components/Sparkline.tsx create mode 100644 src/lib/git/parse.test.ts diff --git a/prisma/migrations/20260726100000_pr_issues_ci/migration.sql b/prisma/migrations/20260726100000_pr_issues_ci/migration.sql new file mode 100644 index 0000000..16791de --- /dev/null +++ b/prisma/migrations/20260726100000_pr_issues_ci/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "RepoSnapshot" ADD COLUMN "ciBranch" TEXT, +ADD COLUMN "ciFinishedAt" TIMESTAMP(3), +ADD COLUMN "ciName" TEXT, +ADD COLUMN "ciState" TEXT, +ADD COLUMN "ciUrl" TEXT, +ADD COLUMN "commitActivity" JSONB NOT NULL DEFAULT '[]', +ADD COLUMN "issues" JSONB NOT NULL DEFAULT '[]', +ADD COLUMN "pullRequests" JSONB NOT NULL DEFAULT '[]'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c06a565..8cf019a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -298,11 +298,23 @@ model RepoSnapshot { /// Rohdaten der Listen – bewusst als JSON, damit providerspezifische /// Zusatzfelder nicht verloren gehen. - branches Json @default("[]") - tags Json @default("[]") - commits Json @default("[]") - releases Json @default("[]") - languages Json @default("{}") + branches Json @default("[]") + tags Json @default("[]") + commits Json @default("[]") + releases Json @default("[]") + languages Json @default("{}") + pullRequests Json @default("[]") + issues Json @default("[]") + /// Commits je Tag der letzten 30 Tage, aeltester zuerst. + commitActivity Json @default("[]") + + // ── Letzter CI-Lauf auf dem Default-Branch ───────────────── + /// success | failure | running | pending | cancelled + ciState String? + ciName String? + ciBranch String? + ciUrl String? + ciFinishedAt DateTime? pushedAt DateTime? fetchedAt DateTime @default(now()) diff --git a/src/components/CiBadge.tsx b/src/components/CiBadge.tsx new file mode 100644 index 0000000..4638870 --- /dev/null +++ b/src/components/CiBadge.tsx @@ -0,0 +1,90 @@ +import { CircleCheck, CircleDashed, CircleSlash, CircleX, LoaderCircle } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export type CiState = "success" | "failure" | "running" | "pending" | "cancelled"; + +const META: Record< + CiState, + { label: string; short: string; className: string; icon: typeof CircleCheck; spin?: boolean } +> = { + success: { + label: "CI erfolgreich", + short: "CI ok", + className: "bg-emerald-500/12 text-emerald-200 ring-emerald-400/30", + icon: CircleCheck, + }, + failure: { + label: "CI fehlgeschlagen", + short: "CI rot", + className: "bg-rose-500/12 text-rose-200 ring-rose-400/30", + icon: CircleX, + }, + running: { + label: "CI laeuft", + short: "CI laeuft", + className: "bg-amber-500/12 text-amber-200 ring-amber-400/30", + icon: LoaderCircle, + spin: true, + }, + pending: { + label: "CI wartet", + short: "CI wartet", + className: "bg-slate-500/12 text-slate-300 ring-slate-400/25", + icon: CircleDashed, + }, + cancelled: { + label: "CI abgebrochen", + short: "CI abgebrochen", + className: "bg-slate-500/12 text-slate-400 ring-slate-400/20", + icon: CircleSlash, + }, +}; + +export function CiBadge({ + state, + name, + url, + compact = false, +}: { + state: string | null | undefined; + name?: string | null; + url?: string | null; + compact?: boolean; +}) { + if (!state || !(state in META)) return null; + const meta = META[state as CiState]; + const Icon = meta.icon; + + const content = ( + <> + + {compact ? meta.short : (name ?? meta.label)} + + ); + + const className = cn( + "inline-flex items-center gap-1.5 rounded-full ring-1 ring-inset", + meta.className, + compact ? "px-2 py-0.5 text-[10px]" : "px-2.5 py-1 text-[11px]", + ); + + if (url && !compact) { + return ( + + {content} + + ); + } + + return ( + + {content} + + ); +} diff --git a/src/components/GitPanel.tsx b/src/components/GitPanel.tsx index c6afbdc..e20e6cb 100644 --- a/src/components/GitPanel.tsx +++ b/src/components/GitPanel.tsx @@ -3,7 +3,9 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { + Activity, AlertTriangle, + CircleDot, ExternalLink, GitBranch, GitCommitHorizontal, @@ -18,6 +20,8 @@ import { Star, Tag, } from "lucide-react"; +import { CiBadge } from "@/components/CiBadge"; +import { Sparkline } from "@/components/Sparkline"; import { asArray, asRecord } from "@/types"; import type { BranchInfo, @@ -25,10 +29,13 @@ import type { ProjectDetail, ReleaseInfo, 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 }, @@ -39,7 +46,7 @@ type TabKey = (typeof TABS)[number]["key"]; export function GitPanel({ project }: { project: ProjectDetail }) { const router = useRouter(); - const [tab, setTab] = useState("branches"); + const [tab, setTab] = useState("pulls"); const [syncing, setSyncing] = useState(false); const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); @@ -49,6 +56,9 @@ export function GitPanel({ project }: { project: ProjectDetail }) { 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 hasRepo = Boolean(project.repoOwner && project.repoName) || Boolean(project.repoUrl); @@ -118,6 +128,7 @@ export function GitPanel({ project }: { project: ProjectDetail }) { ) : null} +

{project.lastSyncedAt @@ -224,6 +235,24 @@ export function GitPanel({ project }: { project: ProjectDetail }) { + {/* 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} + {/* Sprachen */} {languageTotal > 0 ? (
@@ -263,14 +292,14 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
{TABS.map(({ key, label, icon: Icon }) => { - const count = - key === "branches" - ? branches.length - : key === "tags" - ? tags.length - : key === "commits" - ? commits.length - : releases.length; + const count = { + pulls: pulls.length, + issues: issues.length, + branches: branches.length, + tags: tags.length, + commits: commits.length, + releases: releases.length, + }[key]; return (
+ {/* Commit-Aktivitaet */} + {activity.some((value) => value > 0) ? ( +
+ +
+ ) : null} + {/* Git-Kurzinfo */}
+ + {project.connection ? (