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:
@@ -18,7 +18,11 @@ import { z } from "zod";
|
||||
*/
|
||||
|
||||
export const EXPORT_KIND = "nebula.projects";
|
||||
export const EXPORT_VERSION = 1;
|
||||
/**
|
||||
* 2 seit den mehreren Repositories je Projekt. Dateien der Version 1 werden
|
||||
* weiter gelesen: ihre einzelnen Repo-Felder werden zum ersten Repository.
|
||||
*/
|
||||
export const EXPORT_VERSION = 2;
|
||||
|
||||
const STATUS = ["IDEA", "OPEN", "PLANNING", "IN_DEVELOPMENT", "DONE", "ARCHIVED"] as const;
|
||||
const PROVIDERS = ["GITHUB", "GITLAB", "GITEA"] as const;
|
||||
@@ -46,6 +50,16 @@ export const exportedConnectionSchema = z.object({
|
||||
baseUrl: z.string().max(300),
|
||||
});
|
||||
|
||||
export const exportedRepositorySchema = z.object({
|
||||
label: z.string().max(80).nullable().optional(),
|
||||
repoUrl: z.string().max(500).nullable().optional(),
|
||||
repoOwner: z.string().max(200).nullable().optional(),
|
||||
repoName: z.string().max(200).nullable().optional(),
|
||||
autoSync: z.boolean().optional().default(true),
|
||||
/** Nur zur Wiedererkennung – ohne Token. */
|
||||
connection: exportedConnectionSchema.nullable().optional(),
|
||||
});
|
||||
|
||||
export const exportedProjectSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
slug: z.string().max(60).optional(),
|
||||
@@ -57,11 +71,17 @@ export const exportedProjectSchema = z.object({
|
||||
accent: z.string().max(20).optional().default("violet"),
|
||||
tags: z.array(z.string().max(30)).max(12).optional().default([]),
|
||||
favorite: z.boolean().optional().default(false),
|
||||
/** Seit Version 2. */
|
||||
repositories: z.array(exportedRepositorySchema).max(20).optional(),
|
||||
/**
|
||||
* Version 1 kannte genau ein Repository und schrieb es flach ans Projekt.
|
||||
* Die Felder bleiben lesbar, damit aeltere Dateien weiter importierbar
|
||||
* sind; geschrieben werden sie nicht mehr.
|
||||
*/
|
||||
autoSync: z.boolean().optional().default(true),
|
||||
repoUrl: z.string().max(500).nullable().optional(),
|
||||
repoOwner: z.string().max(200).nullable().optional(),
|
||||
repoName: z.string().max(200).nullable().optional(),
|
||||
/** Nur zur Wiedererkennung – ohne Token. */
|
||||
connection: exportedConnectionSchema.nullable().optional(),
|
||||
notes: z.array(exportedNoteSchema).max(500).optional().default([]),
|
||||
tasks: z.array(exportedTaskSchema).max(1000).optional().default([]),
|
||||
@@ -78,6 +98,26 @@ export const exportFileSchema = z.object({
|
||||
});
|
||||
|
||||
export type ExportedProject = z.infer<typeof exportedProjectSchema>;
|
||||
export type ExportedRepository = z.infer<typeof exportedRepositorySchema>;
|
||||
|
||||
/**
|
||||
* Die Repositories einer Exportdatei – gleich, ob sie aus Version 2 stammt
|
||||
* oder aus der Zeit mit genau einem Repo je Projekt.
|
||||
*/
|
||||
export function repositoriesOf(project: ExportedProject): ExportedRepository[] {
|
||||
if (project.repositories?.length) return project.repositories;
|
||||
if (!project.repoUrl && !(project.repoOwner && project.repoName)) return [];
|
||||
return [
|
||||
{
|
||||
label: null,
|
||||
repoUrl: project.repoUrl ?? null,
|
||||
repoOwner: project.repoOwner ?? null,
|
||||
repoName: project.repoName ?? null,
|
||||
autoSync: project.autoSync,
|
||||
connection: project.connection ?? null,
|
||||
},
|
||||
];
|
||||
}
|
||||
export type ExportFile = z.infer<typeof exportFileSchema>;
|
||||
|
||||
/** Dateiname ohne Sonderzeichen, mit Datumsstempel. */
|
||||
|
||||
+82
-40
@@ -1,14 +1,27 @@
|
||||
import type { GitConnection, Note, Project, RepoSnapshot, Task } from "@prisma/client";
|
||||
import type {
|
||||
GitConnection,
|
||||
Note,
|
||||
Prisma,
|
||||
Project,
|
||||
RepoSnapshot,
|
||||
Repository,
|
||||
Task,
|
||||
} from "@prisma/client";
|
||||
import { APP_NAME } from "@/lib/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { repoTitle } from "@/lib/repositories";
|
||||
import { PRIORITY_META, statusOf } from "@/lib/status";
|
||||
import { formatDate, formatDateTime } from "@/lib/utils";
|
||||
import { EXPORT_KIND, EXPORT_VERSION, type ExportFile, type ExportedProject } from "./format";
|
||||
|
||||
type ProjectForExport = Project & {
|
||||
export type RepositoryForExport = Repository & {
|
||||
connection: Pick<GitConnection, "name" | "provider" | "baseUrl"> | null;
|
||||
};
|
||||
|
||||
export type ProjectForExport = Project & {
|
||||
notes: Note[];
|
||||
tasks: Task[];
|
||||
connection: Pick<GitConnection, "name" | "provider" | "baseUrl"> | null;
|
||||
repositories: RepositoryForExport[];
|
||||
};
|
||||
|
||||
/** Formt einen Datensatz in das Austauschformat um – ohne Geheimnisse. */
|
||||
@@ -24,17 +37,25 @@ export function toExportedProject(project: ProjectForExport): ExportedProject {
|
||||
accent: project.accent,
|
||||
tags: project.tags,
|
||||
favorite: project.favorite,
|
||||
autoSync: project.autoSync,
|
||||
repoUrl: project.repoUrl,
|
||||
repoOwner: project.repoOwner,
|
||||
repoName: project.repoName,
|
||||
connection: project.connection
|
||||
? {
|
||||
name: project.connection.name,
|
||||
provider: project.connection.provider,
|
||||
baseUrl: project.connection.baseUrl,
|
||||
}
|
||||
: null,
|
||||
// Version 1 hatte ein autoSync am Projekt. Das Feld ist im Schema noch
|
||||
// vorhanden, damit alte Dateien lesbar bleiben; geschrieben wird der
|
||||
// Wert des fuehrenden Repositories, damit eine Version-2-Datei auch von
|
||||
// einer aelteren Instanz noch sinnvoll gelesen werden koennte.
|
||||
autoSync: project.repositories[0]?.autoSync ?? true,
|
||||
repositories: project.repositories.map((repository) => ({
|
||||
label: repository.label,
|
||||
repoUrl: repository.repoUrl,
|
||||
repoOwner: repository.repoOwner,
|
||||
repoName: repository.repoName,
|
||||
autoSync: repository.autoSync,
|
||||
connection: repository.connection
|
||||
? {
|
||||
name: repository.connection.name,
|
||||
provider: repository.connection.provider,
|
||||
baseUrl: repository.connection.baseUrl,
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
notes: project.notes.map((note) => ({
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
@@ -72,15 +93,21 @@ export async function loadProjectsForExport(
|
||||
): Promise<ProjectForExport[]> {
|
||||
return prisma.project.findMany({
|
||||
where: { ownerId, ...(projectId ? { id: projectId } : {}) },
|
||||
include: {
|
||||
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
|
||||
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
|
||||
connection: { select: { name: true, provider: true, baseUrl: true } },
|
||||
},
|
||||
include: exportInclude,
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Der Umfang, den der Export braucht – an beiden Aufrufstellen derselbe. */
|
||||
export const exportInclude = {
|
||||
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
|
||||
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
|
||||
repositories: {
|
||||
include: { connection: { select: { name: true, provider: true, baseUrl: true } } },
|
||||
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||
},
|
||||
} satisfies Prisma.ProjectInclude;
|
||||
|
||||
// ── Markdown ─────────────────────────────────────────────────
|
||||
|
||||
const PROVIDER_LABEL: Record<string, string> = {
|
||||
@@ -102,7 +129,9 @@ const TASK_LABEL: Record<string, string> = {
|
||||
* Datei auch ohne Netz vollstaendig ist.
|
||||
*/
|
||||
export function toMarkdown(
|
||||
project: ProjectForExport & { snapshot: RepoSnapshot | null },
|
||||
project: ProjectForExport & {
|
||||
repositories: Array<RepositoryForExport & { snapshot: RepoSnapshot | null }>;
|
||||
},
|
||||
): string {
|
||||
const status = statusOf(project.status);
|
||||
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
|
||||
@@ -119,33 +148,46 @@ export function toMarkdown(
|
||||
lines.push(`| Tags | ${project.tags.map((tag) => `\`${tag}\``).join(", ")} |`);
|
||||
}
|
||||
lines.push(`| Angelegt | ${formatDateTime(project.createdAt)} |`);
|
||||
if (project.repoUrl) lines.push(`| Repository | ${project.repoUrl} |`);
|
||||
if (project.connection) {
|
||||
const label = PROVIDER_LABEL[project.connection.provider] ?? project.connection.provider;
|
||||
lines.push(`| Git-System | ${label} (${project.connection.baseUrl}) |`);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
if (project.description) {
|
||||
lines.push("## Beschreibung", "", project.description, "");
|
||||
}
|
||||
|
||||
const snapshot = project.snapshot;
|
||||
if (snapshot) {
|
||||
lines.push("## Stand des Repositories", "");
|
||||
lines.push(`Abgerufen am ${formatDateTime(snapshot.fetchedAt)}.`, "");
|
||||
for (const repository of project.repositories) {
|
||||
const titel = repoTitle(repository);
|
||||
lines.push(
|
||||
project.repositories.length === 1
|
||||
? "## Repository"
|
||||
: `## Repository: ${titel}`,
|
||||
"",
|
||||
);
|
||||
|
||||
lines.push("| | |", "| --- | --- |");
|
||||
if (snapshot.defaultBranch) lines.push(`| Default-Branch | \`${snapshot.defaultBranch}\` |`);
|
||||
if (snapshot.latestTag) lines.push(`| Neuester Tag | \`${snapshot.latestTag}\` |`);
|
||||
lines.push(`| Branches | ${snapshot.branchCount} |`);
|
||||
lines.push(`| Tags | ${snapshot.tagCount} |`);
|
||||
lines.push(`| Offene PRs/MRs | ${snapshot.openPullRequests} |`);
|
||||
lines.push(`| Offene Issues | ${snapshot.openIssues} |`);
|
||||
if (snapshot.lastCommitSha) {
|
||||
lines.push(
|
||||
`| Letzter Commit | \`${snapshot.lastCommitSha.slice(0, 7)}\` – ` +
|
||||
`${snapshot.lastCommitMessage ?? ""} |`,
|
||||
);
|
||||
if (repository.repoUrl) lines.push(`| Adresse | ${repository.repoUrl} |`);
|
||||
if (repository.connection) {
|
||||
const label =
|
||||
PROVIDER_LABEL[repository.connection.provider] ?? repository.connection.provider;
|
||||
lines.push(`| Git-System | ${label} (${repository.connection.baseUrl}) |`);
|
||||
}
|
||||
|
||||
const snapshot = repository.snapshot;
|
||||
if (snapshot) {
|
||||
if (snapshot.defaultBranch) lines.push(`| Default-Branch | \`${snapshot.defaultBranch}\` |`);
|
||||
if (snapshot.latestTag) lines.push(`| Neuester Tag | \`${snapshot.latestTag}\` |`);
|
||||
lines.push(`| Branches | ${snapshot.branchCount} |`);
|
||||
lines.push(`| Tags | ${snapshot.tagCount} |`);
|
||||
lines.push(`| Offene PRs/MRs | ${snapshot.openPullRequests} |`);
|
||||
lines.push(`| Offene Issues | ${snapshot.openIssues} |`);
|
||||
if (snapshot.lastCommitSha) {
|
||||
lines.push(
|
||||
`| Letzter Commit | \`${snapshot.lastCommitSha.slice(0, 7)}\` – ` +
|
||||
`${snapshot.lastCommitMessage ?? ""} |`,
|
||||
);
|
||||
}
|
||||
lines.push(`| Abgerufen | ${formatDateTime(snapshot.fetchedAt)} |`);
|
||||
} else {
|
||||
lines.push("| Stand | noch nie abgeglichen |");
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
@@ -29,6 +29,25 @@ export const PROVIDER_META: Record<
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Wie ein Repository in Ueberschriften und Meldungen heisst.
|
||||
*
|
||||
* Steht hier und nicht bei den uebrigen Repository-Helfern, weil die
|
||||
* Oberflaeche sie braucht: lib/repositories zieht Prisma und damit
|
||||
* node:-Module nach, die in einer Client-Komponente nicht landen duerfen.
|
||||
*/
|
||||
export function repoTitle(repo: {
|
||||
label?: string | null;
|
||||
repoOwner?: string | null;
|
||||
repoName?: string | null;
|
||||
repoUrl?: string | null;
|
||||
}): string {
|
||||
if (repo.label) return repo.label;
|
||||
if (repo.repoOwner && repo.repoName) return `${repo.repoOwner}/${repo.repoName}`;
|
||||
if (repo.repoName) return repo.repoName;
|
||||
return repo.repoUrl ?? "Repository";
|
||||
}
|
||||
|
||||
export function guessProvider(host: string): ProviderKind {
|
||||
const lower = host.toLowerCase();
|
||||
if (lower.includes("github")) return "GITHUB";
|
||||
|
||||
+36
-22
@@ -1,6 +1,7 @@
|
||||
import type { NotificationEvent, Project, RepoSnapshot } from "@prisma/client";
|
||||
import type { NotificationEvent, Project, RepoSnapshot, Repository } from "@prisma/client";
|
||||
import { APP_NAME, APP_URL } from "@/lib/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { repoTitle } from "@/lib/repositories";
|
||||
import { deliver } from "./channels";
|
||||
import { EVENT_META, type NotificationMessage } from "./types";
|
||||
|
||||
@@ -14,25 +15,26 @@ export { emailAvailable, smtpConfig } from "./channels";
|
||||
* Gemeldet wird ausschliesslich bei einer **Aenderung** gegenueber dem,
|
||||
* worueber zuletzt benachrichtigt wurde. Sonst kaeme bei einem Repo mit
|
||||
* abgelaufenem Token mit jedem stuendlichen Durchlauf dieselbe Nachricht.
|
||||
* Der zuletzt gemeldete Zustand steht am Projekt.
|
||||
* Der zuletzt gemeldete Zustand steht am Repository – nicht am Projekt, sonst
|
||||
* verdeckte ein rotes Frontend die rote Pipeline des Backends.
|
||||
*
|
||||
* Fehler beim Zustellen werden geschluckt: eine kaputte Benachrichtigung
|
||||
* darf den Abgleich nicht scheitern lassen.
|
||||
*/
|
||||
export async function notifyAfterSync(projectId: string): Promise<void> {
|
||||
export async function notifyAfterSync(repositoryId: string): Promise<void> {
|
||||
try {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: { snapshot: true },
|
||||
const repository = await prisma.repository.findUnique({
|
||||
where: { id: repositoryId },
|
||||
include: { snapshot: true, project: true },
|
||||
});
|
||||
if (!project?.ownerId) return;
|
||||
if (!repository?.project.ownerId) return;
|
||||
|
||||
const faellig = pendingEvents(project, project.snapshot);
|
||||
const faellig = pendingEvents(repository.project, repository, repository.snapshot);
|
||||
if (faellig.length === 0) return;
|
||||
|
||||
const ziele = await prisma.notificationTarget.findMany({
|
||||
where: {
|
||||
userId: project.ownerId,
|
||||
userId: repository.project.ownerId,
|
||||
isActive: true,
|
||||
events: { hasSome: faellig.map((entry) => entry.event) },
|
||||
},
|
||||
@@ -40,12 +42,12 @@ export async function notifyAfterSync(projectId: string): Promise<void> {
|
||||
|
||||
// Auch ohne Ziel den Merkstand fortschreiben: sonst kaeme beim ersten
|
||||
// eingerichteten Ziel gleich die gesamte Vergangenheit hinterher.
|
||||
await prisma.project.update({
|
||||
where: { id: project.id },
|
||||
await prisma.repository.update({
|
||||
where: { id: repository.id },
|
||||
data: {
|
||||
notifiedSyncError: project.syncError,
|
||||
notifiedCiState: project.snapshot?.ciState ?? null,
|
||||
notifiedRelease: currentRelease(project.snapshot),
|
||||
notifiedSyncError: repository.syncError,
|
||||
notifiedCiState: repository.snapshot?.ciState ?? null,
|
||||
notifiedRelease: currentRelease(repository.snapshot),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -81,6 +83,7 @@ function currentRelease(snapshot: RepoSnapshot | null): string | null {
|
||||
*/
|
||||
export function pendingEvents(
|
||||
project: Project,
|
||||
repository: Repository,
|
||||
snapshot: RepoSnapshot | null,
|
||||
): Array<{ event: NotificationEvent; message: NotificationMessage }> {
|
||||
const treffer: Array<{ event: NotificationEvent; message: NotificationMessage }> = [];
|
||||
@@ -88,13 +91,24 @@ export function pendingEvents(
|
||||
const bezug = { id: project.id, name: project.name, slug: project.slug };
|
||||
const jetzt = new Date().toISOString();
|
||||
|
||||
if (project.syncError && project.syncError !== project.notifiedSyncError) {
|
||||
/**
|
||||
* Der Betreff nennt das Repository nur, wenn es nicht ohnehin so heisst
|
||||
* wie das Projekt. "nebula – nebula: CI ist rot" liest niemand gern.
|
||||
*/
|
||||
const titel = repoTitle(repository);
|
||||
const woher =
|
||||
titel.toLowerCase() === project.name.toLowerCase() ||
|
||||
titel.toLowerCase().endsWith(`/${project.name.toLowerCase()}`)
|
||||
? project.name
|
||||
: `${project.name} · ${titel}`;
|
||||
|
||||
if (repository.syncError && repository.syncError !== repository.notifiedSyncError) {
|
||||
treffer.push({
|
||||
event: "SYNC_FAILED",
|
||||
message: {
|
||||
event: "SYNC_FAILED",
|
||||
title: `${EVENT_META.SYNC_FAILED.emoji} ${project.name}: Abgleich fehlgeschlagen`,
|
||||
body: project.syncError,
|
||||
title: `${EVENT_META.SYNC_FAILED.emoji} ${woher}: Abgleich fehlgeschlagen`,
|
||||
body: repository.syncError,
|
||||
priority: "high",
|
||||
url: projektUrl,
|
||||
project: bezug,
|
||||
@@ -104,12 +118,12 @@ export function pendingEvents(
|
||||
}
|
||||
|
||||
const ci = snapshot?.ciState ?? null;
|
||||
if (ci === "failure" && ci !== project.notifiedCiState) {
|
||||
if (ci === "failure" && ci !== repository.notifiedCiState) {
|
||||
treffer.push({
|
||||
event: "CI_FAILED",
|
||||
message: {
|
||||
event: "CI_FAILED",
|
||||
title: `${EVENT_META.CI_FAILED.emoji} ${project.name}: CI ist rot`,
|
||||
title: `${EVENT_META.CI_FAILED.emoji} ${woher}: CI ist rot`,
|
||||
body:
|
||||
`Der letzte Lauf auf ${snapshot?.ciBranch ?? "dem Default-Branch"} ist ` +
|
||||
`fehlgeschlagen${snapshot?.ciName ? ` (${snapshot.ciName})` : ""}.`,
|
||||
@@ -125,13 +139,13 @@ export function pendingEvents(
|
||||
// Beim allerersten Abgleich ist notifiedRelease noch leer – dann waere
|
||||
// jede vorhandene Version eine "Neuigkeit". Deshalb nur melden, wenn
|
||||
// vorher schon einmal etwas festgehalten wurde.
|
||||
if (release && project.notifiedRelease && release !== project.notifiedRelease) {
|
||||
if (release && repository.notifiedRelease && release !== repository.notifiedRelease) {
|
||||
treffer.push({
|
||||
event: "NEW_RELEASE",
|
||||
message: {
|
||||
event: "NEW_RELEASE",
|
||||
title: `${EVENT_META.NEW_RELEASE.emoji} ${project.name}: ${release}`,
|
||||
body: `Neue Version im Repository: ${release} (vorher ${project.notifiedRelease}).`,
|
||||
title: `${EVENT_META.NEW_RELEASE.emoji} ${woher}: ${release}`,
|
||||
body: `Neue Version im Repository: ${release} (vorher ${repository.notifiedRelease}).`,
|
||||
priority: "normal",
|
||||
url: snapshot?.htmlUrl ?? projektUrl,
|
||||
project: bezug,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { newestCommit } from "./stale";
|
||||
import { daysSince, isStaleDue } from "./stale";
|
||||
|
||||
/**
|
||||
@@ -59,3 +60,26 @@ describe("daysSince", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
+40
-7
@@ -12,10 +12,13 @@ import { EVENT_META, type NotificationMessage } from "./types";
|
||||
* passiert ja gerade nichts. Deshalb ein eigener Durchlauf, hoechstens
|
||||
* einmal am Tag.
|
||||
*
|
||||
* Massstab ist ausschliesslich der letzte Commit im verknuepften
|
||||
* Repository. Das Aenderungsdatum des Projekts taugt nicht: jeder Abgleich
|
||||
* Massstab ist ausschliesslich der letzte Commit in den verknuepften
|
||||
* Repositories. Das Aenderungsdatum des Projekts taugt nicht: jeder Abgleich
|
||||
* fasst es an und wuerde den Stillstand immer wieder zuruecksetzen.
|
||||
* Projekte ohne Repository bleiben folglich aussen vor.
|
||||
*
|
||||
* Bei mehreren Repositories zaehlt der juengste Commit. Ein Vorhaben steht
|
||||
* nicht still, solange an einem seiner Teile gearbeitet wird.
|
||||
*/
|
||||
|
||||
export interface StaleReport {
|
||||
@@ -48,6 +51,22 @@ export function daysSince(date: Date, now: Date = new Date()): number {
|
||||
return Math.floor((now.getTime() - date.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der juengste Commit ueber alle Repositories hinweg. Massgeblich ist das
|
||||
* regste Repo: an einem Vorhaben wird gearbeitet, sobald an irgendeinem
|
||||
* seiner Teile gearbeitet wird.
|
||||
*/
|
||||
export function newestCommit(
|
||||
snapshots: Array<{ lastCommitAt: Date | null }>,
|
||||
): Date | null {
|
||||
let juengster: Date | null = null;
|
||||
for (const snapshot of snapshots) {
|
||||
if (!snapshot.lastCommitAt) continue;
|
||||
if (!juengster || snapshot.lastCommitAt > juengster) juengster = snapshot.lastCommitAt;
|
||||
}
|
||||
return juengster;
|
||||
}
|
||||
|
||||
function message(
|
||||
project: Project,
|
||||
snapshot: RepoSnapshot | null,
|
||||
@@ -79,20 +98,34 @@ export async function runStaleCheck(thresholdDays: number): Promise<StaleReport>
|
||||
// Fertige und archivierte Projekte duerfen ruhen.
|
||||
status: { notIn: ["DONE", "ARCHIVED"] },
|
||||
ownerId: { not: null },
|
||||
snapshot: { isNot: null },
|
||||
repositories: { some: { snapshot: { isNot: null } } },
|
||||
},
|
||||
include: {
|
||||
repositories: {
|
||||
include: { snapshot: true },
|
||||
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||
},
|
||||
},
|
||||
include: { snapshot: true },
|
||||
});
|
||||
|
||||
const letzterCommit = new Map(
|
||||
kandidaten.map((project) => [
|
||||
project.id,
|
||||
newestCommit(project.repositories.map((repo) => repo.snapshot).filter((s) => s !== null)),
|
||||
]),
|
||||
);
|
||||
|
||||
const faellig = kandidaten.filter((project) =>
|
||||
isStaleDue(project, project.snapshot?.lastCommitAt ?? null, cutoff),
|
||||
isStaleDue(project, letzterCommit.get(project.id) ?? null, cutoff),
|
||||
);
|
||||
|
||||
let zugestellt = 0;
|
||||
|
||||
for (const project of faellig) {
|
||||
const lastCommitAt = project.snapshot?.lastCommitAt;
|
||||
const lastCommitAt = letzterCommit.get(project.id);
|
||||
if (!lastCommitAt || !project.ownerId) continue;
|
||||
// Fuer den Link in der Meldung reicht das fuehrende Repository.
|
||||
const snapshot = project.repositories[0]?.snapshot ?? null;
|
||||
|
||||
const ziele = await prisma.notificationTarget.findMany({
|
||||
where: { userId: project.ownerId, isActive: true, events: { has: "PROJECT_STALE" } },
|
||||
@@ -101,7 +134,7 @@ export async function runStaleCheck(thresholdDays: number): Promise<StaleReport>
|
||||
for (const ziel of ziele) {
|
||||
const ergebnis = await deliver(
|
||||
ziel,
|
||||
message(project, project.snapshot, daysSince(lastCommitAt, now)),
|
||||
message(project, snapshot, daysSince(lastCommitAt, now)),
|
||||
);
|
||||
zugestellt += 1;
|
||||
await prisma.notificationTarget.update({
|
||||
|
||||
+13
-4
@@ -46,9 +46,19 @@ export const connectionSafeSelect = Prisma.validator<Prisma.GitConnectionSelect>
|
||||
updatedAt: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Ein Repository mitsamt Verbindung und Stand. Die Reihenfolge ist ueberall
|
||||
* dieselbe: das fuehrende zuerst, denn auf der Karte ist nur fuer eines Platz.
|
||||
*/
|
||||
export const repositoryInclude = Prisma.validator<Prisma.ProjectInclude>()({
|
||||
repositories: {
|
||||
include: { connection: { select: connectionSafeSelect }, snapshot: true },
|
||||
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||
},
|
||||
});
|
||||
|
||||
export const projectListInclude = Prisma.validator<Prisma.ProjectInclude>()({
|
||||
connection: { select: connectionSafeSelect },
|
||||
snapshot: true,
|
||||
...repositoryInclude,
|
||||
_count: { select: { notes: true, tasks: true } },
|
||||
// Nur die offenen Aufgaben zaehlen – fuer die Anzeige "3 von 8" auf der
|
||||
// Karte, ohne die Aufgaben selbst mitzuladen.
|
||||
@@ -56,8 +66,7 @@ export const projectListInclude = Prisma.validator<Prisma.ProjectInclude>()({
|
||||
});
|
||||
|
||||
export const projectDetailInclude = Prisma.validator<Prisma.ProjectInclude>()({
|
||||
connection: { select: connectionSafeSelect },
|
||||
snapshot: true,
|
||||
...repositoryInclude,
|
||||
notes: {
|
||||
orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { repoTitle } from "./git/meta";
|
||||
import { completeRepoInput, namesRepository } from "./repositories";
|
||||
|
||||
describe("completeRepoInput", () => {
|
||||
it("leitet Owner und Name aus der URL ab", () => {
|
||||
expect(completeRepoInput({ repoUrl: "https://github.com/vercel/swr" })).toMatchObject({
|
||||
repoOwner: "vercel",
|
||||
repoName: "swr",
|
||||
});
|
||||
});
|
||||
|
||||
it("laesst ausdruecklich gesetzte Werte in Ruhe", () => {
|
||||
// Bei GitLab-Untergruppen weiss der Benutzer es besser als die Ableitung.
|
||||
expect(
|
||||
completeRepoInput({
|
||||
repoUrl: "https://gitlab.com/gruppe/untergruppe/dings",
|
||||
repoOwner: "gruppe/untergruppe",
|
||||
repoName: "dings",
|
||||
}),
|
||||
).toMatchObject({ repoOwner: "gruppe/untergruppe", repoName: "dings" });
|
||||
});
|
||||
|
||||
it("kommt ohne URL und mit unbrauchbarer URL zurecht", () => {
|
||||
expect(completeRepoInput({ repoOwner: "a", repoName: "b" }).repoUrl).toBeUndefined();
|
||||
expect(completeRepoInput({ repoUrl: "kein-link" }).repoOwner).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("namesRepository", () => {
|
||||
it("erkennt, wann ueberhaupt ein Repo benannt wurde", () => {
|
||||
expect(namesRepository({ repoUrl: "https://github.com/a/b" })).toBe(true);
|
||||
expect(namesRepository({ repoOwner: "a", repoName: "b" })).toBe(true);
|
||||
});
|
||||
|
||||
it("laesst Halbes nicht durchgehen", () => {
|
||||
// Ein Owner ohne Namen ergibt kein Repository, sondern eine leere Zeile.
|
||||
expect(namesRepository({})).toBe(false);
|
||||
expect(namesRepository({ repoOwner: "a" })).toBe(false);
|
||||
expect(namesRepository({ label: "Frontend" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("repoTitle", () => {
|
||||
it("zieht die Bezeichnung allem anderen vor", () => {
|
||||
expect(repoTitle({ label: "Frontend", repoOwner: "a", repoName: "b" })).toBe("Frontend");
|
||||
});
|
||||
|
||||
it("faellt ueber owner/name auf die URL zurueck", () => {
|
||||
expect(repoTitle({ repoOwner: "vercel", repoName: "swr" })).toBe("vercel/swr");
|
||||
expect(repoTitle({ repoName: "swr" })).toBe("swr");
|
||||
expect(repoTitle({ repoUrl: "https://example.com/x" })).toBe("https://example.com/x");
|
||||
expect(repoTitle({})).toBe("Repository");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { parseRepoUrl } from "@/lib/git";
|
||||
import { repoTitle } from "@/lib/git/meta";
|
||||
|
||||
// Der Titel steht in lib/git/meta, weil ihn auch Client-Komponenten
|
||||
// brauchen – dieses Modul zieht Prisma nach. Hier weitergereicht, damit
|
||||
// serverseitiger Code nur eine Anlaufstelle hat.
|
||||
export { repoTitle };
|
||||
|
||||
/**
|
||||
* Ein Projekt kann mehrere Repositories haben – Frontend und Backend etwa,
|
||||
* aber ein Vorhaben.
|
||||
*
|
||||
* Das erste (position 0) ist das fuehrende. Wo nur eines dargestellt werden
|
||||
* kann, ist es dieses: auf der Karte im Dashboard, im Kopf der Projektseite
|
||||
* und in allem, was die alte Projekt-API weiterhin annimmt.
|
||||
*/
|
||||
|
||||
/** Angaben, aus denen ein Repository entsteht. */
|
||||
export interface RepoInput {
|
||||
label?: string | null;
|
||||
repoUrl?: string | null;
|
||||
repoOwner?: string | null;
|
||||
repoName?: string | null;
|
||||
connectionId?: string | null;
|
||||
autoSync?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ergaenzt Owner und Name aus der URL, wenn sie nicht angegeben wurden.
|
||||
* Ohne das muesste man beides von Hand eintragen, obwohl es in der URL steht.
|
||||
*/
|
||||
export function completeRepoInput(input: RepoInput): RepoInput {
|
||||
if (!input.repoUrl || (input.repoOwner && input.repoName)) return input;
|
||||
|
||||
const parsed = parseRepoUrl(input.repoUrl);
|
||||
if (!parsed) return input;
|
||||
|
||||
return {
|
||||
...input,
|
||||
repoOwner: input.repoOwner ?? parsed.owner,
|
||||
repoName: input.repoName ?? parsed.name,
|
||||
};
|
||||
}
|
||||
|
||||
/** Hat die Eingabe ueberhaupt ein Repository benannt? */
|
||||
export function namesRepository(input: RepoInput): boolean {
|
||||
return Boolean(input.repoUrl || (input.repoOwner && input.repoName));
|
||||
}
|
||||
|
||||
/** Prueft Existenz und Besitz in einem Zug – ueber das Projekt. */
|
||||
export async function ownedRepository(repositoryId: string, ownerId: string) {
|
||||
return prisma.repository.findFirst({
|
||||
where: { id: repositoryId, project: { ownerId } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Naechste freie Position am Ende der Liste. */
|
||||
export async function nextRepoPosition(projectId: string): Promise<number> {
|
||||
const last = await prisma.repository.findFirst({
|
||||
where: { projectId },
|
||||
orderBy: { position: "desc" },
|
||||
select: { position: true },
|
||||
});
|
||||
return (last?.position ?? -1) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Angaben am fuehrenden Repository – und legt es an, falls es noch
|
||||
* keines gibt.
|
||||
*
|
||||
* Damit bleibt die Projekt-API unveraendert bedienbar: wer beim Anlegen oder
|
||||
* Bearbeiten eine Repo-URL mitgibt, bekommt weiterhin das erwartete Ergebnis,
|
||||
* ohne von der neuen Tabelle wissen zu muessen.
|
||||
*
|
||||
* Eine geleerte URL entfernt das Repository nicht, sondern raeumt nur seine
|
||||
* Felder – der gesammelte Stand haengt daran, und ein versehentlich
|
||||
* geleertes Feld soll ihn nicht mitnehmen.
|
||||
*/
|
||||
export async function applyLeadingRepo(projectId: string, input: RepoInput): Promise<void> {
|
||||
const vollstaendig = completeRepoInput(input);
|
||||
|
||||
const fuehrend = await prisma.repository.findFirst({
|
||||
where: { projectId },
|
||||
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!fuehrend) {
|
||||
if (!namesRepository(vollstaendig)) return;
|
||||
await prisma.repository.create({
|
||||
data: {
|
||||
projectId,
|
||||
label: vollstaendig.label ?? null,
|
||||
repoUrl: vollstaendig.repoUrl ?? null,
|
||||
repoOwner: vollstaendig.repoOwner ?? null,
|
||||
repoName: vollstaendig.repoName ?? null,
|
||||
connectionId: vollstaendig.connectionId ?? null,
|
||||
autoSync: vollstaendig.autoSync ?? true,
|
||||
position: 0,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (vollstaendig.label !== undefined) data.label = vollstaendig.label;
|
||||
if (vollstaendig.repoUrl !== undefined) data.repoUrl = vollstaendig.repoUrl;
|
||||
if (vollstaendig.repoOwner !== undefined) data.repoOwner = vollstaendig.repoOwner;
|
||||
if (vollstaendig.repoName !== undefined) data.repoName = vollstaendig.repoName;
|
||||
if (vollstaendig.connectionId !== undefined) data.connectionId = vollstaendig.connectionId;
|
||||
if (vollstaendig.autoSync !== undefined) data.autoSync = vollstaendig.autoSync;
|
||||
|
||||
if (Object.keys(data).length === 0) return;
|
||||
await prisma.repository.update({ where: { id: fuehrend.id }, data });
|
||||
}
|
||||
+103
-40
@@ -1,8 +1,9 @@
|
||||
import type { Project } from "@prisma/client";
|
||||
import type { Repository } from "@prisma/client";
|
||||
import { recordActivity, shorten } from "@/lib/activity";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git";
|
||||
import { notifyAfterSync } from "@/lib/notify";
|
||||
import { repoTitle } from "@/lib/repositories";
|
||||
import type { RepoSnapshotData } from "@/lib/git/types";
|
||||
|
||||
export interface SyncResult {
|
||||
@@ -12,21 +13,21 @@ export interface SyncResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt den aktuellen Stand des verknuepften Repositories und schreibt ihn
|
||||
* als Snapshot in die Datenbank. Fehler werden am Projekt vermerkt statt
|
||||
* geworfen, damit ein kaputtes Repo nicht das ganze Dashboard blockiert.
|
||||
* Holt den aktuellen Stand eines Repositories und schreibt ihn als Snapshot
|
||||
* in die Datenbank. Fehler werden am Repository vermerkt statt geworfen,
|
||||
* damit ein kaputtes Repo nicht das ganze Dashboard blockiert.
|
||||
*/
|
||||
export async function syncProject(projectId: string): Promise<SyncResult> {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
export async function syncRepository(repositoryId: string): Promise<SyncResult> {
|
||||
const repository = await prisma.repository.findUnique({
|
||||
where: { id: repositoryId },
|
||||
include: { connection: true },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return { ok: false, message: "Projekt nicht gefunden." };
|
||||
if (!repository) {
|
||||
return { ok: false, message: "Repository nicht gefunden." };
|
||||
}
|
||||
|
||||
const ref = resolveRepoRef(project);
|
||||
const ref = resolveRepoRef(repository);
|
||||
if (!ref) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -35,11 +36,11 @@ export async function syncProject(projectId: string): Promise<SyncResult> {
|
||||
}
|
||||
|
||||
let client;
|
||||
if (project.connection) {
|
||||
client = clientForConnection(project.connection);
|
||||
if (repository.connection) {
|
||||
client = clientForConnection(repository.connection);
|
||||
} else {
|
||||
// Ohne gespeicherte Verbindung: anonymer Zugriff anhand der URL.
|
||||
const parsed = project.repoUrl ? parseRepoUrl(project.repoUrl) : null;
|
||||
const parsed = repository.repoUrl ? parseRepoUrl(repository.repoUrl) : null;
|
||||
if (!parsed) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -55,9 +56,9 @@ export async function syncProject(projectId: string): Promise<SyncResult> {
|
||||
|
||||
try {
|
||||
const data = await client.fetchRepo(ref);
|
||||
await persistSnapshot(project.id, data);
|
||||
await persistSnapshot(repository, data);
|
||||
// Nach dem Speichern, damit die Meldung den neuen Stand sieht.
|
||||
await notifyAfterSync(project.id);
|
||||
await notifyAfterSync(repository.id);
|
||||
return {
|
||||
ok: true,
|
||||
message: `Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.`,
|
||||
@@ -65,8 +66,8 @@ export async function syncProject(projectId: string): Promise<SyncResult> {
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await prisma.project.update({
|
||||
where: { id: project.id },
|
||||
await prisma.repository.update({
|
||||
where: { id: repository.id },
|
||||
data: {
|
||||
syncError: message,
|
||||
lastSyncedAt: new Date(),
|
||||
@@ -76,25 +77,79 @@ export async function syncProject(projectId: string): Promise<SyncResult> {
|
||||
});
|
||||
// Nur der Uebergang in den Fehlerzustand kommt in den Verlauf. Ein Repo
|
||||
// mit abgelaufenem Token schlaegt sonst stuendlich neu auf.
|
||||
if (!project.syncError) {
|
||||
if (!repository.syncError) {
|
||||
await recordActivity({
|
||||
projectId: project.id,
|
||||
projectId: repository.projectId,
|
||||
kind: "SYNC_FAILED",
|
||||
summary: `Abgleich fehlgeschlagen: ${shorten(message, 120)}`,
|
||||
summary: `Abgleich von ${repoTitle(repository)} fehlgeschlagen: ${shorten(message, 100)}`,
|
||||
});
|
||||
}
|
||||
await notifyAfterSync(project.id);
|
||||
await notifyAfterSync(repository.id);
|
||||
return { ok: false, message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Owner/Name aus den Feldern oder ersatzweise aus der URL bestimmen. */
|
||||
export function resolveRepoRef(project: Project) {
|
||||
if (project.repoOwner && project.repoName) {
|
||||
return { owner: project.repoOwner, name: project.repoName };
|
||||
export interface ProjectSyncResult extends SyncResult {
|
||||
/** Ergebnis je Repository – auch dann, wenn nur eines dranhaengt. */
|
||||
repositories: Array<{ id: string; title: string } & SyncResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gleicht alle Repositories eines Projekts ab, nacheinander.
|
||||
*
|
||||
* Nacheinander und nicht parallel: die Repos eines Projekts liegen meist
|
||||
* beim selben Anbieter, und ein Schwung gleichzeitiger Anfragen ist genau
|
||||
* das, worauf Rate-Limits reagieren.
|
||||
*/
|
||||
export async function syncProject(projectId: string): Promise<ProjectSyncResult> {
|
||||
const repositories = await prisma.repository.findMany({
|
||||
where: { projectId },
|
||||
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
|
||||
if (repositories.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "Kein Repository hinterlegt – bitte eines verknuepfen.",
|
||||
repositories: [],
|
||||
};
|
||||
}
|
||||
if (project.repoUrl) {
|
||||
const parsed = parseRepoUrl(project.repoUrl);
|
||||
|
||||
const ergebnisse = [];
|
||||
for (const repository of repositories) {
|
||||
ergebnisse.push({
|
||||
id: repository.id,
|
||||
title: repoTitle(repository),
|
||||
...(await syncRepository(repository.id)),
|
||||
});
|
||||
}
|
||||
|
||||
const fehler = ergebnisse.filter((entry) => !entry.ok);
|
||||
|
||||
return {
|
||||
// Erfolgreich nur, wenn kein einziges Repo gescheitert ist. Ein "teilweise
|
||||
// ok" waere eine Meldung, die man wegklickt, ohne hinzusehen.
|
||||
ok: fehler.length === 0,
|
||||
message:
|
||||
fehler.length === 0
|
||||
? ergebnisse.length === 1
|
||||
? ergebnisse[0].message
|
||||
: `${ergebnisse.length} Repositories abgeglichen.`
|
||||
: fehler.map((entry) => `${entry.title}: ${entry.message}`).join(" · "),
|
||||
fetchedAt: new Date().toISOString(),
|
||||
repositories: ergebnisse,
|
||||
};
|
||||
}
|
||||
|
||||
/** Owner/Name aus den Feldern oder ersatzweise aus der URL bestimmen. */
|
||||
export function resolveRepoRef(
|
||||
repository: Pick<Repository, "repoOwner" | "repoName" | "repoUrl">,
|
||||
) {
|
||||
if (repository.repoOwner && repository.repoName) {
|
||||
return { owner: repository.repoOwner, name: repository.repoName };
|
||||
}
|
||||
if (repository.repoUrl) {
|
||||
const parsed = parseRepoUrl(repository.repoUrl);
|
||||
if (parsed) return { owner: parsed.owner, name: parsed.name };
|
||||
}
|
||||
return null;
|
||||
@@ -109,7 +164,7 @@ export function resolveRepoRef(project: Project) {
|
||||
* Commits hat, ist keine Neuigkeit.
|
||||
*/
|
||||
async function noteNewCommits(
|
||||
projectId: string,
|
||||
repository: Repository,
|
||||
vorher: string | null,
|
||||
data: RepoSnapshotData,
|
||||
) {
|
||||
@@ -121,20 +176,21 @@ async function noteNewCommits(
|
||||
// Zahl als mit einer falschen.
|
||||
const index = data.commits.findIndex((commit) => commit.sha === vorher);
|
||||
const anzahl = index > 0 ? index : null;
|
||||
const wo = data.defaultBranch ? ` auf ${data.defaultBranch}` : "";
|
||||
const wo = data.defaultBranch ? `${repoTitle(repository)} · ${data.defaultBranch}` : repoTitle(repository);
|
||||
const betreff = shorten(data.commits[0]?.message ?? "", 60);
|
||||
|
||||
await recordActivity({
|
||||
projectId,
|
||||
projectId: repository.projectId,
|
||||
kind: "REPO_COMMITS",
|
||||
summary:
|
||||
anzahl === null
|
||||
? `Neue Commits${wo}: ${shorten(data.commits[0]?.message ?? "", 60)}`
|
||||
: `${anzahl} neue${anzahl === 1 ? "r" : ""} Commit${anzahl === 1 ? "" : "s"}${wo}: ${shorten(data.commits[0]?.message ?? "", 60)}`,
|
||||
meta: { from: vorher, to: neuester, count: anzahl },
|
||||
? `Neue Commits in ${wo}: ${betreff}`
|
||||
: `${anzahl} neue${anzahl === 1 ? "r" : ""} Commit${anzahl === 1 ? "" : "s"} in ${wo}: ${betreff}`,
|
||||
meta: { repositoryId: repository.id, from: vorher, to: neuester, count: anzahl },
|
||||
});
|
||||
}
|
||||
|
||||
async function persistSnapshot(projectId: string, data: RepoSnapshotData) {
|
||||
async function persistSnapshot(repository: Repository, data: RepoSnapshotData) {
|
||||
const latestCommit = data.commits[0] ?? null;
|
||||
const latestRelease =
|
||||
data.releases.find((release) => !release.draft && !release.prerelease) ?? data.releases[0] ?? null;
|
||||
@@ -179,18 +235,18 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) {
|
||||
|
||||
// Vor dem Ueberschreiben lesen – danach ist der alte Stand weg.
|
||||
const vorher = await prisma.repoSnapshot.findUnique({
|
||||
where: { projectId },
|
||||
where: { repositoryId: repository.id },
|
||||
select: { lastCommitSha: true },
|
||||
});
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.repoSnapshot.upsert({
|
||||
where: { projectId },
|
||||
create: { projectId, ...payload },
|
||||
where: { repositoryId: repository.id },
|
||||
create: { repositoryId: repository.id, ...payload },
|
||||
update: payload,
|
||||
}),
|
||||
prisma.project.update({
|
||||
where: { id: projectId },
|
||||
prisma.repository.update({
|
||||
where: { id: repository.id },
|
||||
data: {
|
||||
lastSyncedAt: new Date(),
|
||||
syncError: null,
|
||||
@@ -199,7 +255,14 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) {
|
||||
repoUrl: data.htmlUrl ?? undefined,
|
||||
},
|
||||
}),
|
||||
// Das Projekt mit anfassen. Solange die Git-Felder daran hingen, geschah
|
||||
// das nebenbei; jetzt muss es ausdruecklich passieren, sonst faellt ein
|
||||
// regelmaessig abgeglichenes Projekt in der Sortierung nach hinten.
|
||||
prisma.project.update({
|
||||
where: { id: repository.projectId },
|
||||
data: { updatedAt: new Date() },
|
||||
}),
|
||||
]);
|
||||
|
||||
await noteNewCommits(projectId, vorher?.lastCommitSha ?? null, data);
|
||||
await noteNewCommits(repository, vorher?.lastCommitSha ?? null, data);
|
||||
}
|
||||
|
||||
+16
-13
@@ -3,7 +3,7 @@ import { pruneActivities } from "@/lib/activity";
|
||||
import { getAppSettings } from "@/lib/auth/settings";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { runStaleCheck } from "@/lib/notify/stale";
|
||||
import { syncProject } from "@/lib/sync";
|
||||
import { syncRepository } from "@/lib/sync";
|
||||
|
||||
/**
|
||||
* Automatischer Abgleich mit den Git-Systemen.
|
||||
@@ -147,42 +147,45 @@ export async function runAutoSyncPass(
|
||||
const batchSize = Math.max(1, Math.min(200, settings.autoSyncBatchSize));
|
||||
const intervalMs = Math.max(5, settings.autoSyncIntervalMinutes) * 60_000;
|
||||
|
||||
const where: Prisma.ProjectWhereInput = {
|
||||
// Gezaehlt und begrenzt wird seit dem Umbau je Repository, nicht je
|
||||
// Projekt: sonst zoege ein Projekt mit fuenf Repos fuenfmal so viele
|
||||
// Anfragen nach sich wie sein Platz im Kontingent hergibt.
|
||||
const where: Prisma.RepositoryWhereInput = {
|
||||
autoSync: true,
|
||||
// Ohne Repository gibt es nichts abzugleichen.
|
||||
// Ohne Angaben gibt es nichts abzugleichen.
|
||||
OR: [
|
||||
{ AND: [{ repoOwner: { not: null } }, { repoName: { not: null } }] },
|
||||
{ repoUrl: { not: null } },
|
||||
],
|
||||
...(settings.autoSyncActiveOnly
|
||||
? { status: { notIn: ["DONE", "ARCHIVED"] } }
|
||||
? { project: { status: { notIn: ["DONE", "ARCHIVED"] } } }
|
||||
: {}),
|
||||
};
|
||||
|
||||
// Grosszuegig holen: ein Teil faellt gleich der Zurueckhaltung zum Opfer.
|
||||
const candidates = await prisma.project.findMany({
|
||||
const candidates = await prisma.repository.findMany({
|
||||
where,
|
||||
orderBy: [{ lastSyncedAt: { sort: "asc", nulls: "first" } }],
|
||||
take: batchSize * 4,
|
||||
select: { id: true, name: true, lastSyncedAt: true, syncFailureCount: true },
|
||||
select: { id: true, lastSyncedAt: true, syncFailureCount: true },
|
||||
});
|
||||
|
||||
const due = candidates.filter((project) => {
|
||||
if (project.syncFailureCount === 0) return true;
|
||||
if (!project.lastSyncedAt) return true;
|
||||
const due = candidates.filter((repository) => {
|
||||
if (repository.syncFailureCount === 0) return true;
|
||||
if (!repository.lastSyncedAt) return true;
|
||||
|
||||
// Nach jedem Fehlschlag doppelt so lange warten, gedeckelt.
|
||||
const steps = Math.min(project.syncFailureCount, MAX_BACKOFF_STEPS);
|
||||
const steps = Math.min(repository.syncFailureCount, MAX_BACKOFF_STEPS);
|
||||
const wait = intervalMs * 2 ** steps;
|
||||
const ready = Date.now() - project.lastSyncedAt.getTime() >= wait;
|
||||
const ready = Date.now() - repository.lastSyncedAt.getTime() >= wait;
|
||||
if (!ready) skipped += 1;
|
||||
return ready;
|
||||
});
|
||||
|
||||
const batch = due.slice(0, batchSize);
|
||||
|
||||
for (const project of batch) {
|
||||
const result = await syncProject(project.id);
|
||||
for (const repository of batch) {
|
||||
const result = await syncRepository(repository.id);
|
||||
if (result.ok) synced += 1;
|
||||
else failed += 1;
|
||||
if (PAUSE_MS > 0) await new Promise((resolve) => setTimeout(resolve, PAUSE_MS));
|
||||
|
||||
@@ -17,6 +17,12 @@ export const projectCreateSchema = z.object({
|
||||
accent: z.enum(ACCENTS).default("violet"),
|
||||
tags: z.array(z.string().trim().min(1).max(30)).max(12).default([]),
|
||||
favorite: z.boolean().default(false),
|
||||
/**
|
||||
* Die Git-Felder liegen seit dem Umbau am Repository, bleiben hier aber
|
||||
* stehen: sie setzen das fuehrende Repository und legen es notfalls an.
|
||||
* So kommt man mit einem Aufruf zu Projekt und Repo, und bestehende
|
||||
* Aufrufer – auch der Import – muessen nichts wissen.
|
||||
*/
|
||||
repoUrl: z.preprocess(emptyToNull, z.string().trim().max(500).nullable().optional()),
|
||||
repoOwner: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
|
||||
repoName: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
|
||||
@@ -29,6 +35,22 @@ export const projectCreateSchema = z.object({
|
||||
|
||||
export const projectUpdateSchema = projectCreateSchema.partial();
|
||||
|
||||
// ── Repositories ─────────────────────────────────────────────
|
||||
|
||||
export const repositoryCreateSchema = z.object({
|
||||
label: z.preprocess(emptyToNull, z.string().trim().max(80).nullable().optional()),
|
||||
repoUrl: z.preprocess(emptyToNull, z.string().trim().max(500).nullable().optional()),
|
||||
repoOwner: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
|
||||
repoName: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
|
||||
connectionId: z.preprocess(emptyToNull, z.string().trim().nullable().optional()),
|
||||
autoSync: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const repositoryUpdateSchema = repositoryCreateSchema.partial().extend({
|
||||
/** Zum Umsortieren; 0 ist das fuehrende. */
|
||||
position: z.coerce.number().int().min(0).max(50).optional(),
|
||||
});
|
||||
|
||||
// ── Aufgaben ─────────────────────────────────────────────────
|
||||
|
||||
const TASK_STATUS = ["TODO", "DOING", "BLOCKED", "DONE"] as const;
|
||||
|
||||
Reference in New Issue
Block a user