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
+105
View File
@@ -0,0 +1,105 @@
import type { GitConnection } from "@prisma/client";
import { tryDecryptSecret } from "@/lib/crypto";
import { GiteaClient } from "./gitea";
import { GitHubClient } from "./github";
import { GitLabClient } from "./gitlab";
import { guessProvider } from "./meta";
import type { GitClient, ProviderConfig, ProviderKind, RepoRef } from "./types";
export * from "./types";
export { GiteaClient, GitHubClient, GitLabClient };
// PROVIDER_META liegt in ./meta, damit Client-Komponenten es ohne
// node:crypto-Abhaengigkeit importieren koennen.
export { PROVIDER_META, guessProvider } from "./meta";
export function createClient(config: ProviderConfig): GitClient {
switch (config.provider) {
case "GITHUB":
return new GitHubClient(config);
case "GITLAB":
return new GitLabClient(config);
case "GITEA":
return new GiteaClient(config);
default: {
const exhaustive: never = config.provider;
throw new Error(`Unbekannter Provider: ${String(exhaustive)}`);
}
}
}
/** Baut einen Client aus einer gespeicherten Verbindung (Token wird entschluesselt). */
export function clientForConnection(connection: GitConnection): GitClient {
return createClient({
provider: connection.provider,
baseUrl: connection.baseUrl,
token: tryDecryptSecret(connection.tokenCipher),
});
}
export interface ParsedRepoUrl {
host: string;
baseUrl: string;
owner: string;
name: string;
/** Aus dem Host geratener Provider – vom Nutzer ueberschreibbar. */
guessedProvider: ProviderKind;
}
/**
* Zerlegt eine Repository-URL. Unterstuetzt:
* https://github.com/user/repo(.git)
* git@gitlab.com:gruppe/subgruppe/repo.git
* https://git.firma.de/team/repo
*/
export function parseRepoUrl(input: string): ParsedRepoUrl | null {
const raw = input.trim();
if (!raw) return null;
let host = "";
let path = "";
let protocol = "https";
const sshMatch = raw.match(/^(?:ssh:\/\/)?(?:[\w.-]+@)([^/:]+)[:/](.+)$/);
if (sshMatch && !raw.startsWith("http")) {
host = sshMatch[1];
path = sshMatch[2];
} else {
try {
const url = new URL(raw.includes("://") ? raw : `https://${raw}`);
host = url.host;
path = url.pathname;
protocol = url.protocol.replace(":", "") || "https";
} catch {
return null;
}
}
const segments = path
.replace(/\.git$/i, "")
.split("/")
.filter(Boolean)
// GitLab-Weboberflaeche haengt bei Unterseiten "/-/tree/main" an.
.reduce<string[]>((acc, segment) => {
if (segment === "-") return acc.length ? [...acc, "__STOP__"] : acc;
if (acc.includes("__STOP__")) return acc;
return [...acc, segment];
}, [])
.filter((segment) => segment !== "__STOP__");
if (segments.length < 2) return null;
const name = segments[segments.length - 1];
const owner = segments.slice(0, -1).join("/");
return {
host,
baseUrl: `${protocol}://${host}`,
owner,
name,
guessedProvider: guessProvider(host),
};
}
export function repoRefFrom(owner: string, name: string): RepoRef {
return { owner, name };
}