diff --git a/src/lib/git/calendar.test.ts b/src/lib/git/calendar.test.ts index 47838be..3ef3b1c 100644 --- a/src/lib/git/calendar.test.ts +++ b/src/lib/git/calendar.test.ts @@ -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) { + 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); + }); +}); diff --git a/src/lib/git/calendar.ts b/src/lib/git/calendar.ts index d500874..ad60c44 100644 --- a/src/lib/git/calendar.ts +++ b/src/lib/git/calendar.ts @@ -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>, @@ -64,11 +82,18 @@ export async function calendarByPaging( const days: Record = {}; 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; } diff --git a/src/lib/git/gitea.ts b/src/lib/git/gitea.ts index e424eba..2206e60 100644 --- a/src/lib/git/gitea.ts +++ b/src/lib/git/gitea.ts @@ -187,6 +187,11 @@ export class GiteaClient implements GitClient { }), teil.hol("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( "commits", `${base}/commits?limit=100&stat=false`, diff --git a/src/lib/git/github.ts b/src/lib/git/github.ts index b153c3f..8c6b744 100644 --- a/src/lib/git/github.ts +++ b/src/lib/git/github.ts @@ -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("commits", `${base}/commits?per_page=100`, [], { headers, }), diff --git a/src/lib/git/gitlab.ts b/src/lib/git/gitlab.ts index 4a74cb9..5a840d1 100644 --- a/src/lib/git/gitlab.ts +++ b/src/lib/git/gitlab.ts @@ -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( "commits", `${base}/repository/commits?per_page=100`,