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 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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
Reference in New Issue
Block a user