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; }