6 Commits
Author SHA1 Message Date
florian.wilde aeb6c69a71 Merge: Release v1.11.0
CI / pruefen (push) Canceled after 0s
2026-09-09 22:16:55 +00:00
florian.wilde 8ff526262d chore(release): v1.11.0
CI / pruefen (pull_request) Canceled after 0s
Vier Aenderungen seit 1.10.1, alle bereits auf main. Minor statt Patch,
weil eine neue Funktion dabei ist und zwei Schemaaenderungen mitkommen.

Der Schwerpunkt liegt beim Abgleich, und die drei Punkte dort gehoeren
zusammen: Ein verschluckter Fehler sah aus wie ein leeres Ergebnis (#19),
ueberschrieb dabei den letzten guten Stand (#20), und weil niemand die
Ursache sehen konnte, blieb sie tagelang unentdeckt. Erst als die Fehler
sichtbar waren, liess sich der Rest ueberhaupt benennen: eine Seiten-
groesse, die angefragt statt gemessen wurde (#22), und Zeitueber-
schreitungen, die einen zweiten Versuch verdienen (#23).

Dazu die Karten je Spalte (#16) und das Issue-Kennzeichen (#12) aus den
Fassungen davor.

Verlauf und Versionsnummer wandern zusammen; changelog.test.ts haelt das
fest.

Beim Ausrollen laufen zwei Migrationen mit: taskColumnLimit an
AppSettings und missing/completeAt an RepoSnapshot.

Refs #16
Refs #19
Refs #20
Refs #22
Refs #23
2026-09-10 00:16:26 +02:00
florian.wilde e1b71f5fb6 Merge #23: Haengengebliebene Teil-Abrufe nachschlagen
CI / pruefen (push) Canceled after 0s
2026-09-09 22:12:21 +00:00
florian.wilde 6c788b3e7d Merge #22: Seitengroesse an der Antwort messen
CI / pruefen (push) Canceled after 0s
2026-09-09 22:11:45 +00:00
florian.wilde 76c064bd31 fix(git): haengengebliebene Teil-Abrufe einmal nachschlagen
CI / pruefen (pull_request) Canceled after 0s
Behebt florian.wilde/nebula#23.

Beobachtet an der 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.

Daraus folgt die Maßnahme, und zwar genau diese: was haengen bleibt,
bekommt einen zweiten Versuch, und der laeuft NICHT wieder im Schwung
mit. Die Nachschlaege haengen an einer Kette und gehen nacheinander
hinaus.

Die Aufrufstellen in den drei Clients bleiben unberuehrt - hol() liefert
weiterhin den endgueltigen Wert. Das war der Grund, es in Teilabrufe zu
loesen statt hinter dem Promise.all: dort haette jeder Client acht
Variablen neu zuweisen muessen.

ZWEI GRENZEN, BEIDE ABSICHTLICH

Nachgeschlagen wird nur bei 408 und bei 0 - Zeitueberschreitung und eine
Verbindung, die gar nicht zustande kam. Ein 403 wird durch Wiederholen
nicht besser, ein 429 durch sofortiges Wiederholen schlimmer, ein 404
bleibt ein 404.

Und hoechstens zweimal je Abgleich. Acht Zeitueberschreitungen zu je 15
Sekunden waeren zwei Minuten, in denen "Jetzt synchronisieren" wie ein
Absturz aussieht. Wer weg ist, soll schnell als weg erkannt werden.

Klappt auch der zweite Versuch nicht, steht das am Repository - mit dem
Vermerk "(auch im zweiten Versuch)", damit erkennbar bleibt, dass es kein
Zufall war.

NICHT GEMACHT: die acht Aufrufe entzerren. Das waere eine Vermutung ueber
die Ursache. Bleibt das Problem, sagt der Vermerk genau das, und dann ist
die Entzerrung dran.

AM RANDE BEHOBEN

Den beiden afterEach-Bloecken in http.test.ts fehlte restoreAllMocks.
Die Spies zaehlten ueber Tests hinweg weiter; aufgefallen, weil die neuen
Pruefungen an Aufrufzahlen haengen und statt 2 gleich 10 sahen.

Gegengeprobt: mit abgeschaltetem Nachschlag fallen vier der sechs neuen
Tests - die beiden, die das Ausbleiben pruefen, bleiben gruen.

Refs #23
2026-09-09 22:46:09 +02:00
florian.wilde abd9f73405 fix(git): Seitengroesse an der Antwort messen, nicht an der Anfrage
CI / pruefen (pull_request) Canceled after 0s
Behebt florian.wilde/nebula#22.

Gitea deckelt jede Listenantwort auf MAX_RESPONSE_ITEMS (Voreinstellung
50), egal was limit sagt. calendarByPaging verglich gegen die angefragten
100 und hielt damit bei jedem Gitea-Repository die erste Seite fuer die
letzte. Die Heatmap kannte nur die juengsten 50 Commits - und gab deren
aeltesten als Anfang des Zeitraums aus, ohne truncated zu setzen. Sie
behauptete also Vollstaendigkeit, die sie nicht hatte.

Maßstab ist jetzt die erste tatsaechlich gelieferte Seite. Kuerzer als
die heisst letzte Seite, leer heisst Ende, und der Deckel gilt weiterhin
nach CALENDAR_MAX_PAGES - dann aber ehrlich mit truncated.

Am echten Repository nachgesehen, dem aus dem Issue:

  vorher   Seite 1: 50                       -> 50 Commits
  jetzt    Seite 1: 50 | Seite 2: 26         -> 76 Commits

Gitea selbst weist 76 aus.

WAS DAS KOSTET

Bei Gitea macht der Kalender jetzt bis zu fuenf Aufrufe statt einem, und
ein Repository mit kurzer Historie fragt eine Seite mehr, um zu erfahren,
dass nichts mehr kommt. Das laeuft hoechstens einmal am Tag; der Preis
dafuer, dass die Zahl stimmt, ist ihn wert.

VIER BESTEHENDE TESTS ANGEFASST

Ihr Fixture lieferte fuer jede Seite dieselben Daten und hielt nur
deshalb an, weil die alte Regel eine kurze Seite fuer die letzte hielt -
also wegen genau des Fehlers, der hier behoben wird. Kein Server
verhaelt sich so. Sie holen ihre Daten jetzt ueber eineSeite() und
pruefen unveraendert das, worum es ihnen geht: Zaehlen und Filtern.

Vier neue Tests stellen eine deckelnde Gegenseite nach. Gegengeprobt:
mit der alten Zeile fallen genau diese vier.

Refs #22
2026-09-09 22:39:26 +02:00
12 changed files with 387 additions and 17 deletions
+10 -1
View File
@@ -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 der Rest durchgeht — ein 403 wegen fehlender Scopes, ein Rate-Limit, eine
Zeitüberschreitung. 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 **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. jedem Feld, das nicht geholt werden konnte, wird der Grund samt HTTP-Status festgehalten.
Daraus folgt dreierlei: 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. das Zurückhalten wird nicht erhöht, und der nächste Durchlauf versucht es normal wieder.
Der Rest des Standes ist ja frisch. 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 > 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 > letzten guten Stand, und aus der Oberfläche war nicht zu erkennen, dass überhaupt etwas
> schiefgegangen war. > schiefgegangen war.
+4 -3
View File
@@ -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). 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 ## 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. nicht verloren gehen. Nichts davon ist dringend.
### 3.1 Anwendungsname bricht in der Kopfleiste ab `S` ### 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 `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* 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. 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.9.0** Geschlossene Issues haken ihre Aufgabe auch ohne Webhook ab | ✓ |
| **v1.10.0** Wochenrückblick, globale Zeitleiste, Suche über Aufgaben | ✓ | | **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.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 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 beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "nebula", "name": "nebula",
"version": "1.10.1", "version": "1.11.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nebula", "name": "nebula",
"version": "1.10.1", "version": "1.11.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nebula", "name": "nebula",
"version": "1.10.1", "version": "1.11.0",
"private": true, "private": true,
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)", "description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
"scripts": { "scripts": {
+58
View File
@@ -41,6 +41,64 @@ export const KIND_META: Record<ChangeKind, { label: string; className: string }>
}; };
export const CHANGELOG: Release[] = [ 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", version: "1.10.1",
date: "2026-08-18", date: "2026-08-18",
+76 -6
View File
@@ -18,6 +18,19 @@ import {
const JETZT = new Date("2026-07-26T12:00:00.000Z"); const JETZT = new Date("2026-07-26T12:00:00.000Z");
const TAG = 86_400_000; 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. */ /** Erzeugt n Commit-Zeitpunkte, beginnend vor `abTagen` Tagen, einer je Tag. */
function commits(anzahl: number, abTagen: number): string[] { function commits(anzahl: number, abTagen: number): string[] {
return Array.from({ length: anzahl }, (_, index) => return Array.from({ length: anzahl }, (_, index) =>
@@ -45,14 +58,14 @@ describe("calendarByPaging", () => {
it("zaehlt mehrere Commits desselben Tages zusammen", async () => { it("zaehlt mehrere Commits desselben Tages zusammen", async () => {
const heute = JETZT.toISOString(); 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(kalender?.days["2026-07-26"]).toBe(3);
expect(calendarTotal(kalender!)).toBe(3); expect(calendarTotal(kalender!)).toBe(3);
}); });
it("beginnt beim aeltesten bekannten Commit, nicht vor einem Jahr", async () => { it("beginnt beim aeltesten bekannten Commit, nicht vor einem Jahr", async () => {
// Nur eine halbe Seite, zehn Tage zurueck: alles davor ist unbekannt. // 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?.from).toBe("2026-07-17");
expect(kalender?.to).toBe("2026-07-26"); expect(kalender?.to).toBe("2026-07-26");
expect(kalender?.truncated).toBe(false); expect(kalender?.truncated).toBe(false);
@@ -93,10 +106,10 @@ describe("calendarByPaging", () => {
it("uebergeht Commits mit Datum in der Zukunft", async () => { it("uebergeht Commits mit Datum in der Zukunft", async () => {
// Falsch gestellte Uhren gibt es; sie duerfen den Zeitraum nicht verlaengern. // Falsch gestellte Uhren gibt es; sie duerfen den Zeitraum nicht verlaengern.
const kalender = await calendarByPaging( const kalender = await calendarByPaging(
async () => [ eineSeite([
new Date(JETZT.getTime() + 5 * TAG).toISOString(), new Date(JETZT.getTime() + 5 * TAG).toISOString(),
JETZT.toISOString(), JETZT.toISOString(),
], ]),
JETZT, JETZT,
); );
expect(kalender?.to).toBe("2026-07-26"); expect(kalender?.to).toBe("2026-07-26");
@@ -105,7 +118,7 @@ describe("calendarByPaging", () => {
it("uebergeht Unbrauchbares, statt daran zu scheitern", async () => { it("uebergeht Unbrauchbares, statt daran zu scheitern", async () => {
const kalender = await calendarByPaging( const kalender = await calendarByPaging(
async () => [null, "gestern", "", JETZT.toISOString()], eineSeite([null, "gestern", "", JETZT.toISOString()]),
JETZT, JETZT,
); );
expect(calendarTotal(kalender!)).toBe(1); expect(calendarTotal(kalender!)).toBe(1);
@@ -114,9 +127,66 @@ describe("calendarByPaging", () => {
it("laesst Tage ohne Commits weg", async () => { it("laesst Tage ohne Commits weg", async () => {
// Ein Jahr als Liste waeren 365 Eintraege, die meisten davon Nullen. // Ein Jahr als Liste waeren 365 Eintraege, die meisten davon Nullen.
const kalender = await calendarByPaging( 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, JETZT,
); );
expect(Object.keys(kalender!.days)).toHaveLength(2); 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
View File
@@ -16,11 +16,20 @@
export const CALENDAR_DAYS = 365; export const CALENDAR_DAYS = 365;
/** /**
* Hoechstzahl Seiten, wo seitenweise geholt werden muss. Fuenf Seiten sind * Hoechstzahl Seiten, wo seitenweise geholt werden muss. Genug fuer die
* 500 Commits – genug fuer die allermeisten Repositories und wenig genug, * allermeisten Repositories und wenig genug, um kein fremdes Kontingent zu
* um kein fremdes Kontingent zu sprengen. * sprengen.
*/ */
export const CALENDAR_MAX_PAGES = 5; 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 const CALENDAR_PAGE_SIZE = 100;
export interface CommitCalendar { 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 * Abgebrochen wird, sobald eine Seite nicht mehr voll ist (dann gibt es
* nichts weiter), sobald die Seite vollstaendig aus der Zeit vor dem * nichts weiter), sobald die Seite vollstaendig aus der Zeit vor dem
* Zeitraum stammt, oder wenn der Deckel greift. * 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( export async function calendarByPaging(
ladeSeite: (seite: number) => Promise<Array<string | null>>, ladeSeite: (seite: number) => Promise<Array<string | null>>,
@@ -64,11 +82,18 @@ export async function calendarByPaging(
const days: Record<string, number> = {}; const days: Record<string, number> = {};
let aeltester: number | null = null; let aeltester: number | null = null;
let gedeckelt = false; 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) { for (let seite = 1; seite <= CALENDAR_MAX_PAGES; seite += 1) {
const daten = await ladeSeite(seite); const daten = await ladeSeite(seite);
if (daten.length === 0) break; 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; let innerhalb = 0;
for (const value of daten) { for (const value of daten) {
if (!value) continue; if (!value) continue;
@@ -85,10 +110,12 @@ export async function calendarByPaging(
} }
// Nicht mehr voll: das war die letzte Seite. // 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. // Die ganze Seite lag schon vor dem Zeitraum – weiter zurueck lohnt nicht.
if (innerhalb === 0) break; 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; if (seite === CALENDAR_MAX_PAGES) gedeckelt = true;
} }
+5
View File
@@ -187,6 +187,11 @@ export class GiteaClient implements GitClient {
}), }),
teil.hol<GiteaTag[]>("tags", `${base}/tags?limit=25`, [], { headers }), teil.hol<GiteaTag[]>("tags", `${base}/tags?limit=25`, [], { headers }),
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve. // 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[]>( teil.hol<GiteaCommit[]>(
"commits", "commits",
`${base}/commits?limit=100&stat=false`, `${base}/commits?limit=100&stat=false`,
+2
View File
@@ -185,6 +185,8 @@ export class GitHubClient implements GitClient {
headers, headers,
}), }),
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve. // 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`, [], { teil.hol<GitHubCommit[]>("commits", `${base}/commits?per_page=100`, [], {
headers, headers,
}), }),
+2
View File
@@ -189,6 +189,8 @@ export class GitLabClient implements GitClient {
headers, headers,
}), }),
// 100 statt 15: die aelteren brauchen wir fuer die Aktivitaetskurve. // 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[]>( teil.hol<GitLabCommit[]>(
"commits", "commits",
`${base}/repository/commits?per_page=100`, `${base}/repository/commits?per_page=100`,
+138
View File
@@ -51,6 +51,9 @@ describe("Teilabrufe", () => {
afterEach(() => { afterEach(() => {
vi.unstubAllEnvs(); 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 () => { it("meldet nichts, solange alles ankommt", async () => {
@@ -125,3 +128,138 @@ describe("Teilabrufe", () => {
expect(teil.fehler.commits).toBeTruthy(); 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"]);
});
});
+58
View File
@@ -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. */ /** Warum ein einzelner Teil-Abruf nicht geklappt hat. */
export type Teilfehler = Record<string, string>; export type Teilfehler = Record<string, string>;
@@ -252,6 +262,18 @@ export type Teilfehler = Record<string, string>;
export class Teilabrufe { export class Teilabrufe {
readonly fehler: Teilfehler = {}; 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>( async hol<T>(
feld: string, feld: string,
url: string, url: string,
@@ -262,11 +284,47 @@ export class Teilabrufe {
const result = await getJson<T>(url, options); const result = await getJson<T>(url, options);
return (result ?? fallback) as T; return (result ?? fallback) as T;
} catch (error) { } 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); this.fehler[feld] = beschreibe(error);
return fallback; 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. */ /** true, sobald mindestens ein Teil-Abruf gescheitert ist. */
get unvollstaendig(): boolean { get unvollstaendig(): boolean {
return Object.keys(this.fehler).length > 0; return Object.keys(this.fehler).length > 0;