Bisher standen nur Zahlen da. Jetzt kommen die Inhalte mit. Pull Requests und Issues: bis zu 50 offene je Liste, mit Nummer, Titel, Autor, Alter, Labels und Link. Bei GitHub und Gitea liefert der Issue-Endpunkt auch Pull Requests mit - die werden herausgefiltert. CI-Status des letzten Laufs auf dem Default-Branch: - GitHub ueber actions/runs; dort sind Lauf-Status und Ergebnis getrennt und werden auf einen Wert zusammengefuehrt - GitLab ueber pipelines - Gitea ueber den kombinierten Commit-Status. Das deckt Gitea Actions und externe Systeme wie Woodpecker gleichermassen ab - ein reiner Actions-Endpunkt existiert auf aelteren Instanzen nicht. Commit-Aktivitaet als Balkenreihe. Berechnet aus den ohnehin geholten Commits (jetzt 100 statt 15, angezeigt bleiben 15) statt ueber eigene Statistik-Endpunkte: die gibt es nicht bei allen drei Systemen, und bei GitHub antworten sie beim ersten Aufruf mit 202 und ohne Daten. Beim Testen gegen gitlab-runner fiel auf, dass alle 100 Commits in die letzten 30 Tage fielen - die frueheren Tage haetten faelschlich Null gezeigt, obwohl wir ueber sie nichts wissen. Die Kurve wird deshalb auf den tatsaechlich belegten Zeitraum gekuerzt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
312 lines
9.8 KiB
TypeScript
312 lines
9.8 KiB
TypeScript
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 <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 defaultBranch = repo.default_branch;
|
||
|
||
const [branches, tags, commits, releases, languages, pulls, issues, ciRuns] =
|
||
await Promise.all([
|
||
getJsonSafe<GitHubBranch[]>(`${base}/branches?per_page=50`, [], { headers }),
|
||
getJsonSafe<GitHubTag[]>(`${base}/tags?per_page=25`, [], { headers }),
|
||
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
||
getJsonSafe<GitHubCommit[]>(`${base}/commits?per_page=100`, [], { headers }),
|
||
getJsonSafe<GitHubRelease[]>(`${base}/releases?per_page=10`, [], { headers }),
|
||
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
|
||
getJsonSafe<GitHubPull[]>(`${base}/pulls?state=open&per_page=50`, [], { headers }),
|
||
// /issues liefert bei GitHub auch Pull Requests – die filtern wir raus.
|
||
getJsonSafe<GitHubIssue[]>(`${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;
|
||
}
|