Files
nebula/src/lib/git/index.ts
T
florian.wildeandClaude Opus 5 9ea6bb8d36 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>
2026-07-25 19:33:50 +02:00

106 lines
3.0 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 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 };
}