Compare commits
6
Commits
97ba863356
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aeb6c69a71 | ||
|
|
8ff526262d | ||
|
|
e1b71f5fb6 | ||
|
|
6c788b3e7d | ||
|
|
76c064bd31 | ||
|
|
abd9f73405 |
+10
-1
@@ -559,6 +559,15 @@ Diese Listen werden gleichzeitig geholt, und einzelne davon können scheitern, w
|
||||
der Rest durchgeht — ein 403 wegen fehlender Scopes, ein Rate-Limit, eine
|
||||
Zeitüberschreitung.
|
||||
|
||||
**Ein zweiter Versuch, bevor etwas als fehlend gilt.** Zeitüberschreitungen und
|
||||
Verbindungen, die gar nicht zustande kamen, werden einmal nachgeschlagen — einzeln
|
||||
statt im Schwung, weil genau das der beobachtete Fall war: zwei von acht Aufrufen
|
||||
liefen in eine Zeitüberschreitung nach 15 Sekunden, während dieselben Aufrufe einzeln
|
||||
in 80 Millisekunden antworteten. Nachgeschlagen wird höchstens zweimal je Abgleich —
|
||||
acht Zeitüberschreitungen hintereinander wären zwei Minuten. Bei `403`, `404` und `429`
|
||||
gibt es keinen zweiten Versuch: fehlende Rechte werden durch Wiederholen nicht besser,
|
||||
ein Rate-Limit wird schlimmer.
|
||||
|
||||
**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:
|
||||
@@ -575,7 +584,7 @@ Das gilt ausdrücklich **nicht** als Fehlschlag: `syncError` bleibt leer, der Z
|
||||
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
|
||||
> Vor v1.11.0 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.
|
||||
|
||||
+4
-3
@@ -7,7 +7,7 @@ Stück für Stück abarbeiten. Reihenfolge ist ein Vorschlag, keine Festlegung.
|
||||
|
||||
Was bereits steht, listet [FEATURES.md](FEATURES.md).
|
||||
|
||||
**Stand:** v1.10.1. Alle Issues aus dem Dashboard sind abgearbeitet.
|
||||
**Stand:** v1.11.0. Alle Issues aus dem Dashboard sind abgearbeitet.
|
||||
|
||||
---
|
||||
|
||||
@@ -66,7 +66,7 @@ Voraussetzung, falls du das Projekt veröffentlichen willst.
|
||||
|
||||
## 3 · Aufgefallen beim Bauen
|
||||
|
||||
Beobachtungen aus der Arbeit an v1.6.0 bis v1.10.1 — festgehalten, damit sie
|
||||
Beobachtungen aus der Arbeit an v1.6.0 bis v1.11.0 — festgehalten, damit sie
|
||||
nicht verloren gehen. Nichts davon ist dringend.
|
||||
|
||||
### 3.1 Anwendungsname bricht in der Kopfleiste ab `S`
|
||||
@@ -108,7 +108,7 @@ Beide stehen im Änderungsverlauf, existierten aber nie in der
|
||||
`package.json` — dort springt die Historie von 0.1.0 auf 0.4.0. Nachgetragen
|
||||
wurden sie in dem Commit, der den Verlauf einführte, und der liegt *nach*
|
||||
0.4.0. Es gibt also keinen ehrlichen Commit, an dem ein Tag hängen könnte.
|
||||
Die übrigen 19 Fassungen haben seit dem 18.08.2026 ein Release in Gitea.
|
||||
Die übrigen 20 Fassungen haben seit dem 18.08.2026 ein Release in Gitea.
|
||||
|
||||
---
|
||||
|
||||
@@ -179,6 +179,7 @@ Damit klar ist, was **nicht** kommt und warum:
|
||||
| **v1.9.0** Geschlossene Issues haken ihre Aufgabe auch ohne Webhook ab | ✓ |
|
||||
| **v1.10.0** Wochenrückblick, globale Zeitleiste, Suche über Aufgaben | ✓ |
|
||||
| **v1.10.1** Issue-Kennzeichen auf der Aufgabenkarte, Token-Rechte Aufruf für Aufruf belegt | ✓ |
|
||||
| **v1.11.0** Karten je Spalte begrenzen; der Abgleich verschluckt keine Fehler mehr und verliert dabei nichts | ✓ |
|
||||
|
||||
Zur Entscheidung beim Export: Git-Tokens gehen **nicht** mit. Die Verbindung wird nur
|
||||
beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "nebula",
|
||||
"version": "1.10.1",
|
||||
"version": "1.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nebula",
|
||||
"version": "1.10.1",
|
||||
"version": "1.11.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nebula",
|
||||
"version": "1.10.1",
|
||||
"version": "1.11.0",
|
||||
"private": true,
|
||||
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
|
||||
"scripts": {
|
||||
|
||||
@@ -41,6 +41,64 @@ export const KIND_META: Record<ChangeKind, { label: string; className: string }>
|
||||
};
|
||||
|
||||
export const CHANGELOG: Release[] = [
|
||||
{
|
||||
version: "1.11.0",
|
||||
date: "2026-09-10",
|
||||
headline: "Der Abgleich sagt, was er nicht holen konnte — und verliert dabei nichts mehr.",
|
||||
changes: [
|
||||
{
|
||||
kind: "behoben",
|
||||
text:
|
||||
"Ein fehlgeschlagener Teil-Abruf sah aus wie ein leeres Ergebnis. Ein Repository " +
|
||||
"mit 65 Commits meldete „Snapshot aktualisiert: 0 Branches, 0 Tags“ und überschrieb " +
|
||||
"dabei den letzten guten Stand. Jetzt steht am Repository, welcher Teil nicht kam " +
|
||||
"und woran es lag — und die vorhandenen Daten bleiben stehen.",
|
||||
},
|
||||
{
|
||||
kind: "behoben",
|
||||
text:
|
||||
"Die Commit-Heatmap kannte bei Gitea nur die jüngsten 50 Commits und gab deren " +
|
||||
"ältesten als Anfang des Zeitraums aus. Grund war eine Seitengröße, die angefragt " +
|
||||
"statt gemessen wurde: Gitea deckelt jede Antwort auf 50, egal was dasteht. Bei " +
|
||||
"einem Repository mit 76 Commits fehlte damit ein Drittel.",
|
||||
},
|
||||
{
|
||||
kind: "besser",
|
||||
text:
|
||||
"Ein Abruf, der in eine Zeitüberschreitung läuft, wird einmal nachgeschlagen — " +
|
||||
"einzeln statt zusammen mit den sieben anderen. Bei fehlenden Rechten oder einem " +
|
||||
"Rate-Limit passiert das bewusst nicht; das wird durch Wiederholen nicht besser.",
|
||||
},
|
||||
{
|
||||
kind: "neu",
|
||||
text:
|
||||
"Das Aufgabenbrett zeigt je Spalte höchstens so viele Karten, wie unter " +
|
||||
"Admin-Einstellungen eingestellt ist. Der Rest steht hinter „n weitere anzeigen“; " +
|
||||
"die Zahl an der Spalte nennt weiterhin alle.",
|
||||
},
|
||||
{
|
||||
kind: "neu",
|
||||
text:
|
||||
"Eine Aufgabenkarte trägt die Nummer ihres verknüpften Git-Issues. Bisher stand " +
|
||||
"die Verknüpfung nur im Bearbeiten-Dialog.",
|
||||
},
|
||||
{
|
||||
kind: "besser",
|
||||
text:
|
||||
"Die Rechte, die eine Git-Verbindung braucht, stehen Aufruf für Aufruf in der " +
|
||||
"README — getrennt nach reinem Abgleich und zusätzlicher Issue-Anbindung. Der " +
|
||||
"Hinweis unter dem Token-Feld nannte die Rechte für Actions nicht, ohne die bei " +
|
||||
"GitHub kein CI-Zustand ankommt.",
|
||||
},
|
||||
{
|
||||
kind: "behoben",
|
||||
text:
|
||||
"Ein unbrauchbarer Wert in GIT_API_TIMEOUT_MS legte den ganzen Abgleich still: " +
|
||||
"eine leere oder unlesbare Angabe wurde zu 0 ms, und damit brach jeder Aufruf ab, " +
|
||||
"bevor er begann. Jetzt gilt die Vorgabe, mit Warnung im Log.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "1.10.1",
|
||||
date: "2026-08-18",
|
||||
|
||||
@@ -18,6 +18,19 @@ import {
|
||||
const JETZT = new Date("2026-07-26T12:00:00.000Z");
|
||||
const TAG = 86_400_000;
|
||||
|
||||
/**
|
||||
* Eine Quelle, die es nur eine Seite lang gibt.
|
||||
*
|
||||
* Diese Tests pruefen das Zaehlen und Filtern, nicht das Blaettern. Ihr
|
||||
* Fixture lieferte urspruenglich fuer *jede* Seite dieselben Daten und hielt
|
||||
* nur deshalb an, weil die alte Regel eine kurze Seite fuer die letzte hielt
|
||||
* – genau der Fehler, um den es hier geht. Kein Server verhaelt sich so;
|
||||
* nach der letzten Seite kommt nichts mehr.
|
||||
*/
|
||||
function eineSeite(daten: Array<string | null>) {
|
||||
return async (seite: number) => (seite === 1 ? daten : []);
|
||||
}
|
||||
|
||||
/** Erzeugt n Commit-Zeitpunkte, beginnend vor `abTagen` Tagen, einer je Tag. */
|
||||
function commits(anzahl: number, abTagen: number): string[] {
|
||||
return Array.from({ length: anzahl }, (_, index) =>
|
||||
@@ -45,14 +58,14 @@ describe("calendarByPaging", () => {
|
||||
|
||||
it("zaehlt mehrere Commits desselben Tages zusammen", async () => {
|
||||
const heute = JETZT.toISOString();
|
||||
const kalender = await calendarByPaging(async () => [heute, heute, heute], JETZT);
|
||||
const kalender = await calendarByPaging(eineSeite([heute, heute, heute]), JETZT);
|
||||
expect(kalender?.days["2026-07-26"]).toBe(3);
|
||||
expect(calendarTotal(kalender!)).toBe(3);
|
||||
});
|
||||
|
||||
it("beginnt beim aeltesten bekannten Commit, nicht vor einem Jahr", async () => {
|
||||
// Nur eine halbe Seite, zehn Tage zurueck: alles davor ist unbekannt.
|
||||
const kalender = await calendarByPaging(async () => commits(10, 0), JETZT);
|
||||
const kalender = await calendarByPaging(eineSeite(commits(10, 0)), JETZT);
|
||||
expect(kalender?.from).toBe("2026-07-17");
|
||||
expect(kalender?.to).toBe("2026-07-26");
|
||||
expect(kalender?.truncated).toBe(false);
|
||||
@@ -93,10 +106,10 @@ describe("calendarByPaging", () => {
|
||||
it("uebergeht Commits mit Datum in der Zukunft", async () => {
|
||||
// Falsch gestellte Uhren gibt es; sie duerfen den Zeitraum nicht verlaengern.
|
||||
const kalender = await calendarByPaging(
|
||||
async () => [
|
||||
eineSeite([
|
||||
new Date(JETZT.getTime() + 5 * TAG).toISOString(),
|
||||
JETZT.toISOString(),
|
||||
],
|
||||
]),
|
||||
JETZT,
|
||||
);
|
||||
expect(kalender?.to).toBe("2026-07-26");
|
||||
@@ -105,7 +118,7 @@ describe("calendarByPaging", () => {
|
||||
|
||||
it("uebergeht Unbrauchbares, statt daran zu scheitern", async () => {
|
||||
const kalender = await calendarByPaging(
|
||||
async () => [null, "gestern", "", JETZT.toISOString()],
|
||||
eineSeite([null, "gestern", "", JETZT.toISOString()]),
|
||||
JETZT,
|
||||
);
|
||||
expect(calendarTotal(kalender!)).toBe(1);
|
||||
@@ -114,9 +127,66 @@ describe("calendarByPaging", () => {
|
||||
it("laesst Tage ohne Commits weg", async () => {
|
||||
// Ein Jahr als Liste waeren 365 Eintraege, die meisten davon Nullen.
|
||||
const kalender = await calendarByPaging(
|
||||
async () => [JETZT.toISOString(), new Date(JETZT.getTime() - 30 * TAG).toISOString()],
|
||||
eineSeite([JETZT.toISOString(), new Date(JETZT.getTime() - 30 * TAG).toISOString()]),
|
||||
JETZT,
|
||||
);
|
||||
expect(Object.keys(kalender!.days)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Gitea deckelt jede Antwort auf MAX_RESPONSE_ITEMS (Voreinstellung 50),
|
||||
* egal was angefragt wurde. Wer gegen die angefragten 100 vergleicht, haelt
|
||||
* dort jede erste Seite fuer die letzte: die Heatmap kannte nur die
|
||||
* juengsten 50 Commits und gab deren aeltesten als Anfang des Zeitraums aus.
|
||||
*/
|
||||
describe("calendarByPaging gegen eine deckelnde Gegenseite", () => {
|
||||
/** Antwortet nie mit mehr als `deckel` Eintraegen – wie Gitea. */
|
||||
function gedeckelteQuelle(gesamt: number, deckel: number) {
|
||||
const alle = commits(gesamt, 0);
|
||||
return vi.fn(async (seite: number) =>
|
||||
alle.slice((seite - 1) * deckel, seite * deckel),
|
||||
);
|
||||
}
|
||||
|
||||
it("blaettert weiter, obwohl die Seite kleiner ist als angefragt", async () => {
|
||||
// 76 Commits bei einem Deckel von 50 – der Fall aus florian.wilde/flimmer.
|
||||
const ladeSeite = gedeckelteQuelle(76, 50);
|
||||
const kalender = await calendarByPaging(ladeSeite, JETZT);
|
||||
|
||||
// Vorher: eine Seite, 50 Commits, "seit" beim 50. – und truncated false.
|
||||
expect(ladeSeite).toHaveBeenCalledTimes(2);
|
||||
expect(calendarTotal(kalender!)).toBe(76);
|
||||
expect(kalender?.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it("nimmt die erste Antwort als Maßstab, nicht die angefragte Groesse", async () => {
|
||||
// Selbst ein sehr kleiner Deckel darf nicht dazu fuehren, dass nach der
|
||||
// ersten Seite Schluss ist.
|
||||
const ladeSeite = gedeckelteQuelle(25, 10);
|
||||
const kalender = await calendarByPaging(ladeSeite, JETZT);
|
||||
|
||||
expect(ladeSeite).toHaveBeenCalledTimes(3);
|
||||
expect(calendarTotal(kalender!)).toBe(25);
|
||||
});
|
||||
|
||||
it("haelt den Deckel weiterhin ein", async () => {
|
||||
// Mehr Commits als CALENDAR_MAX_PAGES Seiten fassen: abbrechen, aber
|
||||
// ehrlich sein.
|
||||
const ladeSeite = gedeckelteQuelle(50 * CALENDAR_MAX_PAGES + 20, 50);
|
||||
const kalender = await calendarByPaging(ladeSeite, JETZT);
|
||||
|
||||
expect(ladeSeite).toHaveBeenCalledTimes(CALENDAR_MAX_PAGES);
|
||||
expect(kalender?.truncated).toBe(true);
|
||||
expect(calendarTotal(kalender!)).toBe(50 * CALENDAR_MAX_PAGES);
|
||||
});
|
||||
|
||||
it("meldet keine Kuerzung, wenn die Daten einfach zu Ende sind", async () => {
|
||||
// Genau eine volle Seite und dann nichts mehr: das ist kein Deckel.
|
||||
const ladeSeite = gedeckelteQuelle(50, 50);
|
||||
const kalender = await calendarByPaging(ladeSeite, JETZT);
|
||||
|
||||
expect(ladeSeite).toHaveBeenCalledTimes(2);
|
||||
expect(kalender?.truncated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+31
-4
@@ -16,11 +16,20 @@
|
||||
export const CALENDAR_DAYS = 365;
|
||||
|
||||
/**
|
||||
* Hoechstzahl Seiten, wo seitenweise geholt werden muss. Fuenf Seiten sind
|
||||
* 500 Commits – genug fuer die allermeisten Repositories und wenig genug,
|
||||
* um kein fremdes Kontingent zu sprengen.
|
||||
* Hoechstzahl Seiten, wo seitenweise geholt werden muss. Genug fuer die
|
||||
* allermeisten Repositories und wenig genug, um kein fremdes Kontingent zu
|
||||
* sprengen.
|
||||
*/
|
||||
export const CALENDAR_MAX_PAGES = 5;
|
||||
|
||||
/**
|
||||
* Angefragte Seitengroesse – eine Bitte, keine Zusage.
|
||||
*
|
||||
* Gitea antwortet hoechstens mit MAX_RESPONSE_ITEMS Eintraegen
|
||||
* (Voreinstellung 50), egal was hier steht; GitHub und GitLab erlauben 100.
|
||||
* Wie viele wirklich kommen, entscheidet deshalb erst die erste Antwort –
|
||||
* siehe calendarByPaging.
|
||||
*/
|
||||
export const CALENDAR_PAGE_SIZE = 100;
|
||||
|
||||
export interface CommitCalendar {
|
||||
@@ -55,6 +64,15 @@ export function calendarSince(now: Date = new Date()): Date {
|
||||
* Abgebrochen wird, sobald eine Seite nicht mehr voll ist (dann gibt es
|
||||
* nichts weiter), sobald die Seite vollstaendig aus der Zeit vor dem
|
||||
* Zeitraum stammt, oder wenn der Deckel greift.
|
||||
*
|
||||
* "Voll" bemisst sich an der **ersten geliefertern Seite**, nicht an dem, was
|
||||
* angefragt wurde. Gitea deckelt jede Antwort auf MAX_RESPONSE_ITEMS
|
||||
* (Voreinstellung 50), unabhaengig vom angefragten limit. Wer gegen die
|
||||
* angefragten 100 vergleicht, haelt bei Gitea jede erste Seite fuer die
|
||||
* letzte – die Heatmap kannte deshalb nur die juengsten 50 Commits und gab
|
||||
* deren aeltesten als Anfang des Zeitraums aus. Bei einem Repository mit 76
|
||||
* Commits stand dort "50 an 2 Tagen seit dem 01.09.", und das las sich wie
|
||||
* eine Aussage ueber das Repository statt ueber die Seitengroesse.
|
||||
*/
|
||||
export async function calendarByPaging(
|
||||
ladeSeite: (seite: number) => Promise<Array<string | null>>,
|
||||
@@ -64,11 +82,18 @@ export async function calendarByPaging(
|
||||
const days: Record<string, number> = {};
|
||||
let aeltester: number | null = null;
|
||||
let gedeckelt = false;
|
||||
/** Was die Gegenseite tatsaechlich als volle Seite ansieht. */
|
||||
let seitengroesse: number | null = null;
|
||||
|
||||
for (let seite = 1; seite <= CALENDAR_MAX_PAGES; seite += 1) {
|
||||
const daten = await ladeSeite(seite);
|
||||
if (daten.length === 0) break;
|
||||
|
||||
// Die erste Antwort setzt den Maßstab. Sie kann kleiner ausfallen als
|
||||
// angefragt; entscheidend ist nur, dass die folgenden Seiten daran
|
||||
// gemessen werden.
|
||||
if (seitengroesse === null) seitengroesse = daten.length;
|
||||
|
||||
let innerhalb = 0;
|
||||
for (const value of daten) {
|
||||
if (!value) continue;
|
||||
@@ -85,10 +110,12 @@ export async function calendarByPaging(
|
||||
}
|
||||
|
||||
// Nicht mehr voll: das war die letzte Seite.
|
||||
if (daten.length < CALENDAR_PAGE_SIZE) break;
|
||||
if (daten.length < seitengroesse) break;
|
||||
// Die ganze Seite lag schon vor dem Zeitraum – weiter zurueck lohnt nicht.
|
||||
if (innerhalb === 0) break;
|
||||
|
||||
// Letzte erlaubte Seite war voll und lag im Zeitraum: es gibt Aelteres,
|
||||
// das wir nicht geholt haben. Das gehoert an die Anzeige.
|
||||
if (seite === CALENDAR_MAX_PAGES) gedeckelt = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -187,6 +187,11 @@ export class GiteaClient implements GitClient {
|
||||
}),
|
||||
teil.hol<GiteaTag[]>("tags", `${base}/tags?limit=25`, [], { headers }),
|
||||
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
||||
// Gitea liefert davon hoechstens MAX_RESPONSE_ITEMS (Voreinstellung 50).
|
||||
// Das ist hier hinnehmbar - die Kurve kuerzt sich ehrlich auf den
|
||||
// tatsaechlich belegten Zeitraum (siehe bucketCommitActivity), und
|
||||
// blaettern wuerde jeden Abgleich teurer machen. Beim Kalender, der
|
||||
// hoechstens einmal am Tag laeuft, wird dagegen geblaettert.
|
||||
teil.hol<GiteaCommit[]>(
|
||||
"commits",
|
||||
`${base}/commits?limit=100&stat=false`,
|
||||
|
||||
@@ -185,6 +185,8 @@ export class GitHubClient implements GitClient {
|
||||
headers,
|
||||
}),
|
||||
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
||||
// Kommen weniger an, kuerzt sich die Kurve ehrlich auf den tatsaechlich
|
||||
// belegten Zeitraum (siehe bucketCommitActivity).
|
||||
teil.hol<GitHubCommit[]>("commits", `${base}/commits?per_page=100`, [], {
|
||||
headers,
|
||||
}),
|
||||
|
||||
@@ -189,6 +189,8 @@ export class GitLabClient implements GitClient {
|
||||
headers,
|
||||
}),
|
||||
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve.
|
||||
// Kommen weniger an, kuerzt sich die Kurve ehrlich auf den tatsaechlich
|
||||
// belegten Zeitraum (siehe bucketCommitActivity).
|
||||
teil.hol<GitLabCommit[]>(
|
||||
"commits",
|
||||
`${base}/repository/commits?per_page=100`,
|
||||
|
||||
@@ -51,6 +51,9 @@ describe("Teilabrufe", () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
// Ohne das zaehlen die Spies ueber Tests hinweg weiter – und genau an
|
||||
// Aufrufzahlen haengen die Pruefungen hier.
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("meldet nichts, solange alles ankommt", async () => {
|
||||
@@ -125,3 +128,138 @@ describe("Teilabrufe", () => {
|
||||
expect(teil.fehler.commits).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Der zweite Versuch.
|
||||
*
|
||||
* Beobachtet an einer laufenden Instanz: zwei von acht Teil-Abrufen liefen
|
||||
* in eine Zeitueberschreitung nach 15 s, waehrend dieselben Aufrufe von
|
||||
* aussen einzeln in 80 ms antworteten. Nicht die Endpunkte sind langsam –
|
||||
* die Achterrunde erwuergt sich gelegentlich selbst.
|
||||
*/
|
||||
describe("Teilabrufe: Nachschlag", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("GIT_ALLOWED_HOSTS", "git.example.com");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
// Ohne das zaehlen die Spies ueber Tests hinweg weiter – und genau an
|
||||
// Aufrufzahlen haengen die Pruefungen hier.
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/** Antwortet die ersten `fehlschlaege` Male mit Abbruch, danach mit Daten. */
|
||||
function zaehlend(fehlschlaege: number, status = 408) {
|
||||
let aufruf = 0;
|
||||
return vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
|
||||
aufruf += 1;
|
||||
if (aufruf <= fehlschlaege) {
|
||||
if (status === 408) {
|
||||
const abbruch = new Error("aborted");
|
||||
abbruch.name = "AbortError";
|
||||
throw abbruch;
|
||||
}
|
||||
return new Response("nope", { status });
|
||||
}
|
||||
return new Response(JSON.stringify(["da"]), { status: 200 });
|
||||
});
|
||||
}
|
||||
|
||||
it("holt eine Zeitueberschreitung im zweiten Versuch nach", async () => {
|
||||
const fetchSpy = zaehlend(1);
|
||||
const teil = new Teilabrufe();
|
||||
|
||||
const wert = await teil.hol("branches", "https://git.example.com/a", []);
|
||||
|
||||
expect(wert).toEqual(["da"]);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
// Geglueckt heisst: kein Eintrag. Sonst stuende am Repository ein
|
||||
// Fehler, den es nicht mehr gibt.
|
||||
expect(teil.fehler).toEqual({});
|
||||
expect(teil.unvollstaendig).toBe(false);
|
||||
});
|
||||
|
||||
it("vermerkt es, wenn auch der zweite Versuch scheitert", async () => {
|
||||
const fetchSpy = zaehlend(99);
|
||||
const teil = new Teilabrufe();
|
||||
|
||||
const wert = await teil.hol("commits", "https://git.example.com/a", []);
|
||||
|
||||
expect(wert).toEqual([]);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
expect(teil.fehler.commits).toContain("408");
|
||||
expect(teil.fehler.commits).toContain("zweiten Versuch");
|
||||
});
|
||||
|
||||
it("schlaegt bei 403 gar nicht erst nach", async () => {
|
||||
// Fehlende Scopes werden durch Wiederholen nicht besser.
|
||||
const fetchSpy = zaehlend(99, 403);
|
||||
const teil = new Teilabrufe();
|
||||
|
||||
await teil.hol("tags", "https://git.example.com/a", []);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(teil.fehler.tags).not.toContain("zweiten Versuch");
|
||||
});
|
||||
|
||||
it("schlaegt bei 429 nicht nach", async () => {
|
||||
// Sofortiges Wiederholen macht ein Rate-Limit schlimmer, nicht besser.
|
||||
const fetchSpy = zaehlend(99, 429);
|
||||
const teil = new Teilabrufe();
|
||||
|
||||
await teil.hol("issues", "https://git.example.com/a", []);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("gibt hoechstens zwei Nachschlaege je Abgleich", async () => {
|
||||
// Acht Zeitueberschreitungen zu je 15 s waeren zwei Minuten. Wer weg
|
||||
// ist, soll schnell als weg erkannt werden.
|
||||
const fetchSpy = zaehlend(99);
|
||||
const teil = new Teilabrufe();
|
||||
|
||||
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", []),
|
||||
teil.hol("releases", "https://git.example.com/d", []),
|
||||
]);
|
||||
|
||||
// Vier erste Versuche, aber nur zwei Nachschlaege.
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(6);
|
||||
expect(Object.keys(teil.fehler).sort()).toEqual([
|
||||
"branches",
|
||||
"commits",
|
||||
"releases",
|
||||
"tags",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reisst die Kette nicht ab, wenn ein Nachschlag scheitert", async () => {
|
||||
// Der erste Nachschlag geht schief, der zweite muss trotzdem laufen.
|
||||
let aufruf = 0;
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
|
||||
aufruf += 1;
|
||||
// 1 und 2 sind die ersten Versuche, 3 der erste Nachschlag (scheitert),
|
||||
// 4 der zweite Nachschlag (glueckt).
|
||||
if (aufruf <= 3) {
|
||||
const abbruch = new Error("aborted");
|
||||
abbruch.name = "AbortError";
|
||||
throw abbruch;
|
||||
}
|
||||
return new Response(JSON.stringify(["da"]), { status: 200 });
|
||||
});
|
||||
|
||||
const teil = new Teilabrufe();
|
||||
const [erst, zweit] = await Promise.all([
|
||||
teil.hol("branches", "https://git.example.com/a", []),
|
||||
teil.hol("tags", "https://git.example.com/b", []),
|
||||
]);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(4);
|
||||
expect(erst).toEqual([]);
|
||||
expect(zweit).toEqual(["da"]);
|
||||
expect(Object.keys(teil.fehler)).toEqual(["branches"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -234,6 +234,16 @@ export async function getJsonSafe<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele zweite Versuche ein Abgleich hoechstens unternimmt.
|
||||
*
|
||||
* Zwei, nicht acht: jede Zeitueberschreitung kostet volle 15 Sekunden, und
|
||||
* acht davon hintereinander waeren zwei Minuten, in denen "Jetzt
|
||||
* synchronisieren" wie ein Absturz aussieht. Wer wirklich weg ist, soll
|
||||
* schnell als weg erkannt werden.
|
||||
*/
|
||||
const NACHSCHLAG_MAX = 2;
|
||||
|
||||
/** Warum ein einzelner Teil-Abruf nicht geklappt hat. */
|
||||
export type Teilfehler = Record<string, string>;
|
||||
|
||||
@@ -252,6 +262,18 @@ export type Teilfehler = Record<string, string>;
|
||||
export class Teilabrufe {
|
||||
readonly fehler: Teilfehler = {};
|
||||
|
||||
/**
|
||||
* Nachschlaege haengen aneinander, damit sie nacheinander laufen.
|
||||
*
|
||||
* Die Runde selbst ist gleichzeitig – acht Aufrufe auf einmal. Genau das
|
||||
* scheint sie gelegentlich zu erwuergen: beobachtet wurden zwei von acht
|
||||
* mit Zeitueberschreitung nach 15 s, waehrend dieselben Aufrufe einzeln in
|
||||
* 80 ms antworteten. Der zweite Versuch soll deshalb nicht wieder in
|
||||
* denselben Schwung geraten.
|
||||
*/
|
||||
private kette: Promise<unknown> = Promise.resolve();
|
||||
private nachgeschlagen = 0;
|
||||
|
||||
async hol<T>(
|
||||
feld: string,
|
||||
url: string,
|
||||
@@ -262,11 +284,47 @@ export class Teilabrufe {
|
||||
const result = await getJson<T>(url, options);
|
||||
return (result ?? fallback) as T;
|
||||
} catch (error) {
|
||||
if (this.lohntNachschlag(error)) {
|
||||
const zweiter = await this.nachschlag<T>(url, options);
|
||||
if (zweiter.ok) return (zweiter.wert ?? fallback) as T;
|
||||
this.fehler[feld] = `${beschreibe(zweiter.fehler)} (auch im zweiten Versuch)`;
|
||||
return fallback;
|
||||
}
|
||||
this.fehler[feld] = beschreibe(error);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein zweiter Versuch hilft nur bei Fehlern, die nichts ueber die Sache
|
||||
* aussagen: Zeitueberschreitung (408) und eine Verbindung, die gar nicht
|
||||
* zustande kam (0). Ein 403 wird durch Wiederholen nicht besser, ein 429
|
||||
* durch sofortiges Wiederholen schlimmer, und ein 404 bleibt ein 404.
|
||||
*/
|
||||
private lohntNachschlag(error: unknown): boolean {
|
||||
if (this.nachgeschlagen >= NACHSCHLAG_MAX) return false;
|
||||
if (!(error instanceof GitApiError)) return false;
|
||||
return error.status === 408 || error.status === 0;
|
||||
}
|
||||
|
||||
private async nachschlag<T>(
|
||||
url: string,
|
||||
options: FetchOptions,
|
||||
): Promise<{ ok: boolean; wert?: T; fehler?: unknown }> {
|
||||
this.nachgeschlagen += 1;
|
||||
|
||||
const versuch = this.kette.then(async () => getJson<T>(url, options));
|
||||
// Auch ein Fehlschlag darf die Kette nicht abreissen lassen, sonst
|
||||
// scheitern die folgenden Nachschlaege gleich mit.
|
||||
this.kette = versuch.catch(() => undefined);
|
||||
|
||||
try {
|
||||
return { ok: true, wert: await versuch };
|
||||
} catch (fehler) {
|
||||
return { ok: false, fehler };
|
||||
}
|
||||
}
|
||||
|
||||
/** true, sobald mindestens ein Teil-Abruf gescheitert ist. */
|
||||
get unvollstaendig(): boolean {
|
||||
return Object.keys(this.fehler).length > 0;
|
||||
|
||||
Reference in New Issue
Block a user