diff --git a/src/lib/git/gitea.ts b/src/lib/git/gitea.ts new file mode 100644 index 0000000..52e6d54 --- /dev/null +++ b/src/lib/git/gitea.ts @@ -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 ". + */ +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 { + const headers: Record = {}; + if (this.token) headers.Authorization = `token ${this.token}`; + return headers; + } + + async check(): Promise { + 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 { + const url = this.token + ? `${this.apiBase}/user/repos?limit=100` + : `${this.apiBase}/repos/search?limit=50${query ? `&q=${encodeURIComponent(query)}` : ""}`; + + const raw = await getJson(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 { + const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`; + const headers = this.headers(); + + const repo = await getJson(base, { headers }); + + const [branches, tags, commits, releases, languages, pulls] = await Promise.all([ + getJsonSafe(`${base}/branches?limit=50`, [], { headers }), + getJsonSafe(`${base}/tags?limit=25`, [], { headers }), + getJsonSafe(`${base}/commits?limit=15&stat=false`, [], { headers }), + getJsonSafe(`${base}/releases?limit=10`, [], { headers }), + getJsonSafe>(`${base}/languages`, {}, { headers }), + getJsonSafe(`${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; +} diff --git a/src/lib/git/github.ts b/src/lib/git/github.ts new file mode 100644 index 0000000..1423437 --- /dev/null +++ b/src/lib/git/github.ts @@ -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 /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 { + const headers: Record = { + 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 { + 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 { + if (!this.token) { + throw new GitApiError("Fuer das Auflisten von Repos wird ein Token benoetigt.", 401, this.apiBase); + } + const repos = await getJson( + `${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 { + const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`; + const headers = this.headers(); + + const repo = await getJson(base, { headers }); + + const [branches, tags, commits, releases, languages, pulls] = await Promise.all([ + getJsonSafe(`${base}/branches?per_page=50`, [], { headers }), + getJsonSafe(`${base}/tags?per_page=25`, [], { headers }), + getJsonSafe(`${base}/commits?per_page=15`, [], { headers }), + getJsonSafe(`${base}/releases?per_page=10`, [], { headers }), + getJsonSafe>(`${base}/languages`, {}, { headers }), + getJsonSafe(`${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; +} diff --git a/src/lib/git/gitlab.ts b/src/lib/git/gitlab.ts new file mode 100644 index 0000000..20ccb3c --- /dev/null +++ b/src/lib/git/gitlab.ts @@ -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 { + const headers: Record = {}; + if (this.token) headers["PRIVATE-TOKEN"] = this.token; + return headers; + } + + async check(): Promise { + 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 { + 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(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 { + 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(`${base}?statistics=true`, { headers }); + + const [branches, tags, commits, releases, languages, mergeRequests] = await Promise.all([ + getJsonSafe(`${base}/repository/branches?per_page=50`, [], { headers }), + getJsonSafe(`${base}/repository/tags?per_page=25`, [], { headers }), + getJsonSafe(`${base}/repository/commits?per_page=15`, [], { headers }), + getJsonSafe(`${base}/releases?per_page=10`, [], { headers }), + getJsonSafe>(`${base}/languages`, {}, { headers }), + getJsonSafe(`${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; +} diff --git a/src/lib/git/http.ts b/src/lib/git/http.ts new file mode 100644 index 0000000..62ae587 --- /dev/null +++ b/src/lib/git/http.ts @@ -0,0 +1,107 @@ +import { GitApiError } from "./types"; + +const DEFAULT_TIMEOUT = Number(process.env.GIT_API_TIMEOUT_MS ?? 15000); + +export interface FetchOptions { + headers?: Record; + 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( + url: string, + options: FetchOptions = {}, +): Promise { + 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( + url: string, + fallback: T, + options: FetchOptions = {}, +): Promise { + try { + const result = await getJson(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); +} diff --git a/src/lib/git/index.ts b/src/lib/git/index.ts new file mode 100644 index 0000000..f0bafb8 --- /dev/null +++ b/src/lib/git/index.ts @@ -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((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 }; +} diff --git a/src/lib/git/meta.ts b/src/lib/git/meta.ts new file mode 100644 index 0000000..4fc2390 --- /dev/null +++ b/src/lib/git/meta.ts @@ -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"; +} diff --git a/src/lib/git/types.ts b/src/lib/git/types.ts new file mode 100644 index 0000000..dec3bd9 --- /dev/null +++ b/src/lib/git/types.ts @@ -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; +} + +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; + /** Repos, auf die der Token Zugriff hat (fuer die Auswahl beim Verknuepfen). */ + listRepos(query?: string): Promise; + /** Vollstaendiger Stand eines Repositories. */ + fetchRepo(ref: RepoRef): Promise; +} + +/** 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; + } +} diff --git a/src/lib/sync.ts b/src/lib/sync.ts new file mode 100644 index 0000000..0ac69ae --- /dev/null +++ b/src/lib/sync.ts @@ -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 { + 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, + }, + }), + ]); +}