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,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 '[]';
|
||||||
@@ -303,6 +303,18 @@ model RepoSnapshot {
|
|||||||
commits Json @default("[]")
|
commits Json @default("[]")
|
||||||
releases Json @default("[]")
|
releases Json @default("[]")
|
||||||
languages 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?
|
pushedAt DateTime?
|
||||||
fetchedAt DateTime @default(now())
|
fetchedAt DateTime @default(now())
|
||||||
|
|||||||
@@ -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 { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
|
Activity,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
CircleDot,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
GitCommitHorizontal,
|
GitCommitHorizontal,
|
||||||
@@ -18,6 +20,8 @@ import {
|
|||||||
Star,
|
Star,
|
||||||
Tag,
|
Tag,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { CiBadge } from "@/components/CiBadge";
|
||||||
|
import { Sparkline } from "@/components/Sparkline";
|
||||||
import { asArray, asRecord } from "@/types";
|
import { asArray, asRecord } from "@/types";
|
||||||
import type {
|
import type {
|
||||||
BranchInfo,
|
BranchInfo,
|
||||||
@@ -25,10 +29,13 @@ import type {
|
|||||||
ProjectDetail,
|
ProjectDetail,
|
||||||
ReleaseInfo,
|
ReleaseInfo,
|
||||||
TagInfo,
|
TagInfo,
|
||||||
|
TicketInfo,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
import { cn, formatDateTime, formatNumber, formatRelative } from "@/lib/utils";
|
import { cn, formatDateTime, formatNumber, formatRelative } from "@/lib/utils";
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
|
{ key: "pulls", label: "PRs", icon: GitPullRequest },
|
||||||
|
{ key: "issues", label: "Issues", icon: CircleDot },
|
||||||
{ key: "branches", label: "Branches", icon: GitBranch },
|
{ key: "branches", label: "Branches", icon: GitBranch },
|
||||||
{ key: "tags", label: "Tags", icon: Tag },
|
{ key: "tags", label: "Tags", icon: Tag },
|
||||||
{ key: "commits", label: "Commits", icon: GitCommitHorizontal },
|
{ key: "commits", label: "Commits", icon: GitCommitHorizontal },
|
||||||
@@ -39,7 +46,7 @@ type TabKey = (typeof TABS)[number]["key"];
|
|||||||
|
|
||||||
export function GitPanel({ project }: { project: ProjectDetail }) {
|
export function GitPanel({ project }: { project: ProjectDetail }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [tab, setTab] = useState<TabKey>("branches");
|
const [tab, setTab] = useState<TabKey>("pulls");
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
|
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 commits = asArray<CommitInfo>(snapshot?.commits);
|
||||||
const releases = asArray<ReleaseInfo>(snapshot?.releases);
|
const releases = asArray<ReleaseInfo>(snapshot?.releases);
|
||||||
const languages = asRecord(snapshot?.languages);
|
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);
|
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" />
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
) : null}
|
||||||
|
<CiBadge state={snapshot?.ciState} name={snapshot?.ciName} url={snapshot?.ciUrl} />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-slate-500" suppressHydrationWarning>
|
<p className="mt-1 text-xs text-slate-500" suppressHydrationWarning>
|
||||||
{project.lastSyncedAt
|
{project.lastSyncedAt
|
||||||
@@ -224,6 +235,24 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Sprachen */}
|
||||||
{languageTotal > 0 ? (
|
{languageTotal > 0 ? (
|
||||||
<div className="glass p-4">
|
<div className="glass p-4">
|
||||||
@@ -263,14 +292,14 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
|
|||||||
<div className="glass overflow-hidden">
|
<div className="glass overflow-hidden">
|
||||||
<div className="flex flex-wrap gap-1 border-b border-white/6 p-2">
|
<div className="flex flex-wrap gap-1 border-b border-white/6 p-2">
|
||||||
{TABS.map(({ key, label, icon: Icon }) => {
|
{TABS.map(({ key, label, icon: Icon }) => {
|
||||||
const count =
|
const count = {
|
||||||
key === "branches"
|
pulls: pulls.length,
|
||||||
? branches.length
|
issues: issues.length,
|
||||||
: key === "tags"
|
branches: branches.length,
|
||||||
? tags.length
|
tags: tags.length,
|
||||||
: key === "commits"
|
commits: commits.length,
|
||||||
? commits.length
|
releases: releases.length,
|
||||||
: releases.length;
|
}[key];
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
@@ -292,6 +321,16 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[26rem] overflow-y-auto">
|
<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 === "branches" ? <BranchList branches={branches} /> : null}
|
||||||
{tab === "tags" ? <TagList tags={tags} /> : null}
|
{tab === "tags" ? <TagList tags={tags} /> : null}
|
||||||
{tab === "commits" ? <CommitList commits={commits} /> : 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>;
|
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[] }) {
|
function BranchList({ branches }: { branches: BranchInfo[] }) {
|
||||||
if (branches.length === 0) return <Empty text="Keine Branches gefunden." />;
|
if (branches.length === 0) return <Empty text="Keine Branches gefunden." />;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import {
|
|||||||
Star,
|
Star,
|
||||||
Tag,
|
Tag,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { CiBadge } from "@/components/CiBadge";
|
||||||
|
import { Sparkline } from "@/components/Sparkline";
|
||||||
import { StatusSelect } from "@/components/StatusSelect";
|
import { StatusSelect } from "@/components/StatusSelect";
|
||||||
import { PRIORITY_META, accentOf } from "@/lib/status";
|
import { PRIORITY_META, accentOf } from "@/lib/status";
|
||||||
import { cn, formatRelative, truncate } from "@/lib/utils";
|
import { cn, formatRelative, truncate } from "@/lib/utils";
|
||||||
import type { ProjectListItem } from "@/types";
|
import { asArray, type ProjectListItem } from "@/types";
|
||||||
|
|
||||||
const PROVIDER_LABEL: Record<string, string> = {
|
const PROVIDER_LABEL: Record<string, string> = {
|
||||||
GITHUB: "GitHub",
|
GITHUB: "GitHub",
|
||||||
@@ -29,6 +31,7 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
|
|||||||
const accent = accentOf(project.accent);
|
const accent = accentOf(project.accent);
|
||||||
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
|
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
|
||||||
const snapshot = project.snapshot;
|
const snapshot = project.snapshot;
|
||||||
|
const activity = asArray<number>(snapshot?.commitActivity);
|
||||||
const [favorite, setFavorite] = useState(project.favorite);
|
const [favorite, setFavorite] = useState(project.favorite);
|
||||||
|
|
||||||
async function toggleFavorite() {
|
async function toggleFavorite() {
|
||||||
@@ -110,8 +113,17 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Commit-Aktivitaet */}
|
||||||
|
{activity.some((value) => value > 0) ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<Sparkline values={activity} height={20} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Git-Kurzinfo */}
|
{/* Git-Kurzinfo */}
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2 text-[11px] text-slate-400">
|
<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 ? (
|
{project.connection ? (
|
||||||
<span className="glass-soft inline-flex items-center gap-1.5 px-2 py-1">
|
<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" />
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+106
-5
@@ -1,6 +1,8 @@
|
|||||||
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
|
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
|
||||||
import type {
|
import type {
|
||||||
BranchInfo,
|
BranchInfo,
|
||||||
|
CiInfo,
|
||||||
|
CiState,
|
||||||
CommitInfo,
|
CommitInfo,
|
||||||
ConnectionCheck,
|
ConnectionCheck,
|
||||||
GitClient,
|
GitClient,
|
||||||
@@ -10,7 +12,9 @@ import type {
|
|||||||
RepoRef,
|
RepoRef,
|
||||||
RepoSnapshotData,
|
RepoSnapshotData,
|
||||||
TagInfo,
|
TagInfo,
|
||||||
|
TicketInfo,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import { bucketCommitActivity } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gitea (und kompatible Forks wie Forgejo) – API v1.
|
* Gitea (und kompatible Forks wie Forgejo) – API v1.
|
||||||
@@ -95,13 +99,32 @@ export class GiteaClient implements GitClient {
|
|||||||
|
|
||||||
const repo = await getJson<GiteaRepo>(base, { headers });
|
const repo = await getJson<GiteaRepo>(base, { headers });
|
||||||
|
|
||||||
const [branches, tags, commits, releases, languages, pulls] = await Promise.all([
|
const defaultBranch = repo.default_branch ?? "";
|
||||||
|
|
||||||
|
const [branches, tags, commits, releases, languages, pulls, issues, status] =
|
||||||
|
await Promise.all([
|
||||||
getJsonSafe<GiteaBranch[]>(`${base}/branches?limit=50`, [], { headers }),
|
getJsonSafe<GiteaBranch[]>(`${base}/branches?limit=50`, [], { headers }),
|
||||||
getJsonSafe<GiteaTag[]>(`${base}/tags?limit=25`, [], { headers }),
|
getJsonSafe<GiteaTag[]>(`${base}/tags?limit=25`, [], { headers }),
|
||||||
getJsonSafe<GiteaCommit[]>(`${base}/commits?limit=15&stat=false`, [], { headers }),
|
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
||||||
|
getJsonSafe<GiteaCommit[]>(`${base}/commits?limit=100&stat=false`, [], { headers }),
|
||||||
getJsonSafe<GiteaRelease[]>(`${base}/releases?limit=10`, [], { headers }),
|
getJsonSafe<GiteaRelease[]>(`${base}/releases?limit=10`, [], { headers }),
|
||||||
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
|
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
|
||||||
getJsonSafe<GiteaPull[]>(`${base}/pulls?state=open&limit=100`, [], { headers }),
|
getJsonSafe<GiteaPull[]>(`${base}/pulls?state=open&limit=50`, [], { headers }),
|
||||||
|
// type=issues, sonst kommen bei Gitea die Pull Requests mit.
|
||||||
|
getJsonSafe<GiteaIssue[]>(`${base}/issues?state=open&type=issues&limit=50`, [], {
|
||||||
|
headers,
|
||||||
|
}),
|
||||||
|
// Gitea meldet CI-Ergebnisse als Commit-Status. Das deckt Gitea
|
||||||
|
// Actions und externe Systeme wie Woodpecker gleichermassen ab –
|
||||||
|
// anders als ein reiner Actions-Endpunkt, den aeltere Instanzen
|
||||||
|
// ohnehin nicht kennen.
|
||||||
|
defaultBranch
|
||||||
|
? getJsonSafe<GiteaCombinedStatus>(
|
||||||
|
`${base}/commits/${encodeURIComponent(defaultBranch)}/status`,
|
||||||
|
{} as GiteaCombinedStatus,
|
||||||
|
{ headers },
|
||||||
|
)
|
||||||
|
: Promise.resolve({} as GiteaCombinedStatus),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
|
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
|
||||||
@@ -141,6 +164,38 @@ export class GiteaClient implements GitClient {
|
|||||||
draft: Boolean(release.draft),
|
draft: Boolean(release.draft),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mappedPulls: TicketInfo[] = pulls.map((pull) => ({
|
||||||
|
number: pull.number,
|
||||||
|
title: pull.title,
|
||||||
|
author: pull.user?.login ?? null,
|
||||||
|
createdAt: toIso(pull.created_at),
|
||||||
|
url: pull.html_url ?? null,
|
||||||
|
draft: Boolean(pull.draft),
|
||||||
|
labels: (pull.labels ?? []).map((label) => label.name),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mappedIssues: TicketInfo[] = issues.map((issue) => ({
|
||||||
|
number: issue.number,
|
||||||
|
title: issue.title,
|
||||||
|
author: issue.user?.login ?? null,
|
||||||
|
createdAt: toIso(issue.created_at),
|
||||||
|
url: issue.html_url ?? null,
|
||||||
|
draft: false,
|
||||||
|
labels: (issue.labels ?? []).map((label) => label.name),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const latestStatus = status?.statuses?.[0];
|
||||||
|
const ci: CiInfo | null =
|
||||||
|
status?.state && status.state !== "" && status.statuses?.length
|
||||||
|
? {
|
||||||
|
state: mapCommitStatus(status.state),
|
||||||
|
name: latestStatus?.context ?? null,
|
||||||
|
branch: defaultBranch || null,
|
||||||
|
url: latestStatus?.target_url ?? null,
|
||||||
|
finishedAt: toIso(latestStatus?.updated_at),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fullName: repo.full_name,
|
fullName: repo.full_name,
|
||||||
description: repo.description ?? null,
|
description: repo.description ?? null,
|
||||||
@@ -155,13 +210,31 @@ export class GiteaClient implements GitClient {
|
|||||||
pushedAt: toIso(repo.updated_at),
|
pushedAt: toIso(repo.updated_at),
|
||||||
branches: mappedBranches,
|
branches: mappedBranches,
|
||||||
tags: mappedTags,
|
tags: mappedTags,
|
||||||
commits: mappedCommits,
|
commits: mappedCommits.slice(0, 15),
|
||||||
releases: mappedReleases,
|
releases: mappedReleases,
|
||||||
languages: languages ?? {},
|
languages: languages ?? {},
|
||||||
|
pullRequests: mappedPulls,
|
||||||
|
issues: mappedIssues,
|
||||||
|
ci,
|
||||||
|
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapCommitStatus(state: string): CiState {
|
||||||
|
switch (state) {
|
||||||
|
case "success":
|
||||||
|
return "success";
|
||||||
|
case "pending":
|
||||||
|
return "running";
|
||||||
|
case "failure":
|
||||||
|
case "error":
|
||||||
|
return "failure";
|
||||||
|
default:
|
||||||
|
return "pending";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── API-Typen ──────────────────────────────────────────────────
|
// ── API-Typen ──────────────────────────────────────────────────
|
||||||
|
|
||||||
interface GiteaRepo {
|
interface GiteaRepo {
|
||||||
@@ -220,6 +293,34 @@ interface GiteaRelease {
|
|||||||
draft: boolean;
|
draft: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GiteaLabel {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface GiteaPull {
|
interface GiteaPull {
|
||||||
id: number;
|
number: number;
|
||||||
|
title: string;
|
||||||
|
html_url: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
draft?: boolean;
|
||||||
|
user?: { login: string } | null;
|
||||||
|
labels?: GiteaLabel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GiteaIssue {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
html_url: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
user?: { login: string } | null;
|
||||||
|
labels?: GiteaLabel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GiteaCombinedStatus {
|
||||||
|
state?: string;
|
||||||
|
statuses?: Array<{
|
||||||
|
context?: string;
|
||||||
|
target_url?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-6
@@ -1,6 +1,8 @@
|
|||||||
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
|
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
|
||||||
import type {
|
import type {
|
||||||
BranchInfo,
|
BranchInfo,
|
||||||
|
CiInfo,
|
||||||
|
CiState,
|
||||||
CommitInfo,
|
CommitInfo,
|
||||||
ConnectionCheck,
|
ConnectionCheck,
|
||||||
GitClient,
|
GitClient,
|
||||||
@@ -10,8 +12,9 @@ import type {
|
|||||||
RepoRef,
|
RepoRef,
|
||||||
RepoSnapshotData,
|
RepoSnapshotData,
|
||||||
TagInfo,
|
TagInfo,
|
||||||
|
TicketInfo,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { GitApiError } from "./types";
|
import { GitApiError, bucketCommitActivity } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GitHub – funktioniert mit github.com und GitHub Enterprise.
|
* GitHub – funktioniert mit github.com und GitHub Enterprise.
|
||||||
@@ -85,13 +88,24 @@ export class GitHubClient implements GitClient {
|
|||||||
|
|
||||||
const repo = await getJson<GitHubRepo>(base, { headers });
|
const repo = await getJson<GitHubRepo>(base, { headers });
|
||||||
|
|
||||||
const [branches, tags, commits, releases, languages, pulls] = await Promise.all([
|
const defaultBranch = repo.default_branch;
|
||||||
|
|
||||||
|
const [branches, tags, commits, releases, languages, pulls, issues, ciRuns] =
|
||||||
|
await Promise.all([
|
||||||
getJsonSafe<GitHubBranch[]>(`${base}/branches?per_page=50`, [], { headers }),
|
getJsonSafe<GitHubBranch[]>(`${base}/branches?per_page=50`, [], { headers }),
|
||||||
getJsonSafe<GitHubTag[]>(`${base}/tags?per_page=25`, [], { headers }),
|
getJsonSafe<GitHubTag[]>(`${base}/tags?per_page=25`, [], { headers }),
|
||||||
getJsonSafe<GitHubCommit[]>(`${base}/commits?per_page=15`, [], { headers }),
|
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
||||||
|
getJsonSafe<GitHubCommit[]>(`${base}/commits?per_page=100`, [], { headers }),
|
||||||
getJsonSafe<GitHubRelease[]>(`${base}/releases?per_page=10`, [], { headers }),
|
getJsonSafe<GitHubRelease[]>(`${base}/releases?per_page=10`, [], { headers }),
|
||||||
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
|
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
|
||||||
getJsonSafe<GitHubPull[]>(`${base}/pulls?state=open&per_page=100`, [], { headers }),
|
getJsonSafe<GitHubPull[]>(`${base}/pulls?state=open&per_page=50`, [], { headers }),
|
||||||
|
// /issues liefert bei GitHub auch Pull Requests – die filtern wir raus.
|
||||||
|
getJsonSafe<GitHubIssue[]>(`${base}/issues?state=open&per_page=50`, [], { headers }),
|
||||||
|
getJsonSafe<{ workflow_runs?: GitHubRun[] }>(
|
||||||
|
`${base}/actions/runs?per_page=1&branch=${encodeURIComponent(defaultBranch)}`,
|
||||||
|
{},
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
|
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
|
||||||
@@ -131,10 +145,43 @@ export class GitHubClient implements GitClient {
|
|||||||
draft: Boolean(release.draft),
|
draft: Boolean(release.draft),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mappedPulls: TicketInfo[] = pulls.map((pull) => ({
|
||||||
|
number: pull.number,
|
||||||
|
title: pull.title,
|
||||||
|
author: pull.user?.login ?? null,
|
||||||
|
createdAt: toIso(pull.created_at),
|
||||||
|
url: pull.html_url ?? null,
|
||||||
|
draft: Boolean(pull.draft),
|
||||||
|
labels: (pull.labels ?? []).map((label) => label.name),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mappedIssues: TicketInfo[] = issues
|
||||||
|
.filter((issue) => !issue.pull_request)
|
||||||
|
.map((issue) => ({
|
||||||
|
number: issue.number,
|
||||||
|
title: issue.title,
|
||||||
|
author: issue.user?.login ?? null,
|
||||||
|
createdAt: toIso(issue.created_at),
|
||||||
|
url: issue.html_url ?? null,
|
||||||
|
draft: false,
|
||||||
|
labels: (issue.labels ?? []).map((label) => label.name),
|
||||||
|
}));
|
||||||
|
|
||||||
// open_issues_count zaehlt bei GitHub PRs mit – hier wieder herausrechnen.
|
// open_issues_count zaehlt bei GitHub PRs mit – hier wieder herausrechnen.
|
||||||
const openPullRequests = pulls.length;
|
const openPullRequests = pulls.length;
|
||||||
const openIssues = Math.max(0, (repo.open_issues_count ?? 0) - openPullRequests);
|
const openIssues = Math.max(0, (repo.open_issues_count ?? 0) - openPullRequests);
|
||||||
|
|
||||||
|
const run = ciRuns?.workflow_runs?.[0];
|
||||||
|
const ci: CiInfo | null = run
|
||||||
|
? {
|
||||||
|
state: mapRunState(run.status, run.conclusion),
|
||||||
|
name: run.name ?? null,
|
||||||
|
branch: run.head_branch ?? null,
|
||||||
|
url: run.html_url ?? null,
|
||||||
|
finishedAt: toIso(run.updated_at),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fullName: repo.full_name,
|
fullName: repo.full_name,
|
||||||
description: repo.description ?? null,
|
description: repo.description ?? null,
|
||||||
@@ -149,13 +196,36 @@ export class GitHubClient implements GitClient {
|
|||||||
pushedAt: toIso(repo.pushed_at),
|
pushedAt: toIso(repo.pushed_at),
|
||||||
branches: mappedBranches,
|
branches: mappedBranches,
|
||||||
tags: mappedTags,
|
tags: mappedTags,
|
||||||
commits: mappedCommits,
|
// Angezeigt werden die letzten 15; die uebrigen dienten der Kurve.
|
||||||
|
commits: mappedCommits.slice(0, 15),
|
||||||
releases: mappedReleases,
|
releases: mappedReleases,
|
||||||
languages: languages ?? {},
|
languages: languages ?? {},
|
||||||
|
pullRequests: mappedPulls,
|
||||||
|
issues: mappedIssues,
|
||||||
|
ci,
|
||||||
|
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GitHub trennt Lauf-Status und Ergebnis; hier auf einen Wert zusammenfuehren. */
|
||||||
|
function mapRunState(status: string | null, conclusion: string | null): CiState {
|
||||||
|
if (status === "queued" || status === "waiting" || status === "pending") return "pending";
|
||||||
|
if (status === "in_progress") return "running";
|
||||||
|
switch (conclusion) {
|
||||||
|
case "success":
|
||||||
|
return "success";
|
||||||
|
case "cancelled":
|
||||||
|
return "cancelled";
|
||||||
|
case "failure":
|
||||||
|
case "timed_out":
|
||||||
|
case "startup_failure":
|
||||||
|
return "failure";
|
||||||
|
default:
|
||||||
|
return conclusion ? "failure" : "pending";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── API-Typen (nur die tatsaechlich genutzten Felder) ──────────
|
// ── API-Typen (nur die tatsaechlich genutzten Felder) ──────────
|
||||||
|
|
||||||
interface GitHubRepo {
|
interface GitHubRepo {
|
||||||
@@ -206,6 +276,36 @@ interface GitHubRelease {
|
|||||||
draft: boolean;
|
draft: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GitHubLabel {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface GitHubPull {
|
interface GitHubPull {
|
||||||
id: number;
|
number: number;
|
||||||
|
title: string;
|
||||||
|
html_url: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
draft?: boolean;
|
||||||
|
user?: { login: string } | null;
|
||||||
|
labels?: GitHubLabel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitHubIssue {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
html_url: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
user?: { login: string } | null;
|
||||||
|
labels?: GitHubLabel[];
|
||||||
|
/** Gesetzt, wenn der Eintrag in Wahrheit ein Pull Request ist. */
|
||||||
|
pull_request?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitHubRun {
|
||||||
|
name: string | null;
|
||||||
|
status: string | null;
|
||||||
|
conclusion: string | null;
|
||||||
|
head_branch: string | null;
|
||||||
|
html_url: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
+93
-4
@@ -1,6 +1,8 @@
|
|||||||
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
|
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
|
||||||
import type {
|
import type {
|
||||||
BranchInfo,
|
BranchInfo,
|
||||||
|
CiInfo,
|
||||||
|
CiState,
|
||||||
CommitInfo,
|
CommitInfo,
|
||||||
ConnectionCheck,
|
ConnectionCheck,
|
||||||
GitClient,
|
GitClient,
|
||||||
@@ -10,7 +12,9 @@ import type {
|
|||||||
RepoRef,
|
RepoRef,
|
||||||
RepoSnapshotData,
|
RepoSnapshotData,
|
||||||
TagInfo,
|
TagInfo,
|
||||||
|
TicketInfo,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import { bucketCommitActivity } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GitLab – gitlab.com und selbst gehostete Instanzen (API v4).
|
* GitLab – gitlab.com und selbst gehostete Instanzen (API v4).
|
||||||
@@ -80,15 +84,25 @@ export class GitLabClient implements GitClient {
|
|||||||
|
|
||||||
const project = await getJson<GitLabProject>(`${base}?statistics=true`, { headers });
|
const project = await getJson<GitLabProject>(`${base}?statistics=true`, { headers });
|
||||||
|
|
||||||
const [branches, tags, commits, releases, languages, mergeRequests] = await Promise.all([
|
const defaultBranch = project.default_branch ?? "";
|
||||||
|
|
||||||
|
const [branches, tags, commits, releases, languages, mergeRequests, issues, pipelines] =
|
||||||
|
await Promise.all([
|
||||||
getJsonSafe<GitLabBranch[]>(`${base}/repository/branches?per_page=50`, [], { headers }),
|
getJsonSafe<GitLabBranch[]>(`${base}/repository/branches?per_page=50`, [], { headers }),
|
||||||
getJsonSafe<GitLabTag[]>(`${base}/repository/tags?per_page=25`, [], { headers }),
|
getJsonSafe<GitLabTag[]>(`${base}/repository/tags?per_page=25`, [], { headers }),
|
||||||
getJsonSafe<GitLabCommit[]>(`${base}/repository/commits?per_page=15`, [], { headers }),
|
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
||||||
|
getJsonSafe<GitLabCommit[]>(`${base}/repository/commits?per_page=100`, [], { headers }),
|
||||||
getJsonSafe<GitLabRelease[]>(`${base}/releases?per_page=10`, [], { headers }),
|
getJsonSafe<GitLabRelease[]>(`${base}/releases?per_page=10`, [], { headers }),
|
||||||
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
|
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
|
||||||
getJsonSafe<GitLabMergeRequest[]>(`${base}/merge_requests?state=opened&per_page=100`, [], {
|
getJsonSafe<GitLabMergeRequest[]>(`${base}/merge_requests?state=opened&per_page=50`, [], {
|
||||||
headers,
|
headers,
|
||||||
}),
|
}),
|
||||||
|
getJsonSafe<GitLabIssue[]>(`${base}/issues?state=opened&per_page=50`, [], { headers }),
|
||||||
|
getJsonSafe<GitLabPipeline[]>(
|
||||||
|
`${base}/pipelines?per_page=1${defaultBranch ? `&ref=${encodeURIComponent(defaultBranch)}` : ""}`,
|
||||||
|
[],
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
|
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
|
||||||
@@ -128,6 +142,37 @@ export class GitLabClient implements GitClient {
|
|||||||
draft: false,
|
draft: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mappedMergeRequests: TicketInfo[] = mergeRequests.map((entry) => ({
|
||||||
|
number: entry.iid,
|
||||||
|
title: entry.title,
|
||||||
|
author: entry.author?.username ?? null,
|
||||||
|
createdAt: toIso(entry.created_at),
|
||||||
|
url: entry.web_url ?? null,
|
||||||
|
draft: Boolean(entry.draft),
|
||||||
|
labels: entry.labels ?? [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mappedIssues: TicketInfo[] = issues.map((entry) => ({
|
||||||
|
number: entry.iid,
|
||||||
|
title: entry.title,
|
||||||
|
author: entry.author?.username ?? null,
|
||||||
|
createdAt: toIso(entry.created_at),
|
||||||
|
url: entry.web_url ?? null,
|
||||||
|
draft: false,
|
||||||
|
labels: entry.labels ?? [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pipeline = pipelines[0];
|
||||||
|
const ci: CiInfo | null = pipeline
|
||||||
|
? {
|
||||||
|
state: mapPipelineState(pipeline.status),
|
||||||
|
name: pipeline.name ?? `Pipeline #${pipeline.id}`,
|
||||||
|
branch: pipeline.ref ?? null,
|
||||||
|
url: pipeline.web_url ?? null,
|
||||||
|
finishedAt: toIso(pipeline.updated_at),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fullName: project.path_with_namespace,
|
fullName: project.path_with_namespace,
|
||||||
description: project.description ?? null,
|
description: project.description ?? null,
|
||||||
@@ -144,13 +189,33 @@ export class GitLabClient implements GitClient {
|
|||||||
pushedAt: toIso(project.last_activity_at),
|
pushedAt: toIso(project.last_activity_at),
|
||||||
branches: mappedBranches,
|
branches: mappedBranches,
|
||||||
tags: mappedTags,
|
tags: mappedTags,
|
||||||
commits: mappedCommits,
|
commits: mappedCommits.slice(0, 15),
|
||||||
releases: mappedReleases,
|
releases: mappedReleases,
|
||||||
languages: languages ?? {},
|
languages: languages ?? {},
|
||||||
|
pullRequests: mappedMergeRequests,
|
||||||
|
issues: mappedIssues,
|
||||||
|
ci,
|
||||||
|
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapPipelineState(status: string | null): CiState {
|
||||||
|
switch (status) {
|
||||||
|
case "success":
|
||||||
|
return "success";
|
||||||
|
case "running":
|
||||||
|
return "running";
|
||||||
|
case "failed":
|
||||||
|
return "failure";
|
||||||
|
case "canceled":
|
||||||
|
case "skipped":
|
||||||
|
return "cancelled";
|
||||||
|
default:
|
||||||
|
return "pending";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── API-Typen ──────────────────────────────────────────────────
|
// ── API-Typen ──────────────────────────────────────────────────
|
||||||
|
|
||||||
interface GitLabProject {
|
interface GitLabProject {
|
||||||
@@ -205,5 +270,29 @@ interface GitLabRelease {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface GitLabMergeRequest {
|
interface GitLabMergeRequest {
|
||||||
|
iid: number;
|
||||||
|
title: string;
|
||||||
|
created_at: string | null;
|
||||||
|
web_url: string | null;
|
||||||
|
draft?: boolean;
|
||||||
|
labels?: string[];
|
||||||
|
author?: { username: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitLabIssue {
|
||||||
|
iid: number;
|
||||||
|
title: string;
|
||||||
|
created_at: string | null;
|
||||||
|
web_url: string | null;
|
||||||
|
labels?: string[];
|
||||||
|
author?: { username: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitLabPipeline {
|
||||||
id: number;
|
id: number;
|
||||||
|
name?: string | null;
|
||||||
|
status: string | null;
|
||||||
|
ref: string | null;
|
||||||
|
web_url: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { guessProvider } from "./meta";
|
||||||
|
import { parseRepoUrl } from "./index";
|
||||||
|
import { ACTIVITY_DAYS, bucketCommitActivity } from "./types";
|
||||||
|
|
||||||
|
describe("parseRepoUrl", () => {
|
||||||
|
it("zerlegt eine gewoehnliche https-URL", () => {
|
||||||
|
expect(parseRepoUrl("https://github.com/vercel/swr")).toMatchObject({
|
||||||
|
host: "github.com",
|
||||||
|
owner: "vercel",
|
||||||
|
name: "swr",
|
||||||
|
guessedProvider: "GITHUB",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("entfernt die Endung .git", () => {
|
||||||
|
expect(parseRepoUrl("https://github.com/vercel/swr.git")?.name).toBe("swr");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("versteht SSH-Adressen", () => {
|
||||||
|
expect(parseRepoUrl("git@gitlab.com:gruppe/projekt.git")).toMatchObject({
|
||||||
|
host: "gitlab.com",
|
||||||
|
owner: "gruppe",
|
||||||
|
name: "projekt",
|
||||||
|
guessedProvider: "GITLAB",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("behaelt verschachtelte GitLab-Gruppen", () => {
|
||||||
|
expect(parseRepoUrl("https://gitlab.com/gruppe/untergruppe/projekt")).toMatchObject({
|
||||||
|
owner: "gruppe/untergruppe",
|
||||||
|
name: "projekt",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schneidet GitLab-Oberflaechenpfade ab dem /-/ ab", () => {
|
||||||
|
expect(parseRepoUrl("https://gitlab.com/gruppe/projekt/-/tree/main")).toMatchObject({
|
||||||
|
owner: "gruppe",
|
||||||
|
name: "projekt",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kommt mit einer eigenen Gitea-Instanz zurecht", () => {
|
||||||
|
expect(parseRepoUrl("https://git.meine-firma.de/team/repo")).toMatchObject({
|
||||||
|
baseUrl: "https://git.meine-firma.de",
|
||||||
|
owner: "team",
|
||||||
|
name: "repo",
|
||||||
|
guessedProvider: "GITEA",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt null zurueck, wenn kein Repository erkennbar ist", () => {
|
||||||
|
expect(parseRepoUrl("https://github.com/nurbenutzer")).toBeNull();
|
||||||
|
expect(parseRepoUrl("")).toBeNull();
|
||||||
|
expect(parseRepoUrl("kein-url-artiger-text")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("guessProvider", () => {
|
||||||
|
it("erkennt die grossen beiden am Hostnamen", () => {
|
||||||
|
expect(guessProvider("github.com")).toBe("GITHUB");
|
||||||
|
expect(guessProvider("gitlab.example.org")).toBe("GITLAB");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nimmt bei allem anderen Gitea an", () => {
|
||||||
|
expect(guessProvider("git.meine-firma.de")).toBe("GITEA");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bucketCommitActivity", () => {
|
||||||
|
const dayMs = 24 * 60 * 60 * 1000;
|
||||||
|
const daysAgo = (days: number) => new Date(Date.now() - days * dayMs).toISOString();
|
||||||
|
|
||||||
|
it("liefert ohne Commits den vollen Zeitraum", () => {
|
||||||
|
expect(bucketCommitActivity([])).toHaveLength(ACTIVITY_DAYS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sortiert den juengsten Commit ans Ende", () => {
|
||||||
|
const buckets = bucketCommitActivity([daysAgo(29), daysAgo(0)]);
|
||||||
|
expect(buckets).toHaveLength(ACTIVITY_DAYS);
|
||||||
|
expect(buckets[ACTIVITY_DAYS - 1]).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zaehlt mehrere Commits desselben Tages zusammen", () => {
|
||||||
|
const buckets = bucketCommitActivity([daysAgo(29), daysAgo(2), daysAgo(2), daysAgo(2)]);
|
||||||
|
expect(buckets[buckets.length - 3]).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kuerzt auf den belegten Zeitraum, statt Unbekanntes als Null zu zeigen", () => {
|
||||||
|
// Reicht die geholte Historie nur fuenf Tage zurueck, duerfen die
|
||||||
|
// 25 Tage davor nicht als "keine Commits" erscheinen.
|
||||||
|
const buckets = bucketCommitActivity([daysAgo(4), daysAgo(0)]);
|
||||||
|
expect(buckets).toHaveLength(5);
|
||||||
|
expect(buckets[0]).toBe(1);
|
||||||
|
expect(buckets[4]).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignoriert unlesbare Werte", () => {
|
||||||
|
const buckets = bucketCommitActivity([null, "voelliger Unsinn"]);
|
||||||
|
expect(buckets).toHaveLength(ACTIVITY_DAYS);
|
||||||
|
expect(buckets.every((value) => value === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("laesst aeltere Commits den vollen Zeitraum aufspannen", () => {
|
||||||
|
const buckets = bucketCommitActivity([daysAgo(100), daysAgo(1)]);
|
||||||
|
expect(buckets).toHaveLength(ACTIVITY_DAYS);
|
||||||
|
expect(buckets[ACTIVITY_DAYS - 2]).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,6 +47,29 @@ export interface ReleaseInfo {
|
|||||||
draft: boolean;
|
draft: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ein offener Pull Request, Merge Request oder ein Issue. */
|
||||||
|
export interface TicketInfo {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
author: string | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
url: string | null;
|
||||||
|
draft: boolean;
|
||||||
|
labels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ergebnis des letzten CI-Laufs auf dem Default-Branch. */
|
||||||
|
export type CiState = "success" | "failure" | "running" | "pending" | "cancelled";
|
||||||
|
|
||||||
|
export interface CiInfo {
|
||||||
|
state: CiState;
|
||||||
|
/** Name des Workflows bzw. der Pipeline, falls die API ihn liefert. */
|
||||||
|
name: string | null;
|
||||||
|
branch: string | null;
|
||||||
|
url: string | null;
|
||||||
|
finishedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RepoOverview {
|
export interface RepoOverview {
|
||||||
fullName: string;
|
fullName: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
@@ -67,6 +90,53 @@ export interface RepoSnapshotData extends RepoOverview {
|
|||||||
commits: CommitInfo[];
|
commits: CommitInfo[];
|
||||||
releases: ReleaseInfo[];
|
releases: ReleaseInfo[];
|
||||||
languages: Record<string, number>;
|
languages: Record<string, number>;
|
||||||
|
pullRequests: TicketInfo[];
|
||||||
|
issues: TicketInfo[];
|
||||||
|
ci: CiInfo | null;
|
||||||
|
/** Commits je Tag der letzten 30 Tage, aeltester zuerst. */
|
||||||
|
commitActivity: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tage, ueber die die Commit-Aktivitaet gebildet wird. */
|
||||||
|
export const ACTIVITY_DAYS = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verteilt Commit-Zeitpunkte auf Tagesbuckets. Bewusst aus den ohnehin
|
||||||
|
* geholten Commits berechnet 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.
|
||||||
|
*
|
||||||
|
* Wichtig fuer die Ehrlichkeit der Kurve: geholt wird nur eine Seite
|
||||||
|
* Commits. Liegt der aelteste davon noch innerhalb des Zeitraums, wissen wir
|
||||||
|
* ueber die Tage davor schlicht nichts – dort eine Null zu zeichnen waere
|
||||||
|
* falsch. Deshalb wird die Kurve auf den tatsaechlich belegten Zeitraum
|
||||||
|
* gekuerzt.
|
||||||
|
*/
|
||||||
|
export function bucketCommitActivity(dates: Array<string | null>): number[] {
|
||||||
|
const buckets = new Array<number>(ACTIVITY_DAYS).fill(0);
|
||||||
|
const now = Date.now();
|
||||||
|
const dayMs = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
let oldestDaysAgo = -1;
|
||||||
|
|
||||||
|
for (const value of dates) {
|
||||||
|
if (!value) continue;
|
||||||
|
const time = Date.parse(value);
|
||||||
|
if (Number.isNaN(time)) continue;
|
||||||
|
const daysAgo = Math.floor((now - time) / dayMs);
|
||||||
|
if (daysAgo < 0) continue;
|
||||||
|
if (daysAgo > oldestDaysAgo) oldestDaysAgo = daysAgo;
|
||||||
|
if (daysAgo >= ACTIVITY_DAYS) continue;
|
||||||
|
buckets[ACTIVITY_DAYS - 1 - daysAgo] += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reicht die Historie nicht bis an den Rand des Zeitraums, nur den
|
||||||
|
// belegten Ausschnitt zurueckgeben.
|
||||||
|
if (oldestDaysAgo >= 0 && oldestDaysAgo < ACTIVITY_DAYS - 1) {
|
||||||
|
return buckets.slice(ACTIVITY_DAYS - 1 - oldestDaysAgo);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buckets;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepoListItem {
|
export interface RepoListItem {
|
||||||
|
|||||||
@@ -117,6 +117,14 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) {
|
|||||||
commits: data.commits as unknown as object,
|
commits: data.commits as unknown as object,
|
||||||
releases: data.releases as unknown as object,
|
releases: data.releases as unknown as object,
|
||||||
languages: data.languages as unknown as object,
|
languages: data.languages as unknown as object,
|
||||||
|
pullRequests: data.pullRequests as unknown as object,
|
||||||
|
issues: data.issues as unknown as object,
|
||||||
|
commitActivity: data.commitActivity as unknown as object,
|
||||||
|
ciState: data.ci?.state ?? null,
|
||||||
|
ciName: data.ci?.name ?? null,
|
||||||
|
ciBranch: data.ci?.branch ?? null,
|
||||||
|
ciUrl: data.ci?.url ?? null,
|
||||||
|
ciFinishedAt: data.ci?.finishedAt ? new Date(data.ci.finishedAt) : null,
|
||||||
pushedAt: data.pushedAt ? new Date(data.pushedAt) : null,
|
pushedAt: data.pushedAt ? new Date(data.pushedAt) : null,
|
||||||
fetchedAt: new Date(),
|
fetchedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|||||||
+19
-2
@@ -1,5 +1,12 @@
|
|||||||
import type { GitProvider, Note, Project, ProjectStatus, RepoSnapshot } from "@prisma/client";
|
import type { GitProvider, Note, Project, ProjectStatus, RepoSnapshot } from "@prisma/client";
|
||||||
import type { BranchInfo, CommitInfo, ReleaseInfo, TagInfo } from "@/lib/git/types";
|
import type {
|
||||||
|
BranchInfo,
|
||||||
|
CiInfo,
|
||||||
|
CommitInfo,
|
||||||
|
ReleaseInfo,
|
||||||
|
TagInfo,
|
||||||
|
TicketInfo,
|
||||||
|
} from "@/lib/git/types";
|
||||||
|
|
||||||
/** Verbindung ohne Geheimnisse – so wird sie an den Browser gegeben. */
|
/** Verbindung ohne Geheimnisse – so wird sie an den Browser gegeben. */
|
||||||
export interface ConnectionSafe {
|
export interface ConnectionSafe {
|
||||||
@@ -44,7 +51,17 @@ export interface RepoListEntry {
|
|||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { BranchInfo, CommitInfo, Note, ProjectStatus, ReleaseInfo, RepoSnapshot, TagInfo };
|
export type {
|
||||||
|
BranchInfo,
|
||||||
|
CiInfo,
|
||||||
|
CommitInfo,
|
||||||
|
Note,
|
||||||
|
ProjectStatus,
|
||||||
|
ReleaseInfo,
|
||||||
|
RepoSnapshot,
|
||||||
|
TagInfo,
|
||||||
|
TicketInfo,
|
||||||
|
};
|
||||||
|
|
||||||
/** Prisma liefert Json als unknown – hier bequem in Arrays zurueckholen. */
|
/** Prisma liefert Json als unknown – hier bequem in Arrays zurueckholen. */
|
||||||
export function asArray<T>(value: unknown): T[] {
|
export function asArray<T>(value: unknown): T[] {
|
||||||
|
|||||||
Reference in New Issue
Block a user