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,225 @@
|
|||||||
|
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
|
||||||
|
import type {
|
||||||
|
BranchInfo,
|
||||||
|
CommitInfo,
|
||||||
|
ConnectionCheck,
|
||||||
|
GitClient,
|
||||||
|
ProviderConfig,
|
||||||
|
ReleaseInfo,
|
||||||
|
RepoListItem,
|
||||||
|
RepoRef,
|
||||||
|
RepoSnapshotData,
|
||||||
|
TagInfo,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gitea (und kompatible Forks wie Forgejo) – API v1.
|
||||||
|
* Authentifizierung per "Authorization: token <PAT>".
|
||||||
|
*/
|
||||||
|
export class GiteaClient implements GitClient {
|
||||||
|
readonly provider = "GITEA" as const;
|
||||||
|
private readonly apiBase: string;
|
||||||
|
private readonly token: string | null;
|
||||||
|
|
||||||
|
constructor(config: ProviderConfig) {
|
||||||
|
const base = normalizeBaseUrl(config.baseUrl);
|
||||||
|
this.apiBase = `${base}/api/v1`;
|
||||||
|
this.token = config.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private headers(): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (this.token) headers.Authorization = `token ${this.token}`;
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async check(): Promise<ConnectionCheck> {
|
||||||
|
try {
|
||||||
|
const version = await getJsonSafe<{ version?: string }>(
|
||||||
|
`${this.apiBase}/version`,
|
||||||
|
{},
|
||||||
|
{ headers: this.headers() },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!this.token) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: `Instanz erreichbar${version.version ? ` (Gitea ${version.version})` : ""} – ohne Token nur oeffentliche Repos.`,
|
||||||
|
user: null,
|
||||||
|
version: version.version ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getJson<{ login: string; full_name?: string }>(`${this.apiBase}/user`, {
|
||||||
|
headers: this.headers(),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: `Verbunden als ${user.login}${version.version ? ` (Gitea ${version.version})` : ""}`,
|
||||||
|
user: user.login,
|
||||||
|
version: version.version ?? null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRepos(query?: string): Promise<RepoListItem[]> {
|
||||||
|
const url = this.token
|
||||||
|
? `${this.apiBase}/user/repos?limit=100`
|
||||||
|
: `${this.apiBase}/repos/search?limit=50${query ? `&q=${encodeURIComponent(query)}` : ""}`;
|
||||||
|
|
||||||
|
const raw = await getJson<GiteaRepo[] | { data: GiteaRepo[] }>(url, {
|
||||||
|
headers: this.headers(),
|
||||||
|
});
|
||||||
|
const repos = Array.isArray(raw) ? raw : (raw?.data ?? []);
|
||||||
|
const needle = query?.toLowerCase().trim();
|
||||||
|
|
||||||
|
return repos
|
||||||
|
.filter((repo) => !needle || repo.full_name.toLowerCase().includes(needle))
|
||||||
|
.map((repo) => ({
|
||||||
|
fullPath: repo.full_name,
|
||||||
|
owner: repo.owner?.login ?? repo.full_name.split("/")[0] ?? "",
|
||||||
|
name: repo.name,
|
||||||
|
description: repo.description ?? null,
|
||||||
|
htmlUrl: repo.html_url,
|
||||||
|
defaultBranch: repo.default_branch ?? null,
|
||||||
|
visibility: repo.private ? "private" : "public",
|
||||||
|
updatedAt: toIso(repo.updated_at),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchRepo(ref: RepoRef): Promise<RepoSnapshotData> {
|
||||||
|
const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
|
||||||
|
const headers = this.headers();
|
||||||
|
|
||||||
|
const repo = await getJson<GiteaRepo>(base, { headers });
|
||||||
|
|
||||||
|
const [branches, tags, commits, releases, languages, pulls] = await Promise.all([
|
||||||
|
getJsonSafe<GiteaBranch[]>(`${base}/branches?limit=50`, [], { headers }),
|
||||||
|
getJsonSafe<GiteaTag[]>(`${base}/tags?limit=25`, [], { headers }),
|
||||||
|
getJsonSafe<GiteaCommit[]>(`${base}/commits?limit=15&stat=false`, [], { headers }),
|
||||||
|
getJsonSafe<GiteaRelease[]>(`${base}/releases?limit=10`, [], { headers }),
|
||||||
|
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
|
||||||
|
getJsonSafe<GiteaPull[]>(`${base}/pulls?state=open&limit=100`, [], { headers }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
|
||||||
|
name: branch.name,
|
||||||
|
sha: branch.commit?.id ?? null,
|
||||||
|
isDefault: branch.name === repo.default_branch,
|
||||||
|
protected: Boolean(branch.protected),
|
||||||
|
lastCommitAt: toIso(branch.commit?.timestamp),
|
||||||
|
lastCommitMessage: branch.commit?.message?.split("\n")[0] ?? null,
|
||||||
|
lastCommitAuthor: branch.commit?.author?.name ?? null,
|
||||||
|
url: `${repo.html_url}/src/branch/${encodeURIComponent(branch.name)}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mappedTags: TagInfo[] = tags.map((tag) => ({
|
||||||
|
name: tag.name,
|
||||||
|
sha: tag.commit?.sha ?? tag.id ?? null,
|
||||||
|
message: tag.message ?? null,
|
||||||
|
date: toIso(tag.commit?.created),
|
||||||
|
url: tag.zipball_url ? `${repo.html_url}/releases/tag/${encodeURIComponent(tag.name)}` : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mappedCommits: CommitInfo[] = commits.map((commit) => ({
|
||||||
|
sha: commit.sha,
|
||||||
|
shortSha: shortSha(commit.sha),
|
||||||
|
message: commit.commit?.message?.split("\n")[0] ?? "",
|
||||||
|
author: commit.commit?.author?.name ?? commit.author?.login ?? null,
|
||||||
|
date: toIso(commit.commit?.author?.date ?? commit.created),
|
||||||
|
url: commit.html_url ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mappedReleases: ReleaseInfo[] = releases.map((release) => ({
|
||||||
|
name: release.name || release.tag_name,
|
||||||
|
tag: release.tag_name,
|
||||||
|
publishedAt: toIso(release.published_at ?? release.created_at),
|
||||||
|
url: release.html_url ?? null,
|
||||||
|
prerelease: Boolean(release.prerelease),
|
||||||
|
draft: Boolean(release.draft),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
fullName: repo.full_name,
|
||||||
|
description: repo.description ?? null,
|
||||||
|
defaultBranch: repo.default_branch ?? null,
|
||||||
|
htmlUrl: repo.html_url,
|
||||||
|
visibility: repo.private ? "private" : "public",
|
||||||
|
stars: repo.stars_count ?? 0,
|
||||||
|
forks: repo.forks_count ?? 0,
|
||||||
|
openIssues: repo.open_issues_count ?? 0,
|
||||||
|
openPullRequests: repo.open_pr_counter ?? pulls.length,
|
||||||
|
sizeKb: repo.size ?? 0,
|
||||||
|
pushedAt: toIso(repo.updated_at),
|
||||||
|
branches: mappedBranches,
|
||||||
|
tags: mappedTags,
|
||||||
|
commits: mappedCommits,
|
||||||
|
releases: mappedReleases,
|
||||||
|
languages: languages ?? {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API-Typen ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface GiteaRepo {
|
||||||
|
full_name: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
html_url: string;
|
||||||
|
default_branch: string | null;
|
||||||
|
private: boolean;
|
||||||
|
stars_count: number;
|
||||||
|
forks_count: number;
|
||||||
|
open_issues_count: number;
|
||||||
|
open_pr_counter?: number;
|
||||||
|
size: number;
|
||||||
|
updated_at: string | null;
|
||||||
|
owner?: { login: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GiteaBranch {
|
||||||
|
name: string;
|
||||||
|
protected?: boolean;
|
||||||
|
commit?: {
|
||||||
|
id: string;
|
||||||
|
message?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
author?: { name?: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GiteaTag {
|
||||||
|
name: string;
|
||||||
|
id?: string;
|
||||||
|
message?: string | null;
|
||||||
|
zipball_url?: string;
|
||||||
|
commit?: { sha?: string; created?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GiteaCommit {
|
||||||
|
sha: string;
|
||||||
|
html_url?: string;
|
||||||
|
created?: string;
|
||||||
|
author?: { login?: string } | null;
|
||||||
|
commit?: {
|
||||||
|
message?: string;
|
||||||
|
author?: { name?: string; date?: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GiteaRelease {
|
||||||
|
name: string | null;
|
||||||
|
tag_name: string;
|
||||||
|
published_at: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
html_url: string | null;
|
||||||
|
prerelease: boolean;
|
||||||
|
draft: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GiteaPull {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
|
||||||
|
import type {
|
||||||
|
BranchInfo,
|
||||||
|
CommitInfo,
|
||||||
|
ConnectionCheck,
|
||||||
|
GitClient,
|
||||||
|
ProviderConfig,
|
||||||
|
ReleaseInfo,
|
||||||
|
RepoListItem,
|
||||||
|
RepoRef,
|
||||||
|
RepoSnapshotData,
|
||||||
|
TagInfo,
|
||||||
|
} from "./types";
|
||||||
|
import { GitApiError } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub – funktioniert mit github.com und GitHub Enterprise.
|
||||||
|
* Bei Enterprise liegt die API unter <host>/api/v3.
|
||||||
|
*/
|
||||||
|
export class GitHubClient implements GitClient {
|
||||||
|
readonly provider = "GITHUB" as const;
|
||||||
|
private readonly apiBase: string;
|
||||||
|
private readonly token: string | null;
|
||||||
|
|
||||||
|
constructor(config: ProviderConfig) {
|
||||||
|
const base = normalizeBaseUrl(config.baseUrl || "https://github.com");
|
||||||
|
this.apiBase = /github\.com$/i.test(base) ? "https://api.github.com" : `${base}/api/v3`;
|
||||||
|
this.token = config.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private headers(): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
};
|
||||||
|
if (this.token) headers.Authorization = `Bearer ${this.token}`;
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async check(): Promise<ConnectionCheck> {
|
||||||
|
try {
|
||||||
|
if (!this.token) {
|
||||||
|
await getJson(`${this.apiBase}/rate_limit`, { headers: this.headers() });
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: "Erreichbar (ohne Token – nur oeffentliche Repos, striktes Rate-Limit).",
|
||||||
|
user: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const user = await getJson<{ login: string; name?: string }>(`${this.apiBase}/user`, {
|
||||||
|
headers: this.headers(),
|
||||||
|
});
|
||||||
|
return { ok: true, message: `Verbunden als ${user.login}`, user: user.login };
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRepos(query?: string): Promise<RepoListItem[]> {
|
||||||
|
if (!this.token) {
|
||||||
|
throw new GitApiError("Fuer das Auflisten von Repos wird ein Token benoetigt.", 401, this.apiBase);
|
||||||
|
}
|
||||||
|
const repos = await getJson<GitHubRepo[]>(
|
||||||
|
`${this.apiBase}/user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member`,
|
||||||
|
{ headers: this.headers() },
|
||||||
|
);
|
||||||
|
const needle = query?.toLowerCase().trim();
|
||||||
|
return (repos ?? [])
|
||||||
|
.filter((repo) => !needle || repo.full_name.toLowerCase().includes(needle))
|
||||||
|
.map((repo) => ({
|
||||||
|
fullPath: repo.full_name,
|
||||||
|
owner: repo.owner?.login ?? repo.full_name.split("/")[0] ?? "",
|
||||||
|
name: repo.name,
|
||||||
|
description: repo.description ?? null,
|
||||||
|
htmlUrl: repo.html_url,
|
||||||
|
defaultBranch: repo.default_branch ?? null,
|
||||||
|
visibility: repo.private ? "private" : "public",
|
||||||
|
updatedAt: toIso(repo.pushed_at ?? repo.updated_at),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchRepo(ref: RepoRef): Promise<RepoSnapshotData> {
|
||||||
|
const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
|
||||||
|
const headers = this.headers();
|
||||||
|
|
||||||
|
const repo = await getJson<GitHubRepo>(base, { headers });
|
||||||
|
|
||||||
|
const [branches, tags, commits, releases, languages, pulls] = await Promise.all([
|
||||||
|
getJsonSafe<GitHubBranch[]>(`${base}/branches?per_page=50`, [], { headers }),
|
||||||
|
getJsonSafe<GitHubTag[]>(`${base}/tags?per_page=25`, [], { headers }),
|
||||||
|
getJsonSafe<GitHubCommit[]>(`${base}/commits?per_page=15`, [], { headers }),
|
||||||
|
getJsonSafe<GitHubRelease[]>(`${base}/releases?per_page=10`, [], { headers }),
|
||||||
|
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
|
||||||
|
getJsonSafe<GitHubPull[]>(`${base}/pulls?state=open&per_page=100`, [], { headers }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
|
||||||
|
name: branch.name,
|
||||||
|
sha: branch.commit?.sha ?? null,
|
||||||
|
isDefault: branch.name === repo.default_branch,
|
||||||
|
protected: Boolean(branch.protected),
|
||||||
|
lastCommitAt: null,
|
||||||
|
lastCommitMessage: null,
|
||||||
|
lastCommitAuthor: null,
|
||||||
|
url: `${repo.html_url}/tree/${encodeURIComponent(branch.name)}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mappedTags: TagInfo[] = tags.map((tag) => ({
|
||||||
|
name: tag.name,
|
||||||
|
sha: tag.commit?.sha ?? null,
|
||||||
|
message: null,
|
||||||
|
date: null,
|
||||||
|
url: `${repo.html_url}/releases/tag/${encodeURIComponent(tag.name)}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mappedCommits: CommitInfo[] = commits.map((entry) => ({
|
||||||
|
sha: entry.sha,
|
||||||
|
shortSha: shortSha(entry.sha),
|
||||||
|
message: entry.commit?.message?.split("\n")[0] ?? "",
|
||||||
|
author: entry.commit?.author?.name ?? entry.author?.login ?? null,
|
||||||
|
date: toIso(entry.commit?.author?.date ?? entry.commit?.committer?.date),
|
||||||
|
url: entry.html_url ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mappedReleases: ReleaseInfo[] = releases.map((release) => ({
|
||||||
|
name: release.name || release.tag_name,
|
||||||
|
tag: release.tag_name,
|
||||||
|
publishedAt: toIso(release.published_at ?? release.created_at),
|
||||||
|
url: release.html_url ?? null,
|
||||||
|
prerelease: Boolean(release.prerelease),
|
||||||
|
draft: Boolean(release.draft),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// open_issues_count zaehlt bei GitHub PRs mit – hier wieder herausrechnen.
|
||||||
|
const openPullRequests = pulls.length;
|
||||||
|
const openIssues = Math.max(0, (repo.open_issues_count ?? 0) - openPullRequests);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fullName: repo.full_name,
|
||||||
|
description: repo.description ?? null,
|
||||||
|
defaultBranch: repo.default_branch ?? null,
|
||||||
|
htmlUrl: repo.html_url,
|
||||||
|
visibility: repo.private ? "private" : "public",
|
||||||
|
stars: repo.stargazers_count ?? 0,
|
||||||
|
forks: repo.forks_count ?? 0,
|
||||||
|
openIssues,
|
||||||
|
openPullRequests,
|
||||||
|
sizeKb: repo.size ?? 0,
|
||||||
|
pushedAt: toIso(repo.pushed_at),
|
||||||
|
branches: mappedBranches,
|
||||||
|
tags: mappedTags,
|
||||||
|
commits: mappedCommits,
|
||||||
|
releases: mappedReleases,
|
||||||
|
languages: languages ?? {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API-Typen (nur die tatsaechlich genutzten Felder) ──────────
|
||||||
|
|
||||||
|
interface GitHubRepo {
|
||||||
|
full_name: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
html_url: string;
|
||||||
|
default_branch: string;
|
||||||
|
private: boolean;
|
||||||
|
stargazers_count: number;
|
||||||
|
forks_count: number;
|
||||||
|
open_issues_count: number;
|
||||||
|
size: number;
|
||||||
|
pushed_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
owner?: { login: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitHubBranch {
|
||||||
|
name: string;
|
||||||
|
protected?: boolean;
|
||||||
|
commit?: { sha: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitHubTag {
|
||||||
|
name: string;
|
||||||
|
commit?: { sha: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitHubCommit {
|
||||||
|
sha: string;
|
||||||
|
html_url?: string;
|
||||||
|
author?: { login: string } | null;
|
||||||
|
commit?: {
|
||||||
|
message?: string;
|
||||||
|
author?: { name?: string; date?: string };
|
||||||
|
committer?: { date?: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitHubRelease {
|
||||||
|
name: string | null;
|
||||||
|
tag_name: string;
|
||||||
|
published_at: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
html_url: string | null;
|
||||||
|
prerelease: boolean;
|
||||||
|
draft: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitHubPull {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { ProviderKind } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reine Metadaten zu den Git-Systemen – bewusst frei von Node-Imports,
|
||||||
|
* damit dieses Modul auch in Client-Komponenten benutzt werden kann.
|
||||||
|
*/
|
||||||
|
export const PROVIDER_META: Record<
|
||||||
|
ProviderKind,
|
||||||
|
{ label: string; defaultBaseUrl: string; tokenHelp: string; docsUrl: string }
|
||||||
|
> = {
|
||||||
|
GITHUB: {
|
||||||
|
label: "GitHub",
|
||||||
|
defaultBaseUrl: "https://github.com",
|
||||||
|
tokenHelp:
|
||||||
|
"Fine-grained Token mit Lesezugriff auf Contents, Metadata, Issues und Pull Requests.",
|
||||||
|
docsUrl: "https://github.com/settings/tokens",
|
||||||
|
},
|
||||||
|
GITLAB: {
|
||||||
|
label: "GitLab",
|
||||||
|
defaultBaseUrl: "https://gitlab.com",
|
||||||
|
tokenHelp: "Personal Access Token mit Scope 'read_api' (bzw. 'read_repository').",
|
||||||
|
docsUrl: "https://gitlab.com/-/user_settings/personal_access_tokens",
|
||||||
|
},
|
||||||
|
GITEA: {
|
||||||
|
label: "Gitea / Forgejo",
|
||||||
|
defaultBaseUrl: "https://git.example.com",
|
||||||
|
tokenHelp: "Access Token mit Leserechten auf repository, issue und user.",
|
||||||
|
docsUrl: "https://docs.gitea.com/development/api-usage",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function guessProvider(host: string): ProviderKind {
|
||||||
|
const lower = host.toLowerCase();
|
||||||
|
if (lower.includes("github")) return "GITHUB";
|
||||||
|
if (lower.includes("gitlab")) return "GITLAB";
|
||||||
|
return "GITEA";
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { GitProvider } from "@prisma/client";
|
||||||
|
|
||||||
|
export type ProviderKind = GitProvider;
|
||||||
|
|
||||||
|
/** Zeiger auf ein konkretes Repository. */
|
||||||
|
export interface RepoRef {
|
||||||
|
/** Owner / Namespace / Gruppe – bei GitLab auch verschachtelt ("gruppe/sub"). */
|
||||||
|
owner: string;
|
||||||
|
/** Repository-Name ohne Namespace. */
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BranchInfo {
|
||||||
|
name: string;
|
||||||
|
sha: string | null;
|
||||||
|
isDefault: boolean;
|
||||||
|
protected: boolean;
|
||||||
|
lastCommitAt: string | null;
|
||||||
|
lastCommitMessage: string | null;
|
||||||
|
lastCommitAuthor: string | null;
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagInfo {
|
||||||
|
name: string;
|
||||||
|
sha: string | null;
|
||||||
|
message: string | null;
|
||||||
|
date: string | null;
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommitInfo {
|
||||||
|
sha: string;
|
||||||
|
shortSha: string;
|
||||||
|
message: string;
|
||||||
|
author: string | null;
|
||||||
|
date: string | null;
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleaseInfo {
|
||||||
|
name: string;
|
||||||
|
tag: string;
|
||||||
|
publishedAt: string | null;
|
||||||
|
url: string | null;
|
||||||
|
prerelease: boolean;
|
||||||
|
draft: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepoOverview {
|
||||||
|
fullName: string;
|
||||||
|
description: string | null;
|
||||||
|
defaultBranch: string | null;
|
||||||
|
htmlUrl: string | null;
|
||||||
|
visibility: string | null;
|
||||||
|
stars: number;
|
||||||
|
forks: number;
|
||||||
|
openIssues: number;
|
||||||
|
openPullRequests: number;
|
||||||
|
sizeKb: number;
|
||||||
|
pushedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepoSnapshotData extends RepoOverview {
|
||||||
|
branches: BranchInfo[];
|
||||||
|
tags: TagInfo[];
|
||||||
|
commits: CommitInfo[];
|
||||||
|
releases: ReleaseInfo[];
|
||||||
|
languages: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepoListItem {
|
||||||
|
fullPath: string;
|
||||||
|
owner: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
htmlUrl: string;
|
||||||
|
defaultBranch: string | null;
|
||||||
|
visibility: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionCheck {
|
||||||
|
ok: boolean;
|
||||||
|
message: string;
|
||||||
|
user?: string | null;
|
||||||
|
version?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderConfig {
|
||||||
|
provider: ProviderKind;
|
||||||
|
baseUrl: string;
|
||||||
|
token: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitClient {
|
||||||
|
readonly provider: ProviderKind;
|
||||||
|
/** Prueft Erreichbarkeit und Token-Gueltigkeit. */
|
||||||
|
check(): Promise<ConnectionCheck>;
|
||||||
|
/** Repos, auf die der Token Zugriff hat (fuer die Auswahl beim Verknuepfen). */
|
||||||
|
listRepos(query?: string): Promise<RepoListItem[]>;
|
||||||
|
/** Vollstaendiger Stand eines Repositories. */
|
||||||
|
fetchRepo(ref: RepoRef): Promise<RepoSnapshotData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fehler mit HTTP-Kontext, damit die UI verstaendliche Meldungen zeigen kann. */
|
||||||
|
export class GitApiError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly url: string;
|
||||||
|
|
||||||
|
constructor(message: string, status: number, url: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "GitApiError";
|
||||||
|
this.status = status;
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
import type { Project } from "@prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git";
|
||||||
|
import type { RepoSnapshotData } from "@/lib/git/types";
|
||||||
|
|
||||||
|
export interface SyncResult {
|
||||||
|
ok: boolean;
|
||||||
|
message: string;
|
||||||
|
fetchedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt den aktuellen Stand des verknuepften Repositories und schreibt ihn
|
||||||
|
* als Snapshot in die Datenbank. Fehler werden am Projekt vermerkt statt
|
||||||
|
* geworfen, damit ein kaputtes Repo nicht das ganze Dashboard blockiert.
|
||||||
|
*/
|
||||||
|
export async function syncProject(projectId: string): Promise<SyncResult> {
|
||||||
|
const project = await prisma.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
include: { connection: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return { ok: false, message: "Projekt nicht gefunden." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ref = resolveRepoRef(project);
|
||||||
|
if (!ref) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "Kein Repository hinterlegt – bitte Repo-URL oder Owner/Name setzen.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let client;
|
||||||
|
if (project.connection) {
|
||||||
|
client = clientForConnection(project.connection);
|
||||||
|
} else {
|
||||||
|
// Ohne gespeicherte Verbindung: anonymer Zugriff anhand der URL.
|
||||||
|
const parsed = project.repoUrl ? parseRepoUrl(project.repoUrl) : null;
|
||||||
|
if (!parsed) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "Keine Git-Verbindung ausgewaehlt und die Repo-URL ist nicht auswertbar.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
client = createClient({
|
||||||
|
provider: parsed.guessedProvider,
|
||||||
|
baseUrl: parsed.baseUrl,
|
||||||
|
token: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await client.fetchRepo(ref);
|
||||||
|
await persistSnapshot(project.id, data);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: `Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.`,
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
await prisma.project.update({
|
||||||
|
where: { id: project.id },
|
||||||
|
data: { syncError: message, lastSyncedAt: new Date() },
|
||||||
|
});
|
||||||
|
return { ok: false, message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owner/Name aus den Feldern oder ersatzweise aus der URL bestimmen. */
|
||||||
|
export function resolveRepoRef(project: Project) {
|
||||||
|
if (project.repoOwner && project.repoName) {
|
||||||
|
return { owner: project.repoOwner, name: project.repoName };
|
||||||
|
}
|
||||||
|
if (project.repoUrl) {
|
||||||
|
const parsed = parseRepoUrl(project.repoUrl);
|
||||||
|
if (parsed) return { owner: parsed.owner, name: parsed.name };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistSnapshot(projectId: string, data: RepoSnapshotData) {
|
||||||
|
const latestCommit = data.commits[0] ?? null;
|
||||||
|
const latestRelease =
|
||||||
|
data.releases.find((release) => !release.draft && !release.prerelease) ?? data.releases[0] ?? null;
|
||||||
|
const latestTag = data.tags[0]?.name ?? latestRelease?.tag ?? null;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
fullName: data.fullName,
|
||||||
|
description: data.description,
|
||||||
|
defaultBranch: data.defaultBranch,
|
||||||
|
htmlUrl: data.htmlUrl,
|
||||||
|
visibility: data.visibility,
|
||||||
|
stars: data.stars,
|
||||||
|
forks: data.forks,
|
||||||
|
openIssues: data.openIssues,
|
||||||
|
openPullRequests: data.openPullRequests,
|
||||||
|
sizeKb: data.sizeKb,
|
||||||
|
lastCommitSha: latestCommit?.sha ?? null,
|
||||||
|
lastCommitMessage: latestCommit?.message ?? null,
|
||||||
|
lastCommitAuthor: latestCommit?.author ?? null,
|
||||||
|
lastCommitAt: latestCommit?.date ? new Date(latestCommit.date) : null,
|
||||||
|
latestTag,
|
||||||
|
latestRelease: latestRelease?.name ?? null,
|
||||||
|
latestReleaseAt: latestRelease?.publishedAt ? new Date(latestRelease.publishedAt) : null,
|
||||||
|
branchCount: data.branches.length,
|
||||||
|
tagCount: data.tags.length,
|
||||||
|
branches: data.branches as unknown as object,
|
||||||
|
tags: data.tags as unknown as object,
|
||||||
|
commits: data.commits as unknown as object,
|
||||||
|
releases: data.releases as unknown as object,
|
||||||
|
languages: data.languages as unknown as object,
|
||||||
|
pushedAt: data.pushedAt ? new Date(data.pushedAt) : null,
|
||||||
|
fetchedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.repoSnapshot.upsert({
|
||||||
|
where: { projectId },
|
||||||
|
create: { projectId, ...payload },
|
||||||
|
update: payload,
|
||||||
|
}),
|
||||||
|
prisma.project.update({
|
||||||
|
where: { id: projectId },
|
||||||
|
data: {
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
syncError: null,
|
||||||
|
defaultBranch: data.defaultBranch,
|
||||||
|
repoUrl: data.htmlUrl ?? undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user