feat(git): Adapter fuer GitHub, GitLab und Gitea

Ein gemeinsames Interface (GitClient) mit drei Implementierungen. Jeder
Adapter liefert dieselbe normalisierte Struktur, sodass die Oberflaeche die
Unterschiede der APIs nicht kennen muss:

- GitHub  – REST v3, Bearer-Token, funktioniert auch mit Enterprise (/api/v3).
            open_issues_count enthaelt dort PRs, das wird herausgerechnet.
- GitLab  – API v4, PRIVATE-TOKEN, Projekte ueber den URL-kodierten Pfad,
            damit verschachtelte Gruppen funktionieren.
- Gitea   – API v1, token-Header, deckt auch Forgejo ab.

Geholt werden Repo-Eckdaten, Branches, Tags, Commits, Releases, Sprachen und
offene PRs/MRs. Die Teilabfragen laufen parallel; eine fehlschlagende Liste
bricht den restlichen Sync nicht ab.

Weiteres:
- http.ts uebersetzt HTTP-Status in verstaendliche Meldungen (401 Token,
  403 Scopes/Rate-Limit, 404 Pfad) und erzwingt ein Timeout.
- parseRepoUrl versteht https-, ssh- und GitLab-Weboberflaechen-URLs.
- meta.ts haelt die Provider-Metadaten frei von node:crypto, damit
  Client-Komponenten sie importieren koennen.
- sync.ts schreibt das Ergebnis als Snapshot und vermerkt Fehler am Projekt,
  statt eine Ausnahme bis in die Seite durchzureichen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-25 19:33:50 +02:00
co-authored by Claude Opus 5
parent c9e09a2762
commit 9ea6bb8d36
8 changed files with 1146 additions and 0 deletions
+209
View File
@@ -0,0 +1,209 @@
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
import type {
BranchInfo,
CommitInfo,
ConnectionCheck,
GitClient,
ProviderConfig,
ReleaseInfo,
RepoListItem,
RepoRef,
RepoSnapshotData,
TagInfo,
} 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),
};
});
}
async fetchRepo(ref: RepoRef): Promise<RepoSnapshotData> {
const path = [ref.owner, ref.name].filter(Boolean).join("/");
const encoded = encodeURIComponent(path);
const base = `${this.apiBase}/projects/${encoded}`;
const headers = this.headers();
const project = await getJson<GitLabProject>(`${base}?statistics=true`, { headers });
const [branches, tags, commits, releases, languages, mergeRequests] = await Promise.all([
getJsonSafe<GitLabBranch[]>(`${base}/repository/branches?per_page=50`, [], { headers }),
getJsonSafe<GitLabTag[]>(`${base}/repository/tags?per_page=25`, [], { headers }),
getJsonSafe<GitLabCommit[]>(`${base}/repository/commits?per_page=15`, [], { 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=100`, [], {
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,
}));
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,
releases: mappedReleases,
languages: languages ?? {},
};
}
}
// ── 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 {
id: number;
}