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
This commit is contained in:
2026-09-09 22:39:26 +02:00
parent 97ba863356
commit abd9f73405
5 changed files with 116 additions and 10 deletions
+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`,