import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http"; import type { BranchInfo, CiInfo, CiState, CommitInfo, ConnectionCheck, GitClient, ProviderConfig, ReleaseInfo, RepoListItem, RepoRef, RepoSnapshotData, TagInfo, TicketInfo, } from "./types"; import { GitApiError, bucketCommitActivity } 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 defaultBranch = repo.default_branch; const [branches, tags, commits, releases, languages, pulls, issues, ciRuns] = await Promise.all([ getJsonSafe(`${base}/branches?per_page=50`, [], { headers }), getJsonSafe(`${base}/tags?per_page=25`, [], { headers }), // 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve. getJsonSafe(`${base}/commits?per_page=100`, [], { headers }), getJsonSafe(`${base}/releases?per_page=10`, [], { headers }), getJsonSafe>(`${base}/languages`, {}, { headers }), getJsonSafe(`${base}/pulls?state=open&per_page=50`, [], { headers }), // /issues liefert bei GitHub auch Pull Requests – die filtern wir raus. getJsonSafe(`${base}/issues?state=open&per_page=50`, [], { headers }), getJsonSafe<{ workflow_runs?: GitHubRun[] }>( `${base}/actions/runs?per_page=1&branch=${encodeURIComponent(defaultBranch)}`, {}, { 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), })); const mappedPulls: TicketInfo[] = pulls.map((pull) => ({ number: pull.number, title: pull.title, author: pull.user?.login ?? null, createdAt: toIso(pull.created_at), url: pull.html_url ?? null, draft: Boolean(pull.draft), labels: (pull.labels ?? []).map((label) => label.name), })); const mappedIssues: TicketInfo[] = issues .filter((issue) => !issue.pull_request) .map((issue) => ({ number: issue.number, title: issue.title, author: issue.user?.login ?? null, createdAt: toIso(issue.created_at), url: issue.html_url ?? null, draft: false, labels: (issue.labels ?? []).map((label) => label.name), })); // 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); const run = ciRuns?.workflow_runs?.[0]; const ci: CiInfo | null = run ? { state: mapRunState(run.status, run.conclusion), name: run.name ?? null, branch: run.head_branch ?? null, url: run.html_url ?? null, finishedAt: toIso(run.updated_at), } : null; 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, // Angezeigt werden die letzten 15; die uebrigen dienten der Kurve. commits: mappedCommits.slice(0, 15), releases: mappedReleases, languages: languages ?? {}, pullRequests: mappedPulls, issues: mappedIssues, ci, commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)), }; } } /** GitHub trennt Lauf-Status und Ergebnis; hier auf einen Wert zusammenfuehren. */ function mapRunState(status: string | null, conclusion: string | null): CiState { if (status === "queued" || status === "waiting" || status === "pending") return "pending"; if (status === "in_progress") return "running"; switch (conclusion) { case "success": return "success"; case "cancelled": return "cancelled"; case "failure": case "timed_out": case "startup_failure": return "failure"; default: return conclusion ? "failure" : "pending"; } } // ── 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 GitHubLabel { name: string; } interface GitHubPull { number: number; title: string; html_url: string | null; created_at: string | null; draft?: boolean; user?: { login: string } | null; labels?: GitHubLabel[]; } interface GitHubIssue { number: number; title: string; html_url: string | null; created_at: string | null; user?: { login: string } | null; labels?: GitHubLabel[]; /** Gesetzt, wenn der Eintrag in Wahrheit ein Pull Request ist. */ pull_request?: unknown; } interface GitHubRun { name: string | null; status: string | null; conclusion: string | null; head_branch: string | null; html_url: string | null; updated_at: string | null; }