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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 = (
|
||||
<>
|
||||
<Icon className={cn("h-3 w-3 shrink-0", meta.spin && "animate-spin")} />
|
||||
{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 (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className={cn(className, "transition-all hover:brightness-125")}
|
||||
title={meta.label}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={className} title={name ? `${meta.label} · ${name}` : meta.label}>
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+109
-9
@@ -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<TabKey>("branches");
|
||||
const [tab, setTab] = useState<TabKey>("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<CommitInfo>(snapshot?.commits);
|
||||
const releases = asArray<ReleaseInfo>(snapshot?.releases);
|
||||
const languages = asRecord(snapshot?.languages);
|
||||
const pulls = asArray<TicketInfo>(snapshot?.pullRequests);
|
||||
const issues = asArray<TicketInfo>(snapshot?.issues);
|
||||
const activity = asArray<number>(snapshot?.commitActivity);
|
||||
|
||||
const hasRepo = Boolean(project.repoOwner && project.repoName) || Boolean(project.repoUrl);
|
||||
|
||||
@@ -118,6 +128,7 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
) : null}
|
||||
<CiBadge state={snapshot?.ciState} name={snapshot?.ciName} url={snapshot?.ciUrl} />
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500" suppressHydrationWarning>
|
||||
{project.lastSyncedAt
|
||||
@@ -224,6 +235,24 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commit-Aktivitaet */}
|
||||
{activity.some((value) => value > 0) ? (
|
||||
<div className="glass p-4">
|
||||
<div className="mb-2.5 flex items-center justify-between">
|
||||
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
|
||||
<Activity className="h-3 w-3" />
|
||||
{/* Der Zeitraum richtet sich danach, wie weit die geholten
|
||||
Commits zurueckreichen – hoechstens 30 Tage. */}
|
||||
Commits der letzten {activity.length} Tage
|
||||
</p>
|
||||
<span className="text-xs tabular-nums text-slate-400">
|
||||
{activity.reduce((sum, value) => sum + value, 0)}
|
||||
</span>
|
||||
</div>
|
||||
<Sparkline values={activity} height={40} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Sprachen */}
|
||||
{languageTotal > 0 ? (
|
||||
<div className="glass p-4">
|
||||
@@ -263,14 +292,14 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
|
||||
<div className="glass overflow-hidden">
|
||||
<div className="flex flex-wrap gap-1 border-b border-white/6 p-2">
|
||||
{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 (
|
||||
<button
|
||||
key={key}
|
||||
@@ -292,6 +321,16 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
|
||||
</div>
|
||||
|
||||
<div className="max-h-[26rem] overflow-y-auto">
|
||||
{tab === "pulls" ? (
|
||||
<TicketList
|
||||
tickets={pulls}
|
||||
empty="Keine offenen Pull oder Merge Requests."
|
||||
icon={GitPullRequest}
|
||||
/>
|
||||
) : null}
|
||||
{tab === "issues" ? (
|
||||
<TicketList tickets={issues} empty="Keine offenen Issues." icon={CircleDot} />
|
||||
) : null}
|
||||
{tab === "branches" ? <BranchList branches={branches} /> : null}
|
||||
{tab === "tags" ? <TagList tags={tags} /> : null}
|
||||
{tab === "commits" ? <CommitList commits={commits} /> : null}
|
||||
@@ -342,6 +381,67 @@ function Empty({ text }: { text: string }) {
|
||||
return <p className="px-4 py-8 text-center text-xs text-slate-500">{text}</p>;
|
||||
}
|
||||
|
||||
function TicketList({
|
||||
tickets,
|
||||
empty,
|
||||
icon: Icon,
|
||||
}: {
|
||||
tickets: TicketInfo[];
|
||||
empty: string;
|
||||
icon: typeof GitPullRequest;
|
||||
}) {
|
||||
if (tickets.length === 0) return <Empty text={empty} />;
|
||||
return (
|
||||
<div>
|
||||
{tickets.map((ticket) => (
|
||||
<Row key={`${ticket.number}-${ticket.title}`}>
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<Icon
|
||||
className={cn(
|
||||
"mt-0.5 h-3.5 w-3.5 shrink-0",
|
||||
ticket.draft ? "text-slate-600" : "text-emerald-400/70",
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs text-slate-200">
|
||||
{ticket.title}
|
||||
{ticket.draft ? (
|
||||
<span className="ml-1.5 rounded bg-slate-500/15 px-1 py-0.5 text-[10px] text-slate-400">
|
||||
Entwurf
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-slate-500">
|
||||
<span className="font-mono text-slate-600">#{ticket.number}</span>
|
||||
<span>{ticket.author ?? "unbekannt"}</span>
|
||||
{ticket.labels.slice(0, 3).map((label) => (
|
||||
<span key={label} className="rounded bg-white/6 px-1 py-0.5 text-[10px]">
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-[11px] text-slate-500">
|
||||
<span suppressHydrationWarning>{formatRelative(ticket.createdAt)}</span>
|
||||
{ticket.url ? (
|
||||
<a
|
||||
href={ticket.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-slate-600 transition-colors hover:text-violet-300"
|
||||
aria-label="Im Git-System oeffnen"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</Row>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BranchList({ branches }: { branches: BranchInfo[] }) {
|
||||
if (branches.length === 0) return <Empty text="Keine Branches gefunden." />;
|
||||
return (
|
||||
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
Star,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
import { CiBadge } from "@/components/CiBadge";
|
||||
import { Sparkline } from "@/components/Sparkline";
|
||||
import { StatusSelect } from "@/components/StatusSelect";
|
||||
import { PRIORITY_META, accentOf } from "@/lib/status";
|
||||
import { cn, formatRelative, truncate } from "@/lib/utils";
|
||||
import type { ProjectListItem } from "@/types";
|
||||
import { asArray, type ProjectListItem } from "@/types";
|
||||
|
||||
const PROVIDER_LABEL: Record<string, string> = {
|
||||
GITHUB: "GitHub",
|
||||
@@ -29,6 +31,7 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
|
||||
const accent = accentOf(project.accent);
|
||||
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
|
||||
const snapshot = project.snapshot;
|
||||
const activity = asArray<number>(snapshot?.commitActivity);
|
||||
const [favorite, setFavorite] = useState(project.favorite);
|
||||
|
||||
async function toggleFavorite() {
|
||||
@@ -110,8 +113,17 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commit-Aktivitaet */}
|
||||
{activity.some((value) => value > 0) ? (
|
||||
<div className="mt-3">
|
||||
<Sparkline values={activity} height={20} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Git-Kurzinfo */}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2 text-[11px] text-slate-400">
|
||||
<CiBadge state={snapshot?.ciState} name={snapshot?.ciName} compact />
|
||||
|
||||
{project.connection ? (
|
||||
<span className="glass-soft inline-flex items-center gap-1.5 px-2 py-1">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-violet-400" aria-hidden="true" />
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Commit-Aktivitaet als kleine Balkenreihe.
|
||||
*
|
||||
* Bewusst als reines SVG ohne Diagrammbibliothek: es sind dreissig Werte,
|
||||
* die Achsen, Legende und Tooltips einer Bibliothek waeren hier nur Ballast.
|
||||
*/
|
||||
export function Sparkline({
|
||||
values,
|
||||
className,
|
||||
height = 28,
|
||||
label = "Commits",
|
||||
}: {
|
||||
values: number[];
|
||||
className?: string;
|
||||
height?: number;
|
||||
label?: string;
|
||||
}) {
|
||||
if (!values.length) return null;
|
||||
|
||||
const total = values.reduce((sum, value) => sum + value, 0);
|
||||
if (total === 0) return null;
|
||||
|
||||
const max = Math.max(...values);
|
||||
const gap = 1;
|
||||
const barWidth = 3;
|
||||
const width = values.length * (barWidth + gap) - gap;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
className={cn("w-full", className)}
|
||||
style={{ height }}
|
||||
role="img"
|
||||
aria-label={`${label} der letzten ${values.length} Tage: insgesamt ${total}`}
|
||||
>
|
||||
<title>{`${label} der letzten ${values.length} Tage: insgesamt ${total}`}</title>
|
||||
{values.map((value, index) => {
|
||||
// Auch ein einzelner Commit soll sichtbar sein, daher die Untergrenze.
|
||||
const barHeight = value === 0 ? 1.5 : Math.max(3, (value / max) * height);
|
||||
return (
|
||||
<rect
|
||||
key={index}
|
||||
x={index * (barWidth + gap)}
|
||||
y={height - barHeight}
|
||||
width={barWidth}
|
||||
height={barHeight}
|
||||
rx={1}
|
||||
className={value === 0 ? "fill-white/10" : "fill-violet-400/80"}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user