@@ -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 (
+ {tab === "pulls" ? (
+
+ ) : null}
+ {tab === "issues" ? (
+
+ ) : null}
{tab === "branches" ?
: null}
{tab === "tags" ?
: null}
{tab === "commits" ?
: null}
@@ -342,6 +381,67 @@ 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 (
diff --git a/src/components/ProjectCard.tsx b/src/components/ProjectCard.tsx
index fc68549..cb4629e 100644
--- a/src/components/ProjectCard.tsx
+++ b/src/components/ProjectCard.tsx
@@ -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
= {
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(snapshot?.commitActivity);
const [favorite, setFavorite] = useState(project.favorite);
async function toggleFavorite() {
@@ -110,8 +113,17 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
+ {/* Commit-Aktivitaet */}
+ {activity.some((value) => value > 0) ? (
+
+
+
+ ) : null}
+
{/* Git-Kurzinfo */}
+
+
{project.connection ? (
diff --git a/src/components/Sparkline.tsx b/src/components/Sparkline.tsx
new file mode 100644
index 0000000..1ee22cd
--- /dev/null
+++ b/src/components/Sparkline.tsx
@@ -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 (
+
+ {`${label} der letzten ${values.length} Tage: insgesamt ${total}`}
+ {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 (
+
+ );
+ })}
+
+ );
+}
diff --git a/src/lib/git/gitea.ts b/src/lib/git/gitea.ts
index 52e6d54..dc839b5 100644
--- a/src/lib/git/gitea.ts
+++ b/src/lib/git/gitea.ts
@@ -1,6 +1,8 @@
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
import type {
BranchInfo,
+ CiInfo,
+ CiState,
CommitInfo,
ConnectionCheck,
GitClient,
@@ -10,7 +12,9 @@ import type {
RepoRef,
RepoSnapshotData,
TagInfo,
+ TicketInfo,
} from "./types";
+import { bucketCommitActivity } from "./types";
/**
* Gitea (und kompatible Forks wie Forgejo) – API v1.
@@ -95,14 +99,33 @@ export class GiteaClient implements GitClient {
const repo = await getJson(base, { headers });
- const [branches, tags, commits, releases, languages, pulls] = await Promise.all([
- getJsonSafe(`${base}/branches?limit=50`, [], { headers }),
- getJsonSafe(`${base}/tags?limit=25`, [], { headers }),
- getJsonSafe(`${base}/commits?limit=15&stat=false`, [], { headers }),
- getJsonSafe(`${base}/releases?limit=10`, [], { headers }),
- getJsonSafe>(`${base}/languages`, {}, { headers }),
- getJsonSafe(`${base}/pulls?state=open&limit=100`, [], { headers }),
- ]);
+ const defaultBranch = repo.default_branch ?? "";
+
+ const [branches, tags, commits, releases, languages, pulls, issues, status] =
+ await Promise.all([
+ getJsonSafe(`${base}/branches?limit=50`, [], { headers }),
+ getJsonSafe(`${base}/tags?limit=25`, [], { headers }),
+ // 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
+ getJsonSafe(`${base}/commits?limit=100&stat=false`, [], { headers }),
+ getJsonSafe(`${base}/releases?limit=10`, [], { headers }),
+ getJsonSafe>(`${base}/languages`, {}, { headers }),
+ getJsonSafe(`${base}/pulls?state=open&limit=50`, [], { headers }),
+ // type=issues, sonst kommen bei Gitea die Pull Requests mit.
+ getJsonSafe(`${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(
+ `${base}/commits/${encodeURIComponent(defaultBranch)}/status`,
+ {} as GiteaCombinedStatus,
+ { headers },
+ )
+ : Promise.resolve({} as GiteaCombinedStatus),
+ ]);
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
name: branch.name,
@@ -141,6 +164,38 @@ export class GiteaClient implements GitClient {
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 {
fullName: repo.full_name,
description: repo.description ?? null,
@@ -155,13 +210,31 @@ export class GiteaClient implements GitClient {
pushedAt: toIso(repo.updated_at),
branches: mappedBranches,
tags: mappedTags,
- commits: mappedCommits,
+ commits: mappedCommits.slice(0, 15),
releases: mappedReleases,
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 ──────────────────────────────────────────────────
interface GiteaRepo {
@@ -220,6 +293,34 @@ interface GiteaRelease {
draft: boolean;
}
+interface GiteaLabel {
+ name: string;
+}
+
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;
+ }>;
}
diff --git a/src/lib/git/github.ts b/src/lib/git/github.ts
index 1423437..c5156f5 100644
--- a/src/lib/git/github.ts
+++ b/src/lib/git/github.ts
@@ -1,6 +1,8 @@
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
import type {
BranchInfo,
+ CiInfo,
+ CiState,
CommitInfo,
ConnectionCheck,
GitClient,
@@ -10,8 +12,9 @@ import type {
RepoRef,
RepoSnapshotData,
TagInfo,
+ TicketInfo,
} from "./types";
-import { GitApiError } from "./types";
+import { GitApiError, bucketCommitActivity } from "./types";
/**
* GitHub – funktioniert mit github.com und GitHub Enterprise.
@@ -85,14 +88,25 @@ export class GitHubClient implements GitClient {
const repo = await getJson(base, { headers });
- const [branches, tags, commits, releases, languages, pulls] = await Promise.all([
- getJsonSafe(`${base}/branches?per_page=50`, [], { headers }),
- getJsonSafe(`${base}/tags?per_page=25`, [], { headers }),
- getJsonSafe(`${base}/commits?per_page=15`, [], { headers }),
- getJsonSafe(`${base}/releases?per_page=10`, [], { headers }),
- getJsonSafe>(`${base}/languages`, {}, { headers }),
- getJsonSafe(`${base}/pulls?state=open&per_page=100`, [], { headers }),
- ]);
+ const defaultBranch = repo.default_branch;
+
+ const [branches, tags, commits, releases, languages, pulls, issues, ciRuns] =
+ await Promise.all([
+ getJsonSafe(`${base}/branches?per_page=50`, [], { headers }),
+ getJsonSafe(`${base}/tags?per_page=25`, [], { headers }),
+ // 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
+ getJsonSafe(`${base}/commits?per_page=100`, [], { headers }),
+ getJsonSafe(`${base}/releases?per_page=10`, [], { headers }),
+ getJsonSafe>(`${base}/languages`, {}, { headers }),
+ getJsonSafe(`${base}/pulls?state=open&per_page=50`, [], { headers }),
+ // /issues liefert bei GitHub auch Pull Requests – die filtern wir raus.
+ getJsonSafe(`${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) => ({
name: branch.name,
@@ -131,10 +145,43 @@ export class GitHubClient implements GitClient {
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.
const openPullRequests = pulls.length;
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 {
fullName: repo.full_name,
description: repo.description ?? null,
@@ -149,13 +196,36 @@ export class GitHubClient implements GitClient {
pushedAt: toIso(repo.pushed_at),
branches: mappedBranches,
tags: mappedTags,
- commits: mappedCommits,
+ // Angezeigt werden die letzten 15; die uebrigen dienten der Kurve.
+ commits: mappedCommits.slice(0, 15),
releases: mappedReleases,
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) ──────────
interface GitHubRepo {
@@ -206,6 +276,36 @@ interface GitHubRelease {
draft: boolean;
}
+interface GitHubLabel {
+ name: string;
+}
+
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;
}
diff --git a/src/lib/git/gitlab.ts b/src/lib/git/gitlab.ts
index 20ccb3c..9031671 100644
--- a/src/lib/git/gitlab.ts
+++ b/src/lib/git/gitlab.ts
@@ -1,6 +1,8 @@
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
import type {
BranchInfo,
+ CiInfo,
+ CiState,
CommitInfo,
ConnectionCheck,
GitClient,
@@ -10,7 +12,9 @@ import type {
RepoRef,
RepoSnapshotData,
TagInfo,
+ TicketInfo,
} from "./types";
+import { bucketCommitActivity } from "./types";
/**
* GitLab – gitlab.com und selbst gehostete Instanzen (API v4).
@@ -80,16 +84,26 @@ export class GitLabClient implements GitClient {
const project = await getJson(`${base}?statistics=true`, { headers });
- const [branches, tags, commits, releases, languages, mergeRequests] = await Promise.all([
- getJsonSafe(`${base}/repository/branches?per_page=50`, [], { headers }),
- getJsonSafe(`${base}/repository/tags?per_page=25`, [], { headers }),
- getJsonSafe(`${base}/repository/commits?per_page=15`, [], { headers }),
- getJsonSafe(`${base}/releases?per_page=10`, [], { headers }),
- getJsonSafe>(`${base}/languages`, {}, { headers }),
- getJsonSafe(`${base}/merge_requests?state=opened&per_page=100`, [], {
- headers,
- }),
- ]);
+ const defaultBranch = project.default_branch ?? "";
+
+ const [branches, tags, commits, releases, languages, mergeRequests, issues, pipelines] =
+ await Promise.all([
+ getJsonSafe(`${base}/repository/branches?per_page=50`, [], { headers }),
+ getJsonSafe(`${base}/repository/tags?per_page=25`, [], { headers }),
+ // 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
+ getJsonSafe(`${base}/repository/commits?per_page=100`, [], { headers }),
+ getJsonSafe(`${base}/releases?per_page=10`, [], { headers }),
+ getJsonSafe>(`${base}/languages`, {}, { headers }),
+ getJsonSafe(`${base}/merge_requests?state=opened&per_page=50`, [], {
+ headers,
+ }),
+ getJsonSafe(`${base}/issues?state=opened&per_page=50`, [], { headers }),
+ getJsonSafe(
+ `${base}/pipelines?per_page=1${defaultBranch ? `&ref=${encodeURIComponent(defaultBranch)}` : ""}`,
+ [],
+ { headers },
+ ),
+ ]);
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
name: branch.name,
@@ -128,6 +142,37 @@ export class GitLabClient implements GitClient {
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 {
fullName: project.path_with_namespace,
description: project.description ?? null,
@@ -144,13 +189,33 @@ export class GitLabClient implements GitClient {
pushedAt: toIso(project.last_activity_at),
branches: mappedBranches,
tags: mappedTags,
- commits: mappedCommits,
+ commits: mappedCommits.slice(0, 15),
releases: mappedReleases,
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 ──────────────────────────────────────────────────
interface GitLabProject {
@@ -205,5 +270,29 @@ interface GitLabRelease {
}
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;
+ name?: string | null;
+ status: string | null;
+ ref: string | null;
+ web_url: string | null;
+ updated_at: string | null;
}
diff --git a/src/lib/git/parse.test.ts b/src/lib/git/parse.test.ts
new file mode 100644
index 0000000..1d6a7a9
--- /dev/null
+++ b/src/lib/git/parse.test.ts
@@ -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);
+ });
+});
diff --git a/src/lib/git/types.ts b/src/lib/git/types.ts
index dec3bd9..654d359 100644
--- a/src/lib/git/types.ts
+++ b/src/lib/git/types.ts
@@ -47,6 +47,29 @@ export interface ReleaseInfo {
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 {
fullName: string;
description: string | null;
@@ -67,6 +90,53 @@ export interface RepoSnapshotData extends RepoOverview {
commits: CommitInfo[];
releases: ReleaseInfo[];
languages: Record;
+ 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): number[] {
+ const buckets = new Array(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 {
diff --git a/src/lib/sync/index.ts b/src/lib/sync/index.ts
index b853669..1c8072d 100644
--- a/src/lib/sync/index.ts
+++ b/src/lib/sync/index.ts
@@ -117,6 +117,14 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) {
commits: data.commits as unknown as object,
releases: data.releases 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,
fetchedAt: new Date(),
};
diff --git a/src/types/index.ts b/src/types/index.ts
index 6a7f04f..bfc1da2 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -1,5 +1,12 @@
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. */
export interface ConnectionSafe {
@@ -44,7 +51,17 @@ export interface RepoListEntry {
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. */
export function asArray(value: unknown): T[] {