This commit was merged in pull request #24.
This commit is contained in:
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user