Merge #22: Seitengroesse an der Antwort messen
CI / pruefen (push) Canceled after 0s

This commit was merged in pull request #24.
This commit is contained in:
2026-09-09 22:11:45 +00:00
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`,