diff --git a/CLAUDE.md b/CLAUDE.md index eea4abe..019c6c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,6 +85,16 @@ SSRF-Schutz (`lib/security/ssrf.ts`). `100vh` beziehen sich weiter auf das volle. `components/ui/Modal.tsx` hängt dafür an `window.visualViewport`; wer ein eigenes Overlay baut, braucht dasselbe. +- **Ein verschluckter Fehler ist schlimmer als ein lauter.** `getJsonSafe` machte aus + jedem Fehlschlag eine leere Liste. Damit war ein 403 nicht mehr von „das Repository + hat keine Branches" zu unterscheiden: Der Abgleich meldete Erfolg, überschrieb den + letzten guten Stand mit Nullen, und die Ursache war von außen nicht zu ermitteln — + die Auskunft, die sie benannt hätte, war ja gerade weggeworfen worden. Wo ein + Fallback nötig ist, gehört der Grund festgehalten (`Teilabrufe` in `lib/git/http.ts`). +- **`Number()` auf einer Umgebungsvariable prüft nichts.** `Number("")` ist `0`, + `Number("abc")` ist `NaN`, und `setTimeout` löst bei beidem sofort aus. Eine + verrutschte Zeile in der `.env` konnte so den kompletten Abgleich stilllegen, ohne + dass irgendwo etwas stand. - **Blättern über Cursor, nicht über `skip`.** Der Aktivitätsverlauf schreibt mehrere Zeilen im selben Augenblick; ein Offset über so eine Grenze hinweg überspringt oder verdoppelt lautlos. Muster in `lib/timeline.ts`: diff --git a/FEATURES.md b/FEATURES.md index f053c33..0b2ce48 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -553,6 +553,33 @@ deshalb ohne Risiko. | Releases | bis zu 10, inklusive Pre-Release- und Draft-Kennzeichnung | | Sprachen | Verteilung als Balken mit Prozentwerten | +#### Wenn ein Teil nicht ankommt + +Diese Listen werden gleichzeitig geholt, und einzelne davon können scheitern, während +der Rest durchgeht — ein 403 wegen fehlender Scopes, ein Rate-Limit, eine +Zeitüberschreitung. + +**Ein gescheiterter Abruf ist keine leere Liste.** Der Unterschied steht am Snapshot: zu +jedem Feld, das nicht geholt werden konnte, wird der Grund samt HTTP-Status festgehalten. +Daraus folgt dreierlei: + +- Die vorhandenen Daten **bleiben stehen**. Ein Abgleich, der die Tags nicht holen konnte, + ersetzt die gespeicherten nicht durch eine leere Liste. Angezeigt wird dann der letzte + vollständige Stand, mit Datum. +- Die Meldung heißt „Teilweise aktualisiert – nicht geholt: …" statt „Snapshot + aktualisiert: 0 Branches, 0 Tags". +- Auf der Projektseite steht ein Hinweis am Repository, der jedes fehlende Feld mit + seinem Grund nennt. + +Das gilt ausdrücklich **nicht** als Fehlschlag: `syncError` bleibt leer, der Zähler für +das Zurückhalten wird nicht erhöht, und der nächste Durchlauf versucht es normal wieder. +Der Rest des Standes ist ja frisch. + +> Vor v1.10.2 wurde jeder Fehlschlag in eine leere Liste verwandelt. Ein Repository mit +> 65 Commits meldete „Snapshot aktualisiert: 0 Branches, 0 Tags", überschrieb dabei den +> letzten guten Stand, und aus der Oberfläche war nicht zu erkennen, dass überhaupt etwas +> schiefgegangen war. + Woher der CI-Status kommt: | System | Quelle | diff --git a/README.md b/README.md index 6e531ca..2d070d1 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ Alle Variablen sind in [`.env.example`](.env.example) dokumentiert. | `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | Zugangsdaten des DB-Containers | `nebula` | | `POSTGRES_PORT` | Host-Port der Datenbank — wirkt nur mit `docker-compose.dev.yml`, sonst hat die DB keinen | `127.0.0.1:5432` | | `ADMINER_PORT` | Host-Port von Adminer (`--profile tools`) | `127.0.0.1:8080` | -| `GIT_API_TIMEOUT_MS` | Timeout für Git-API-Aufrufe | `15000` | +| `GIT_API_TIMEOUT_MS` | Timeout für Git-API-Aufrufe, in Millisekunden. Alles unter 1000 oder Unlesbares wird verworfen (mit Warnung im Log) — sonst bräche jeder Aufruf sofort ab | `15000` | | `GIT_ALLOW_LOOPBACK` | Git-Instanz auf demselben Rechner erlauben | `false` | | `GIT_BLOCK_PRIVATE` | Zugriff ins private LAN sperren | `false` | | `GIT_ALLOWED_HOSTS` | Strengster Modus: nur diese Hosts erlauben | leer | diff --git a/prisma/migrations/20260901160000_snapshot_teilabrufe/migration.sql b/prisma/migrations/20260901160000_snapshot_teilabrufe/migration.sql new file mode 100644 index 0000000..af79c38 --- /dev/null +++ b/prisma/migrations/20260901160000_snapshot_teilabrufe/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "RepoSnapshot" ADD COLUMN "completeAt" TIMESTAMP(3), +ADD COLUMN "missing" JSONB NOT NULL DEFAULT '{}'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d865db8..6ded25a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -649,6 +649,19 @@ model RepoSnapshot { ciUrl String? ciFinishedAt DateTime? + /// Teil-Abrufe, die beim letzten Abgleich nicht geklappt haben: + /// { "branches": "HTTP 403: Zugriff verweigert …" }. Leeres Objekt heisst, + /// dass alle Listen wirklich geholt wurden. + /// + /// Ohne diese Angabe ist eine leere Liste zweideutig – "nicht geholt" sieht + /// aus wie "nichts vorhanden". Genau daran ist eine Fehlersuche einmal + /// haengen geblieben. + missing Json @default("{}") + pushedAt DateTime? fetchedAt DateTime @default(now()) + /// Wann zuletzt ein Abgleich lief, bei dem alle Teile ankamen. Steht der + /// weiter zurueck als fetchedAt, ist ein Teil der Anzeige aelter als sie + /// vorgibt. + completeAt DateTime? } diff --git a/src/components/GitPanel.tsx b/src/components/GitPanel.tsx index c38da89..42705e6 100644 --- a/src/components/GitPanel.tsx +++ b/src/components/GitPanel.tsx @@ -30,13 +30,14 @@ import { RepositoryDialog } from "@/components/RepositoryDialog"; import { WebhookDialog } from "@/components/WebhookDialog"; import { Sparkline } from "@/components/Sparkline"; import { repoTitle } from "@/lib/git/meta"; -import { asArray, asCalendar, asRecord } from "@/types"; +import { asArray, asCalendar, asRecord, asTextRecord } from "@/types"; import type { BranchInfo, CommitInfo, ConnectionSafe, ProjectDetail, ReleaseInfo, + RepoSnapshot, RepositoryWithSnapshot, TagInfo, TicketInfo, @@ -69,17 +70,26 @@ export function GitPanel({ const [tab, setTab] = useState("pulls"); const [syncing, setSyncing] = useState(false); const [importing, setImporting] = useState(false); - const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>( + null, + ); const [addOpen, setAddOpen] = useState(false); const [webhookOpen, setWebhookOpen] = useState(false); const repositories = project.repositories; - const [selectedId, setSelectedId] = useState(repositories[0]?.id ?? null); + const [selectedId, setSelectedId] = useState( + repositories[0]?.id ?? null, + ); // Nach dem Entfernen zeigt die Auswahl ins Leere – dann auf das fuehrende // zurueckfallen, statt eine leere Ansicht zu zeigen. - const repository = repositories.find((entry) => entry.id === selectedId) ?? repositories[0] ?? null; - const webhookAktiv = repository ? webhookRepoIds.includes(repository.id) : false; + const repository = + repositories.find((entry) => entry.id === selectedId) ?? + repositories[0] ?? + null; + const webhookAktiv = repository + ? webhookRepoIds.includes(repository.id) + : false; /** Offene Issues als Aufgaben anlegen – ohne erneuten Abruf beim Provider. */ async function importIssues() { @@ -87,13 +97,17 @@ export function GitPanel({ setImporting(true); setMessage(null); try { - const response = await fetch(`/api/projects/${project.id}/tasks/from-issues`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ repositoryId: repository.id }), - }); + const response = await fetch( + `/api/projects/${project.id}/tasks/from-issues`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ repositoryId: repository.id }), + }, + ); const body = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(body.error ?? "Uebernahme fehlgeschlagen."); + if (!response.ok) + throw new Error(body.error ?? "Uebernahme fehlgeschlagen."); setMessage({ ok: true, text: @@ -103,7 +117,10 @@ export function GitPanel({ }); router.refresh(); } catch (caught) { - setMessage({ ok: false, text: caught instanceof Error ? caught.message : String(caught) }); + setMessage({ + ok: false, + text: caught instanceof Error ? caught.message : String(caught), + }); } finally { setImporting(false); } @@ -130,7 +147,10 @@ export function GitPanel({ : `/api/projects/${project.id}/sync`; const response = await fetch(url, { method: "POST" }); const payload = await response.json().catch(() => ({})); - setMessage({ ok: response.ok, text: payload.message ?? "Unbekannte Antwort." }); + setMessage({ + ok: response.ok, + text: payload.message ?? "Unbekannte Antwort.", + }); router.refresh(); } catch (caught) { setMessage({ @@ -150,7 +170,9 @@ export function GitPanel({ ) { return; } - await fetch(`/api/repositories/${entry.id}`, { method: "DELETE" }).catch(() => undefined); + await fetch(`/api/repositories/${entry.id}`, { method: "DELETE" }).catch( + () => undefined, + ); setSelectedId(null); router.refresh(); } @@ -160,12 +182,19 @@ export function GitPanel({ <>
-

Kein Repository verknuepft

+

+ Kein Repository verknuepft +

- Verknuepfe ein Repository, um Branches, Tags, Commits und Releases hier zu sehen. - Ein Projekt darf auch mehrere haben – etwa Frontend und Backend. + Verknuepfe ein Repository, um Branches, Tags, Commits und Releases + hier zu sehen. Ein Projekt darf auch mehrere haben – etwa Frontend + und Backend.

- @@ -180,7 +209,10 @@ export function GitPanel({ ); } - const languageTotal = Object.values(languages).reduce((sum, value) => sum + value, 0); + const languageTotal = Object.values(languages).reduce( + (sum, value) => sum + value, + 0, + ); const topLanguages = Object.entries(languages) .sort((a, b) => b[1] - a[1]) .slice(0, 5); @@ -190,7 +222,10 @@ export function GitPanel({ {/* Auswahl, sobald es mehr als eines gibt. Bei einem einzigen waere eine Leiste mit genau einem Knopf nur Ballast. */} {repositories.length > 1 ? ( -
+
{repositories.map((entry) => { const aktiv = entry.id === repository.id; return ( @@ -256,7 +291,11 @@ export function GitPanel({ ) : null} - +

{repository.lastSyncedAt @@ -368,12 +407,22 @@ export function GitPanel({

) : null} + + {snapshot ? ( <> {/* Kennzahlen */}
- - + + - + 1024 ? `${Math.round(snapshot.sizeKb / 1024)} MB` : `${snapshot.sizeKb} KB`} + value={ + snapshot.sizeKb > 1024 + ? `${Math.round(snapshot.sizeKb / 1024)} MB` + : `${snapshot.sizeKb} KB` + } />
@@ -408,11 +465,15 @@ export function GitPanel({ {snapshot.lastCommitSha.slice(0, 7)} {snapshot.lastCommitAuthor ?? "unbekannt"} - {formatRelative(snapshot.lastCommitAt)} + + {formatRelative(snapshot.lastCommitAt)} +

) : ( -

Keine Commit-Daten.

+

+ Keine Commit-Daten. +

)}
@@ -424,8 +485,11 @@ export function GitPanel({ {snapshot.latestTag ?? snapshot.latestRelease ?? "–"} - {snapshot.latestRelease && snapshot.latestRelease !== snapshot.latestTag ? ( - {snapshot.latestRelease} + {snapshot.latestRelease && + snapshot.latestRelease !== snapshot.latestTag ? ( + + {snapshot.latestRelease} + ) : null}

@@ -470,7 +534,8 @@ export function GitPanel({ title={`${name} – ${Math.round((bytes / languageTotal) * 100)}%`} style={{ width: `${(bytes / languageTotal) * 100}%`, - backgroundColor: LANGUAGE_COLORS[index % LANGUAGE_COLORS.length], + backgroundColor: + LANGUAGE_COLORS[index % LANGUAGE_COLORS.length], }} /> ))} @@ -480,7 +545,10 @@ export function GitPanel({ {name} @@ -537,8 +605,8 @@ export function GitPanel({ {issues.length > 0 ? (

- Aus offenen Issues Aufgaben machen. Bereits uebernommene werden - uebersprungen. + Aus offenen Issues Aufgaben machen. Bereits uebernommene + werden uebersprungen.

) : null} - + ) : null} {tab === "branches" ? : null} @@ -567,7 +639,8 @@ export function GitPanel({ ) : (
- Noch keine Daten geladen. Mit „Jetzt synchronisieren“ den ersten Snapshot holen. + Noch keine Daten geladen. Mit „Jetzt synchronisieren“ den ersten + Snapshot holen.
)} @@ -591,7 +664,9 @@ function Metric({ {label}

-

{value}

+

+ {value} +

); } @@ -639,10 +714,15 @@ function TicketList({ ) : null}

- #{ticket.number} + + #{ticket.number} + {ticket.author ?? "unbekannt"} {ticket.labels.slice(0, 3).map((label) => ( - + {label} ))} @@ -650,7 +730,9 @@ function TicketList({

- {formatRelative(ticket.createdAt)} + + {formatRelative(ticket.createdAt)} + {ticket.url ? (
- {branch.name} + + {branch.name} + {branch.isDefault ? ( default @@ -689,10 +773,14 @@ function BranchList({ branches }: { branches: BranchInfo[] }) {
{branch.lastCommitAt ? ( - {formatRelative(branch.lastCommitAt)} + + {formatRelative(branch.lastCommitAt)} + ) : null} {branch.sha ? ( - {branch.sha.slice(0, 7)} + + {branch.sha.slice(0, 7)} + ) : null}
@@ -709,15 +797,23 @@ function TagList({ tags }: { tags: TagInfo[] }) {
- {tag.name} + + {tag.name} + {tag.message ? ( - {tag.message} + + {tag.message} + ) : null}
- {tag.date ? {formatRelative(tag.date)} : null} + {tag.date ? ( + {formatRelative(tag.date)} + ) : null} {tag.sha ? ( - {tag.sha.slice(0, 7)} + + {tag.sha.slice(0, 7)} + ) : null}
@@ -735,7 +831,9 @@ function CommitList({ commits }: { commits: CommitInfo[] }) {

{commit.message}

- {commit.shortSha} + + {commit.shortSha} + {commit.author ?? "unbekannt"}

@@ -760,14 +858,17 @@ function CommitList({ commits }: { commits: CommitInfo[] }) { } function ReleaseList({ releases }: { releases: ReleaseInfo[] }) { - if (releases.length === 0) return ; + if (releases.length === 0) + return ; return (
{releases.map((release) => (
- {release.name} + + {release.name} + {release.tag} @@ -783,7 +884,9 @@ function ReleaseList({ releases }: { releases: ReleaseInfo[] }) { ) : null}
- {formatRelative(release.publishedAt)} + + {formatRelative(release.publishedAt)} + {release.url ? ( ); } + +/** + * Teile, die beim letzten Abgleich nicht ankamen. + * + * Bernstein statt Rot: der Abgleich ist nicht gescheitert, es fehlt nur + * etwas – und was danebensteht, ist der letzte gute Stand, keine Null. + * + * Bis v1.10.1 sah eine nicht geholte Liste genauso aus wie eine leere. Ein + * Repository mit 65 Commits meldete "Snapshot aktualisiert: 0 Branches, + * 0 Tags", und aus der Oberflaeche war nicht zu erkennen, dass ueberhaupt + * etwas schiefgegangen war. + */ +function UnvollstaendigerStand({ + snapshot, +}: { + snapshot: RepoSnapshot | null; +}) { + if (!snapshot) return null; + + const fehlend = asTextRecord(snapshot.missing); + // Sortiert: die Reihenfolge im Objekt haengt daran, welcher Aufruf zuerst + // gescheitert ist, und das waere bei jedem Abgleich eine andere. + const felder = Object.keys(fehlend).sort(); + if (felder.length === 0) return null; + + return ( +
+

+ + + Beim letzten Abgleich nicht geholt:{" "} + {felder.join(", ")}. Dafuer steht hier weiterhin der + vorherige Stand + {snapshot.completeAt ? ( + <> + {" "} + von{" "} + + {formatDateTime(snapshot.completeAt)} + + + ) : null} + . + +

+
    + {felder.map((feld) => ( +
  • + {feld} — {fehlend[feld]} +
  • + ))} +
+
+ ); +} diff --git a/src/lib/git/gitea.ts b/src/lib/git/gitea.ts index 7fb9b94..e424eba 100644 --- a/src/lib/git/gitea.ts +++ b/src/lib/git/gitea.ts @@ -1,4 +1,12 @@ -import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http"; +import { + Teilabrufe, + getJson, + getJsonSafe, + normalizeBaseUrl, + sendJson, + shortSha, + toIso, +} from "./http"; import { CALENDAR_PAGE_SIZE, calendarByPaging, @@ -62,7 +70,11 @@ export class GiteaClient implements GitClient { return { number: angelegt.number, url: angelegt.html_url ?? null }; } - async setIssueState(ref: RepoRef, number: number, open: boolean): Promise { + async setIssueState( + ref: RepoRef, + number: number, + open: boolean, + ): Promise { await sendJson( `${this.repoBase(ref)}/issues/${number}`, "PATCH", @@ -71,7 +83,10 @@ export class GiteaClient implements GitClient { ); } - async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> { + async getIssueState( + ref: RepoRef, + number: number, + ): Promise<"open" | "closed" | null> { const issue = await getJson<{ state?: string } | null>( `${this.repoBase(ref)}/issues/${number}`, { headers: this.headers(), allow404: true }, @@ -97,9 +112,12 @@ export class GiteaClient implements GitClient { }; } - const user = await getJson<{ login: string; full_name?: string }>(`${this.apiBase}/user`, { - headers: this.headers(), - }); + const user = await getJson<{ login: string; full_name?: string }>( + `${this.apiBase}/user`, + { + headers: this.headers(), + }, + ); return { ok: true, message: `Verbunden als ${user.login}${version.version ? ` (Gitea ${version.version})` : ""}`, @@ -107,7 +125,10 @@ export class GiteaClient implements GitClient { version: version.version ?? null, }; } catch (error) { - return { ok: false, message: error instanceof Error ? error.message : String(error) }; + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + }; } } @@ -123,7 +144,9 @@ export class GiteaClient implements GitClient { const needle = query?.toLowerCase().trim(); return repos - .filter((repo) => !needle || repo.full_name.toLowerCase().includes(needle)) + .filter( + (repo) => !needle || repo.full_name.toLowerCase().includes(needle), + ) .map((repo) => ({ fullPath: repo.full_name, owner: repo.owner?.login ?? repo.full_name.split("/")[0] ?? "", @@ -144,31 +167,71 @@ export class GiteaClient implements GitClient { const defaultBranch = repo.default_branch ?? ""; - const [branches, tags, commits, releases, languages, pulls, issues, status] = - await Promise.all([ - getJsonSafe(`${base}/branches?limit=50`, [], { headers }), - getJsonSafe(`${base}/tags?limit=25`, [], { headers }), - // 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve. - getJsonSafe(`${base}/commits?limit=100&stat=false`, [], { headers }), - getJsonSafe(`${base}/releases?limit=10`, [], { headers }), - getJsonSafe>(`${base}/languages`, {}, { headers }), - getJsonSafe(`${base}/pulls?state=open&limit=50`, [], { headers }), - // type=issues, sonst kommen bei Gitea die Pull Requests mit. - getJsonSafe(`${base}/issues?state=open&type=issues&limit=50`, [], { + // Sammelt, welche Liste nicht geholt werden konnte und warum. Frueher + // stand hier getJsonSafe, das jeden Fehler in eine leere Liste verwandelt + // hat – siehe Teilabrufe. + const teil = new Teilabrufe(); + + const [ + branches, + tags, + commits, + releases, + languages, + pulls, + issues, + status, + ] = await Promise.all([ + teil.hol("branches", `${base}/branches?limit=50`, [], { + headers, + }), + teil.hol("tags", `${base}/tags?limit=25`, [], { headers }), + // 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve. + teil.hol( + "commits", + `${base}/commits?limit=100&stat=false`, + [], + { headers, - }), - // Gitea meldet CI-Ergebnisse als Commit-Status. Das deckt Gitea - // Actions und externe Systeme wie Woodpecker gleichermassen ab – - // anders als ein reiner Actions-Endpunkt, den aeltere Instanzen - // ohnehin nicht kennen. - defaultBranch - ? getJsonSafe( - `${base}/commits/${encodeURIComponent(defaultBranch)}/status`, - {} as GiteaCombinedStatus, - { headers }, - ) - : Promise.resolve({} as GiteaCombinedStatus), - ]); + }, + ), + teil.hol("releases", `${base}/releases?limit=10`, [], { + headers, + }), + teil.hol>( + "languages", + `${base}/languages`, + {}, + { headers }, + ), + teil.hol( + "pullRequests", + `${base}/pulls?state=open&limit=50`, + [], + { + headers, + }, + ), + // type=issues, sonst kommen bei Gitea die Pull Requests mit. + teil.hol( + "issues", + `${base}/issues?state=open&type=issues&limit=50`, + [], + { headers }, + ), + // Gitea meldet CI-Ergebnisse als Commit-Status. Das deckt Gitea + // Actions und externe Systeme wie Woodpecker gleichermassen ab – + // anders als ein reiner Actions-Endpunkt, den aeltere Instanzen + // ohnehin nicht kennen. + defaultBranch + ? teil.hol( + "ci", + `${base}/commits/${encodeURIComponent(defaultBranch)}/status`, + {} as GiteaCombinedStatus, + { headers }, + ) + : Promise.resolve({} as GiteaCombinedStatus), + ]); const mappedBranches: BranchInfo[] = branches.map((branch) => ({ name: branch.name, @@ -186,7 +249,9 @@ export class GiteaClient implements GitClient { sha: tag.commit?.sha ?? tag.id ?? null, message: tag.message ?? null, date: toIso(tag.commit?.created), - url: tag.zipball_url ? `${repo.html_url}/releases/tag/${encodeURIComponent(tag.name)}` : null, + url: tag.zipball_url + ? `${repo.html_url}/releases/tag/${encodeURIComponent(tag.name)}` + : null, })); const mappedCommits: CommitInfo[] = commits.map((commit) => ({ @@ -259,7 +324,10 @@ export class GiteaClient implements GitClient { pullRequests: mappedPulls, issues: mappedIssues, ci, - commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)), + commitActivity: bucketCommitActivity( + mappedCommits.map((commit) => commit.date), + ), + missing: teil.fehler, }; } @@ -298,7 +366,9 @@ export class GiteaClient implements GitClient { { headers }, ); const name = pickReadme( - eintraege.filter((eintrag) => eintrag.type === "file").map((eintrag) => eintrag.name), + eintraege + .filter((eintrag) => eintrag.type === "file") + .map((eintrag) => eintrag.name), ); if (!name) return null; diff --git a/src/lib/git/github.ts b/src/lib/git/github.ts index 93857dc..b153c3f 100644 --- a/src/lib/git/github.ts +++ b/src/lib/git/github.ts @@ -1,4 +1,12 @@ -import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http"; +import { + Teilabrufe, + getJson, + getJsonSafe, + normalizeBaseUrl, + sendJson, + shortSha, + toIso, +} from "./http"; import type { BranchInfo, CiInfo, @@ -31,7 +39,9 @@ export class GitHubClient implements GitClient { 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.apiBase = /github\.com$/i.test(base) + ? "https://api.github.com" + : `${base}/api/v3`; this.token = config.token; } @@ -61,7 +71,11 @@ export class GitHubClient implements GitClient { return { number: angelegt.number, url: angelegt.html_url ?? null }; } - async setIssueState(ref: RepoRef, number: number, open: boolean): Promise { + async setIssueState( + ref: RepoRef, + number: number, + open: boolean, + ): Promise { await sendJson( `${this.repoBase(ref)}/issues/${number}`, "PATCH", @@ -70,7 +84,10 @@ export class GitHubClient implements GitClient { ); } - async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> { + async getIssueState( + ref: RepoRef, + number: number, + ): Promise<"open" | "closed" | null> { const issue = await getJson<{ state?: string } | null>( `${this.repoBase(ref)}/issues/${number}`, { headers: this.headers(), allow404: true }, @@ -82,25 +99,42 @@ export class GitHubClient implements GitClient { async check(): Promise { try { if (!this.token) { - await getJson(`${this.apiBase}/rate_limit`, { headers: this.headers() }); + await getJson(`${this.apiBase}/rate_limit`, { + headers: this.headers(), + }); return { ok: true, - message: "Erreichbar (ohne Token – nur oeffentliche Repos, striktes Rate-Limit).", + 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 }; + 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) }; + 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); + 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`, @@ -108,7 +142,9 @@ export class GitHubClient implements GitClient { ); const needle = query?.toLowerCase().trim(); return (repos ?? []) - .filter((repo) => !needle || repo.full_name.toLowerCase().includes(needle)) + .filter( + (repo) => !needle || repo.full_name.toLowerCase().includes(needle), + ) .map((repo) => ({ fullPath: repo.full_name, owner: repo.owner?.login ?? repo.full_name.split("/")[0] ?? "", @@ -129,23 +165,61 @@ export class GitHubClient implements GitClient { 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 }, - ), - ]); + // Sammelt, welche Liste nicht geholt werden konnte und warum. + const teil = new Teilabrufe(); + + const [ + branches, + tags, + commits, + releases, + languages, + pulls, + issues, + ciRuns, + ] = await Promise.all([ + teil.hol("branches", `${base}/branches?per_page=50`, [], { + headers, + }), + teil.hol("tags", `${base}/tags?per_page=25`, [], { + headers, + }), + // 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve. + teil.hol("commits", `${base}/commits?per_page=100`, [], { + headers, + }), + teil.hol( + "releases", + `${base}/releases?per_page=10`, + [], + { headers }, + ), + teil.hol>( + "languages", + `${base}/languages`, + {}, + { headers }, + ), + teil.hol( + "pullRequests", + `${base}/pulls?state=open&per_page=50`, + [], + { headers }, + ), + // /issues liefert bei GitHub auch Pull Requests – die filtern wir raus. + teil.hol( + "issues", + `${base}/issues?state=open&per_page=50`, + [], + { headers }, + ), + teil.hol<{ workflow_runs?: GitHubRun[] }>( + "ci", + `${base}/actions/runs?per_page=1&branch=${encodeURIComponent(defaultBranch)}`, + {}, + { headers }, + ), + ]); const mappedBranches: BranchInfo[] = branches.map((branch) => ({ name: branch.name, @@ -208,7 +282,10 @@ export class GitHubClient implements GitClient { // 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 openIssues = Math.max( + 0, + (repo.open_issues_count ?? 0) - openPullRequests, + ); const run = ciRuns?.workflow_runs?.[0]; const ci: CiInfo | null = run @@ -242,7 +319,10 @@ export class GitHubClient implements GitClient { pullRequests: mappedPulls, issues: mappedIssues, ci, - commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)), + commitActivity: bucketCommitActivity( + mappedCommits.map((commit) => commit.date), + ), + missing: teil.fehler, }; } @@ -260,9 +340,12 @@ export class GitHubClient implements GitClient { */ async fetchCommitCalendar(ref: RepoRef): Promise { const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`; - const wochen = await getJson(`${base}/stats/commit_activity`, { - headers: this.headers(), - }); + const wochen = await getJson( + `${base}/stats/commit_activity`, + { + headers: this.headers(), + }, + ); if (!Array.isArray(wochen) || wochen.length === 0) { // Entweder rechnet GitHub noch, oder das Repository hat keine Commits. @@ -300,7 +383,9 @@ export class GitHubClient implements GitClient { let datei: GitHubContent; try { - datei = await getJson(`${base}/readme`, { headers: this.headers() }); + datei = await getJson(`${base}/readme`, { + headers: this.headers(), + }); } catch (error) { if (error instanceof GitApiError && error.status === 404) return null; throw error; @@ -337,8 +422,12 @@ interface GitHubWeek { } /** 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"; +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": diff --git a/src/lib/git/gitlab.ts b/src/lib/git/gitlab.ts index 5928ca1..4a74cb9 100644 --- a/src/lib/git/gitlab.ts +++ b/src/lib/git/gitlab.ts @@ -1,4 +1,12 @@ -import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http"; +import { + Teilabrufe, + getJson, + getJsonSafe, + normalizeBaseUrl, + sendJson, + shortSha, + toIso, +} from "./http"; import { CALENDAR_PAGE_SIZE, calendarByPaging, @@ -61,7 +69,11 @@ export class GitLabClient implements GitClient { return { number: angelegt.iid, url: angelegt.web_url ?? null }; } - async setIssueState(ref: RepoRef, number: number, open: boolean): Promise { + async setIssueState( + ref: RepoRef, + number: number, + open: boolean, + ): Promise { await sendJson( `${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`, "PUT", @@ -70,7 +82,10 @@ export class GitLabClient implements GitClient { ); } - async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> { + async getIssueState( + ref: RepoRef, + number: number, + ): Promise<"open" | "closed" | null> { const issue = await getJson<{ state?: string } | null>( `${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`, { headers: this.headers(), allow404: true }, @@ -83,15 +98,31 @@ export class GitLabClient implements GitClient { 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 }; + 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 }; + 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) }; + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + }; } } @@ -101,7 +132,9 @@ export class GitLabClient implements GitClient { ? `${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() }); + const projects = await getJson(url, { + headers: this.headers(), + }); return (projects ?? []).map((project) => { const path = project.path_with_namespace; const segments = path.split("/"); @@ -127,35 +160,83 @@ export class GitLabClient implements GitClient { const base = `${this.apiBase}/projects/${this.projectPath(ref)}`; const headers = this.headers(); - const project = await getJson(`${base}?statistics=true`, { headers }); + const project = await getJson(`${base}?statistics=true`, { + headers, + }); const defaultBranch = project.default_branch ?? ""; - const [branches, tags, commits, releases, languages, mergeRequests, issues, pipelines] = - await Promise.all([ - getJsonSafe(`${base}/repository/branches?per_page=50`, [], { headers }), - getJsonSafe(`${base}/repository/tags?per_page=25`, [], { headers }), - // 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve. - getJsonSafe(`${base}/repository/commits?per_page=100`, [], { headers }), - getJsonSafe(`${base}/releases?per_page=10`, [], { headers }), - getJsonSafe>(`${base}/languages`, {}, { headers }), - getJsonSafe(`${base}/merge_requests?state=opened&per_page=50`, [], { + // Sammelt, welche Liste nicht geholt werden konnte und warum. + const teil = new Teilabrufe(); + + const [ + branches, + tags, + commits, + releases, + languages, + mergeRequests, + issues, + pipelines, + ] = await Promise.all([ + teil.hol( + "branches", + `${base}/repository/branches?per_page=50`, + [], + { headers }, + ), + teil.hol("tags", `${base}/repository/tags?per_page=25`, [], { + headers, + }), + // 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve. + teil.hol( + "commits", + `${base}/repository/commits?per_page=100`, + [], + { headers }, + ), + teil.hol( + "releases", + `${base}/releases?per_page=10`, + [], + { headers }, + ), + teil.hol>( + "languages", + `${base}/languages`, + {}, + { headers }, + ), + teil.hol( + "pullRequests", + `${base}/merge_requests?state=opened&per_page=50`, + [], + { headers, - }), - getJsonSafe(`${base}/issues?state=opened&per_page=50`, [], { headers }), - getJsonSafe( - `${base}/pipelines?per_page=1${defaultBranch ? `&ref=${encodeURIComponent(defaultBranch)}` : ""}`, - [], - { headers }, - ), - ]); + }, + ), + teil.hol( + "issues", + `${base}/issues?state=opened&per_page=50`, + [], + { headers }, + ), + teil.hol( + "ci", + `${base}/pipelines?per_page=1${defaultBranch ? `&ref=${encodeURIComponent(defaultBranch)}` : ""}`, + [], + { 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), + 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, @@ -240,7 +321,10 @@ export class GitLabClient implements GitClient { pullRequests: mappedMergeRequests, issues: mappedIssues, ci, - commitActivity: bucketCommitActivity(mappedCommits.map((commit) => commit.date)), + commitActivity: bucketCommitActivity( + mappedCommits.map((commit) => commit.date), + ), + missing: teil.fehler, }; } @@ -260,7 +344,9 @@ export class GitLabClient implements GitClient { `&since=${encodeURIComponent(seit)}`, { headers: this.headers() }, ); - return commits.map((commit) => toIso(commit.committed_date ?? commit.created_at)); + return commits.map((commit) => + toIso(commit.committed_date ?? commit.created_at), + ); }); } @@ -278,18 +364,27 @@ export class GitLabClient implements GitClient { { headers }, ); const name = pickReadme( - eintraege.filter((eintrag) => eintrag.type === "blob").map((eintrag) => eintrag.name), + 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 }, - ); + 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 }); + const projekt = await getJsonSafe( + base, + {} as GitLabProject, + { headers }, + ); return { content, diff --git a/src/lib/git/http.test.ts b/src/lib/git/http.test.ts new file mode 100644 index 0000000..ed485dc --- /dev/null +++ b/src/lib/git/http.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Teilabrufe, zeitlimit } from "./http"; + +/** + * Diese beiden Stellen haben zusammen einen Ausfall verursacht, den niemand + * sehen konnte: Der Abgleich holte die Listen eines Repositories, verlor sie + * unterwegs, verwandelte jeden Fehler in eine leere Liste und meldete + * "Snapshot aktualisiert: 0 Branches, 0 Tags". Aus der Oberflaeche war nicht + * zu erkennen, dass ueberhaupt etwas schiefgegangen war. + */ + +describe("zeitlimit", () => { + it("nimmt einen brauchbaren Wert", () => { + expect(zeitlimit("30000")).toBe(30000); + expect(zeitlimit(" 20000 ")).toBe(20000); + }); + + it("faellt ohne Angabe auf die Vorgabe zurueck", () => { + expect(zeitlimit(undefined)).toBe(15000); + expect(zeitlimit("")).toBe(15000); + expect(zeitlimit(" ")).toBe(15000); + }); + + it("faengt genau die Werte ab, die alles stillgelegt haetten", () => { + // Number("") ist 0, Number("abc") ist NaN. setTimeout loest bei beidem + // sofort aus – jeder ausgehende Aufruf braeche ab, bevor er beginnt, + // und der Abgleich meldete ueberall leere Listen. + const warnungen: string[] = []; + const merken = (text: string) => warnungen.push(text); + + expect(zeitlimit("0", merken)).toBe(15000); + expect(zeitlimit("abc", merken)).toBe(15000); + expect(zeitlimit("-5000", merken)).toBe(15000); + expect(zeitlimit("500", merken)).toBe(15000); + + // Und zwar nicht stillschweigend. + expect(warnungen).toHaveLength(4); + for (const warnung of warnungen) { + expect(warnung).toContain("GIT_API_TIMEOUT_MS"); + } + }); +}); + +describe("Teilabrufe", () => { + // Vor jedem Aufruf prueft der SSRF-Schutz das Ziel und loest dafuer den + // Hostnamen auf. Eine ausdrueckliche Freigabe ueberspringt das – sonst + // haengt der Test an einem DNS-Server und misst nicht, was er messen will. + beforeEach(() => { + vi.stubEnv("GIT_ALLOWED_HOSTS", "git.example.com"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("meldet nichts, solange alles ankommt", async () => { + const teil = new Teilabrufe(); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify([{ name: "main" }]), { status: 200 }), + ); + + const wert = await teil.hol("branches", "https://git.example.com/api/v1/x", []); + + expect(wert).toEqual([{ name: "main" }]); + expect(teil.fehler).toEqual({}); + expect(teil.unvollstaendig).toBe(false); + }); + + it("gibt den Fallback zurueck, haelt aber den Grund fest", async () => { + // Der Kern des Fehlers: vorher war dieser Fall von "das Repository hat + // keine Branches" nicht zu unterscheiden. + const teil = new Teilabrufe(); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("no scopes", { status: 403 }), + ); + + const wert = await teil.hol("branches", "https://git.example.com/api/v1/x", []); + + expect(wert).toEqual([]); + expect(teil.unvollstaendig).toBe(true); + expect(teil.fehler.branches).toContain("403"); + }); + + it("nennt den Status, weil daran die Ursache haengt", async () => { + // 403 heisst Scopes, 429 Rate-Limit, 408 Zeitueberschreitung. Ohne die + // Zahl bliebe nur "hat nicht geklappt". + const teil = new Teilabrufe(); + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("slow down", { status: 429 })); + + await teil.hol("tags", "https://git.example.com/api/v1/x", []); + + expect(teil.fehler.tags).toMatch(/429/); + }); + + it("haelt mehrere Fehlschlaege getrennt auseinander", async () => { + const teil = new Teilabrufe(); + let aufruf = 0; + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + aufruf += 1; + // Der zweite geht durch – ein Teilausfall ist der haeufige Fall. + return aufruf === 2 + ? new Response(JSON.stringify([1, 2]), { status: 200 }) + : new Response("nope", { status: 403 }); + }); + + const [branches, tags, commits] = await Promise.all([ + teil.hol("branches", "https://git.example.com/a", []), + teil.hol("tags", "https://git.example.com/b", []), + teil.hol("commits", "https://git.example.com/c", []), + ]); + + expect(branches).toEqual([]); + expect(tags).toEqual([1, 2]); + expect(commits).toEqual([]); + expect(Object.keys(teil.fehler).sort()).toEqual(["branches", "commits"]); + }); + + it("faengt auch einen Abbruch ab, nicht nur eine Fehlerantwort", async () => { + const teil = new Teilabrufe(); + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("socket hang up")); + + const wert = await teil.hol("commits", "https://git.example.com/api/v1/x", []); + + expect(wert).toEqual([]); + expect(teil.fehler.commits).toBeTruthy(); + }); +}); diff --git a/src/lib/git/http.ts b/src/lib/git/http.ts index 72dc6a0..e5d4612 100644 --- a/src/lib/git/http.ts +++ b/src/lib/git/http.ts @@ -1,7 +1,37 @@ import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf"; import { GitApiError } from "./types"; -const DEFAULT_TIMEOUT = Number(process.env.GIT_API_TIMEOUT_MS ?? 15000); +const TIMEOUT_VORGABE = 15000; + +/** + * Zeitlimit je Aufruf. + * + * Bewusst mit Pruefung: Number("") ist 0 und Number("abc") ist NaN – beides + * laesst setTimeout sofort ausloesen, und dann bricht jeder ausgehende + * Aufruf ab, bevor er begonnen hat. Eine verrutschte Zeile in der .env + * legte damit den ganzen Abgleich still, ohne dass irgendwo etwas stand. + */ +export function zeitlimit( + roh: string | undefined, + warnen: (text: string) => void = console.warn, +): number { + const wert = roh?.trim(); + if (!wert) return TIMEOUT_VORGABE; + + const zahl = Number(wert); + // Untergrenze 1000 ms: darunter ist kein Aufruf ueber das Netz zu + // schaffen, und wer das eintraegt, hat sich vertan. + if (!Number.isFinite(zahl) || zahl < 1000) { + warnen( + `[nebula] GIT_API_TIMEOUT_MS="${wert}" ist unbrauchbar (Zahl ab 1000 erwartet). ` + + `Es gelten ${TIMEOUT_VORGABE} ms.`, + ); + return TIMEOUT_VORGABE; + } + return zahl; +} + +const DEFAULT_TIMEOUT = zeitlimit(process.env.GIT_API_TIMEOUT_MS); export interface FetchOptions { headers?: Record; @@ -73,7 +103,11 @@ export async function getJson( if (!response.ok) { const body = await response.text().catch(() => ""); - throw new GitApiError(friendlyMessage(response.status, url, body), response.status, url); + throw new GitApiError( + friendlyMessage(response.status, url, body), + response.status, + url, + ); } const text = await response.text(); @@ -150,7 +184,11 @@ export async function sendJson( if (!response.ok) { const text = await response.text().catch(() => ""); - throw new GitApiError(friendlyMessage(response.status, url, text), response.status, url); + throw new GitApiError( + friendlyMessage(response.status, url, text), + response.status, + url, + ); } const text = await response.text(); @@ -175,7 +213,14 @@ export async function sendJson( } } -/** Wie getJson, gibt aber bei jedem Fehler den Fallback zurueck. */ +/** + * Wie getJson, gibt aber bei jedem Fehler den Fallback zurueck. + * + * Nur fuer Abrufe benutzen, bei denen ein Fehlschlag wirklich nichts + * bedeutet. Fuer die Listen eines Snapshots ist das falsch – dort ist + * "nicht geholt" etwas anderes als "nichts vorhanden". Dafuer gibt es + * Teilabrufe. + */ export async function getJsonSafe( url: string, fallback: T, @@ -189,6 +234,63 @@ export async function getJsonSafe( } } +/** Warum ein einzelner Teil-Abruf nicht geklappt hat. */ +export type Teilfehler = Record; + +/** + * Sammelstelle fuer die Listen eines Snapshots. + * + * Der Vorgaenger dieser Klasse war ein getJsonSafe je Liste. Das hat jeden + * Fehler in eine leere Liste verwandelt, und damit war ein 403 nicht mehr + * von einem Repository ohne Branches zu unterscheiden: Der Abgleich meldete + * "Snapshot aktualisiert: 0 Branches, 0 Tags", schrieb Nullen ueber den + * letzten guten Stand und niemand konnte sehen, woran es lag. + * + * Der Fallback bleibt – ein einzelner Fehlschlag darf den ganzen Abgleich + * nicht abbrechen –, aber der Grund wird festgehalten. + */ +export class Teilabrufe { + readonly fehler: Teilfehler = {}; + + async hol( + feld: string, + url: string, + fallback: T, + options: FetchOptions = {}, + ): Promise { + try { + const result = await getJson(url, options); + return (result ?? fallback) as T; + } catch (error) { + this.fehler[feld] = beschreibe(error); + return fallback; + } + } + + /** true, sobald mindestens ein Teil-Abruf gescheitert ist. */ + get unvollstaendig(): boolean { + return Object.keys(this.fehler).length > 0; + } +} + +/** + * Kurzfassung eines Fehlers, wie sie am Repository angezeigt wird. + * + * getJson gibt ausschliesslich GitApiError heraus – ein blockiertes Ziel + * kommt als 403, eine Zeitueberschreitung als 408, ein nicht erreichter + * Host als 0. Der Status ist damit die eigentliche Auskunft, und die + * uebrigen Zweige sind nur fuer den Fall da, dass sich das einmal aendert. + */ +function beschreibe(error: unknown): string { + if (error instanceof GitApiError) { + return error.status + ? `HTTP ${error.status}: ${error.message}` + : error.message; + } + if (error instanceof Error) return `${error.name}: ${error.message}`; + return String(error); +} + export function toIso(value: unknown): string | null { if (!value) return null; const date = new Date(String(value)); diff --git a/src/lib/git/types.ts b/src/lib/git/types.ts index 6f22bdc..a303bbb 100644 --- a/src/lib/git/types.ts +++ b/src/lib/git/types.ts @@ -99,6 +99,15 @@ export interface RepoSnapshotData extends RepoOverview { ci: CiInfo | null; /** Commits je Tag der letzten 30 Tage, aeltester zuerst. */ commitActivity: number[]; + /** + * Teil-Abrufe, die nicht geklappt haben: Feldname -> Grund mit HTTP-Status. + * Leer heisst, dass alle Listen wirklich geholt wurden. + * + * Der Unterschied ist wesentlich: eine leere Liste in `tags` bedeutet mit + * einem Eintrag hier "nicht geholt" und ohne ihn "es gibt keine Tags". + * Nur im ersten Fall darf der gespeicherte Stand stehen bleiben. + */ + missing: Record; } /** Tage, ueber die die Commit-Aktivitaet gebildet wird. */ diff --git a/src/lib/sync/index.ts b/src/lib/sync/index.ts index bf07851..e931045 100644 --- a/src/lib/sync/index.ts +++ b/src/lib/sync/index.ts @@ -12,6 +12,15 @@ export interface SyncResult { ok: boolean; message: string; fetchedAt?: string; + /** + * Der Abgleich lief durch, aber einzelne Listen kamen nicht an. + * + * Getrennt von `ok`, weil beides verschiedene Folgen hat: `ok: false` + * setzt syncError, zaehlt Fehlversuche und haelt das Repository beim + * naechsten Durchlauf zurueck. Ein unvollstaendiger Abgleich soll nichts + * davon ausloesen – der Rest ist ja frisch – aber sichtbar sein. + */ + unvollstaendig?: boolean; } /** @@ -19,7 +28,9 @@ export interface SyncResult { * in die Datenbank. Fehler werden am Repository vermerkt statt geworfen, * damit ein kaputtes Repo nicht das ganze Dashboard blockiert. */ -export async function syncRepository(repositoryId: string): Promise { +export async function syncRepository( + repositoryId: string, +): Promise { const repository = await prisma.repository.findUnique({ where: { id: repositoryId }, include: { connection: true }, @@ -33,7 +44,8 @@ export async function syncRepository(repositoryId: string): Promise if (!ref) { return { ok: false, - message: "Kein Repository hinterlegt – bitte Repo-URL oder Owner/Name setzen.", + message: + "Kein Repository hinterlegt – bitte Repo-URL oder Owner/Name setzen.", }; } @@ -46,7 +58,8 @@ export async function syncRepository(repositoryId: string): Promise if (!parsed) { return { ok: false, - message: "Keine Git-Verbindung ausgewaehlt und die Repo-URL ist nicht auswertbar.", + message: + "Keine Git-Verbindung ausgewaehlt und die Repo-URL ist nicht auswertbar.", }; } client = createClient({ @@ -81,10 +94,28 @@ export async function syncRepository(repositoryId: string): Promise // Nach dem Speichern, damit die Meldung den neuen Stand sieht. await notifyAfterSync(repository.id); + + /** + * Fehlgeschlagene Teile gehoeren in die Meldung. + * + * Vorher stand hier "Snapshot aktualisiert: 0 Branches, 0 Tags" – und das + * war die einzige Spur, wenn saemtliche Listen unterwegs verloren gingen. + * Wer das liest, denkt an ein leeres Repository, nicht an einen Fehler. + */ + // Sortiert, damit die Meldung bei jedem Durchlauf gleich aussieht – + // die Reihenfolge im Objekt haengt sonst daran, welcher Aufruf zuerst + // gescheitert ist. + const fehlend = Object.keys(data.missing).sort(); + const zahlen = fehlend.length + ? `Teilweise aktualisiert – nicht geholt: ${fehlend.join(", ")}. ` + + `Der vorherige Stand bleibt dafuer stehen.` + : `Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.`; + return { ok: true, + unvollstaendig: fehlend.length > 0, message: - `Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.` + + zahlen + (abgehakt > 0 ? ` ${abgehakt} Aufgabe(n) abgehakt, weil das Issue geschlossen wurde.` : ""), @@ -127,7 +158,9 @@ export interface ProjectSyncResult extends SyncResult { * beim selben Anbieter, und ein Schwung gleichzeitiger Anfragen ist genau * das, worauf Rate-Limits reagieren. */ -export async function syncProject(projectId: string): Promise { +export async function syncProject( + projectId: string, +): Promise { const repositories = await prisma.repository.findMany({ where: { projectId }, orderBy: [{ position: "asc" }, { createdAt: "asc" }], @@ -214,7 +247,10 @@ async function maybeRefreshCalendar( // Rate-Limit, Zeitueberschreitung, Ausfall: der Kalender ist Beiwerk und // soll den Abgleich nicht scheitern lassen. Ohne Zeitstempel versucht es // der naechste Durchgang von selbst wieder. - console.error("[nebula] Commit-Kalender konnte nicht geholt werden:", error); + console.error( + "[nebula] Commit-Kalender konnte nicht geholt werden:", + error, + ); } } @@ -294,7 +330,9 @@ async function noteNewCommits( // Zahl als mit einer falschen. const index = data.commits.findIndex((commit) => commit.sha === vorher); const anzahl = index > 0 ? index : null; - const wo = data.defaultBranch ? `${repoTitle(repository)} · ${data.defaultBranch}` : repoTitle(repository); + const wo = data.defaultBranch + ? `${repoTitle(repository)} · ${data.defaultBranch}` + : repoTitle(repository); const betreff = shorten(data.commits[0]?.message ?? "", 60); await recordActivity({ @@ -304,16 +342,38 @@ async function noteNewCommits( anzahl === null ? `Neue Commits in ${wo}: ${betreff}` : `${anzahl} neue${anzahl === 1 ? "r" : ""} Commit${anzahl === 1 ? "" : "s"} in ${wo}: ${betreff}`, - meta: { repositoryId: repository.id, from: vorher, to: neuester, count: anzahl }, + meta: { + repositoryId: repository.id, + from: vorher, + to: neuester, + count: anzahl, + }, }); } async function persistSnapshot(repository: Repository, data: RepoSnapshotData) { const latestCommit = data.commits[0] ?? null; const latestRelease = - data.releases.find((release) => !release.draft && !release.prerelease) ?? data.releases[0] ?? null; + data.releases.find((release) => !release.draft && !release.prerelease) ?? + data.releases[0] ?? + null; const latestTag = data.tags[0]?.name ?? latestRelease?.tag ?? null; + /** + * Ein Teil, der nicht geholt werden konnte, bleibt so, wie er war. + * + * Vorher wurde bedingungslos ueberschrieben. Kam eine Liste leer zurueck, + * weil der Abruf gescheitert war, ersetzte sie den letzten guten Stand – + * ein Klick auf "Jetzt synchronisieren" genuegte, und die Commits eines + * Repositories waren weg. Der Snapshot ist ein Zwischenspeicher: ein + * alter Stand ist immer brauchbarer als eine leere Liste, die aussieht + * wie eine Aussage. + */ + const geholt = (feld: string, wert: T): T | undefined => + feld in data.missing ? undefined : wert; + + const vollstaendig = Object.keys(data.missing).length === 0; + const payload = { fullName: data.fullName, description: data.description, @@ -325,30 +385,55 @@ async function persistSnapshot(repository: Repository, data: RepoSnapshotData) { openIssues: data.openIssues, openPullRequests: data.openPullRequests, sizeKb: data.sizeKb, - lastCommitSha: latestCommit?.sha ?? null, - lastCommitMessage: latestCommit?.message ?? null, - lastCommitAuthor: latestCommit?.author ?? null, - lastCommitAt: latestCommit?.date ? new Date(latestCommit.date) : null, - latestTag, - latestRelease: latestRelease?.name ?? null, - latestReleaseAt: latestRelease?.publishedAt ? new Date(latestRelease.publishedAt) : null, - branchCount: data.branches.length, - tagCount: data.tags.length, - branches: data.branches as unknown as object, - tags: data.tags as unknown as object, - commits: data.commits as unknown as object, - releases: data.releases as unknown as object, - languages: data.languages as unknown as object, - pullRequests: data.pullRequests as unknown as object, - issues: data.issues as unknown as object, - commitActivity: data.commitActivity as unknown as object, - ciState: data.ci?.state ?? null, - ciName: data.ci?.name ?? null, - ciBranch: data.ci?.branch ?? null, - ciUrl: data.ci?.url ?? null, - ciFinishedAt: data.ci?.finishedAt ? new Date(data.ci.finishedAt) : null, + // Die Commit-Felder haengen alle an derselben Liste – entweder alle + // neu oder alle unveraendert. Halb aktualisiert waeren sie in sich + // widerspruechlich. + lastCommitSha: geholt("commits", latestCommit?.sha ?? null), + lastCommitMessage: geholt("commits", latestCommit?.message ?? null), + lastCommitAuthor: geholt("commits", latestCommit?.author ?? null), + lastCommitAt: geholt( + "commits", + latestCommit?.date ? new Date(latestCommit.date) : null, + ), + // latestTag faellt auf den Release-Tag zurueck; steht einer der beiden + // Abrufe aus, ist die Ableitung nicht mehr belastbar. + latestTag: + "tags" in data.missing || "releases" in data.missing + ? undefined + : latestTag, + latestRelease: geholt("releases", latestRelease?.name ?? null), + latestReleaseAt: geholt( + "releases", + latestRelease?.publishedAt ? new Date(latestRelease.publishedAt) : null, + ), + branchCount: geholt("branches", data.branches.length), + tagCount: geholt("tags", data.tags.length), + branches: geholt("branches", data.branches as unknown as object), + tags: geholt("tags", data.tags as unknown as object), + commits: geholt("commits", data.commits as unknown as object), + releases: geholt("releases", data.releases as unknown as object), + languages: geholt("languages", data.languages as unknown as object), + pullRequests: geholt( + "pullRequests", + data.pullRequests as unknown as object, + ), + issues: geholt("issues", data.issues as unknown as object), + // Die Kurve wird aus den Commits gerechnet – ohne sie waere sie leer. + commitActivity: geholt("commits", data.commitActivity as unknown as object), + ciState: geholt("ci", data.ci?.state ?? null), + ciName: geholt("ci", data.ci?.name ?? null), + ciBranch: geholt("ci", data.ci?.branch ?? null), + ciUrl: geholt("ci", data.ci?.url ?? null), + ciFinishedAt: geholt( + "ci", + data.ci?.finishedAt ? new Date(data.ci.finishedAt) : null, + ), pushedAt: data.pushedAt ? new Date(data.pushedAt) : null, + missing: data.missing as unknown as object, fetchedAt: new Date(), + // Nur setzen, wenn wirklich alles ankam. Sonst bleibt der Zeitpunkt + // stehen, an dem der Stand zuletzt vollstaendig war. + ...(vollstaendig ? { completeAt: new Date() } : {}), }; // Vor dem Ueberschreiben lesen – danach ist der alte Stand weg. diff --git a/src/types/index.ts b/src/types/index.ts index e9c37a1..30c95b7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -47,7 +47,10 @@ export interface ConnectionWithCount extends ConnectionSafe { * repositorySafeSelect. Ob einer eingerichtet ist, reicht die Projektseite * getrennt durch. */ -export interface RepositoryWithSnapshot extends Omit { +export interface RepositoryWithSnapshot extends Omit< + Repository, + "webhookSecret" +> { connection: ConnectionSafe | null; snapshot: RepoSnapshot | null; } @@ -159,7 +162,8 @@ export function asArray(value: unknown): T[] { export function asCalendar(value: unknown): CommitCalendar | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const kandidat = value as Partial; - if (typeof kandidat.from !== "string" || typeof kandidat.to !== "string") return null; + if (typeof kandidat.from !== "string" || typeof kandidat.to !== "string") + return null; if (!kandidat.days || typeof kandidat.days !== "object") return null; return { from: kandidat.from, @@ -176,3 +180,14 @@ export function asRecord(value: unknown): Record { } return {}; } + +/** + * Wie asRecord, aber fuer Text – etwa `missing` am Snapshot, das je Feld + * den Grund des Fehlschlags traegt. + */ +export function asTextRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + return {}; +}