Fix #19 #20: Teil-Abrufe verschlucken ihre Fehler nicht mehr #21

Merged
florian.wilde merged 1 commits from fix/19-abrufe-nicht-verschlucken into main 2026-09-09 20:05:27 +00:00
14 changed files with 1002 additions and 199 deletions
Showing only changes of commit b062eb36a1 - Show all commits
+10
View File
@@ -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`:
+27
View File
@@ -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 |
+1 -1
View File
@@ -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 |
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "RepoSnapshot" ADD COLUMN "completeAt" TIMESTAMP(3),
ADD COLUMN "missing" JSONB NOT NULL DEFAULT '{}';
+13
View File
@@ -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?
}
+208 -50
View File
@@ -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<TabKey>("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<string | null>(repositories[0]?.id ?? null);
const [selectedId, setSelectedId] = useState<string | null>(
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({
<>
<div className="glass px-5 py-10 text-center">
<GitBranch className="mx-auto h-8 w-8 text-slate-600" />
<h3 className="mt-3 text-sm font-semibold text-slate-200">Kein Repository verknuepft</h3>
<h3 className="mt-3 text-sm font-semibold text-slate-200">
Kein Repository verknuepft
</h3>
<p className="mx-auto mt-1 max-w-sm text-sm text-slate-500">
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.
</p>
<button type="button" className="btn btn-primary mt-4" onClick={() => setAddOpen(true)}>
<button
type="button"
className="btn btn-primary mt-4"
onClick={() => setAddOpen(true)}
>
<Plus className="h-4 w-4" />
Repository verknuepfen
</button>
@@ -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 ? (
<div className="glass flex flex-wrap items-center gap-1.5 p-2" role="tablist">
<div
className="glass flex flex-wrap items-center gap-1.5 p-2"
role="tablist"
>
{repositories.map((entry) => {
const aktiv = entry.id === repository.id;
return (
@@ -256,7 +291,11 @@ export function GitPanel({
<ExternalLink className="h-3.5 w-3.5" />
</a>
) : null}
<CiBadge state={snapshot?.ciState} name={snapshot?.ciName} url={snapshot?.ciUrl} />
<CiBadge
state={snapshot?.ciState}
name={snapshot?.ciName}
url={snapshot?.ciUrl}
/>
</div>
<p className="mt-1 text-xs text-slate-500" suppressHydrationWarning>
{repository.lastSyncedAt
@@ -368,12 +407,22 @@ export function GitPanel({
</p>
) : null}
<UnvollstaendigerStand snapshot={snapshot} />
{snapshot ? (
<>
{/* Kennzahlen */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<Metric icon={Star} label="Stars" value={formatNumber(snapshot.stars)} />
<Metric icon={GitMerge} label="Forks" value={formatNumber(snapshot.forks)} />
<Metric
icon={Star}
label="Stars"
value={formatNumber(snapshot.stars)}
/>
<Metric
icon={GitMerge}
label="Forks"
value={formatNumber(snapshot.forks)}
/>
<Metric
icon={GitPullRequest}
label="Offene PRs/MRs"
@@ -384,11 +433,19 @@ export function GitPanel({
label="Offene Issues"
value={formatNumber(snapshot.openIssues)}
/>
<Metric icon={GitBranch} label="Branches" value={formatNumber(snapshot.branchCount)} />
<Metric
icon={GitBranch}
label="Branches"
value={formatNumber(snapshot.branchCount)}
/>
<Metric
icon={HardDrive}
label="Groesse"
value={snapshot.sizeKb > 1024 ? `${Math.round(snapshot.sizeKb / 1024)} MB` : `${snapshot.sizeKb} KB`}
value={
snapshot.sizeKb > 1024
? `${Math.round(snapshot.sizeKb / 1024)} MB`
: `${snapshot.sizeKb} KB`
}
/>
</div>
@@ -408,11 +465,15 @@ export function GitPanel({
{snapshot.lastCommitSha.slice(0, 7)}
</span>
<span>{snapshot.lastCommitAuthor ?? "unbekannt"}</span>
<span suppressHydrationWarning>{formatRelative(snapshot.lastCommitAt)}</span>
<span suppressHydrationWarning>
{formatRelative(snapshot.lastCommitAt)}
</span>
</p>
</>
) : (
<p className="mt-2 text-sm text-slate-500">Keine Commit-Daten.</p>
<p className="mt-2 text-sm text-slate-500">
Keine Commit-Daten.
</p>
)}
</div>
@@ -424,8 +485,11 @@ export function GitPanel({
<span className="font-mono text-2xl text-white">
{snapshot.latestTag ?? snapshot.latestRelease ?? "–"}
</span>
{snapshot.latestRelease && snapshot.latestRelease !== snapshot.latestTag ? (
<span className="text-sm text-slate-400">{snapshot.latestRelease}</span>
{snapshot.latestRelease &&
snapshot.latestRelease !== snapshot.latestTag ? (
<span className="text-sm text-slate-400">
{snapshot.latestRelease}
</span>
) : null}
</div>
<p className="mt-1 text-xs text-slate-500">
@@ -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({
<span key={name} className="inline-flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: LANGUAGE_COLORS[index % LANGUAGE_COLORS.length] }}
style={{
backgroundColor:
LANGUAGE_COLORS[index % LANGUAGE_COLORS.length],
}}
/>
{name}
<span className="text-slate-600">
@@ -537,8 +605,8 @@ export function GitPanel({
{issues.length > 0 ? (
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-white/6 bg-white/2 px-4 py-2">
<p className="text-[11px] text-slate-500">
Aus offenen Issues Aufgaben machen. Bereits uebernommene werden
uebersprungen.
Aus offenen Issues Aufgaben machen. Bereits uebernommene
werden uebersprungen.
</p>
<button
type="button"
@@ -555,7 +623,11 @@ export function GitPanel({
</button>
</div>
) : null}
<TicketList tickets={issues} empty="Keine offenen Issues." icon={CircleDot} />
<TicketList
tickets={issues}
empty="Keine offenen Issues."
icon={CircleDot}
/>
</>
) : null}
{tab === "branches" ? <BranchList branches={branches} /> : null}
@@ -567,7 +639,8 @@ export function GitPanel({
</>
) : (
<div className="glass px-5 py-10 text-center text-sm text-slate-500">
Noch keine Daten geladen. Mit „Jetzt synchronisieren“ den ersten Snapshot holen.
Noch keine Daten geladen. Mit „Jetzt synchronisieren“ den ersten
Snapshot holen.
</div>
)}
</div>
@@ -591,7 +664,9 @@ function Metric({
<Icon className="h-3 w-3" />
{label}
</p>
<p className="mt-1 text-lg font-semibold tabular-nums text-slate-100">{value}</p>
<p className="mt-1 text-lg font-semibold tabular-nums text-slate-100">
{value}
</p>
</div>
);
}
@@ -639,10 +714,15 @@ function TicketList({
) : null}
</p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-slate-500">
<span className="font-mono text-slate-600">#{ticket.number}</span>
<span className="font-mono text-slate-600">
#{ticket.number}
</span>
<span>{ticket.author ?? "unbekannt"}</span>
{ticket.labels.slice(0, 3).map((label) => (
<span key={label} className="rounded bg-white/6 px-1 py-0.5 text-[10px]">
<span
key={label}
className="rounded bg-white/6 px-1 py-0.5 text-[10px]"
>
{label}
</span>
))}
@@ -650,7 +730,9 @@ function TicketList({
</div>
</div>
<div className="flex shrink-0 items-center gap-2 text-[11px] text-slate-500">
<span suppressHydrationWarning>{formatRelative(ticket.createdAt)}</span>
<span suppressHydrationWarning>
{formatRelative(ticket.createdAt)}
</span>
{ticket.url ? (
<a
href={ticket.url}
@@ -677,7 +759,9 @@ function BranchList({ branches }: { branches: BranchInfo[] }) {
<Row key={branch.name}>
<div className="flex min-w-0 items-center gap-2">
<GitBranch className="h-3.5 w-3.5 shrink-0 text-slate-600" />
<span className="truncate font-mono text-xs text-slate-200">{branch.name}</span>
<span className="truncate font-mono text-xs text-slate-200">
{branch.name}
</span>
{branch.isDefault ? (
<span className="shrink-0 rounded bg-violet-500/15 px-1.5 py-0.5 text-[10px] text-violet-200">
default
@@ -689,10 +773,14 @@ function BranchList({ branches }: { branches: BranchInfo[] }) {
</div>
<div className="flex shrink-0 items-center gap-3 text-[11px] text-slate-500">
{branch.lastCommitAt ? (
<span suppressHydrationWarning>{formatRelative(branch.lastCommitAt)}</span>
<span suppressHydrationWarning>
{formatRelative(branch.lastCommitAt)}
</span>
) : null}
{branch.sha ? (
<span className="font-mono text-slate-600">{branch.sha.slice(0, 7)}</span>
<span className="font-mono text-slate-600">
{branch.sha.slice(0, 7)}
</span>
) : null}
</div>
</Row>
@@ -709,15 +797,23 @@ function TagList({ tags }: { tags: TagInfo[] }) {
<Row key={tag.name}>
<div className="flex min-w-0 items-center gap-2">
<Tag className="h-3.5 w-3.5 shrink-0 text-slate-600" />
<span className="truncate font-mono text-xs text-slate-200">{tag.name}</span>
<span className="truncate font-mono text-xs text-slate-200">
{tag.name}
</span>
{tag.message ? (
<span className="truncate text-[11px] text-slate-500">{tag.message}</span>
<span className="truncate text-[11px] text-slate-500">
{tag.message}
</span>
) : null}
</div>
<div className="flex shrink-0 items-center gap-3 text-[11px] text-slate-500">
{tag.date ? <span suppressHydrationWarning>{formatRelative(tag.date)}</span> : null}
{tag.date ? (
<span suppressHydrationWarning>{formatRelative(tag.date)}</span>
) : null}
{tag.sha ? (
<span className="font-mono text-slate-600">{tag.sha.slice(0, 7)}</span>
<span className="font-mono text-slate-600">
{tag.sha.slice(0, 7)}
</span>
) : null}
</div>
</Row>
@@ -735,7 +831,9 @@ function CommitList({ commits }: { commits: CommitInfo[] }) {
<div className="min-w-0">
<p className="truncate text-xs text-slate-200">{commit.message}</p>
<p className="mt-0.5 flex items-center gap-2 text-[11px] text-slate-500">
<span className="font-mono text-violet-300/80">{commit.shortSha}</span>
<span className="font-mono text-violet-300/80">
{commit.shortSha}
</span>
<span>{commit.author ?? "unbekannt"}</span>
</p>
</div>
@@ -760,14 +858,17 @@ function CommitList({ commits }: { commits: CommitInfo[] }) {
}
function ReleaseList({ releases }: { releases: ReleaseInfo[] }) {
if (releases.length === 0) return <Empty text="Keine Releases veroeffentlicht." />;
if (releases.length === 0)
return <Empty text="Keine Releases veroeffentlicht." />;
return (
<div>
{releases.map((release) => (
<Row key={`${release.tag}-${release.name}`}>
<div className="flex min-w-0 items-center gap-2">
<Package className="h-3.5 w-3.5 shrink-0 text-slate-600" />
<span className="truncate text-xs text-slate-200">{release.name}</span>
<span className="truncate text-xs text-slate-200">
{release.name}
</span>
<span className="shrink-0 rounded bg-white/6 px-1.5 py-0.5 font-mono text-[10px] text-slate-400">
{release.tag}
</span>
@@ -783,7 +884,9 @@ function ReleaseList({ releases }: { releases: ReleaseInfo[] }) {
) : null}
</div>
<div className="flex shrink-0 items-center gap-2 text-[11px] text-slate-500">
<span suppressHydrationWarning>{formatRelative(release.publishedAt)}</span>
<span suppressHydrationWarning>
{formatRelative(release.publishedAt)}
</span>
{release.url ? (
<a
href={release.url}
@@ -801,3 +904,58 @@ function ReleaseList({ releases }: { releases: ReleaseInfo[] }) {
</div>
);
}
/**
* 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 (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-3 py-2.5 text-sm text-amber-100">
<p className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
Beim letzten Abgleich nicht geholt:{" "}
<strong>{felder.join(", ")}</strong>. Dafuer steht hier weiterhin der
vorherige Stand
{snapshot.completeAt ? (
<>
{" "}
von{" "}
<span suppressHydrationWarning>
{formatDateTime(snapshot.completeAt)}
</span>
</>
) : null}
.
</span>
</p>
<ul className="mt-2 space-y-0.5 pl-6 text-[11px] text-amber-200/90">
{felder.map((feld) => (
<li key={feld}>
<span className="font-mono">{feld}</span> — {fehlend[feld]}
</li>
))}
</ul>
</div>
);
}
+105 -35
View File
@@ -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<void> {
async setIssueState(
ref: RepoRef,
number: number,
open: boolean,
): Promise<void> {
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<GiteaBranch[]>(`${base}/branches?limit=50`, [], { headers }),
getJsonSafe<GiteaTag[]>(`${base}/tags?limit=25`, [], { headers }),
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
getJsonSafe<GiteaCommit[]>(`${base}/commits?limit=100&stat=false`, [], { headers }),
getJsonSafe<GiteaRelease[]>(`${base}/releases?limit=10`, [], { headers }),
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
getJsonSafe<GiteaPull[]>(`${base}/pulls?state=open&limit=50`, [], { headers }),
// type=issues, sonst kommen bei Gitea die Pull Requests mit.
getJsonSafe<GiteaIssue[]>(`${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<GiteaBranch[]>("branches", `${base}/branches?limit=50`, [], {
headers,
}),
teil.hol<GiteaTag[]>("tags", `${base}/tags?limit=25`, [], { headers }),
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
teil.hol<GiteaCommit[]>(
"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<GiteaCombinedStatus>(
`${base}/commits/${encodeURIComponent(defaultBranch)}/status`,
{} as GiteaCombinedStatus,
{ headers },
)
: Promise.resolve({} as GiteaCombinedStatus),
]);
},
),
teil.hol<GiteaRelease[]>("releases", `${base}/releases?limit=10`, [], {
headers,
}),
teil.hol<Record<string, number>>(
"languages",
`${base}/languages`,
{},
{ headers },
),
teil.hol<GiteaPull[]>(
"pullRequests",
`${base}/pulls?state=open&limit=50`,
[],
{
headers,
},
),
// type=issues, sonst kommen bei Gitea die Pull Requests mit.
teil.hol<GiteaIssue[]>(
"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<GiteaCombinedStatus>(
"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;
+127 -38
View File
@@ -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<void> {
async setIssueState(
ref: RepoRef,
number: number,
open: boolean,
): Promise<void> {
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<ConnectionCheck> {
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<RepoListItem[]> {
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<GitHubRepo[]>(
`${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<GitHubBranch[]>(`${base}/branches?per_page=50`, [], { headers }),
getJsonSafe<GitHubTag[]>(`${base}/tags?per_page=25`, [], { headers }),
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
getJsonSafe<GitHubCommit[]>(`${base}/commits?per_page=100`, [], { headers }),
getJsonSafe<GitHubRelease[]>(`${base}/releases?per_page=10`, [], { headers }),
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
getJsonSafe<GitHubPull[]>(`${base}/pulls?state=open&per_page=50`, [], { headers }),
// /issues liefert bei GitHub auch Pull Requests – die filtern wir raus.
getJsonSafe<GitHubIssue[]>(`${base}/issues?state=open&per_page=50`, [], { headers }),
getJsonSafe<{ workflow_runs?: GitHubRun[] }>(
`${base}/actions/runs?per_page=1&branch=${encodeURIComponent(defaultBranch)}`,
{},
{ headers },
),
]);
// 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<GitHubBranch[]>("branches", `${base}/branches?per_page=50`, [], {
headers,
}),
teil.hol<GitHubTag[]>("tags", `${base}/tags?per_page=25`, [], {
headers,
}),
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
teil.hol<GitHubCommit[]>("commits", `${base}/commits?per_page=100`, [], {
headers,
}),
teil.hol<GitHubRelease[]>(
"releases",
`${base}/releases?per_page=10`,
[],
{ headers },
),
teil.hol<Record<string, number>>(
"languages",
`${base}/languages`,
{},
{ headers },
),
teil.hol<GitHubPull[]>(
"pullRequests",
`${base}/pulls?state=open&per_page=50`,
[],
{ headers },
),
// /issues liefert bei GitHub auch Pull Requests – die filtern wir raus.
teil.hol<GitHubIssue[]>(
"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<CommitCalendar | null> {
const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
const wochen = await getJson<GitHubWeek[]>(`${base}/stats/commit_activity`, {
headers: this.headers(),
});
const wochen = await getJson<GitHubWeek[]>(
`${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<GitHubContent>(`${base}/readme`, { headers: this.headers() });
datei = await getJson<GitHubContent>(`${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":
+133 -38
View File
@@ -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<void> {
async setIssueState(
ref: RepoRef,
number: number,
open: boolean,
): Promise<void> {
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<ConnectionCheck> {
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<GitLabProject[]>(url, { headers: this.headers() });
const projects = await getJson<GitLabProject[]>(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<GitLabProject>(`${base}?statistics=true`, { headers });
const project = await getJson<GitLabProject>(`${base}?statistics=true`, {
headers,
});
const defaultBranch = project.default_branch ?? "";
const [branches, tags, commits, releases, languages, mergeRequests, issues, pipelines] =
await Promise.all([
getJsonSafe<GitLabBranch[]>(`${base}/repository/branches?per_page=50`, [], { headers }),
getJsonSafe<GitLabTag[]>(`${base}/repository/tags?per_page=25`, [], { headers }),
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
getJsonSafe<GitLabCommit[]>(`${base}/repository/commits?per_page=100`, [], { headers }),
getJsonSafe<GitLabRelease[]>(`${base}/releases?per_page=10`, [], { headers }),
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
getJsonSafe<GitLabMergeRequest[]>(`${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<GitLabBranch[]>(
"branches",
`${base}/repository/branches?per_page=50`,
[],
{ headers },
),
teil.hol<GitLabTag[]>("tags", `${base}/repository/tags?per_page=25`, [], {
headers,
}),
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
teil.hol<GitLabCommit[]>(
"commits",
`${base}/repository/commits?per_page=100`,
[],
{ headers },
),
teil.hol<GitLabRelease[]>(
"releases",
`${base}/releases?per_page=10`,
[],
{ headers },
),
teil.hol<Record<string, number>>(
"languages",
`${base}/languages`,
{},
{ headers },
),
teil.hol<GitLabMergeRequest[]>(
"pullRequests",
`${base}/merge_requests?state=opened&per_page=50`,
[],
{
headers,
}),
getJsonSafe<GitLabIssue[]>(`${base}/issues?state=opened&per_page=50`, [], { headers }),
getJsonSafe<GitLabPipeline[]>(
`${base}/pipelines?per_page=1${defaultBranch ? `&ref=${encodeURIComponent(defaultBranch)}` : ""}`,
[],
{ headers },
),
]);
},
),
teil.hol<GitLabIssue[]>(
"issues",
`${base}/issues?state=opened&per_page=50`,
[],
{ headers },
),
teil.hol<GitLabPipeline[]>(
"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<GitLabProject>(base, {} as GitLabProject, { headers });
const projekt = await getJsonSafe<GitLabProject>(
base,
{} as GitLabProject,
{ headers },
);
return {
content,
+127
View File
@@ -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();
});
});
+106 -4
View File
@@ -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<string, string>;
@@ -73,7 +103,11 @@ export async function getJson<T>(
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<T>(
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<T>(
}
}
/** 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<T>(
url: string,
fallback: T,
@@ -189,6 +234,63 @@ export async function getJsonSafe<T>(
}
}
/** Warum ein einzelner Teil-Abruf nicht geklappt hat. */
export type Teilfehler = Record<string, string>;
/**
* 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<T>(
feld: string,
url: string,
fallback: T,
options: FetchOptions = {},
): Promise<T> {
try {
const result = await getJson<T>(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));
+9
View File
@@ -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<string, string>;
}
/** Tage, ueber die die Commit-Aktivitaet gebildet wird. */
+116 -31
View File
@@ -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<SyncResult> {
export async function syncRepository(
repositoryId: string,
): Promise<SyncResult> {
const repository = await prisma.repository.findUnique({
where: { id: repositoryId },
include: { connection: true },
@@ -33,7 +44,8 @@ export async function syncRepository(repositoryId: string): Promise<SyncResult>
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<SyncResult>
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<SyncResult>
// 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<ProjectSyncResult> {
export async function syncProject(
projectId: string,
): Promise<ProjectSyncResult> {
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 = <T>(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.
+17 -2
View File
@@ -47,7 +47,10 @@ export interface ConnectionWithCount extends ConnectionSafe {
* repositorySafeSelect. Ob einer eingerichtet ist, reicht die Projektseite
* getrennt durch.
*/
export interface RepositoryWithSnapshot extends Omit<Repository, "webhookSecret"> {
export interface RepositoryWithSnapshot extends Omit<
Repository,
"webhookSecret"
> {
connection: ConnectionSafe | null;
snapshot: RepoSnapshot | null;
}
@@ -159,7 +162,8 @@ export function asArray<T>(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<CommitCalendar>;
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<string, number> {
}
return {};
}
/**
* Wie asRecord, aber fuer Text – etwa `missing` am Snapshot, das je Feld
* den Grund des Fehlschlags traegt.
*/
export function asTextRecord(value: unknown): Record<string, string> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, string>;
}
return {};
}