Files
nebula/src/lib/git/gitlab.ts
T
florian.wildeandClaude Opus 5 f8e6c66876 feat(git): README des Repositories holen
Erspart den Wechsel ins Git, nur um zu sehen, worum ein halbvergessenes
Projekt eigentlich ging.

GitHub hat einen eigenen Endpunkt, der jede Schreibweise findet - ein Aufruf
genuegt. GitLab und Gitea kennen keinen; dort wird erst das
Wurzelverzeichnis aufgelistet und dann die gefundene Datei geholt. Erkannt
werden README.md und die ueblichen Varianten, Gross- und Kleinschreibung
egal; Markdown hat Vorrang, weil nur das wirklich dargestellt werden kann.

Geholt wird hoechstens einmal am Tag, wie der Kalender, und mit eigenem
Zeitstempel: schlaegt das eine fehl, soll das andere nicht mit blockiert
werden.

Bei 60 000 Zeichen wird abgeschnitten, aber an einer Zeilengrenze - sonst
bliebe womoeglich ein halber Codeblock stehen. Dekodiert wird ueber Buffer
und nicht ueber atob, sonst kaemen Umlaute als Kauderwelsch heraus.

Kein README ist eine Auskunft, kein Fehlschlag: der Zeitstempel wird dann
trotzdem gesetzt, damit nicht jeder Abgleich erneut sucht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:41:18 +02:00

367 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
import {
CALENDAR_PAGE_SIZE,
calendarByPaging,
calendarSince,
type CommitCalendar,
} from "./calendar";
import { capReadme, decodeBase64, pickReadme, type RepoReadme } from "./readme";
import type {
BranchInfo,
CiInfo,
CiState,
CommitInfo,
ConnectionCheck,
GitClient,
ProviderConfig,
ReleaseInfo,
RepoListItem,
RepoRef,
RepoSnapshotData,
TagInfo,
TicketInfo,
} from "./types";
import { bucketCommitActivity } from "./types";
/**
* GitLab – gitlab.com und selbst gehostete Instanzen (API v4).
* Projekte werden ueber den URL-kodierten Pfad "gruppe%2Fprojekt" adressiert,
* daher funktionieren auch verschachtelte Gruppen.
*/
export class GitLabClient implements GitClient {
readonly provider = "GITLAB" as const;
private readonly apiBase: string;
private readonly token: string | null;
constructor(config: ProviderConfig) {
const base = normalizeBaseUrl(config.baseUrl || "https://gitlab.com");
this.apiBase = `${base}/api/v4`;
this.token = config.token;
}
private headers(): Record<string, string> {
const headers: Record<string, string> = {};
if (this.token) headers["PRIVATE-TOKEN"] = this.token;
return headers;
}
async check(): Promise<ConnectionCheck> {
try {
if (!this.token) {
await getJson(`${this.apiBase}/projects?per_page=1`, { headers: this.headers() });
return { ok: true, message: "Erreichbar (ohne Token – nur oeffentliche Projekte).", user: null };
}
const user = await getJson<{ username: string; name?: string }>(`${this.apiBase}/user`, {
headers: this.headers(),
});
return { ok: true, message: `Verbunden als ${user.username}`, user: user.username };
} catch (error) {
return { ok: false, message: error instanceof Error ? error.message : String(error) };
}
}
async listRepos(query?: string): Promise<RepoListItem[]> {
const search = query ? `&search=${encodeURIComponent(query)}` : "";
const url = this.token
? `${this.apiBase}/projects?membership=true&per_page=100&order_by=last_activity_at${search}`
: `${this.apiBase}/projects?per_page=50&order_by=last_activity_at${search}`;
const projects = await getJson<GitLabProject[]>(url, { headers: this.headers() });
return (projects ?? []).map((project) => {
const path = project.path_with_namespace;
const segments = path.split("/");
return {
fullPath: path,
owner: segments.slice(0, -1).join("/"),
name: segments[segments.length - 1] ?? project.name,
description: project.description ?? null,
htmlUrl: project.web_url,
defaultBranch: project.default_branch ?? null,
visibility: project.visibility ?? null,
updatedAt: toIso(project.last_activity_at),
};
});
}
/** "gruppe/sub/projekt" URL-kodiert – so adressiert GitLab ein Projekt. */
private projectPath(ref: RepoRef): string {
return encodeURIComponent([ref.owner, ref.name].filter(Boolean).join("/"));
}
async fetchRepo(ref: RepoRef): Promise<RepoSnapshotData> {
const base = `${this.apiBase}/projects/${this.projectPath(ref)}`;
const headers = this.headers();
const project = await getJson<GitLabProject>(`${base}?statistics=true`, { headers });
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<GitLabTag[]>(`${base}/repository/tags?per_page=25`, [], { 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<Record<string, number>>(`${base}/languages`, {}, { headers }),
getJsonSafe<GitLabMergeRequest[]>(`${base}/merge_requests?state=opened&per_page=50`, [], {
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) => ({
name: branch.name,
sha: branch.commit?.id ?? null,
isDefault: Boolean(branch.default),
protected: Boolean(branch.protected),
lastCommitAt: toIso(branch.commit?.committed_date ?? branch.commit?.created_at),
lastCommitMessage: branch.commit?.title ?? null,
lastCommitAuthor: branch.commit?.author_name ?? null,
url: branch.web_url ?? null,
}));
const mappedTags: TagInfo[] = tags.map((tag) => ({
name: tag.name,
sha: tag.commit?.id ?? null,
message: tag.message ?? null,
date: toIso(tag.commit?.committed_date ?? tag.commit?.created_at),
url: `${project.web_url}/-/tags/${encodeURIComponent(tag.name)}`,
}));
const mappedCommits: CommitInfo[] = commits.map((commit) => ({
sha: commit.id,
shortSha: commit.short_id ?? shortSha(commit.id),
message: commit.title ?? commit.message?.split("\n")[0] ?? "",
author: commit.author_name ?? null,
date: toIso(commit.committed_date ?? commit.created_at),
url: commit.web_url ?? null,
}));
const mappedReleases: ReleaseInfo[] = releases.map((release) => ({
name: release.name || release.tag_name,
tag: release.tag_name,
publishedAt: toIso(release.released_at ?? release.created_at),
url: release._links?.self ?? null,
prerelease: Boolean(release.upcoming_release),
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,
defaultBranch: project.default_branch ?? null,
htmlUrl: project.web_url,
visibility: project.visibility ?? null,
stars: project.star_count ?? 0,
forks: project.forks_count ?? 0,
openIssues: project.open_issues_count ?? 0,
openPullRequests: mergeRequests.length,
sizeKb: project.statistics?.repository_size
? Math.round(project.statistics.repository_size / 1024)
: 0,
pushedAt: toIso(project.last_activity_at),
branches: mappedBranches,
tags: mappedTags,
commits: mappedCommits.slice(0, 15),
releases: mappedReleases,
languages: languages ?? {},
pullRequests: mappedMergeRequests,
issues: mappedIssues,
ci,
commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)),
};
}
/**
* GitLab kennt keine fertige Jahresstatistik, also seitenweise mit `since`.
* Der Deckel in calendarByPaging begrenzt das auf wenige Aufrufe.
*/
async fetchCommitCalendar(ref: RepoRef): Promise<CommitCalendar | null> {
const base = `${this.apiBase}/projects/${this.projectPath(ref)}`;
const seit = calendarSince().toISOString();
// getJson und nicht getJsonSafe: ein Rate-Limit darf nicht als "keine
// Daten" durchgehen – siehe maybeRefreshCalendar.
return calendarByPaging(async (seite) => {
const commits = await getJson<GitLabCommit[]>(
`${base}/repository/commits?per_page=${CALENDAR_PAGE_SIZE}&page=${seite}` +
`&since=${encodeURIComponent(seit)}`,
{ headers: this.headers() },
);
return commits.map((commit) => toIso(commit.committed_date ?? commit.created_at));
});
}
/**
* GitLab kennt keinen README-Endpunkt. Erst das Wurzelverzeichnis
* auflisten, dann die gefundene Datei holen – zwei Aufrufe.
*/
async fetchReadme(ref: RepoRef): Promise<RepoReadme | null> {
const base = `${this.apiBase}/projects/${this.projectPath(ref)}`;
const headers = this.headers();
const eintraege = await getJsonSafe<Array<{ name: string; type: string }>>(
`${base}/repository/tree?per_page=100`,
[],
{ headers },
);
const name = pickReadme(
eintraege.filter((eintrag) => eintrag.type === "blob").map((eintrag) => eintrag.name),
);
if (!name) return null;
const datei = await getJson<{ content?: string; encoding?: string; file_path?: string }>(
`${base}/repository/files/${encodeURIComponent(name)}?ref=HEAD`,
{ headers },
);
if (!datei?.content || datei.encoding !== "base64") return null;
const { content, truncated } = capReadme(decodeBase64(datei.content));
const projekt = await getJsonSafe<GitLabProject>(base, {} as GitLabProject, { headers });
return {
content,
path: datei.file_path ?? name,
baseUrl:
projekt?.web_url && projekt.default_branch
? `${projekt.web_url}/-/blob/${encodeURIComponent(projekt.default_branch)}/`
: null,
truncated,
};
}
}
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 {
id: number;
name: string;
path_with_namespace: string;
description: string | null;
web_url: string;
default_branch: string | null;
visibility: string | null;
star_count: number;
forks_count: number;
open_issues_count: number;
last_activity_at: string | null;
statistics?: { repository_size?: number };
}
interface GitLabCommitStub {
id: string;
short_id?: string;
title?: string;
message?: string;
author_name?: string;
committed_date?: string;
created_at?: string;
web_url?: string;
}
interface GitLabBranch {
name: string;
default?: boolean;
protected?: boolean;
web_url?: string;
commit?: GitLabCommitStub;
}
interface GitLabTag {
name: string;
message: string | null;
commit?: GitLabCommitStub;
}
type GitLabCommit = GitLabCommitStub;
interface GitLabRelease {
name: string | null;
tag_name: string;
released_at: string | null;
created_at: string | null;
upcoming_release?: boolean;
_links?: { self?: string };
}
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;
}