Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72e5eb43c4 | ||
|
|
e97bd43b23 | ||
|
|
f8e6c66876 |
+23
@@ -299,6 +299,29 @@ App und Infrastruktur. Sie gehören trotzdem in **eine** Karte.
|
||||
Der Projektdialog bearbeitet weiterhin nur das führende Repository; weitere verwaltet das
|
||||
Git-Panel. Beim Anlegen eines Projekts entsteht aus den Git-Feldern das erste.
|
||||
|
||||
### 4.0a README-Vorschau
|
||||
|
||||
Das README des führenden Repositories steht auf der Projektseite — man muss nicht ins Git
|
||||
wechseln, nur um zu sehen, worum ein halbvergessenes Projekt eigentlich ging. Lange
|
||||
READMEs sind eingeklappt, ein Knopf zeigt alles.
|
||||
|
||||
- Geholt wird **höchstens einmal am Tag**, wie die Heatmap. GitHub hat einen eigenen
|
||||
Endpunkt, der jede Schreibweise findet; bei GitLab und Gitea braucht es zwei Aufrufe
|
||||
(Wurzelverzeichnis auflisten, dann die Datei holen).
|
||||
- Erkannt werden `README.md` und die üblichen Varianten, Groß- und Kleinschreibung egal.
|
||||
Markdown hat Vorrang — nur das lässt sich wirklich darstellen.
|
||||
- Bei 60 000 Zeichen wird an einer **Zeilengrenze** abgeschnitten, damit kein halber
|
||||
Codeblock stehen bleibt.
|
||||
- **Relative Links** werden gegen das Verzeichnis der Datei im Repository aufgelöst, sonst
|
||||
liefen sie ins Leere.
|
||||
- **Bilder werden nicht geladen.** Die Content-Security-Policy erlaubt `img-src` nur von
|
||||
der eigenen Instanz; sie dafür zu öffnen hieße, jedem fremden README das Nachladen
|
||||
beliebiger Bilder zu gestatten — also auch Zählpixeln. Für Abzeichen und
|
||||
Bildschirmfotos, die in einer Vorschau ohnehin Beiwerk sind, ist das kein guter Tausch.
|
||||
Stattdessen steht ein anklickbarer Verweis mit dem Alternativtext.
|
||||
- Der Inhalt stammt aus einem fremden Repository und läuft deshalb durch dieselbe
|
||||
Bereinigung wie Notizen (`rehype-sanitize`).
|
||||
|
||||
### 4.1 Unterstützte Systeme
|
||||
|
||||
| System | API | Authentifizierung | Besonderheit |
|
||||
|
||||
+3
-8
@@ -20,20 +20,14 @@ erreichbar ist. Der Zeitplan bleibt als Rückfalllösung.
|
||||
|
||||
---
|
||||
|
||||
### 1.2 README-Vorschau `S`
|
||||
|
||||
Das README aus dem Repo holen und auf der Projektseite anzeigen. Ein
|
||||
zusätzlicher API-Aufruf, Markdown kann Nebula schon. Erspart den Wechsel ins
|
||||
Git, nur um zu sehen, worum ein halbvergessenes Projekt eigentlich ging.
|
||||
|
||||
### 1.3 Läuft es noch? `M`
|
||||
### 1.2 Läuft es noch? `M`
|
||||
|
||||
Eine Health-Check-URL je Projekt, die der Zeitgeber regelmäßig anpingt.
|
||||
Nebula wüsste dann, ob ein Projekt nicht nur existiert, sondern auch läuft.
|
||||
Nutzt Zeitgeber und Benachrichtigungen, die es schon gibt. Der SSRF-Schutz
|
||||
greift dabei wie bei den Git-Aufrufen.
|
||||
|
||||
### 1.4 Persönliche API-Token `M`
|
||||
### 1.3 Persönliche API-Token `M`
|
||||
|
||||
Derzeit geht die API nur mit Sitzungscookie. Ein Token je Konto würde
|
||||
`curl`-Aufrufe und Handy-Shortcuts erlauben — Idee aus dem Terminal anlegen,
|
||||
@@ -115,6 +109,7 @@ Damit klar ist, was **nicht** kommt und warum:
|
||||
| **v1.1.0** Freie Reihenfolge im Brett, Mehrfachauswahl, installierbar als App, Änderungsverlauf | ✓ |
|
||||
| **v1.2.0** Commit-Heatmap über ein Jahr, Projektvorlagen | ✓ |
|
||||
| **v1.3.0** Wiederkehrende Aufgaben, Barrierefreiheit nach WCAG 2.1 AA | ✓ |
|
||||
| **v1.4.0** README-Vorschau auf der Projektseite | ✓ |
|
||||
|
||||
Zur Entscheidung beim Export: Git-Tokens gehen **nicht** mit. Die Verbindung wird nur
|
||||
beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nebula",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
|
||||
"scripts": {
|
||||
|
||||
@@ -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;
|
||||
@@ -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?
|
||||
|
||||
@@ -124,6 +124,30 @@
|
||||
}
|
||||
.md a:hover { color: #ddd6fe; }
|
||||
|
||||
/* Bilder werden nicht geladen, sondern als Marke gezeigt – siehe die
|
||||
Begruendung in Markdown.tsx. Damit sie nicht wie Fliesstext aussieht,
|
||||
bekommt sie einen Rahmen und ein Symbol davor. */
|
||||
.md .md-image {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3em;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.16);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.05em 0.45em;
|
||||
font-size: 0.85em;
|
||||
color: #8a98b0;
|
||||
text-decoration: none;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
.md .md-image::before {
|
||||
content: "▣";
|
||||
opacity: 0.6;
|
||||
}
|
||||
.md a.md-image:hover {
|
||||
color: #c4b5fd;
|
||||
border-color: rgba(196, 181, 253, 0.4);
|
||||
}
|
||||
|
||||
.md strong { color: #f1f5f9; font-weight: 600; }
|
||||
|
||||
.md ul, .md ol { margin: 0.75em 0; padding-left: 1.35em; }
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ActivityFeed } from "@/components/ActivityFeed";
|
||||
import { GitPanel } from "@/components/GitPanel";
|
||||
import { Markdown } from "@/components/Markdown";
|
||||
import { NotesPanel } from "@/components/NotesPanel";
|
||||
import { ReadmePanel } from "@/components/ReadmePanel";
|
||||
import { ProjectHeader } from "@/components/ProjectHeader";
|
||||
import { TaskBoard } from "@/components/tasks/TaskBoard";
|
||||
import { requireUserPage } from "@/lib/auth/guard";
|
||||
@@ -54,6 +55,20 @@ export default async function ProjectPage({ params }: PageProps) {
|
||||
|
||||
const detail = project as unknown as ProjectDetail;
|
||||
|
||||
const fuehrend = detail.repositories[0]?.snapshot;
|
||||
const readme = fuehrend?.readme
|
||||
? {
|
||||
content: fuehrend.readme,
|
||||
path: fuehrend.readmePath,
|
||||
baseUrl: fuehrend.readmeBaseUrl,
|
||||
// Die Basis endet auf dem Verzeichnis; angehaengt ergibt das die Datei.
|
||||
fileUrl:
|
||||
fuehrend.readmeBaseUrl && fuehrend.readmePath
|
||||
? `${fuehrend.readmeBaseUrl}${fuehrend.readmePath.split("/").pop()}`
|
||||
: (fuehrend.htmlUrl ?? null),
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pt-4">
|
||||
<ProjectHeader
|
||||
@@ -83,6 +98,17 @@ export default async function ProjectPage({ params }: PageProps) {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Das README des fuehrenden Repositories – bei mehreren waere eine
|
||||
zweite Vorschau untereinander mehr Laenge als Nutzen. */}
|
||||
{readme ? (
|
||||
<ReadmePanel
|
||||
content={readme.content}
|
||||
path={readme.path}
|
||||
baseUrl={readme.baseUrl}
|
||||
fileUrl={readme.fileUrl}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<NotesPanel projectId={detail.id} notes={detail.notes} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,12 +24,93 @@ const schema = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Loest relative Verweise gegen die Basis auf; laesst absolute in Ruhe. */
|
||||
function absolut(href: string | undefined, base: string | null | undefined): string | undefined {
|
||||
if (!href || !base) return href;
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//") || href.startsWith("#")) {
|
||||
return href;
|
||||
}
|
||||
try {
|
||||
return new URL(href, base).toString();
|
||||
} catch {
|
||||
return href;
|
||||
}
|
||||
}
|
||||
|
||||
type HastNode = {
|
||||
type: string;
|
||||
tagName?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
children?: HastNode[];
|
||||
value?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ersetzt Bilder durch eine anklickbare Marke – siehe die Begruendung
|
||||
* unten am `.md-image`-Stil.
|
||||
*
|
||||
* Das passiert im Baum und nicht ueber eine Komponente, weil es auf den
|
||||
* Elternknoten ankommt: `[](ziel)` ist in READMEs die
|
||||
* Regel, und ein `<a>` in einem `<a>` ist ungueltiges HTML. Der Browser
|
||||
* zieht es beim Parsen auseinander, was zu einem Hydrationsfehler fuehrt –
|
||||
* genau daran ist es beim ersten Versuch gescheitert. Innerhalb eines Links
|
||||
* bleibt deshalb nur der Text stehen; der umgebende Link ist ohnehin das,
|
||||
* was man anklicken will.
|
||||
*/
|
||||
function bilderAlsMarke(base: string | null | undefined) {
|
||||
return () => (tree: HastNode) => {
|
||||
const gehe = (knoten: HastNode, inLink: boolean) => {
|
||||
const kinder = knoten.children;
|
||||
if (!kinder) return;
|
||||
|
||||
for (let index = 0; index < kinder.length; index += 1) {
|
||||
const kind = kinder[index];
|
||||
|
||||
if (kind.type === "element" && kind.tagName === "img") {
|
||||
const quelle = typeof kind.properties?.src === "string" ? kind.properties.src : undefined;
|
||||
const alt = typeof kind.properties?.alt === "string" ? kind.properties.alt.trim() : "";
|
||||
const text: HastNode = { type: "text", value: alt || "Bild" };
|
||||
const ziel = absolut(quelle, base);
|
||||
|
||||
kinder[index] =
|
||||
inLink || !ziel
|
||||
? { type: "element", tagName: "span", properties: { className: ["md-image"] }, children: [text] }
|
||||
: {
|
||||
type: "element",
|
||||
tagName: "a",
|
||||
properties: {
|
||||
className: ["md-image"],
|
||||
href: ziel,
|
||||
title: ziel,
|
||||
target: "_blank",
|
||||
rel: "noreferrer noopener nofollow",
|
||||
},
|
||||
children: [text],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
gehe(kind, inLink || (kind.type === "element" && kind.tagName === "a"));
|
||||
}
|
||||
};
|
||||
|
||||
gehe(tree, false);
|
||||
};
|
||||
}
|
||||
|
||||
export function Markdown({
|
||||
children,
|
||||
className,
|
||||
baseUrl,
|
||||
}: {
|
||||
children: string | null | undefined;
|
||||
className?: string;
|
||||
/**
|
||||
* Wogegen relative Verweise aufzuloesen sind. Gesetzt beim README aus
|
||||
* einem Repository – dort zeigen Links auf Dateien daneben, die ohne
|
||||
* Basis ins Leere liefen.
|
||||
*/
|
||||
baseUrl?: string | null;
|
||||
}) {
|
||||
if (!children?.trim()) return null;
|
||||
|
||||
@@ -37,12 +118,22 @@ export function Markdown({
|
||||
<div className={cn("md", className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[[rehypeSanitize, schema]]}
|
||||
// Erst bereinigen, dann umbauen: die Marke soll nicht selbst durch
|
||||
// die Bereinigung laufen und dabei ihre Klassen verlieren.
|
||||
rehypePlugins={[[rehypeSanitize, schema], bilderAlsMarke(baseUrl)]}
|
||||
components={{
|
||||
// Links aus fremdem Inhalt immer in neuem Tab und ohne
|
||||
// Zugriff auf das oeffnende Fenster.
|
||||
a: ({ href, children: content }) => (
|
||||
<a href={href} target="_blank" rel="noreferrer noopener nofollow">
|
||||
// className muss durchgereicht werden, sonst verlieren die
|
||||
// Bildmarken aus dem Umbau oben ihre Gestalt.
|
||||
a: ({ href, className: klasse, title, children: content }) => (
|
||||
<a
|
||||
href={absolut(href, baseUrl)}
|
||||
className={klasse}
|
||||
title={title}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
),
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { BookOpen, ChevronDown, ExternalLink } from "lucide-react";
|
||||
import { Markdown } from "@/components/Markdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Das README des verknuepften Repositories.
|
||||
*
|
||||
* Erspart den Wechsel ins Git, nur um zu sehen, worum ein halbvergessenes
|
||||
* Projekt eigentlich ging. Nicht zum Lesen ganzer Handbuecher gedacht:
|
||||
* eingeklappt zeigt es die ersten Zeilen, ausgeklappt alles.
|
||||
*/
|
||||
export function ReadmePanel({
|
||||
content,
|
||||
path,
|
||||
baseUrl,
|
||||
fileUrl,
|
||||
}: {
|
||||
content: string;
|
||||
path: string | null;
|
||||
baseUrl: string | null;
|
||||
/** Direktlink auf die Datei im Git-System. */
|
||||
fileUrl: string | null;
|
||||
}) {
|
||||
const [offen, setOffen] = useState(false);
|
||||
|
||||
// Kurze READMEs vollstaendig zu zeigen ist einfacher, als sie mit einem
|
||||
// Knopf zu versehen, der nichts freigibt.
|
||||
const kurz = content.length < 900;
|
||||
|
||||
return (
|
||||
<section className="glass p-5">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-300">
|
||||
<BookOpen className="h-4 w-4 text-cyan-300" />
|
||||
{path ?? "README"}
|
||||
</h2>
|
||||
{fileUrl ? (
|
||||
<a
|
||||
href={fileUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-slate-500 transition-colors hover:text-violet-300"
|
||||
>
|
||||
Im Git öffnen
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden transition-[max-height] duration-300",
|
||||
!kurz && !offen && "max-h-72",
|
||||
)}
|
||||
>
|
||||
<Markdown baseUrl={baseUrl}>{content}</Markdown>
|
||||
</div>
|
||||
|
||||
{/* Verlauf ueber den abgeschnittenen Rand, damit erkennbar ist, dass
|
||||
es weitergeht. Nur wenn wirklich etwas verdeckt wird. */}
|
||||
{!kurz && !offen ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-[#0b0d1c] to-transparent"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!kurz ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOffen((wert) => !wert)}
|
||||
className="btn btn-ghost mt-2 w-full text-xs"
|
||||
aria-expanded={offen}
|
||||
>
|
||||
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform", offen && "rotate-180")} />
|
||||
{offen ? "Weniger" : "Ganzes README anzeigen"}
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,32 @@ export const KIND_META: Record<ChangeKind, { label: string; className: string }>
|
||||
};
|
||||
|
||||
export const CHANGELOG: Release[] = [
|
||||
{
|
||||
version: "1.4.0",
|
||||
date: "2026-07-26",
|
||||
headline: "Worum ging es hier nochmal?",
|
||||
changes: [
|
||||
{
|
||||
kind: "neu",
|
||||
text:
|
||||
"README-Vorschau auf der Projektseite. Erspart den Wechsel ins Git, nur um zu " +
|
||||
"sehen, worum ein halbvergessenes Projekt eigentlich ging.",
|
||||
},
|
||||
{
|
||||
kind: "besser",
|
||||
text:
|
||||
"Relative Links im README zeigen dorthin, wo sie hingehören — aufgelöst gegen " +
|
||||
"das Verzeichnis der Datei im Repository.",
|
||||
},
|
||||
{
|
||||
kind: "besser",
|
||||
text:
|
||||
"Bilder im README werden bewusst nicht geladen, sondern als anklickbarer " +
|
||||
"Verweis gezeigt. Die Content-Security-Policy dafür zu öffnen hieße, jedem " +
|
||||
"fremden README das Nachladen beliebiger Bilder zu erlauben — auch Zählpixeln.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "1.3.0",
|
||||
date: "2026-07-26",
|
||||
|
||||
@@ -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<RepoReadme | null> {
|
||||
const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
|
||||
const headers = this.headers();
|
||||
|
||||
const eintraege = await getJsonSafe<Array<{ name: string; type: string }>>(
|
||||
`${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 {
|
||||
|
||||
@@ -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<RepoReadme | null> {
|
||||
const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
|
||||
|
||||
let datei: GitHubContent;
|
||||
try {
|
||||
datei = await getJson<GitHubContent>(`${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 {
|
||||
|
||||
@@ -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<RepoReadme | null> {
|
||||
const base = `${this.apiBase}/projects/${this.projectPath(ref)}`;
|
||||
const headers = this.headers();
|
||||
|
||||
const eintraege = await getJsonSafe<Array<{ name: string; type: string }>>(
|
||||
`${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<GitLabProject>(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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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<CommitCalendar | null>;
|
||||
/**
|
||||
* 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<RepoReadme | null>;
|
||||
}
|
||||
|
||||
/** Fehler mit HTTP-Kontext, damit die UI verstaendliche Meldungen zeigen kann. */
|
||||
|
||||
+44
-2
@@ -58,9 +58,10 @@ export async function syncRepository(repositoryId: string): Promise<SyncResult>
|
||||
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<void> {
|
||||
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<Repository, "repoOwner" | "repoName" | "repoUrl">,
|
||||
|
||||
Reference in New Issue
Block a user