From f8e6c668763c597a0331f46027d4deabd105ff4c Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 26 Jul 2026 23:41:18 +0200 Subject: [PATCH] feat(git): README des Repositories holen Erspart den Wechsel ins Git, nur um zu sehen, worum ein halbvergessenes Projekt eigentlich ging. GitHub hat einen eigenen Endpunkt, der jede Schreibweise findet - ein Aufruf genuegt. GitLab und Gitea kennen keinen; dort wird erst das Wurzelverzeichnis aufgelistet und dann die gefundene Datei geholt. Erkannt werden README.md und die ueblichen Varianten, Gross- und Kleinschreibung egal; Markdown hat Vorrang, weil nur das wirklich dargestellt werden kann. Geholt wird hoechstens einmal am Tag, wie der Kalender, und mit eigenem Zeitstempel: schlaegt das eine fehl, soll das andere nicht mit blockiert werden. Bei 60 000 Zeichen wird abgeschnitten, aber an einer Zeilengrenze - sonst bliebe womoeglich ein halber Codeblock stehen. Dekodiert wird ueber Buffer und nicht ueber atob, sonst kaemen Umlaute als Kauderwelsch heraus. Kein README ist eine Auskunft, kein Fehlschlag: der Zeitstempel wird dann trotzdem gesetzt, damit nicht jeder Abgleich erneut sucht. Co-Authored-By: Claude Opus 5 (1M context) --- .../20260726280000_repo_readme/migration.sql | 5 ++ prisma/schema.prisma | 10 +++ src/lib/git/gitea.ts | 38 +++++++++ src/lib/git/github.ts | 38 +++++++++ src/lib/git/gitlab.ts | 39 ++++++++++ src/lib/git/readme.test.ts | 72 +++++++++++++++++ src/lib/git/readme.ts | 78 +++++++++++++++++++ src/lib/git/types.ts | 9 ++- src/lib/sync/index.ts | 46 ++++++++++- 9 files changed, 332 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20260726280000_repo_readme/migration.sql create mode 100644 src/lib/git/readme.test.ts create mode 100644 src/lib/git/readme.ts diff --git a/prisma/migrations/20260726280000_repo_readme/migration.sql b/prisma/migrations/20260726280000_repo_readme/migration.sql new file mode 100644 index 0000000..08b7592 --- /dev/null +++ b/prisma/migrations/20260726280000_repo_readme/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "RepoSnapshot" ADD COLUMN "readme" TEXT, +ADD COLUMN "readmeAt" TIMESTAMP(3), +ADD COLUMN "readmeBaseUrl" TEXT, +ADD COLUMN "readmePath" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 958f0fd..b95114c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -612,6 +612,16 @@ model RepoSnapshot { commitCalendar Json @default("{}") commitCalendarAt DateTime? + /// Das README des Repositories, roh als Markdown. Wie der Kalender + /// hoechstens einmal am Tag geholt – es aendert sich selten, und der + /// Abgleich laeuft im Zweifel stuendlich. + readme String? + /// Dateiname, wie er im Repository steht ("README.md", "readme.rst" …). + readmePath String? + /// Wogegen relative Links im README aufzuloesen sind. + readmeBaseUrl String? + readmeAt DateTime? + // ── Letzter CI-Lauf auf dem Default-Branch ───────────────── /// success | failure | running | pending | cancelled ciState String? diff --git a/src/lib/git/gitea.ts b/src/lib/git/gitea.ts index 40529ae..543efbd 100644 --- a/src/lib/git/gitea.ts +++ b/src/lib/git/gitea.ts @@ -5,6 +5,7 @@ import { calendarSince, type CommitCalendar, } from "./calendar"; +import { capReadme, decodeBase64, pickReadme, type RepoReadme } from "./readme"; import type { BranchInfo, CiInfo, @@ -246,6 +247,43 @@ export class GiteaClient implements GitClient { return commits.map((commit) => toIso(commit.commit?.author?.date)); }); } + + /** + * Gitea kennt keinen README-Endpunkt. Erst das Wurzelverzeichnis + * auflisten, dann die gefundene Datei holen – zwei Aufrufe. + */ + async fetchReadme(ref: RepoRef): Promise { + const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`; + const headers = this.headers(); + + const eintraege = await getJsonSafe>( + `${base}/contents`, + [], + { headers }, + ); + const name = pickReadme( + eintraege.filter((eintrag) => eintrag.type === "file").map((eintrag) => eintrag.name), + ); + if (!name) return null; + + const datei = await getJson<{ + content?: string; + encoding?: string; + path?: string; + html_url?: string | null; + }>(`${base}/contents/${encodeURIComponent(name)}`, { headers }); + if (!datei?.content || datei.encoding !== "base64") return null; + + const { content, truncated } = capReadme(decodeBase64(datei.content)); + return { + content, + path: datei.path ?? name, + // html_url zeigt auf die Datei; fuer relative Links braucht es das + // Verzeichnis darum herum. + baseUrl: datei.html_url ? datei.html_url.replace(/\/[^/]*$/, "/") : null, + truncated, + }; + } } function mapCommitStatus(state: string): CiState { diff --git a/src/lib/git/github.ts b/src/lib/git/github.ts index 1ad08e6..7609779 100644 --- a/src/lib/git/github.ts +++ b/src/lib/git/github.ts @@ -16,6 +16,7 @@ import type { TicketInfo, } from "./types"; import { dayKey } from "./calendar"; +import { capReadme, decodeBase64, type RepoReadme } from "./readme"; import { GitApiError, bucketCommitActivity } from "./types"; /** @@ -252,6 +253,43 @@ export class GitHubClient implements GitClient { return { from: von, to: heute, days, source: "stats", truncated: false }; } + + /** + * GitHub hat einen eigenen Endpunkt, der jede Schreibweise findet – ein + * Aufruf genuegt. Ohne README antwortet er mit 404; das ist kein Fehler, + * sondern die Auskunft "gibt es nicht". + */ + async fetchReadme(ref: RepoRef): Promise { + const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`; + + let datei: GitHubContent; + try { + datei = await getJson(`${base}/readme`, { headers: this.headers() }); + } catch (error) { + if (error instanceof GitApiError && error.status === 404) return null; + throw error; + } + + if (!datei?.content || datei.encoding !== "base64") return null; + + const { content, truncated } = capReadme(decodeBase64(datei.content)); + return { + content, + path: datei.path ?? datei.name ?? "README", + // html_url zeigt auf die Datei; fuer relative Links braucht es das + // Verzeichnis darum herum. + baseUrl: datei.html_url ? datei.html_url.replace(/\/[^/]*$/, "/") : null, + truncated, + }; + } +} + +interface GitHubContent { + name?: string; + path?: string; + content?: string; + encoding?: string; + html_url?: string | null; } interface GitHubWeek { diff --git a/src/lib/git/gitlab.ts b/src/lib/git/gitlab.ts index 984b089..fa8b5f7 100644 --- a/src/lib/git/gitlab.ts +++ b/src/lib/git/gitlab.ts @@ -5,6 +5,7 @@ import { calendarSince, type CommitCalendar, } from "./calendar"; +import { capReadme, decodeBase64, pickReadme, type RepoReadme } from "./readme"; import type { BranchInfo, CiInfo, @@ -227,6 +228,44 @@ export class GitLabClient implements GitClient { return commits.map((commit) => toIso(commit.committed_date ?? commit.created_at)); }); } + + /** + * GitLab kennt keinen README-Endpunkt. Erst das Wurzelverzeichnis + * auflisten, dann die gefundene Datei holen – zwei Aufrufe. + */ + async fetchReadme(ref: RepoRef): Promise { + const base = `${this.apiBase}/projects/${this.projectPath(ref)}`; + const headers = this.headers(); + + const eintraege = await getJsonSafe>( + `${base}/repository/tree?per_page=100`, + [], + { headers }, + ); + const name = pickReadme( + eintraege.filter((eintrag) => eintrag.type === "blob").map((eintrag) => eintrag.name), + ); + if (!name) return null; + + const datei = await getJson<{ content?: string; encoding?: string; file_path?: string }>( + `${base}/repository/files/${encodeURIComponent(name)}?ref=HEAD`, + { headers }, + ); + if (!datei?.content || datei.encoding !== "base64") return null; + + const { content, truncated } = capReadme(decodeBase64(datei.content)); + const projekt = await getJsonSafe(base, {} as GitLabProject, { headers }); + + return { + content, + path: datei.file_path ?? name, + baseUrl: + projekt?.web_url && projekt.default_branch + ? `${projekt.web_url}/-/blob/${encodeURIComponent(projekt.default_branch)}/` + : null, + truncated, + }; + } } function mapPipelineState(status: string | null): CiState { diff --git a/src/lib/git/readme.test.ts b/src/lib/git/readme.test.ts new file mode 100644 index 0000000..6ba1e9e --- /dev/null +++ b/src/lib/git/readme.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { README_MAX_CHARS, capReadme, decodeBase64, pickReadme } from "./readme"; + +describe("pickReadme", () => { + it("findet die uebliche Schreibweise", () => { + expect(pickReadme(["src", "README.md", "package.json"])).toBe("README.md"); + }); + + it("ist bei der Gross- und Kleinschreibung nachsichtig", () => { + expect(pickReadme(["readme.md"])).toBe("readme.md"); + expect(pickReadme(["ReadMe.MD"])).toBe("ReadMe.MD"); + }); + + it("zieht Markdown den uebrigen Formaten vor", () => { + // Beides kommt vor; nur das erste laesst sich wirklich darstellen. + expect(pickReadme(["README.rst", "README.md"])).toBe("README.md"); + }); + + it("nimmt auch ein README ohne Endung", () => { + expect(pickReadme(["LICENSE", "README"])).toBe("README"); + }); + + it("liefert null, wenn keines dabei ist", () => { + expect(pickReadme(["src", "package.json"])).toBeNull(); + expect(pickReadme([])).toBeNull(); + }); + + it("verwechselt Aehnliches nicht mit dem README", () => { + expect(pickReadme(["README-CONTRIBUTING.md", "readme.md.bak"])).toBeNull(); + }); +}); + +describe("capReadme", () => { + it("laesst Kurzes unangetastet", () => { + const { content, truncated } = capReadme("# Titel\n\nEin Satz."); + expect(content).toBe("# Titel\n\nEin Satz."); + expect(truncated).toBe(false); + }); + + it("kuerzt Langes an einer Zeilengrenze", () => { + // Sonst bliebe womoeglich ein halber Codeblock stehen. + const zeile = "x".repeat(80); + const lang = Array.from({ length: 2000 }, () => zeile).join("\n"); + const { content, truncated } = capReadme(lang); + expect(truncated).toBe(true); + expect(content.length).toBeLessThanOrEqual(README_MAX_CHARS); + expect(content.endsWith("x")).toBe(true); + expect(content.split("\n").at(-1)).toBe(zeile); + }); + + it("kuerzt notfalls hart, wenn es gar keine Zeilengrenze gibt", () => { + // Eine einzige riesige Zeile: hier waere der letzte Umbruch bei -1. + const { content, truncated } = capReadme("y".repeat(README_MAX_CHARS + 500)); + expect(truncated).toBe(true); + expect(content).toHaveLength(README_MAX_CHARS); + }); +}); + +describe("decodeBase64", () => { + it("dekodiert UTF-8 richtig", () => { + // Ueber Bytes gedacht kaeme hier Kauderwelsch heraus. + const kodiert = Buffer.from("# Nebula – Übersicht", "utf8").toString("base64"); + expect(decodeBase64(kodiert)).toBe("# Nebula – Übersicht"); + }); + + it("uebergeht Zeilenumbrueche in der Kodierung", () => { + // GitHub bricht den base64-Block alle 60 Zeichen um. + const roh = Buffer.from("Hallo Welt", "utf8").toString("base64"); + const umgebrochen = `${roh.slice(0, 4)}\n${roh.slice(4)}`; + expect(decodeBase64(umgebrochen)).toBe("Hallo Welt"); + }); +}); diff --git a/src/lib/git/readme.ts b/src/lib/git/readme.ts new file mode 100644 index 0000000..8438e95 --- /dev/null +++ b/src/lib/git/readme.ts @@ -0,0 +1,78 @@ +/** + * Das README eines Repositories – gemeinsame Teile fuer alle drei Anbieter. + * + * Gezeigt wird es auf der Projektseite, damit man nicht ins Git wechseln + * muss, nur um zu sehen, worum ein halbvergessenes Projekt eigentlich ging. + */ + +export interface RepoReadme { + /** Roher Markdown-Text. */ + content: string; + /** Dateiname im Repository, etwa "README.md". */ + path: string; + /** + * Basis, gegen die relative Links im Text aufzuloesen sind – die + * Web-Ansicht des Repositories im Verzeichnis des READMEs. + */ + baseUrl: string | null; + /** Wurde am Deckel abgeschnitten? */ + truncated: boolean; +} + +/** + * Hoechstlaenge. Ein README von einem Megabyte gibt es, und es waere in + * einer Vorschau ohnehin unlesbar – abgeschnitten wird an einer + * Zeilengrenze, damit kein halber Codeblock stehen bleibt. + */ +export const README_MAX_CHARS = 60_000; + +/** + * Dateinamen, die als README gelten – in dieser Reihenfolge. Markdown zuerst, + * weil nur das dargestellt werden kann; die uebrigen landen als Klartext in + * einem Codeblock, was immer noch besser ist als nichts. + */ +const KANDIDATEN = [ + "readme.md", + "readme.markdown", + "readme.mdown", + "readme", + "readme.txt", + "readme.rst", +]; + +/** + * Sucht aus einer Verzeichnisauflistung die README-Datei heraus. + * Gross- und Kleinschreibung ist egal; die Reihenfolge oben entscheidet. + */ +export function pickReadme(namen: string[]): string | null { + const klein = new Map(namen.map((name) => [name.toLowerCase(), name])); + for (const kandidat of KANDIDATEN) { + const treffer = klein.get(kandidat); + if (treffer) return treffer; + } + return null; +} + +/** Kuerzt am Deckel, aber an einer Zeilengrenze. */ +export function capReadme(text: string): { content: string; truncated: boolean } { + if (text.length <= README_MAX_CHARS) return { content: text, truncated: false }; + + const roh = text.slice(0, README_MAX_CHARS); + const letzterUmbruch = roh.lastIndexOf("\n"); + return { + // Nur wenn der Umbruch nicht ganz am Anfang liegt – sonst bliebe fast + // nichts uebrig. + content: letzterUmbruch > README_MAX_CHARS / 2 ? roh.slice(0, letzterUmbruch) : roh, + truncated: true, + }; +} + +/** + * Dekodiert, was die Anbieter als base64 liefern. + * + * Bewusst ueber Buffer und nicht ueber atob: der Inhalt ist UTF-8, und atob + * gaebe Bytes zurueck, aus denen Umlaute als Kauderwelsch herauskaemen. + */ +export function decodeBase64(value: string): string { + return Buffer.from(value.replace(/\s/g, ""), "base64").toString("utf8"); +} diff --git a/src/lib/git/types.ts b/src/lib/git/types.ts index 5eec8be..f95fdc9 100644 --- a/src/lib/git/types.ts +++ b/src/lib/git/types.ts @@ -1,7 +1,8 @@ import type { GitProvider } from "@prisma/client"; import type { CommitCalendar } from "./calendar"; +import type { RepoReadme } from "./readme"; -export type { CommitCalendar }; +export type { CommitCalendar, RepoReadme }; export type ProviderKind = GitProvider; @@ -181,6 +182,12 @@ export interface GitClient { * hoechstens einmal am Tag noetig ist. null heisst: nichts zu holen. */ fetchCommitCalendar(ref: RepoRef): Promise; + /** + * Das README des Repositories. Wie der Kalender getrennt, weil es + * zusaetzliche Aufrufe kostet und sich selten aendert. null heisst: es + * gibt keins. + */ + fetchReadme(ref: RepoRef): Promise; } /** Fehler mit HTTP-Kontext, damit die UI verstaendliche Meldungen zeigen kann. */ diff --git a/src/lib/sync/index.ts b/src/lib/sync/index.ts index 437c7bc..1129262 100644 --- a/src/lib/sync/index.ts +++ b/src/lib/sync/index.ts @@ -58,9 +58,10 @@ export async function syncRepository(repositoryId: string): Promise try { const data = await client.fetchRepo(ref); await persistSnapshot(repository, data); - // Erst nach dem Snapshot: die Zeile muss existieren, bevor der Kalender - // hineingeschrieben werden kann. + // Erst nach dem Snapshot: die Zeile muss existieren, bevor Kalender und + // README hineingeschrieben werden koennen. await maybeRefreshCalendar(repository.id, client, ref); + await maybeRefreshReadme(repository.id, client, ref); // Nach dem Speichern, damit die Meldung den neuen Stand sieht. await notifyAfterSync(repository.id); return { @@ -196,6 +197,47 @@ async function maybeRefreshCalendar( } } +/** + * Holt das README – wie der Kalender hoechstens einmal am Tag. + * + * Ein eigener Zeitstempel und nicht der des Kalenders: schlaegt das eine + * fehl, soll das andere nicht mit blockiert werden. + */ +async function maybeRefreshReadme( + repositoryId: string, + client: GitClient, + ref: { owner: string; name: string }, +): Promise { + try { + const snapshot = await prisma.repoSnapshot.findUnique({ + where: { repositoryId }, + select: { readmeAt: true }, + }); + + const zuletzt = snapshot?.readmeAt?.getTime() ?? 0; + if (Date.now() - zuletzt < CALENDAR_TTL_MS) return; + + const readme = await client.fetchReadme(ref); + + // Kein README im Repository: das ist eine Auskunft, kein Fehlschlag. + // Der Zeitstempel wird gesetzt, damit nicht jeder Abgleich erneut sucht, + // und ein etwaiger alter Stand verschwindet. + await prisma.repoSnapshot.update({ + where: { repositoryId }, + data: { + readme: readme?.content ?? null, + readmePath: readme?.path ?? null, + readmeBaseUrl: readme?.baseUrl ?? null, + readmeAt: new Date(), + }, + }); + } catch (error) { + // Wie beim Kalender: Beiwerk. Ohne Zeitstempel versucht es der naechste + // Durchgang von selbst wieder. + console.error("[nebula] README konnte nicht geholt werden:", error); + } +} + /** Owner/Name aus den Feldern oder ersatzweise aus der URL bestimmen. */ export function resolveRepoRef( repository: Pick,