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>
This commit is contained in:
@@ -1,24 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Project, RepoSnapshot } from "@prisma/client";
|
||||
import type { Project, RepoSnapshot, Repository } from "@prisma/client";
|
||||
import { pendingEvents } from "./index";
|
||||
|
||||
/**
|
||||
* Die Entprellung ist der Kern: gemeldet wird nur bei einer Aenderung
|
||||
* gegenueber dem zuletzt gemeldeten Stand. Ohne sie kaeme bei einem Repo mit
|
||||
* abgelaufenem Token mit jedem stuendlichen Durchlauf dieselbe Nachricht.
|
||||
*
|
||||
* Der Merkstand liegt am Repository, nicht am Projekt – sonst verdeckte ein
|
||||
* rotes Frontend die rote Pipeline des Backends.
|
||||
*/
|
||||
|
||||
function projekt(overrides: Partial<Project> = {}): Project {
|
||||
const PROJEKT: Project = {
|
||||
id: "p1",
|
||||
name: "Testprojekt",
|
||||
slug: "testprojekt",
|
||||
} as Project;
|
||||
|
||||
function repo(overrides: Partial<Repository> = {}): Repository {
|
||||
return {
|
||||
id: "p1",
|
||||
name: "Testprojekt",
|
||||
slug: "testprojekt",
|
||||
id: "r1",
|
||||
projectId: "p1",
|
||||
label: null,
|
||||
repoOwner: "wer",
|
||||
repoName: "testprojekt",
|
||||
repoUrl: null,
|
||||
syncError: null,
|
||||
notifiedSyncError: null,
|
||||
notifiedCiState: null,
|
||||
notifiedRelease: null,
|
||||
...overrides,
|
||||
} as Project;
|
||||
} as Repository;
|
||||
}
|
||||
|
||||
function snapshot(overrides: Partial<RepoSnapshot> = {}): RepoSnapshot {
|
||||
@@ -34,49 +46,49 @@ function snapshot(overrides: Partial<RepoSnapshot> = {}): RepoSnapshot {
|
||||
} as RepoSnapshot;
|
||||
}
|
||||
|
||||
const events = (p: Project, s: RepoSnapshot | null) =>
|
||||
pendingEvents(p, s).map((entry) => entry.event);
|
||||
const events = (r: Repository, s: RepoSnapshot | null) =>
|
||||
pendingEvents(PROJEKT, r, s).map((entry) => entry.event);
|
||||
|
||||
describe("Abgleichsfehler", () => {
|
||||
it("meldet einen neuen Fehler", () => {
|
||||
expect(events(projekt({ syncError: "404" }), null)).toEqual(["SYNC_FAILED"]);
|
||||
expect(events(repo({ syncError: "404" }), null)).toEqual(["SYNC_FAILED"]);
|
||||
});
|
||||
|
||||
it("meldet denselben Fehler kein zweites Mal", () => {
|
||||
expect(events(projekt({ syncError: "404", notifiedSyncError: "404" }), null)).toEqual([]);
|
||||
expect(events(repo({ syncError: "404", notifiedSyncError: "404" }), null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("meldet, wenn sich der Fehler aendert", () => {
|
||||
expect(
|
||||
events(projekt({ syncError: "401 Token", notifiedSyncError: "404" }), null),
|
||||
events(repo({ syncError: "401 Token", notifiedSyncError: "404" }), null),
|
||||
).toEqual(["SYNC_FAILED"]);
|
||||
});
|
||||
|
||||
it("meldet nichts, wenn kein Fehler vorliegt", () => {
|
||||
expect(events(projekt({ syncError: null, notifiedSyncError: "404" }), null)).toEqual([]);
|
||||
expect(events(repo({ syncError: null, notifiedSyncError: "404" }), null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CI", () => {
|
||||
it("meldet den Wechsel auf rot", () => {
|
||||
expect(events(projekt(), snapshot({ ciState: "failure" }))).toEqual(["CI_FAILED"]);
|
||||
expect(events(repo(), snapshot({ ciState: "failure" }))).toEqual(["CI_FAILED"]);
|
||||
});
|
||||
|
||||
it("meldet dauerhaft rote CI nur einmal", () => {
|
||||
expect(
|
||||
events(projekt({ notifiedCiState: "failure" }), snapshot({ ciState: "failure" })),
|
||||
events(repo({ notifiedCiState: "failure" }), snapshot({ ciState: "failure" })),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("meldet nicht, wenn die CI gruen ist", () => {
|
||||
expect(
|
||||
events(projekt({ notifiedCiState: "failure" }), snapshot({ ciState: "success" })),
|
||||
events(repo({ notifiedCiState: "failure" }), snapshot({ ciState: "success" })),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("meldet erneut, wenn die CI nach gruen wieder rot wird", () => {
|
||||
expect(
|
||||
events(projekt({ notifiedCiState: "success" }), snapshot({ ciState: "failure" })),
|
||||
events(repo({ notifiedCiState: "success" }), snapshot({ ciState: "failure" })),
|
||||
).toEqual(["CI_FAILED"]);
|
||||
});
|
||||
});
|
||||
@@ -85,40 +97,63 @@ describe("Release", () => {
|
||||
it("schweigt beim allerersten Abgleich", () => {
|
||||
// Sonst waere jede bereits vorhandene Version eine "Neuigkeit".
|
||||
expect(
|
||||
events(projekt({ notifiedRelease: null }), snapshot({ latestTag: "v1.0.0" })),
|
||||
events(repo({ notifiedRelease: null }), snapshot({ latestTag: "v1.0.0" })),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("meldet eine neue Version", () => {
|
||||
expect(
|
||||
events(projekt({ notifiedRelease: "v1.0.0" }), snapshot({ latestTag: "v1.1.0" })),
|
||||
events(repo({ notifiedRelease: "v1.0.0" }), snapshot({ latestTag: "v1.1.0" })),
|
||||
).toEqual(["NEW_RELEASE"]);
|
||||
});
|
||||
|
||||
it("meldet dieselbe Version nicht erneut", () => {
|
||||
expect(
|
||||
events(projekt({ notifiedRelease: "v1.1.0" }), snapshot({ latestTag: "v1.1.0" })),
|
||||
events(repo({ notifiedRelease: "v1.1.0" }), snapshot({ latestTag: "v1.1.0" })),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("zieht den Releasenamen dem Tag vor", () => {
|
||||
const treffer = pendingEvents(
|
||||
projekt({ notifiedRelease: "alt" }),
|
||||
PROJEKT,
|
||||
repo({ notifiedRelease: "alt" }),
|
||||
snapshot({ latestTag: "v2.0.0", latestRelease: "Zweite Ausgabe" }),
|
||||
);
|
||||
expect(treffer[0].message.title).toContain("Zweite Ausgabe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Betreff bei mehreren Repositories", () => {
|
||||
it("nennt das Repository nicht, wenn es ohnehin so heisst wie das Projekt", () => {
|
||||
// "Testprojekt · wer/testprojekt: CI ist rot" liest niemand gern.
|
||||
const treffer = pendingEvents(
|
||||
PROJEKT,
|
||||
repo({ repoOwner: "wer", repoName: "testprojekt" }),
|
||||
snapshot({ ciState: "failure" }),
|
||||
);
|
||||
expect(treffer[0].message.title).toContain("Testprojekt: CI ist rot");
|
||||
expect(treffer[0].message.title).not.toContain("wer/");
|
||||
});
|
||||
|
||||
it("nennt es, sobald es sich unterscheidet", () => {
|
||||
const treffer = pendingEvents(
|
||||
PROJEKT,
|
||||
repo({ label: "Backend" }),
|
||||
snapshot({ ciState: "failure" }),
|
||||
);
|
||||
expect(treffer[0].message.title).toContain("Testprojekt · Backend");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mehrere Anlaesse gleichzeitig", () => {
|
||||
it("meldet Fehler und rote CI zusammen", () => {
|
||||
expect(
|
||||
events(projekt({ syncError: "Zeitueberschreitung" }), snapshot({ ciState: "failure" })),
|
||||
events(repo({ syncError: "Zeitueberschreitung" }), snapshot({ ciState: "failure" })),
|
||||
).toEqual(["SYNC_FAILED", "CI_FAILED"]);
|
||||
});
|
||||
|
||||
it("haengt Projektbezug und Tieflink an jede Nachricht", () => {
|
||||
const treffer = pendingEvents(projekt({ syncError: "404" }), null);
|
||||
const treffer = pendingEvents(PROJEKT, repo({ syncError: "404" }), null);
|
||||
expect(treffer[0].message.project?.slug).toBe("testprojekt");
|
||||
expect(treffer[0].message.url).toContain("/projects/p1");
|
||||
expect(treffer[0].message.priority).toBe("high");
|
||||
|
||||
Reference in New Issue
Block a user