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:
@@ -0,0 +1,107 @@
|
||||
import { GitApiError } from "./types";
|
||||
|
||||
const DEFAULT_TIMEOUT = Number(process.env.GIT_API_TIMEOUT_MS ?? 15000);
|
||||
|
||||
export interface FetchOptions {
|
||||
headers?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
/** Wenn true, liefert die Funktion bei 404 null statt zu werfen. */
|
||||
allow404?: boolean;
|
||||
}
|
||||
|
||||
/** Entfernt einen abschliessenden Slash, damit URLs sauber zusammengesetzt werden. */
|
||||
export function normalizeBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function friendlyMessage(status: number, url: string, body: string): string {
|
||||
switch (status) {
|
||||
case 401:
|
||||
return "Nicht autorisiert (401) – Token fehlt, ist abgelaufen oder ungueltig.";
|
||||
case 403:
|
||||
return body.toLowerCase().includes("rate limit")
|
||||
? "Rate-Limit erreicht (403) – bitte spaeter erneut versuchen oder Token hinterlegen."
|
||||
: "Zugriff verweigert (403) – Token hat nicht die noetigen Scopes.";
|
||||
case 404:
|
||||
return "Nicht gefunden (404) – Repo-Pfad pruefen oder Token hat keinen Lesezugriff.";
|
||||
case 429:
|
||||
return "Zu viele Anfragen (429) – Rate-Limit des Git-Systems.";
|
||||
default:
|
||||
if (status >= 500) return `Serverfehler ${status} beim Git-System.`;
|
||||
return `Anfrage fehlgeschlagen (${status}) auf ${url}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** GET mit Timeout, JSON-Parsing und sprechenden Fehlern. */
|
||||
export async function getJson<T>(
|
||||
url: string,
|
||||
options: FetchOptions = {},
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
options.timeoutMs ?? DEFAULT_TIMEOUT,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Nebula-Dashboard",
|
||||
...options.headers,
|
||||
},
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (response.status === 404 && options.allow404) {
|
||||
return null as T;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => "");
|
||||
throw new GitApiError(friendlyMessage(response.status, url, body), response.status, url);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text) return null as T;
|
||||
return JSON.parse(text) as T;
|
||||
} catch (error) {
|
||||
if (error instanceof GitApiError) throw error;
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new GitApiError(
|
||||
`Zeitueberschreitung nach ${options.timeoutMs ?? DEFAULT_TIMEOUT} ms`,
|
||||
408,
|
||||
url,
|
||||
);
|
||||
}
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new GitApiError(`Verbindung fehlgeschlagen: ${reason}`, 0, url);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/** Wie getJson, gibt aber bei jedem Fehler den Fallback zurueck. */
|
||||
export async function getJsonSafe<T>(
|
||||
url: string,
|
||||
fallback: T,
|
||||
options: FetchOptions = {},
|
||||
): Promise<T> {
|
||||
try {
|
||||
const result = await getJson<T>(url, options);
|
||||
return (result ?? fallback) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function toIso(value: unknown): string | null {
|
||||
if (!value) return null;
|
||||
const date = new Date(String(value));
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
export function shortSha(sha: string | null | undefined): string {
|
||||
return (sha ?? "").slice(0, 7);
|
||||
}
|
||||
Reference in New Issue
Block a user