Files
nebula/src/lib/notify/stale.test.ts
T
florian.wildeandClaude Opus 5 a843f08211 feat(git): mehrere Repositories je Projekt
Ein Vorhaben besteht oft aus Frontend und Backend - zwei Repositories, aber
eine Karte. Die Git-Felder wandern deshalb vom Projekt in eine eigene
Tabelle, der Snapshot haengt jetzt am Repository.

Das erste Repository ist das fuehrende. Wo nur eines dargestellt werden
kann - Karte, Kanban -, ist es dieses. Ein Abgleichsfehler an *irgendeinem*
erscheint aber trotzdem auf der Karte, sonst bliebe er unbemerkt.

Der Merkstand fuer Benachrichtigungen liegt nun am Repository. Am Projekt
verdeckte ein rotes Frontend die rote Pipeline des Backends. Stillstand
bleibt dagegen eine Aussage ueber das Vorhaben und bemisst sich am juengsten
Commit ueber alle Repos: an einem Projekt wird gearbeitet, sobald an einem
seiner Teile gearbeitet wird.

Der Zeitgeber zaehlt und begrenzt jetzt Repositories statt Projekte. Sonst
zoege ein Projekt mit fuenf Repos fuenfmal so viele Anfragen nach sich, wie
sein Platz im Kontingent hergibt. "Alle abgleichen" laeuft nacheinander -
ein Schwung gleichzeitiger Anfragen an denselben Anbieter ist genau das,
worauf Rate-Limits reagieren.

Die Issue-Nummer allein reicht als Dublettenschutz nicht mehr: Frontend und
Backend haben beide ein Issue 5, und das sind zwei Aufgaben. Die Herkunft
gehoert deshalb mit in die Eindeutigkeit. In Postgres nachgewiesen: dieselbe
Nummer in zwei Repos geht durch, zweimal im selben Repo nicht, mehrere
Aufgaben ohne Herkunft bleiben erlaubt.

Die Projekt-API nimmt die Git-Felder weiterhin an und setzt damit das
fuehrende Repository - so kommt man mit einem Aufruf zu Projekt und Repo,
und der Import muss von der neuen Tabelle nichts wissen.

Die Exportdatei zaehlt auf 2 hoch und traegt eine Liste. Version 1 bleibt
lesbar: dort stand genau ein Repository flach am Projekt.

Die Datenmigration ist von Hand geschrieben - migrate diff kennt nur den
Zielzustand und haette die Spalten samt Snapshots ersatzlos geloescht.
Gegen eine Kopie mit echten Daten geprueft: Verknuepfung, Verbindung,
Fehlerzaehler und Snapshot kommen vollstaendig mit, Projekte ohne Repo
bekommen keine leere Zeile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:23:53 +02:00

86 lines
3.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import { newestCommit } from "./stale";
import { daysSince, isStaleDue } from "./stale";
/**
* Die Entscheidung, wann ein Projekt als eingeschlafen gilt – und vor allem,
* wann es *nicht* erneut gemeldet wird.
*/
const TAG = 86_400_000;
const jetzt = new Date("2026-07-26T12:00:00Z");
const vorTagen = (tage: number) => new Date(jetzt.getTime() - tage * TAG);
/** Schwelle: 60 Tage. */
const cutoff = vorTagen(60);
describe("isStaleDue", () => {
it("meldet ein Projekt, dessen letzter Commit laenger zurueckliegt", () => {
expect(isStaleDue({ notifiedStaleAt: null }, vorTagen(90), cutoff)).toBe(true);
});
it("meldet nichts innerhalb der Frist", () => {
expect(isStaleDue({ notifiedStaleAt: null }, vorTagen(30), cutoff)).toBe(false);
});
it("meldet nichts genau an der Schwelle", () => {
expect(isStaleDue({ notifiedStaleAt: null }, cutoff, cutoff)).toBe(false);
});
it("meldet nichts ohne bekannten letzten Commit", () => {
// Ein Projekt ohne Repository oder ohne Snapshot laesst sich nicht
// beurteilen – lieber schweigen als raten.
expect(isStaleDue({ notifiedStaleAt: null }, null, cutoff)).toBe(false);
});
it("meldet dasselbe Projekt kein zweites Mal", () => {
expect(isStaleDue({ notifiedStaleAt: vorTagen(5) }, vorTagen(90), cutoff)).toBe(false);
});
it("meldet erneut, wenn zwischendurch gearbeitet wurde und es wieder still ist", () => {
// Gemeldet vor 200 Tagen, danach ein Commit vor 90 Tagen, seitdem Ruhe.
expect(isStaleDue({ notifiedStaleAt: vorTagen(200) }, vorTagen(90), cutoff)).toBe(true);
});
it("meldet nicht erneut, wenn der Commit vor der letzten Meldung lag", () => {
expect(isStaleDue({ notifiedStaleAt: vorTagen(10) }, vorTagen(90), cutoff)).toBe(false);
});
});
describe("daysSince", () => {
it("zaehlt volle Tage", () => {
expect(daysSince(vorTagen(60), jetzt)).toBe(60);
expect(daysSince(vorTagen(1), jetzt)).toBe(1);
});
it("rundet angebrochene Tage ab", () => {
expect(daysSince(new Date(jetzt.getTime() - 1.9 * TAG), jetzt)).toBe(1);
});
it("liefert null fuer soeben", () => {
expect(daysSince(jetzt, jetzt)).toBe(0);
});
});
describe("newestCommit", () => {
it("nimmt den juengsten Commit ueber alle Repositories", () => {
// An einem Vorhaben wird gearbeitet, sobald an einem seiner Teile
// gearbeitet wird – ein ruhendes Backend macht es nicht stillstehend.
const juengster = newestCommit([
{ lastCommitAt: vorTagen(90) },
{ lastCommitAt: vorTagen(3) },
{ lastCommitAt: vorTagen(40) },
]);
expect(daysSince(juengster!, jetzt)).toBe(3);
});
it("uebergeht Repositories ohne Commit-Datum", () => {
const juengster = newestCommit([{ lastCommitAt: null }, { lastCommitAt: vorTagen(5) }]);
expect(daysSince(juengster!, jetzt)).toBe(5);
});
it("liefert null, wenn nirgends ein Commit bekannt ist", () => {
expect(newestCommit([])).toBeNull();
expect(newestCommit([{ lastCommitAt: null }])).toBeNull();
});
});