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:
@@ -0,0 +1,113 @@
|
|||||||
|
-- Mehrere Repositories je Projekt.
|
||||||
|
--
|
||||||
|
-- Die Git-Felder wandern vom Projekt in eine eigene Tabelle. Bestehende
|
||||||
|
-- Projekte bekommen daraus genau ein Repository, damit nichts verlorengeht;
|
||||||
|
-- der Snapshot haengt danach am Repository statt am Projekt.
|
||||||
|
--
|
||||||
|
-- Von Hand geschrieben: prisma migrate diff wuerde die Spalten am Projekt
|
||||||
|
-- ersatzlos loeschen und die Snapshots gleich mit.
|
||||||
|
|
||||||
|
-- ── 1. Neue Tabelle ─────────────────────────────────────────
|
||||||
|
CREATE TABLE "Repository" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"label" TEXT,
|
||||||
|
"repoUrl" TEXT,
|
||||||
|
"repoOwner" TEXT,
|
||||||
|
"repoName" TEXT,
|
||||||
|
"defaultBranch" TEXT,
|
||||||
|
"connectionId" TEXT,
|
||||||
|
"position" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"lastSyncedAt" TIMESTAMP(3),
|
||||||
|
"syncError" TEXT,
|
||||||
|
"autoSync" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"syncFailureCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"notifiedSyncError" TEXT,
|
||||||
|
"notifiedCiState" TEXT,
|
||||||
|
"notifiedRelease" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Repository_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "Repository_projectId_position_idx" ON "Repository"("projectId", "position");
|
||||||
|
CREATE INDEX "Repository_connectionId_idx" ON "Repository"("connectionId");
|
||||||
|
|
||||||
|
ALTER TABLE "Repository" ADD CONSTRAINT "Repository_projectId_fkey"
|
||||||
|
FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "Repository" ADD CONSTRAINT "Repository_connectionId_fkey"
|
||||||
|
FOREIGN KEY ("connectionId") REFERENCES "GitConnection"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- ── 2. Bestand uebernehmen ──────────────────────────────────
|
||||||
|
-- Nur Projekte, an denen wirklich etwas haengt. Ein Projekt, das bloss eine
|
||||||
|
-- Verbindung ausgewaehlt hatte, ohne je ein Repo zu nennen, braucht keine
|
||||||
|
-- leere Zeile.
|
||||||
|
INSERT INTO "Repository" (
|
||||||
|
"id", "projectId", "repoUrl", "repoOwner", "repoName", "defaultBranch",
|
||||||
|
"connectionId", "position", "lastSyncedAt", "syncError", "autoSync",
|
||||||
|
"syncFailureCount", "notifiedSyncError", "notifiedCiState", "notifiedRelease",
|
||||||
|
"createdAt", "updatedAt"
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid()::text, "id", "repoUrl", "repoOwner", "repoName", "defaultBranch",
|
||||||
|
"connectionId", 0, "lastSyncedAt", "syncError", "autoSync",
|
||||||
|
"syncFailureCount", "notifiedSyncError", "notifiedCiState", "notifiedRelease",
|
||||||
|
"createdAt", CURRENT_TIMESTAMP
|
||||||
|
FROM "Project"
|
||||||
|
WHERE "repoUrl" IS NOT NULL
|
||||||
|
OR ("repoOwner" IS NOT NULL AND "repoName" IS NOT NULL);
|
||||||
|
|
||||||
|
-- ── 3. Snapshots umhaengen ──────────────────────────────────
|
||||||
|
ALTER TABLE "RepoSnapshot" ADD COLUMN "repositoryId" TEXT;
|
||||||
|
|
||||||
|
UPDATE "RepoSnapshot" AS s
|
||||||
|
SET "repositoryId" = r."id"
|
||||||
|
FROM "Repository" AS r
|
||||||
|
WHERE r."projectId" = s."projectId";
|
||||||
|
|
||||||
|
-- Ein Snapshot ohne zugehoeriges Repository kann nur entstanden sein, wenn
|
||||||
|
-- jemand die Repo-Angaben nachtraeglich geleert hat. Er ist ohnehin nicht
|
||||||
|
-- mehr zuzuordnen und beim naechsten Abgleich neu geholt.
|
||||||
|
DELETE FROM "RepoSnapshot" WHERE "repositoryId" IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "RepoSnapshot" ALTER COLUMN "repositoryId" SET NOT NULL;
|
||||||
|
ALTER TABLE "RepoSnapshot" DROP CONSTRAINT "RepoSnapshot_projectId_fkey";
|
||||||
|
DROP INDEX "RepoSnapshot_projectId_key";
|
||||||
|
ALTER TABLE "RepoSnapshot" DROP COLUMN "projectId";
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "RepoSnapshot_repositoryId_key" ON "RepoSnapshot"("repositoryId");
|
||||||
|
ALTER TABLE "RepoSnapshot" ADD CONSTRAINT "RepoSnapshot_repositoryId_fkey"
|
||||||
|
FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- ── 4. Herkunft uebernommener Aufgaben ──────────────────────
|
||||||
|
-- Bisher war die Issue-Nummer je Projekt eindeutig. Mit mehreren Repos
|
||||||
|
-- reicht das nicht mehr: Frontend und Backend haben beide ein Issue 5.
|
||||||
|
ALTER TABLE "Task" ADD COLUMN "sourceRepositoryId" TEXT;
|
||||||
|
|
||||||
|
UPDATE "Task" AS t
|
||||||
|
SET "sourceRepositoryId" = r."id"
|
||||||
|
FROM "Repository" AS r
|
||||||
|
WHERE r."projectId" = t."projectId" AND t."sourceIssue" IS NOT NULL;
|
||||||
|
|
||||||
|
DROP INDEX "Task_projectId_sourceIssue_key";
|
||||||
|
CREATE UNIQUE INDEX "Task_projectId_sourceRepositoryId_sourceIssue_key"
|
||||||
|
ON "Task"("projectId", "sourceRepositoryId", "sourceIssue");
|
||||||
|
|
||||||
|
-- ── 5. Projekt aufraeumen ───────────────────────────────────
|
||||||
|
ALTER TABLE "Project" DROP CONSTRAINT "Project_connectionId_fkey";
|
||||||
|
DROP INDEX "Project_connectionId_idx";
|
||||||
|
|
||||||
|
ALTER TABLE "Project"
|
||||||
|
DROP COLUMN "repoUrl",
|
||||||
|
DROP COLUMN "repoOwner",
|
||||||
|
DROP COLUMN "repoName",
|
||||||
|
DROP COLUMN "defaultBranch",
|
||||||
|
DROP COLUMN "connectionId",
|
||||||
|
DROP COLUMN "lastSyncedAt",
|
||||||
|
DROP COLUMN "syncError",
|
||||||
|
DROP COLUMN "autoSync",
|
||||||
|
DROP COLUMN "syncFailureCount",
|
||||||
|
DROP COLUMN "notifiedSyncError",
|
||||||
|
DROP COLUMN "notifiedCiState",
|
||||||
|
DROP COLUMN "notifiedRelease";
|
||||||
+61
-29
@@ -286,7 +286,7 @@ model GitConnection {
|
|||||||
ownerId String?
|
ownerId String?
|
||||||
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
projects Project[]
|
repositories Repository[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -316,15 +316,61 @@ model Project {
|
|||||||
tags String[] @default([])
|
tags String[] @default([])
|
||||||
favorite Boolean @default(false)
|
favorite Boolean @default(false)
|
||||||
|
|
||||||
// ── Git-Anbindung ──────────────────────────────────────────
|
/// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim
|
||||||
|
/// Einrichten des ersten Kontos uebernommen werden koennen.
|
||||||
|
ownerId String?
|
||||||
|
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
/// Fortschritt aus dem Verhaeltnis erledigter Aufgaben berechnen statt
|
||||||
|
/// von Hand zu schieben. Nur sinnvoll, wenn es ueberhaupt Aufgaben gibt.
|
||||||
|
progressFromTasks Boolean @default(false)
|
||||||
|
|
||||||
|
/// Wann zuletzt wegen Stillstands gemeldet wurde. Erneut gemeldet wird
|
||||||
|
/// erst, wenn danach wieder ein Commit kam und es dann wieder still wurde.
|
||||||
|
/// Bleibt am Projekt: Stillstand ist eine Aussage ueber das Vorhaben, nicht
|
||||||
|
/// ueber ein einzelnes Repository.
|
||||||
|
notifiedStaleAt DateTime?
|
||||||
|
|
||||||
|
notes Note[]
|
||||||
|
tasks Task[]
|
||||||
|
activities Activity[]
|
||||||
|
repositories Repository[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([ownerId, slug])
|
||||||
|
@@index([status])
|
||||||
|
@@index([updatedAt])
|
||||||
|
@@index([ownerId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Repository eines Projekts. Ein Vorhaben besteht oft aus mehreren –
|
||||||
|
/// Frontend und Backend etwa –, gehoert aber in eine Karte.
|
||||||
|
///
|
||||||
|
/// Das erste Repository (position 0) ist das fuehrende: seine Kennzahlen
|
||||||
|
/// stehen auf der Projektkarte, wo fuer mehrere kein Platz ist.
|
||||||
|
model Repository {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
/// Frei waehlbare Bezeichnung wie "Frontend". Leer heisst: der Repo-Name
|
||||||
|
/// steht fuer sich.
|
||||||
|
label String?
|
||||||
|
|
||||||
repoUrl String?
|
repoUrl String?
|
||||||
/// Owner bzw. Namespace/Gruppe (bei GitLab auch verschachtelt: "gruppe/subgruppe")
|
/// Owner bzw. Namespace/Gruppe (bei GitLab auch verschachtelt: "gruppe/subgruppe")
|
||||||
repoOwner String?
|
repoOwner String?
|
||||||
repoName String?
|
repoName String?
|
||||||
defaultBranch String?
|
defaultBranch String?
|
||||||
|
|
||||||
connectionId String?
|
connectionId String?
|
||||||
connection GitConnection? @relation(fields: [connectionId], references: [id], onDelete: SetNull)
|
connection GitConnection? @relation(fields: [connectionId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
/// Reihenfolge in der Anzeige.
|
||||||
|
position Int @default(0)
|
||||||
|
|
||||||
lastSyncedAt DateTime?
|
lastSyncedAt DateTime?
|
||||||
syncError String?
|
syncError String?
|
||||||
/// Am automatischen Abgleich teilnehmen. Einzeln abschaltbar, etwa bei
|
/// Am automatischen Abgleich teilnehmen. Einzeln abschaltbar, etwa bei
|
||||||
@@ -334,38 +380,21 @@ model Project {
|
|||||||
/// jedem Fehlschlag wird der naechste Versuch weiter hinausgezoegert.
|
/// jedem Fehlschlag wird der naechste Versuch weiter hinausgezoegert.
|
||||||
syncFailureCount Int @default(0)
|
syncFailureCount Int @default(0)
|
||||||
|
|
||||||
/// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim
|
|
||||||
/// Einrichten des ersten Kontos uebernommen werden koennen.
|
|
||||||
ownerId String?
|
|
||||||
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
/// Fortschritt aus dem Verhaeltnis erledigter Aufgaben berechnen statt
|
|
||||||
/// von Hand zu schieben. Nur sinnvoll, wenn es ueberhaupt Aufgaben gibt.
|
|
||||||
progressFromTasks Boolean @default(false)
|
|
||||||
|
|
||||||
/// Woruber zuletzt benachrichtigt wurde. Gemeldet wird nur bei einer
|
/// Woruber zuletzt benachrichtigt wurde. Gemeldet wird nur bei einer
|
||||||
/// Aenderung – sonst kaeme bei einem dauerhaft kaputten Repo mit jedem
|
/// Aenderung – sonst kaeme bei einem dauerhaft kaputten Repo mit jedem
|
||||||
/// Abgleich dieselbe Nachricht.
|
/// Abgleich dieselbe Nachricht. Je Repository, nicht je Projekt: sonst
|
||||||
|
/// verdeckt ein rotes Frontend die rote Pipeline des Backends.
|
||||||
notifiedSyncError String?
|
notifiedSyncError String?
|
||||||
notifiedCiState String?
|
notifiedCiState String?
|
||||||
notifiedRelease String?
|
notifiedRelease String?
|
||||||
/// Wann zuletzt wegen Stillstands gemeldet wurde. Erneut gemeldet wird
|
|
||||||
/// erst, wenn danach wieder ein Commit kam und es dann wieder still wurde.
|
|
||||||
notifiedStaleAt DateTime?
|
|
||||||
|
|
||||||
notes Note[]
|
|
||||||
tasks Task[]
|
|
||||||
snapshot RepoSnapshot?
|
snapshot RepoSnapshot?
|
||||||
activities Activity[]
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@unique([ownerId, slug])
|
@@index([projectId, position])
|
||||||
@@index([status])
|
|
||||||
@@index([connectionId])
|
@@index([connectionId])
|
||||||
@@index([updatedAt])
|
|
||||||
@@index([ownerId])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ein Zustellweg fuer Benachrichtigungen, gehoert genau einem Konto.
|
/// Ein Zustellweg fuer Benachrichtigungen, gehoert genau einem Konto.
|
||||||
@@ -416,17 +445,20 @@ model Task {
|
|||||||
/// Zeitpunkt, zu dem die Aufgabe nach DONE gewandert ist.
|
/// Zeitpunkt, zu dem die Aufgabe nach DONE gewandert ist.
|
||||||
doneAt DateTime?
|
doneAt DateTime?
|
||||||
|
|
||||||
/// Stammt die Aufgabe aus einem Git-Issue? Nummer und Link merken, damit
|
/// Stammt die Aufgabe aus einem Git-Issue? Nummer, Repository und Link
|
||||||
/// dasselbe Issue nicht zweimal uebernommen wird.
|
/// merken, damit dasselbe Issue nicht zweimal uebernommen wird.
|
||||||
sourceIssue Int?
|
sourceIssue Int?
|
||||||
sourceUrl String?
|
sourceUrl String?
|
||||||
|
/// Aus welchem Repository. Muss mit in die Eindeutigkeit: Frontend und
|
||||||
|
/// Backend haben beide ein Issue Nummer 5, und das sind zwei Aufgaben.
|
||||||
|
sourceRepositoryId String?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
/// Verhindert Dubletten beim erneuten Uebernehmen. Mehrere Aufgaben ohne
|
/// Verhindert Dubletten beim erneuten Uebernehmen. Mehrere Aufgaben ohne
|
||||||
/// Herkunft sind erlaubt: NULL gilt in Postgres als verschieden.
|
/// Herkunft sind erlaubt: NULL gilt in Postgres als verschieden.
|
||||||
@@unique([projectId, sourceIssue])
|
@@unique([projectId, sourceRepositoryId, sourceIssue])
|
||||||
@@index([projectId, status, position])
|
@@index([projectId, status, position])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,12 +503,12 @@ model Note {
|
|||||||
@@index([pinned])
|
@@index([pinned])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Zwischengespeicherter Stand des verknuepften Repositories. Wird beim
|
/// Zwischengespeicherter Stand eines Repositories. Wird beim Sync
|
||||||
/// Sync ueberschrieben, damit das Dashboard auch ohne Netz Daten zeigt.
|
/// ueberschrieben, damit das Dashboard auch ohne Netz Daten zeigt.
|
||||||
model RepoSnapshot {
|
model RepoSnapshot {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
projectId String @unique
|
repositoryId String @unique
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
fullName String?
|
fullName String?
|
||||||
description String?
|
description String?
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ export default async function AdminPage() {
|
|||||||
}),
|
}),
|
||||||
// Bewusst ueber alle Konten: der Abgleich ist ein Serverauftrag, keine
|
// Bewusst ueber alle Konten: der Abgleich ist ein Serverauftrag, keine
|
||||||
// Dateneinsicht. Gezaehlt wird nur, nicht gelesen.
|
// Dateneinsicht. Gezaehlt wird nur, nicht gelesen.
|
||||||
prisma.project.count({
|
// Gezaehlt werden Repositories, nicht Projekte: sie sind die Einheit, in
|
||||||
|
// der abgeglichen und begrenzt wird.
|
||||||
|
prisma.repository.count({
|
||||||
where: {
|
where: {
|
||||||
autoSync: true,
|
autoSync: true,
|
||||||
OR: [
|
OR: [
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type Params = { params: Promise<{ id: string }> };
|
|||||||
|
|
||||||
const safeSelect = {
|
const safeSelect = {
|
||||||
...connectionSafeSelect,
|
...connectionSafeSelect,
|
||||||
_count: { select: { projects: true } },
|
_count: { select: { repositories: true } },
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function PATCH(request: Request, { params }: Params) {
|
export async function PATCH(request: Request, { params }: Params) {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const dynamic = "force-dynamic";
|
|||||||
/** Verbindungen ohne Token-Chiffrat – das verlaesst den Server nie. */
|
/** Verbindungen ohne Token-Chiffrat – das verlaesst den Server nie. */
|
||||||
const safeSelect = {
|
const safeSelect = {
|
||||||
...connectionSafeSelect,
|
...connectionSafeSelect,
|
||||||
_count: { select: { projects: true } },
|
_count: { select: { repositories: true } },
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { requireUserApi } from "@/lib/auth/guard";
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { exportFileName } from "@/lib/export/format";
|
import { exportFileName } from "@/lib/export/format";
|
||||||
import { buildExportFile, toMarkdown } from "@/lib/export/project";
|
import { buildExportFile, exportInclude, toMarkdown } from "@/lib/export/project";
|
||||||
import { jsonError } from "@/lib/security/request";
|
import { jsonError } from "@/lib/security/request";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -24,11 +24,17 @@ export async function GET(request: Request, { params }: Params) {
|
|||||||
const project = await prisma.project.findFirst({
|
const project = await prisma.project.findFirst({
|
||||||
where: { id, ownerId: auth.user.id },
|
where: { id, ownerId: auth.user.id },
|
||||||
include: {
|
include: {
|
||||||
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
|
...exportInclude,
|
||||||
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
|
// Fuer die Markdown-Fassung zusaetzlich der gespeicherte Stand, damit
|
||||||
|
// die Datei auch ohne Netz vollstaendig ist.
|
||||||
|
repositories: {
|
||||||
|
include: {
|
||||||
connection: { select: { name: true, provider: true, baseUrl: true } },
|
connection: { select: { name: true, provider: true, baseUrl: true } },
|
||||||
snapshot: true,
|
snapshot: true,
|
||||||
},
|
},
|
||||||
|
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!project) return jsonError("Projekt nicht gefunden", 404);
|
if (!project) return jsonError("Projekt nicht gefunden", 404);
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { recordActivity, shorten } from "@/lib/activity";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { connectionSafeSelect } from "@/lib/projects";
|
||||||
|
import {
|
||||||
|
completeRepoInput,
|
||||||
|
namesRepository,
|
||||||
|
nextRepoPosition,
|
||||||
|
repoTitle,
|
||||||
|
} from "@/lib/repositories";
|
||||||
|
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||||
|
import { ownedProject } from "@/lib/tasks";
|
||||||
|
import { formatZodError, repositoryCreateSchema } from "@/lib/validation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
/** Hoechstzahl je Projekt. Wer mehr braucht, hat mehrere Vorhaben. */
|
||||||
|
const MAX_REPOS = 20;
|
||||||
|
|
||||||
|
const include = {
|
||||||
|
connection: { select: connectionSafeSelect },
|
||||||
|
snapshot: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export async function GET(_request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
if (!(await ownedProject(id, auth.user.id))) {
|
||||||
|
return jsonError("Projekt nicht gefunden", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const repositories = await prisma.repository.findMany({
|
||||||
|
where: { projectId: id },
|
||||||
|
include,
|
||||||
|
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ repositories });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
if (!(await ownedProject(id, auth.user.id))) {
|
||||||
|
return jsonError("Projekt nicht gefunden", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const parsed = repositoryCreateSchema.safeParse(body.data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eingabe = completeRepoInput(parsed.data);
|
||||||
|
if (!namesRepository(eingabe)) {
|
||||||
|
return jsonError("Bitte eine Repo-URL oder Owner und Name angeben.", 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine fremde Verbindung darf nicht untergeschoben werden.
|
||||||
|
if (eingabe.connectionId) {
|
||||||
|
const connection = await prisma.gitConnection.findFirst({
|
||||||
|
where: { id: eingabe.connectionId, ownerId: auth.user.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorhanden = await prisma.repository.count({ where: { projectId: id } });
|
||||||
|
if (vorhanden >= MAX_REPOS) {
|
||||||
|
return jsonError(`Mehr als ${MAX_REPOS} Repositories je Projekt sind nicht vorgesehen.`, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dasselbe Repo zweimal am selben Projekt ist immer ein Versehen.
|
||||||
|
if (eingabe.repoOwner && eingabe.repoName) {
|
||||||
|
const doppelt = await prisma.repository.findFirst({
|
||||||
|
where: { projectId: id, repoOwner: eingabe.repoOwner, repoName: eingabe.repoName },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (doppelt) {
|
||||||
|
return jsonError(
|
||||||
|
`${eingabe.repoOwner}/${eingabe.repoName} ist mit diesem Projekt bereits verknuepft.`,
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const repository = await prisma.repository.create({
|
||||||
|
data: {
|
||||||
|
projectId: id,
|
||||||
|
label: eingabe.label ?? null,
|
||||||
|
repoUrl: eingabe.repoUrl ?? null,
|
||||||
|
repoOwner: eingabe.repoOwner ?? null,
|
||||||
|
repoName: eingabe.repoName ?? null,
|
||||||
|
connectionId: eingabe.connectionId ?? null,
|
||||||
|
autoSync: eingabe.autoSync ?? true,
|
||||||
|
position: await nextRepoPosition(id),
|
||||||
|
},
|
||||||
|
include,
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordActivity({
|
||||||
|
projectId: id,
|
||||||
|
userId: auth.user.id,
|
||||||
|
kind: "REPO_LINKED",
|
||||||
|
summary: `Repository verknüpft: ${shorten(repoTitle(repository))}`,
|
||||||
|
meta: { repositoryId: repository.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ repository }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@ import { NextResponse } from "next/server";
|
|||||||
import { describeFields, describeStatus, recordActivities, shorten } from "@/lib/activity";
|
import { describeFields, describeStatus, recordActivities, shorten } from "@/lib/activity";
|
||||||
import { requireUserApi } from "@/lib/auth/guard";
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { parseRepoUrl } from "@/lib/git";
|
|
||||||
import { projectDetailInclude, projectListInclude, uniqueSlug } from "@/lib/projects";
|
import { projectDetailInclude, projectListInclude, uniqueSlug } from "@/lib/projects";
|
||||||
|
import { applyLeadingRepo, type RepoInput } from "@/lib/repositories";
|
||||||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||||
import { refreshDerivedProgress } from "@/lib/tasks";
|
import { refreshDerivedProgress } from "@/lib/tasks";
|
||||||
import { formatZodError, projectUpdateSchema } from "@/lib/validation";
|
import { formatZodError, projectUpdateSchema } from "@/lib/validation";
|
||||||
@@ -77,30 +77,26 @@ export async function PATCH(request: Request, { params }: Params) {
|
|||||||
"accent",
|
"accent",
|
||||||
"tags",
|
"tags",
|
||||||
"favorite",
|
"favorite",
|
||||||
"autoSync",
|
|
||||||
"progressFromTasks",
|
"progressFromTasks",
|
||||||
"connectionId",
|
|
||||||
] as const) {
|
] as const) {
|
||||||
if (data[key] !== undefined) update[key] = data[key];
|
if (data[key] !== undefined) update[key] = data[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.repoUrl !== undefined) {
|
|
||||||
update.repoUrl = data.repoUrl;
|
|
||||||
const parsedUrl = data.repoUrl ? parseRepoUrl(data.repoUrl) : null;
|
|
||||||
if (parsedUrl) {
|
|
||||||
update.repoOwner = data.repoOwner ?? parsedUrl.owner;
|
|
||||||
update.repoName = data.repoName ?? parsedUrl.name;
|
|
||||||
} else if (!data.repoUrl) {
|
|
||||||
update.repoOwner = null;
|
|
||||||
update.repoName = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (data.repoOwner !== undefined) update.repoOwner = data.repoOwner;
|
|
||||||
if (data.repoName !== undefined) update.repoName = data.repoName;
|
|
||||||
|
|
||||||
await prisma.project.update({ where: { id }, data: update });
|
await prisma.project.update({ where: { id }, data: update });
|
||||||
|
|
||||||
await noteChanges(id, auth.user.id, existing, update);
|
// Die Git-Felder gehen an das fuehrende Repository. Sie hier weiterhin
|
||||||
|
// anzunehmen erspart der Oberflaeche zwei Aufrufe fuer einen Speichervorgang.
|
||||||
|
const repoFelder = {
|
||||||
|
repoUrl: data.repoUrl,
|
||||||
|
repoOwner: data.repoOwner,
|
||||||
|
repoName: data.repoName,
|
||||||
|
connectionId: data.connectionId,
|
||||||
|
autoSync: data.autoSync,
|
||||||
|
};
|
||||||
|
const repoBeruehrt = Object.values(repoFelder).some((value) => value !== undefined);
|
||||||
|
if (repoBeruehrt) await applyLeadingRepo(id, repoFelder);
|
||||||
|
|
||||||
|
await noteChanges(id, auth.user.id, existing, update, repoBeruehrt ? repoFelder : null);
|
||||||
|
|
||||||
// Wird die Ableitung eingeschaltet, soll der Wert sofort stimmen und nicht
|
// Wird die Ableitung eingeschaltet, soll der Wert sofort stimmen und nicht
|
||||||
// erst beim naechsten Aufgabenwechsel.
|
// erst beim naechsten Aufgabenwechsel.
|
||||||
@@ -125,12 +121,11 @@ export async function PATCH(request: Request, { params }: Params) {
|
|||||||
async function noteChanges(
|
async function noteChanges(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
before: { status: ProjectStatus; repoUrl: string | null },
|
before: { status: ProjectStatus },
|
||||||
update: Record<string, unknown>,
|
update: Record<string, unknown>,
|
||||||
|
repo: RepoInput | null,
|
||||||
) {
|
) {
|
||||||
const rest = Object.keys(update).filter(
|
const rest = Object.keys(update).filter((key) => key !== "status" && key !== "slug");
|
||||||
(key) => key !== "status" && key !== "slug" && !key.startsWith("repo"),
|
|
||||||
);
|
|
||||||
const entries: Parameters<typeof recordActivities>[0] = [];
|
const entries: Parameters<typeof recordActivities>[0] = [];
|
||||||
|
|
||||||
if (update.status !== undefined && update.status !== before.status) {
|
if (update.status !== undefined && update.status !== before.status) {
|
||||||
@@ -144,16 +139,20 @@ async function noteChanges(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (update.repoUrl !== undefined && update.repoUrl !== before.repoUrl) {
|
// Nur die Verknuepfung selbst ist eine Nachricht wert. Ob der automatische
|
||||||
const url = update.repoUrl as string | null;
|
// Abgleich an oder aus ist, gehoert zu den uebrigen Feldern.
|
||||||
|
if (repo && (repo.repoUrl !== undefined || repo.repoName !== undefined)) {
|
||||||
|
const ziel = repo.repoUrl ?? (repo.repoOwner ? `${repo.repoOwner}/${repo.repoName}` : null);
|
||||||
entries.push({
|
entries.push({
|
||||||
projectId,
|
projectId,
|
||||||
userId,
|
userId,
|
||||||
kind: "REPO_LINKED",
|
kind: "REPO_LINKED",
|
||||||
summary: url ? `Repository verknüpft: ${shorten(url)}` : "Repository-Verknüpfung entfernt",
|
summary: ziel ? `Repository verknüpft: ${shorten(ziel)}` : "Repository-Angaben geleert",
|
||||||
meta: { repoUrl: url },
|
meta: { repoUrl: repo.repoUrl ?? null },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (repo?.autoSync !== undefined) rest.push("autoSync");
|
||||||
|
if (repo?.connectionId !== undefined) rest.push("connectionId");
|
||||||
|
|
||||||
const felder = describeFields(rest);
|
const felder = describeFields(rest);
|
||||||
if (felder) {
|
if (felder) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { recordActivity } from "@/lib/activity";
|
import { recordActivity } from "@/lib/activity";
|
||||||
|
import { repoTitle } from "@/lib/repositories";
|
||||||
import { requireUserApi } from "@/lib/auth/guard";
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||||
@@ -15,6 +16,8 @@ type Params = { params: Promise<{ id: string }> };
|
|||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
/** Leer heisst: alle offenen Issues aus dem Snapshot. */
|
/** Leer heisst: alle offenen Issues aus dem Snapshot. */
|
||||||
numbers: z.array(z.number().int()).max(200).optional(),
|
numbers: z.array(z.number().int()).max(200).optional(),
|
||||||
|
/** Aus welchem Repository. Leer heisst: dem fuehrenden. */
|
||||||
|
repositoryId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,7 +52,23 @@ export async function POST(request: Request, { params }: Params) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = await prisma.repoSnapshot.findUnique({ where: { projectId: id } });
|
// Die Besitzerbedingung laeuft ueber das Projekt, dessen Besitz eben
|
||||||
|
// geprueft wurde – eine fremde Repository-ID trifft hier also nichts.
|
||||||
|
const repository = await prisma.repository.findFirst({
|
||||||
|
where: { projectId: id, ...(parsed.data.repositoryId ? { id: parsed.data.repositoryId } : {}) },
|
||||||
|
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||||
|
include: { snapshot: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!repository) {
|
||||||
|
// 404 nur, wenn nach einem bestimmten gefragt wurde. Wer nichts angibt,
|
||||||
|
// hat schlicht noch keines verknuepft – und das ist kein "nicht gefunden".
|
||||||
|
return parsed.data.repositoryId
|
||||||
|
? jsonError("Repository nicht gefunden", 404)
|
||||||
|
: jsonError("Kein Repository verknuepft. Bitte zuerst eines hinterlegen.", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = repository.snapshot;
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
return jsonError(
|
return jsonError(
|
||||||
"Noch kein Repo-Stand vorhanden. Bitte zuerst synchronisieren.",
|
"Noch kein Repo-Stand vorhanden. Bitte zuerst synchronisieren.",
|
||||||
@@ -68,7 +87,11 @@ export async function POST(request: Request, { params }: Params) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const vorhanden = await prisma.task.findMany({
|
const vorhanden = await prisma.task.findMany({
|
||||||
where: { projectId: id, sourceIssue: { in: auswahl.map((issue) => issue.number) } },
|
where: {
|
||||||
|
projectId: id,
|
||||||
|
sourceRepositoryId: repository.id,
|
||||||
|
sourceIssue: { in: auswahl.map((issue) => issue.number) },
|
||||||
|
},
|
||||||
select: { sourceIssue: true },
|
select: { sourceIssue: true },
|
||||||
});
|
});
|
||||||
const bekannt = new Set(vorhanden.map((task) => task.sourceIssue));
|
const bekannt = new Set(vorhanden.map((task) => task.sourceIssue));
|
||||||
@@ -90,6 +113,7 @@ export async function POST(request: Request, { params }: Params) {
|
|||||||
labels: issue.labels.slice(0, 8),
|
labels: issue.labels.slice(0, 8),
|
||||||
sourceIssue: issue.number,
|
sourceIssue: issue.number,
|
||||||
sourceUrl: issue.url,
|
sourceUrl: issue.url,
|
||||||
|
sourceRepositoryId: repository.id,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -106,9 +130,12 @@ export async function POST(request: Request, { params }: Params) {
|
|||||||
kind: "TASKS_IMPORTED",
|
kind: "TASKS_IMPORTED",
|
||||||
summary:
|
summary:
|
||||||
angelegt.length === 1
|
angelegt.length === 1
|
||||||
? "1 Issue als Aufgabe übernommen"
|
? `1 Issue aus ${repoTitle(repository)} als Aufgabe übernommen`
|
||||||
: `${angelegt.length} Issues als Aufgaben übernommen`,
|
: `${angelegt.length} Issues aus ${repoTitle(repository)} als Aufgaben übernommen`,
|
||||||
meta: { numbers: angelegt.map((task) => task.sourceIssue) },
|
meta: {
|
||||||
|
repositoryId: repository.id,
|
||||||
|
numbers: angelegt.map((task) => task.sourceIssue),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { z } from "zod";
|
|||||||
import { recordActivity } from "@/lib/activity";
|
import { recordActivity } from "@/lib/activity";
|
||||||
import { requireUserApi } from "@/lib/auth/guard";
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { exportFileSchema, type ExportedProject } from "@/lib/export/format";
|
import {
|
||||||
|
exportFileSchema,
|
||||||
|
repositoriesOf,
|
||||||
|
type ExportedRepository,
|
||||||
|
} from "@/lib/export/format";
|
||||||
import { uniqueSlug } from "@/lib/projects";
|
import { uniqueSlug } from "@/lib/projects";
|
||||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||||
import {
|
import {
|
||||||
@@ -85,7 +89,7 @@ export async function POST(request: Request) {
|
|||||||
select: { id: true, name: true, provider: true, baseUrl: true },
|
select: { id: true, name: true, provider: true, baseUrl: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
function matchConnection(described: ExportedProject["connection"]) {
|
function matchConnection(described: ExportedRepository["connection"]) {
|
||||||
if (!described) return null;
|
if (!described) return null;
|
||||||
const byName = connections.find(
|
const byName = connections.find(
|
||||||
(candidate) => candidate.name.toLowerCase() === described.name.toLowerCase(),
|
(candidate) => candidate.name.toLowerCase() === described.name.toLowerCase(),
|
||||||
@@ -105,12 +109,26 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
for (const incoming of file.projects) {
|
for (const incoming of file.projects) {
|
||||||
const slug = incoming.slug || slugify(incoming.name) || "projekt";
|
const slug = incoming.slug || slugify(incoming.name) || "projekt";
|
||||||
const connection = matchConnection(incoming.connection);
|
|
||||||
const connectionState: ItemResult["connection"] = incoming.connection
|
// Version 1 kannte ein Repository je Projekt und schrieb es flach; seit
|
||||||
? connection
|
// Version 2 steht eine Liste da. repositoriesOf glaettet den Unterschied.
|
||||||
? "matched"
|
const eingehendeRepos = repositoriesOf(incoming);
|
||||||
: "missing"
|
const repos = eingehendeRepos.map((repo, index) => ({
|
||||||
: "none";
|
label: repo.label ?? null,
|
||||||
|
repoUrl: repo.repoUrl ?? null,
|
||||||
|
repoOwner: repo.repoOwner ?? null,
|
||||||
|
repoName: repo.repoName ?? null,
|
||||||
|
autoSync: repo.autoSync ?? true,
|
||||||
|
connectionId: matchConnection(repo.connection ?? null)?.id ?? null,
|
||||||
|
position: index,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Fuer die Rueckmeldung genuegt, ob ueberhaupt eine beschriebene
|
||||||
|
// Verbindung fehlt – welche, steht in der Datei.
|
||||||
|
const beschrieben = eingehendeRepos.filter((repo) => repo.connection);
|
||||||
|
const fehlend = beschrieben.filter((repo) => !matchConnection(repo.connection ?? null));
|
||||||
|
const connectionState: ItemResult["connection"] =
|
||||||
|
beschrieben.length === 0 ? "none" : fehlend.length > 0 ? "missing" : "matched";
|
||||||
|
|
||||||
const existing = await prisma.project.findFirst({
|
const existing = await prisma.project.findFirst({
|
||||||
where: { slug, ownerId: auth.user.id },
|
where: { slug, ownerId: auth.user.id },
|
||||||
@@ -123,7 +141,7 @@ export async function POST(request: Request) {
|
|||||||
action: "create",
|
action: "create",
|
||||||
notes: incoming.notes.length,
|
notes: incoming.notes.length,
|
||||||
connection: connectionState,
|
connection: connectionState,
|
||||||
connectionName: incoming.connection?.name,
|
connectionName: (fehlend[0] ?? beschrieben[0])?.connection?.name,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (existing && strategy === "skip") {
|
if (existing && strategy === "skip") {
|
||||||
@@ -155,11 +173,6 @@ export async function POST(request: Request) {
|
|||||||
accent: incoming.accent,
|
accent: incoming.accent,
|
||||||
tags: incoming.tags,
|
tags: incoming.tags,
|
||||||
favorite: incoming.favorite,
|
favorite: incoming.favorite,
|
||||||
autoSync: incoming.autoSync,
|
|
||||||
repoUrl: incoming.repoUrl ?? null,
|
|
||||||
repoOwner: incoming.repoOwner ?? null,
|
|
||||||
repoName: incoming.repoName ?? null,
|
|
||||||
connectionId: connection?.id ?? null,
|
|
||||||
progressFromTasks: incoming.progressFromTasks,
|
progressFromTasks: incoming.progressFromTasks,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -195,9 +208,19 @@ export async function POST(request: Request) {
|
|||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
prisma.note.deleteMany({ where: { projectId: existing.id } }),
|
prisma.note.deleteMany({ where: { projectId: existing.id } }),
|
||||||
prisma.task.deleteMany({ where: { projectId: existing.id } }),
|
prisma.task.deleteMany({ where: { projectId: existing.id } }),
|
||||||
|
// Wie Notizen und Aufgaben: austauschen statt zusammenfuehren. Der
|
||||||
|
// gesammelte Repo-Stand geht dabei mit, ist aber beim naechsten
|
||||||
|
// Abgleich zurueck.
|
||||||
|
prisma.repository.deleteMany({ where: { projectId: existing.id } }),
|
||||||
prisma.project.update({
|
prisma.project.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: { ...data, slug, notes: { create: notes }, tasks: { create: tasks } },
|
data: {
|
||||||
|
...data,
|
||||||
|
slug,
|
||||||
|
notes: { create: notes },
|
||||||
|
tasks: { create: tasks },
|
||||||
|
repositories: { create: repos },
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
results.push({ ...base, action: "overwrite" });
|
results.push({ ...base, action: "overwrite" });
|
||||||
@@ -210,6 +233,7 @@ export async function POST(request: Request) {
|
|||||||
slug: finalSlug,
|
slug: finalSlug,
|
||||||
notes: { create: notes },
|
notes: { create: notes },
|
||||||
tasks: { create: tasks },
|
tasks: { create: tasks },
|
||||||
|
repositories: { create: repos },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Der Verlauf wird nicht mitexportiert – der erste Eintrag hier haelt
|
// Der Verlauf wird nicht mitexportiert – der erste Eintrag hier haelt
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { NextResponse } from "next/server";
|
|||||||
import { recordActivity } from "@/lib/activity";
|
import { recordActivity } from "@/lib/activity";
|
||||||
import { requireUserApi } from "@/lib/auth/guard";
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { parseRepoUrl } from "@/lib/git";
|
|
||||||
import { projectListInclude, uniqueSlug } from "@/lib/projects";
|
import { projectListInclude, uniqueSlug } from "@/lib/projects";
|
||||||
|
import { applyLeadingRepo } from "@/lib/repositories";
|
||||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||||
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
|
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
|
||||||
import { statusOf } from "@/lib/status";
|
import { statusOf } from "@/lib/status";
|
||||||
@@ -74,18 +74,7 @@ export async function POST(request: Request) {
|
|||||||
if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404);
|
if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Owner/Name aus der URL ableiten, falls nicht explizit angegeben.
|
const angelegt = await prisma.project.create({
|
||||||
let repoOwner = data.repoOwner ?? null;
|
|
||||||
let repoName = data.repoName ?? null;
|
|
||||||
if (data.repoUrl && (!repoOwner || !repoName)) {
|
|
||||||
const parsedUrl = parseRepoUrl(data.repoUrl);
|
|
||||||
if (parsedUrl) {
|
|
||||||
repoOwner = repoOwner ?? parsedUrl.owner;
|
|
||||||
repoName = repoName ?? parsedUrl.name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const project = await prisma.project.create({
|
|
||||||
data: {
|
data: {
|
||||||
ownerId: auth.user.id,
|
ownerId: auth.user.id,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
@@ -98,12 +87,21 @@ export async function POST(request: Request) {
|
|||||||
accent: data.accent,
|
accent: data.accent,
|
||||||
tags: data.tags,
|
tags: data.tags,
|
||||||
favorite: data.favorite,
|
favorite: data.favorite,
|
||||||
autoSync: data.autoSync,
|
|
||||||
repoUrl: data.repoUrl ?? null,
|
|
||||||
repoOwner,
|
|
||||||
repoName,
|
|
||||||
connectionId: data.connectionId ?? null,
|
|
||||||
},
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Die Git-Angaben werden zum fuehrenden Repository – falls welche kamen.
|
||||||
|
await applyLeadingRepo(angelegt.id, {
|
||||||
|
repoUrl: data.repoUrl,
|
||||||
|
repoOwner: data.repoOwner,
|
||||||
|
repoName: data.repoName,
|
||||||
|
connectionId: data.connectionId,
|
||||||
|
autoSync: data.autoSync,
|
||||||
|
});
|
||||||
|
|
||||||
|
const project = await prisma.project.findUniqueOrThrow({
|
||||||
|
where: { id: angelegt.id },
|
||||||
include: projectListInclude,
|
include: projectListInclude,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { recordActivity, shorten } from "@/lib/activity";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { connectionSafeSelect } from "@/lib/projects";
|
||||||
|
import { completeRepoInput, ownedRepository, repoTitle } from "@/lib/repositories";
|
||||||
|
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||||
|
import { formatZodError, repositoryUpdateSchema } from "@/lib/validation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const existing = await ownedRepository(id, auth.user.id);
|
||||||
|
if (!existing) return jsonError("Repository nicht gefunden", 404);
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const parsed = repositoryUpdateSchema.safeParse(body.data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eingabe = completeRepoInput(parsed.data);
|
||||||
|
|
||||||
|
if (eingabe.connectionId) {
|
||||||
|
const connection = await prisma.gitConnection.findFirst({
|
||||||
|
where: { id: eingabe.connectionId, ownerId: auth.user.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
for (const key of ["label", "repoUrl", "repoOwner", "repoName", "connectionId", "autoSync"] as const) {
|
||||||
|
if (eingabe[key] !== undefined) data[key] = eingabe[key];
|
||||||
|
}
|
||||||
|
if (parsed.data.position !== undefined) data.position = parsed.data.position;
|
||||||
|
|
||||||
|
// Zeigt das Repo woandershin, taugt der gesammelte Stand nicht mehr. Ihn
|
||||||
|
// stehen zu lassen waere schlimmer als ihn zu verlieren: er saehe echt aus.
|
||||||
|
const zieltWoandershin =
|
||||||
|
(eingabe.repoOwner !== undefined && eingabe.repoOwner !== existing.repoOwner) ||
|
||||||
|
(eingabe.repoName !== undefined && eingabe.repoName !== existing.repoName);
|
||||||
|
if (zieltWoandershin) {
|
||||||
|
data.lastSyncedAt = null;
|
||||||
|
data.syncError = null;
|
||||||
|
data.syncFailureCount = 0;
|
||||||
|
data.notifiedSyncError = null;
|
||||||
|
data.notifiedCiState = null;
|
||||||
|
data.notifiedRelease = null;
|
||||||
|
await prisma.repoSnapshot.deleteMany({ where: { repositoryId: id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const repository = await prisma.repository.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
include: { connection: { select: connectionSafeSelect }, snapshot: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ repository });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Vor dem Loeschen lesen: danach ist nicht mehr feststellbar, worum es ging.
|
||||||
|
const vorher = await ownedRepository(id, auth.user.id);
|
||||||
|
if (!vorher) return jsonError("Repository nicht gefunden", 404);
|
||||||
|
|
||||||
|
await prisma.repository.delete({ where: { id } });
|
||||||
|
|
||||||
|
await recordActivity({
|
||||||
|
projectId: vorher.projectId,
|
||||||
|
userId: auth.user.id,
|
||||||
|
kind: "REPO_LINKED",
|
||||||
|
summary: `Repository entfernt: ${shorten(repoTitle(vorher))}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { connectionSafeSelect } from "@/lib/projects";
|
||||||
|
import { ownedRepository } from "@/lib/repositories";
|
||||||
|
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||||
|
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
|
||||||
|
import { syncRepository } from "@/lib/sync";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
/** Gleicht ein einzelnes Repository ab. */
|
||||||
|
export async function POST(request: Request, { params }: Params) {
|
||||||
|
const auth = await requireUserApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
// Dieser Endpunkt loest ausgehende Verbindungen aus – hier wird enger
|
||||||
|
// begrenzt als bei reinen Datenbankzugriffen.
|
||||||
|
const limited = rateLimit(
|
||||||
|
`outbound:${auth.user.id}:${clientIp(request)}`,
|
||||||
|
LIMITS.outbound.limit,
|
||||||
|
LIMITS.outbound.windowMs,
|
||||||
|
);
|
||||||
|
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
if (!(await ownedRepository(id, auth.user.id))) {
|
||||||
|
return jsonError("Repository nicht gefunden", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await syncRepository(id);
|
||||||
|
|
||||||
|
const repository = await prisma.repository.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { connection: { select: connectionSafeSelect }, snapshot: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ...result, repository }, { status: result.ok ? 200 : 502 });
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ export default async function ConnectionsPage() {
|
|||||||
|
|
||||||
const connections = await prisma.gitConnection.findMany({
|
const connections = await prisma.gitConnection.findMany({
|
||||||
where: { ownerId: user.id },
|
where: { ownerId: user.id },
|
||||||
select: { ...connectionSafeSelect, _count: { select: { projects: true } } },
|
select: { ...connectionSafeSelect, _count: { select: { repositories: true } } },
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ export default async function DashboardPage() {
|
|||||||
const ideas = active.filter((project) => project.status === "IDEA").length;
|
const ideas = active.filter((project) => project.status === "IDEA").length;
|
||||||
const inDev = active.filter((project) => project.status === "IN_DEVELOPMENT").length;
|
const inDev = active.filter((project) => project.status === "IN_DEVELOPMENT").length;
|
||||||
const done = active.filter((project) => project.status === "DONE").length;
|
const done = active.filter((project) => project.status === "DONE").length;
|
||||||
const linked = active.filter((project) => Boolean(project.repoOwner && project.repoName)).length;
|
const linked = active.filter((project) => project.repositories.length > 0).length;
|
||||||
|
|
||||||
const averageProgress =
|
const averageProgress =
|
||||||
active.length > 0
|
active.length > 0
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ import { z } from "zod";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const EXPORT_KIND = "nebula.projects";
|
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 STATUS = ["IDEA", "OPEN", "PLANNING", "IN_DEVELOPMENT", "DONE", "ARCHIVED"] as const;
|
||||||
const PROVIDERS = ["GITHUB", "GITLAB", "GITEA"] as const;
|
const PROVIDERS = ["GITHUB", "GITLAB", "GITEA"] as const;
|
||||||
@@ -46,6 +50,16 @@ export const exportedConnectionSchema = z.object({
|
|||||||
baseUrl: z.string().max(300),
|
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({
|
export const exportedProjectSchema = z.object({
|
||||||
name: z.string().min(1).max(120),
|
name: z.string().min(1).max(120),
|
||||||
slug: z.string().max(60).optional(),
|
slug: z.string().max(60).optional(),
|
||||||
@@ -57,11 +71,17 @@ export const exportedProjectSchema = z.object({
|
|||||||
accent: z.string().max(20).optional().default("violet"),
|
accent: z.string().max(20).optional().default("violet"),
|
||||||
tags: z.array(z.string().max(30)).max(12).optional().default([]),
|
tags: z.array(z.string().max(30)).max(12).optional().default([]),
|
||||||
favorite: z.boolean().optional().default(false),
|
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),
|
autoSync: z.boolean().optional().default(true),
|
||||||
repoUrl: z.string().max(500).nullable().optional(),
|
repoUrl: z.string().max(500).nullable().optional(),
|
||||||
repoOwner: z.string().max(200).nullable().optional(),
|
repoOwner: z.string().max(200).nullable().optional(),
|
||||||
repoName: z.string().max(200).nullable().optional(),
|
repoName: z.string().max(200).nullable().optional(),
|
||||||
/** Nur zur Wiedererkennung – ohne Token. */
|
|
||||||
connection: exportedConnectionSchema.nullable().optional(),
|
connection: exportedConnectionSchema.nullable().optional(),
|
||||||
notes: z.array(exportedNoteSchema).max(500).optional().default([]),
|
notes: z.array(exportedNoteSchema).max(500).optional().default([]),
|
||||||
tasks: z.array(exportedTaskSchema).max(1000).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 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>;
|
export type ExportFile = z.infer<typeof exportFileSchema>;
|
||||||
|
|
||||||
/** Dateiname ohne Sonderzeichen, mit Datumsstempel. */
|
/** Dateiname ohne Sonderzeichen, mit Datumsstempel. */
|
||||||
|
|||||||
+68
-26
@@ -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 { APP_NAME } from "@/lib/config";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { repoTitle } from "@/lib/repositories";
|
||||||
import { PRIORITY_META, statusOf } from "@/lib/status";
|
import { PRIORITY_META, statusOf } from "@/lib/status";
|
||||||
import { formatDate, formatDateTime } from "@/lib/utils";
|
import { formatDate, formatDateTime } from "@/lib/utils";
|
||||||
import { EXPORT_KIND, EXPORT_VERSION, type ExportFile, type ExportedProject } from "./format";
|
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[];
|
notes: Note[];
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
connection: Pick<GitConnection, "name" | "provider" | "baseUrl"> | null;
|
repositories: RepositoryForExport[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Formt einen Datensatz in das Austauschformat um – ohne Geheimnisse. */
|
/** Formt einen Datensatz in das Austauschformat um – ohne Geheimnisse. */
|
||||||
@@ -24,17 +37,25 @@ export function toExportedProject(project: ProjectForExport): ExportedProject {
|
|||||||
accent: project.accent,
|
accent: project.accent,
|
||||||
tags: project.tags,
|
tags: project.tags,
|
||||||
favorite: project.favorite,
|
favorite: project.favorite,
|
||||||
autoSync: project.autoSync,
|
// Version 1 hatte ein autoSync am Projekt. Das Feld ist im Schema noch
|
||||||
repoUrl: project.repoUrl,
|
// vorhanden, damit alte Dateien lesbar bleiben; geschrieben wird der
|
||||||
repoOwner: project.repoOwner,
|
// Wert des fuehrenden Repositories, damit eine Version-2-Datei auch von
|
||||||
repoName: project.repoName,
|
// einer aelteren Instanz noch sinnvoll gelesen werden koennte.
|
||||||
connection: project.connection
|
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: project.connection.name,
|
name: repository.connection.name,
|
||||||
provider: project.connection.provider,
|
provider: repository.connection.provider,
|
||||||
baseUrl: project.connection.baseUrl,
|
baseUrl: repository.connection.baseUrl,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
})),
|
||||||
notes: project.notes.map((note) => ({
|
notes: project.notes.map((note) => ({
|
||||||
title: note.title,
|
title: note.title,
|
||||||
content: note.content,
|
content: note.content,
|
||||||
@@ -72,15 +93,21 @@ export async function loadProjectsForExport(
|
|||||||
): Promise<ProjectForExport[]> {
|
): Promise<ProjectForExport[]> {
|
||||||
return prisma.project.findMany({
|
return prisma.project.findMany({
|
||||||
where: { ownerId, ...(projectId ? { id: projectId } : {}) },
|
where: { ownerId, ...(projectId ? { id: projectId } : {}) },
|
||||||
include: {
|
include: exportInclude,
|
||||||
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
|
|
||||||
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
|
|
||||||
connection: { select: { name: true, provider: true, baseUrl: true } },
|
|
||||||
},
|
|
||||||
orderBy: { name: "asc" },
|
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 ─────────────────────────────────────────────────
|
// ── Markdown ─────────────────────────────────────────────────
|
||||||
|
|
||||||
const PROVIDER_LABEL: Record<string, string> = {
|
const PROVIDER_LABEL: Record<string, string> = {
|
||||||
@@ -102,7 +129,9 @@ const TASK_LABEL: Record<string, string> = {
|
|||||||
* Datei auch ohne Netz vollstaendig ist.
|
* Datei auch ohne Netz vollstaendig ist.
|
||||||
*/
|
*/
|
||||||
export function toMarkdown(
|
export function toMarkdown(
|
||||||
project: ProjectForExport & { snapshot: RepoSnapshot | null },
|
project: ProjectForExport & {
|
||||||
|
repositories: Array<RepositoryForExport & { snapshot: RepoSnapshot | null }>;
|
||||||
|
},
|
||||||
): string {
|
): string {
|
||||||
const status = statusOf(project.status);
|
const status = statusOf(project.status);
|
||||||
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
|
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
|
||||||
@@ -119,22 +148,31 @@ export function toMarkdown(
|
|||||||
lines.push(`| Tags | ${project.tags.map((tag) => `\`${tag}\``).join(", ")} |`);
|
lines.push(`| Tags | ${project.tags.map((tag) => `\`${tag}\``).join(", ")} |`);
|
||||||
}
|
}
|
||||||
lines.push(`| Angelegt | ${formatDateTime(project.createdAt)} |`);
|
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("");
|
lines.push("");
|
||||||
|
|
||||||
if (project.description) {
|
if (project.description) {
|
||||||
lines.push("## Beschreibung", "", project.description, "");
|
lines.push("## Beschreibung", "", project.description, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = project.snapshot;
|
for (const repository of project.repositories) {
|
||||||
if (snapshot) {
|
const titel = repoTitle(repository);
|
||||||
lines.push("## Stand des Repositories", "");
|
lines.push(
|
||||||
lines.push(`Abgerufen am ${formatDateTime(snapshot.fetchedAt)}.`, "");
|
project.repositories.length === 1
|
||||||
|
? "## Repository"
|
||||||
|
: `## Repository: ${titel}`,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
lines.push("| | |", "| --- | --- |");
|
lines.push("| | |", "| --- | --- |");
|
||||||
|
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.defaultBranch) lines.push(`| Default-Branch | \`${snapshot.defaultBranch}\` |`);
|
||||||
if (snapshot.latestTag) lines.push(`| Neuester Tag | \`${snapshot.latestTag}\` |`);
|
if (snapshot.latestTag) lines.push(`| Neuester Tag | \`${snapshot.latestTag}\` |`);
|
||||||
lines.push(`| Branches | ${snapshot.branchCount} |`);
|
lines.push(`| Branches | ${snapshot.branchCount} |`);
|
||||||
@@ -147,6 +185,10 @@ export function toMarkdown(
|
|||||||
`${snapshot.lastCommitMessage ?? ""} |`,
|
`${snapshot.lastCommitMessage ?? ""} |`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
lines.push(`| Abgerufen | ${formatDateTime(snapshot.fetchedAt)} |`);
|
||||||
|
} else {
|
||||||
|
lines.push("| Stand | noch nie abgeglichen |");
|
||||||
|
}
|
||||||
lines.push("");
|
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 {
|
export function guessProvider(host: string): ProviderKind {
|
||||||
const lower = host.toLowerCase();
|
const lower = host.toLowerCase();
|
||||||
if (lower.includes("github")) return "GITHUB";
|
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 { APP_NAME, APP_URL } from "@/lib/config";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { repoTitle } from "@/lib/repositories";
|
||||||
import { deliver } from "./channels";
|
import { deliver } from "./channels";
|
||||||
import { EVENT_META, type NotificationMessage } from "./types";
|
import { EVENT_META, type NotificationMessage } from "./types";
|
||||||
|
|
||||||
@@ -14,25 +15,26 @@ export { emailAvailable, smtpConfig } from "./channels";
|
|||||||
* Gemeldet wird ausschliesslich bei einer **Aenderung** gegenueber dem,
|
* Gemeldet wird ausschliesslich bei einer **Aenderung** gegenueber dem,
|
||||||
* worueber zuletzt benachrichtigt wurde. Sonst kaeme bei einem Repo mit
|
* worueber zuletzt benachrichtigt wurde. Sonst kaeme bei einem Repo mit
|
||||||
* abgelaufenem Token mit jedem stuendlichen Durchlauf dieselbe Nachricht.
|
* 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
|
* Fehler beim Zustellen werden geschluckt: eine kaputte Benachrichtigung
|
||||||
* darf den Abgleich nicht scheitern lassen.
|
* darf den Abgleich nicht scheitern lassen.
|
||||||
*/
|
*/
|
||||||
export async function notifyAfterSync(projectId: string): Promise<void> {
|
export async function notifyAfterSync(repositoryId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const project = await prisma.project.findUnique({
|
const repository = await prisma.repository.findUnique({
|
||||||
where: { id: projectId },
|
where: { id: repositoryId },
|
||||||
include: { snapshot: true },
|
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;
|
if (faellig.length === 0) return;
|
||||||
|
|
||||||
const ziele = await prisma.notificationTarget.findMany({
|
const ziele = await prisma.notificationTarget.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: project.ownerId,
|
userId: repository.project.ownerId,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
events: { hasSome: faellig.map((entry) => entry.event) },
|
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
|
// Auch ohne Ziel den Merkstand fortschreiben: sonst kaeme beim ersten
|
||||||
// eingerichteten Ziel gleich die gesamte Vergangenheit hinterher.
|
// eingerichteten Ziel gleich die gesamte Vergangenheit hinterher.
|
||||||
await prisma.project.update({
|
await prisma.repository.update({
|
||||||
where: { id: project.id },
|
where: { id: repository.id },
|
||||||
data: {
|
data: {
|
||||||
notifiedSyncError: project.syncError,
|
notifiedSyncError: repository.syncError,
|
||||||
notifiedCiState: project.snapshot?.ciState ?? null,
|
notifiedCiState: repository.snapshot?.ciState ?? null,
|
||||||
notifiedRelease: currentRelease(project.snapshot),
|
notifiedRelease: currentRelease(repository.snapshot),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,6 +83,7 @@ function currentRelease(snapshot: RepoSnapshot | null): string | null {
|
|||||||
*/
|
*/
|
||||||
export function pendingEvents(
|
export function pendingEvents(
|
||||||
project: Project,
|
project: Project,
|
||||||
|
repository: Repository,
|
||||||
snapshot: RepoSnapshot | null,
|
snapshot: RepoSnapshot | null,
|
||||||
): Array<{ event: NotificationEvent; message: NotificationMessage }> {
|
): Array<{ event: NotificationEvent; message: NotificationMessage }> {
|
||||||
const treffer: 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 bezug = { id: project.id, name: project.name, slug: project.slug };
|
||||||
const jetzt = new Date().toISOString();
|
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({
|
treffer.push({
|
||||||
event: "SYNC_FAILED",
|
event: "SYNC_FAILED",
|
||||||
message: {
|
message: {
|
||||||
event: "SYNC_FAILED",
|
event: "SYNC_FAILED",
|
||||||
title: `${EVENT_META.SYNC_FAILED.emoji} ${project.name}: Abgleich fehlgeschlagen`,
|
title: `${EVENT_META.SYNC_FAILED.emoji} ${woher}: Abgleich fehlgeschlagen`,
|
||||||
body: project.syncError,
|
body: repository.syncError,
|
||||||
priority: "high",
|
priority: "high",
|
||||||
url: projektUrl,
|
url: projektUrl,
|
||||||
project: bezug,
|
project: bezug,
|
||||||
@@ -104,12 +118,12 @@ export function pendingEvents(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ci = snapshot?.ciState ?? null;
|
const ci = snapshot?.ciState ?? null;
|
||||||
if (ci === "failure" && ci !== project.notifiedCiState) {
|
if (ci === "failure" && ci !== repository.notifiedCiState) {
|
||||||
treffer.push({
|
treffer.push({
|
||||||
event: "CI_FAILED",
|
event: "CI_FAILED",
|
||||||
message: {
|
message: {
|
||||||
event: "CI_FAILED",
|
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:
|
body:
|
||||||
`Der letzte Lauf auf ${snapshot?.ciBranch ?? "dem Default-Branch"} ist ` +
|
`Der letzte Lauf auf ${snapshot?.ciBranch ?? "dem Default-Branch"} ist ` +
|
||||||
`fehlgeschlagen${snapshot?.ciName ? ` (${snapshot.ciName})` : ""}.`,
|
`fehlgeschlagen${snapshot?.ciName ? ` (${snapshot.ciName})` : ""}.`,
|
||||||
@@ -125,13 +139,13 @@ export function pendingEvents(
|
|||||||
// Beim allerersten Abgleich ist notifiedRelease noch leer – dann waere
|
// Beim allerersten Abgleich ist notifiedRelease noch leer – dann waere
|
||||||
// jede vorhandene Version eine "Neuigkeit". Deshalb nur melden, wenn
|
// jede vorhandene Version eine "Neuigkeit". Deshalb nur melden, wenn
|
||||||
// vorher schon einmal etwas festgehalten wurde.
|
// vorher schon einmal etwas festgehalten wurde.
|
||||||
if (release && project.notifiedRelease && release !== project.notifiedRelease) {
|
if (release && repository.notifiedRelease && release !== repository.notifiedRelease) {
|
||||||
treffer.push({
|
treffer.push({
|
||||||
event: "NEW_RELEASE",
|
event: "NEW_RELEASE",
|
||||||
message: {
|
message: {
|
||||||
event: "NEW_RELEASE",
|
event: "NEW_RELEASE",
|
||||||
title: `${EVENT_META.NEW_RELEASE.emoji} ${project.name}: ${release}`,
|
title: `${EVENT_META.NEW_RELEASE.emoji} ${woher}: ${release}`,
|
||||||
body: `Neue Version im Repository: ${release} (vorher ${project.notifiedRelease}).`,
|
body: `Neue Version im Repository: ${release} (vorher ${repository.notifiedRelease}).`,
|
||||||
priority: "normal",
|
priority: "normal",
|
||||||
url: snapshot?.htmlUrl ?? projektUrl,
|
url: snapshot?.htmlUrl ?? projektUrl,
|
||||||
project: bezug,
|
project: bezug,
|
||||||
|
|||||||
@@ -1,24 +1,36 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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";
|
import { pendingEvents } from "./index";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die Entprellung ist der Kern: gemeldet wird nur bei einer Aenderung
|
* Die Entprellung ist der Kern: gemeldet wird nur bei einer Aenderung
|
||||||
* gegenueber dem zuletzt gemeldeten Stand. Ohne sie kaeme bei einem Repo mit
|
* gegenueber dem zuletzt gemeldeten Stand. Ohne sie kaeme bei einem Repo mit
|
||||||
* abgelaufenem Token mit jedem stuendlichen Durchlauf dieselbe Nachricht.
|
* 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 = {
|
||||||
return {
|
|
||||||
id: "p1",
|
id: "p1",
|
||||||
name: "Testprojekt",
|
name: "Testprojekt",
|
||||||
slug: "testprojekt",
|
slug: "testprojekt",
|
||||||
|
} as Project;
|
||||||
|
|
||||||
|
function repo(overrides: Partial<Repository> = {}): Repository {
|
||||||
|
return {
|
||||||
|
id: "r1",
|
||||||
|
projectId: "p1",
|
||||||
|
label: null,
|
||||||
|
repoOwner: "wer",
|
||||||
|
repoName: "testprojekt",
|
||||||
|
repoUrl: null,
|
||||||
syncError: null,
|
syncError: null,
|
||||||
notifiedSyncError: null,
|
notifiedSyncError: null,
|
||||||
notifiedCiState: null,
|
notifiedCiState: null,
|
||||||
notifiedRelease: null,
|
notifiedRelease: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
} as Project;
|
} as Repository;
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshot(overrides: Partial<RepoSnapshot> = {}): RepoSnapshot {
|
function snapshot(overrides: Partial<RepoSnapshot> = {}): RepoSnapshot {
|
||||||
@@ -34,49 +46,49 @@ function snapshot(overrides: Partial<RepoSnapshot> = {}): RepoSnapshot {
|
|||||||
} as RepoSnapshot;
|
} as RepoSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
const events = (p: Project, s: RepoSnapshot | null) =>
|
const events = (r: Repository, s: RepoSnapshot | null) =>
|
||||||
pendingEvents(p, s).map((entry) => entry.event);
|
pendingEvents(PROJEKT, r, s).map((entry) => entry.event);
|
||||||
|
|
||||||
describe("Abgleichsfehler", () => {
|
describe("Abgleichsfehler", () => {
|
||||||
it("meldet einen neuen Fehler", () => {
|
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", () => {
|
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", () => {
|
it("meldet, wenn sich der Fehler aendert", () => {
|
||||||
expect(
|
expect(
|
||||||
events(projekt({ syncError: "401 Token", notifiedSyncError: "404" }), null),
|
events(repo({ syncError: "401 Token", notifiedSyncError: "404" }), null),
|
||||||
).toEqual(["SYNC_FAILED"]);
|
).toEqual(["SYNC_FAILED"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("meldet nichts, wenn kein Fehler vorliegt", () => {
|
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", () => {
|
describe("CI", () => {
|
||||||
it("meldet den Wechsel auf rot", () => {
|
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", () => {
|
it("meldet dauerhaft rote CI nur einmal", () => {
|
||||||
expect(
|
expect(
|
||||||
events(projekt({ notifiedCiState: "failure" }), snapshot({ ciState: "failure" })),
|
events(repo({ notifiedCiState: "failure" }), snapshot({ ciState: "failure" })),
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("meldet nicht, wenn die CI gruen ist", () => {
|
it("meldet nicht, wenn die CI gruen ist", () => {
|
||||||
expect(
|
expect(
|
||||||
events(projekt({ notifiedCiState: "failure" }), snapshot({ ciState: "success" })),
|
events(repo({ notifiedCiState: "failure" }), snapshot({ ciState: "success" })),
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("meldet erneut, wenn die CI nach gruen wieder rot wird", () => {
|
it("meldet erneut, wenn die CI nach gruen wieder rot wird", () => {
|
||||||
expect(
|
expect(
|
||||||
events(projekt({ notifiedCiState: "success" }), snapshot({ ciState: "failure" })),
|
events(repo({ notifiedCiState: "success" }), snapshot({ ciState: "failure" })),
|
||||||
).toEqual(["CI_FAILED"]);
|
).toEqual(["CI_FAILED"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -85,40 +97,63 @@ describe("Release", () => {
|
|||||||
it("schweigt beim allerersten Abgleich", () => {
|
it("schweigt beim allerersten Abgleich", () => {
|
||||||
// Sonst waere jede bereits vorhandene Version eine "Neuigkeit".
|
// Sonst waere jede bereits vorhandene Version eine "Neuigkeit".
|
||||||
expect(
|
expect(
|
||||||
events(projekt({ notifiedRelease: null }), snapshot({ latestTag: "v1.0.0" })),
|
events(repo({ notifiedRelease: null }), snapshot({ latestTag: "v1.0.0" })),
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("meldet eine neue Version", () => {
|
it("meldet eine neue Version", () => {
|
||||||
expect(
|
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"]);
|
).toEqual(["NEW_RELEASE"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("meldet dieselbe Version nicht erneut", () => {
|
it("meldet dieselbe Version nicht erneut", () => {
|
||||||
expect(
|
expect(
|
||||||
events(projekt({ notifiedRelease: "v1.1.0" }), snapshot({ latestTag: "v1.1.0" })),
|
events(repo({ notifiedRelease: "v1.1.0" }), snapshot({ latestTag: "v1.1.0" })),
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("zieht den Releasenamen dem Tag vor", () => {
|
it("zieht den Releasenamen dem Tag vor", () => {
|
||||||
const treffer = pendingEvents(
|
const treffer = pendingEvents(
|
||||||
projekt({ notifiedRelease: "alt" }),
|
PROJEKT,
|
||||||
|
repo({ notifiedRelease: "alt" }),
|
||||||
snapshot({ latestTag: "v2.0.0", latestRelease: "Zweite Ausgabe" }),
|
snapshot({ latestTag: "v2.0.0", latestRelease: "Zweite Ausgabe" }),
|
||||||
);
|
);
|
||||||
expect(treffer[0].message.title).toContain("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", () => {
|
describe("mehrere Anlaesse gleichzeitig", () => {
|
||||||
it("meldet Fehler und rote CI zusammen", () => {
|
it("meldet Fehler und rote CI zusammen", () => {
|
||||||
expect(
|
expect(
|
||||||
events(projekt({ syncError: "Zeitueberschreitung" }), snapshot({ ciState: "failure" })),
|
events(repo({ syncError: "Zeitueberschreitung" }), snapshot({ ciState: "failure" })),
|
||||||
).toEqual(["SYNC_FAILED", "CI_FAILED"]);
|
).toEqual(["SYNC_FAILED", "CI_FAILED"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("haengt Projektbezug und Tieflink an jede Nachricht", () => {
|
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.project?.slug).toBe("testprojekt");
|
||||||
expect(treffer[0].message.url).toContain("/projects/p1");
|
expect(treffer[0].message.url).toContain("/projects/p1");
|
||||||
expect(treffer[0].message.priority).toBe("high");
|
expect(treffer[0].message.priority).toBe("high");
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { newestCommit } from "./stale";
|
||||||
import { daysSince, isStaleDue } from "./stale";
|
import { daysSince, isStaleDue } from "./stale";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,3 +60,26 @@ describe("daysSince", () => {
|
|||||||
expect(daysSince(jetzt, jetzt)).toBe(0);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+39
-6
@@ -12,10 +12,13 @@ import { EVENT_META, type NotificationMessage } from "./types";
|
|||||||
* passiert ja gerade nichts. Deshalb ein eigener Durchlauf, hoechstens
|
* passiert ja gerade nichts. Deshalb ein eigener Durchlauf, hoechstens
|
||||||
* einmal am Tag.
|
* einmal am Tag.
|
||||||
*
|
*
|
||||||
* Massstab ist ausschliesslich der letzte Commit im verknuepften
|
* Massstab ist ausschliesslich der letzte Commit in den verknuepften
|
||||||
* Repository. Das Aenderungsdatum des Projekts taugt nicht: jeder Abgleich
|
* Repositories. Das Aenderungsdatum des Projekts taugt nicht: jeder Abgleich
|
||||||
* fasst es an und wuerde den Stillstand immer wieder zuruecksetzen.
|
* fasst es an und wuerde den Stillstand immer wieder zuruecksetzen.
|
||||||
* Projekte ohne Repository bleiben folglich aussen vor.
|
* 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 {
|
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);
|
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(
|
function message(
|
||||||
project: Project,
|
project: Project,
|
||||||
snapshot: RepoSnapshot | null,
|
snapshot: RepoSnapshot | null,
|
||||||
@@ -79,20 +98,34 @@ export async function runStaleCheck(thresholdDays: number): Promise<StaleReport>
|
|||||||
// Fertige und archivierte Projekte duerfen ruhen.
|
// Fertige und archivierte Projekte duerfen ruhen.
|
||||||
status: { notIn: ["DONE", "ARCHIVED"] },
|
status: { notIn: ["DONE", "ARCHIVED"] },
|
||||||
ownerId: { not: null },
|
ownerId: { not: null },
|
||||||
snapshot: { isNot: null },
|
repositories: { some: { snapshot: { isNot: null } } },
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
repositories: {
|
||||||
include: { snapshot: true },
|
include: { snapshot: true },
|
||||||
|
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) =>
|
const faellig = kandidaten.filter((project) =>
|
||||||
isStaleDue(project, project.snapshot?.lastCommitAt ?? null, cutoff),
|
isStaleDue(project, letzterCommit.get(project.id) ?? null, cutoff),
|
||||||
);
|
);
|
||||||
|
|
||||||
let zugestellt = 0;
|
let zugestellt = 0;
|
||||||
|
|
||||||
for (const project of faellig) {
|
for (const project of faellig) {
|
||||||
const lastCommitAt = project.snapshot?.lastCommitAt;
|
const lastCommitAt = letzterCommit.get(project.id);
|
||||||
if (!lastCommitAt || !project.ownerId) continue;
|
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({
|
const ziele = await prisma.notificationTarget.findMany({
|
||||||
where: { userId: project.ownerId, isActive: true, events: { has: "PROJECT_STALE" } },
|
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) {
|
for (const ziel of ziele) {
|
||||||
const ergebnis = await deliver(
|
const ergebnis = await deliver(
|
||||||
ziel,
|
ziel,
|
||||||
message(project, project.snapshot, daysSince(lastCommitAt, now)),
|
message(project, snapshot, daysSince(lastCommitAt, now)),
|
||||||
);
|
);
|
||||||
zugestellt += 1;
|
zugestellt += 1;
|
||||||
await prisma.notificationTarget.update({
|
await prisma.notificationTarget.update({
|
||||||
|
|||||||
+13
-4
@@ -46,9 +46,19 @@ export const connectionSafeSelect = Prisma.validator<Prisma.GitConnectionSelect>
|
|||||||
updatedAt: true,
|
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>()({
|
export const projectListInclude = Prisma.validator<Prisma.ProjectInclude>()({
|
||||||
connection: { select: connectionSafeSelect },
|
...repositoryInclude,
|
||||||
snapshot: true,
|
|
||||||
_count: { select: { notes: true, tasks: true } },
|
_count: { select: { notes: true, tasks: true } },
|
||||||
// Nur die offenen Aufgaben zaehlen – fuer die Anzeige "3 von 8" auf der
|
// Nur die offenen Aufgaben zaehlen – fuer die Anzeige "3 von 8" auf der
|
||||||
// Karte, ohne die Aufgaben selbst mitzuladen.
|
// Karte, ohne die Aufgaben selbst mitzuladen.
|
||||||
@@ -56,8 +66,7 @@ export const projectListInclude = Prisma.validator<Prisma.ProjectInclude>()({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const projectDetailInclude = Prisma.validator<Prisma.ProjectInclude>()({
|
export const projectDetailInclude = Prisma.validator<Prisma.ProjectInclude>()({
|
||||||
connection: { select: connectionSafeSelect },
|
...repositoryInclude,
|
||||||
snapshot: true,
|
|
||||||
notes: {
|
notes: {
|
||||||
orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }],
|
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 { recordActivity, shorten } from "@/lib/activity";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git";
|
import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git";
|
||||||
import { notifyAfterSync } from "@/lib/notify";
|
import { notifyAfterSync } from "@/lib/notify";
|
||||||
|
import { repoTitle } from "@/lib/repositories";
|
||||||
import type { RepoSnapshotData } from "@/lib/git/types";
|
import type { RepoSnapshotData } from "@/lib/git/types";
|
||||||
|
|
||||||
export interface SyncResult {
|
export interface SyncResult {
|
||||||
@@ -12,21 +13,21 @@ export interface SyncResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holt den aktuellen Stand des verknuepften Repositories und schreibt ihn
|
* Holt den aktuellen Stand eines Repositories und schreibt ihn als Snapshot
|
||||||
* als Snapshot in die Datenbank. Fehler werden am Projekt vermerkt statt
|
* in die Datenbank. Fehler werden am Repository vermerkt statt geworfen,
|
||||||
* geworfen, damit ein kaputtes Repo nicht das ganze Dashboard blockiert.
|
* damit ein kaputtes Repo nicht das ganze Dashboard blockiert.
|
||||||
*/
|
*/
|
||||||
export async function syncProject(projectId: string): Promise<SyncResult> {
|
export async function syncRepository(repositoryId: string): Promise<SyncResult> {
|
||||||
const project = await prisma.project.findUnique({
|
const repository = await prisma.repository.findUnique({
|
||||||
where: { id: projectId },
|
where: { id: repositoryId },
|
||||||
include: { connection: true },
|
include: { connection: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!project) {
|
if (!repository) {
|
||||||
return { ok: false, message: "Projekt nicht gefunden." };
|
return { ok: false, message: "Repository nicht gefunden." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const ref = resolveRepoRef(project);
|
const ref = resolveRepoRef(repository);
|
||||||
if (!ref) {
|
if (!ref) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -35,11 +36,11 @@ export async function syncProject(projectId: string): Promise<SyncResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let client;
|
let client;
|
||||||
if (project.connection) {
|
if (repository.connection) {
|
||||||
client = clientForConnection(project.connection);
|
client = clientForConnection(repository.connection);
|
||||||
} else {
|
} else {
|
||||||
// Ohne gespeicherte Verbindung: anonymer Zugriff anhand der URL.
|
// 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) {
|
if (!parsed) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -55,9 +56,9 @@ export async function syncProject(projectId: string): Promise<SyncResult> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await client.fetchRepo(ref);
|
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.
|
// Nach dem Speichern, damit die Meldung den neuen Stand sieht.
|
||||||
await notifyAfterSync(project.id);
|
await notifyAfterSync(repository.id);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
message: `Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.`,
|
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) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
await prisma.project.update({
|
await prisma.repository.update({
|
||||||
where: { id: project.id },
|
where: { id: repository.id },
|
||||||
data: {
|
data: {
|
||||||
syncError: message,
|
syncError: message,
|
||||||
lastSyncedAt: new Date(),
|
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
|
// Nur der Uebergang in den Fehlerzustand kommt in den Verlauf. Ein Repo
|
||||||
// mit abgelaufenem Token schlaegt sonst stuendlich neu auf.
|
// mit abgelaufenem Token schlaegt sonst stuendlich neu auf.
|
||||||
if (!project.syncError) {
|
if (!repository.syncError) {
|
||||||
await recordActivity({
|
await recordActivity({
|
||||||
projectId: project.id,
|
projectId: repository.projectId,
|
||||||
kind: "SYNC_FAILED",
|
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 };
|
return { ok: false, message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Owner/Name aus den Feldern oder ersatzweise aus der URL bestimmen. */
|
export interface ProjectSyncResult extends SyncResult {
|
||||||
export function resolveRepoRef(project: Project) {
|
/** Ergebnis je Repository – auch dann, wenn nur eines dranhaengt. */
|
||||||
if (project.repoOwner && project.repoName) {
|
repositories: Array<{ id: string; title: string } & SyncResult>;
|
||||||
return { owner: project.repoOwner, name: project.repoName };
|
|
||||||
}
|
}
|
||||||
if (project.repoUrl) {
|
|
||||||
const parsed = parseRepoUrl(project.repoUrl);
|
/**
|
||||||
|
* 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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
if (parsed) return { owner: parsed.owner, name: parsed.name };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -109,7 +164,7 @@ export function resolveRepoRef(project: Project) {
|
|||||||
* Commits hat, ist keine Neuigkeit.
|
* Commits hat, ist keine Neuigkeit.
|
||||||
*/
|
*/
|
||||||
async function noteNewCommits(
|
async function noteNewCommits(
|
||||||
projectId: string,
|
repository: Repository,
|
||||||
vorher: string | null,
|
vorher: string | null,
|
||||||
data: RepoSnapshotData,
|
data: RepoSnapshotData,
|
||||||
) {
|
) {
|
||||||
@@ -121,20 +176,21 @@ async function noteNewCommits(
|
|||||||
// Zahl als mit einer falschen.
|
// Zahl als mit einer falschen.
|
||||||
const index = data.commits.findIndex((commit) => commit.sha === vorher);
|
const index = data.commits.findIndex((commit) => commit.sha === vorher);
|
||||||
const anzahl = index > 0 ? index : null;
|
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({
|
await recordActivity({
|
||||||
projectId,
|
projectId: repository.projectId,
|
||||||
kind: "REPO_COMMITS",
|
kind: "REPO_COMMITS",
|
||||||
summary:
|
summary:
|
||||||
anzahl === null
|
anzahl === null
|
||||||
? `Neue Commits${wo}: ${shorten(data.commits[0]?.message ?? "", 60)}`
|
? `Neue Commits in ${wo}: ${betreff}`
|
||||||
: `${anzahl} neue${anzahl === 1 ? "r" : ""} Commit${anzahl === 1 ? "" : "s"}${wo}: ${shorten(data.commits[0]?.message ?? "", 60)}`,
|
: `${anzahl} neue${anzahl === 1 ? "r" : ""} Commit${anzahl === 1 ? "" : "s"} in ${wo}: ${betreff}`,
|
||||||
meta: { from: vorher, to: neuester, count: anzahl },
|
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 latestCommit = data.commits[0] ?? null;
|
||||||
const latestRelease =
|
const latestRelease =
|
||||||
data.releases.find((release) => !release.draft && !release.prerelease) ?? data.releases[0] ?? null;
|
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.
|
// Vor dem Ueberschreiben lesen – danach ist der alte Stand weg.
|
||||||
const vorher = await prisma.repoSnapshot.findUnique({
|
const vorher = await prisma.repoSnapshot.findUnique({
|
||||||
where: { projectId },
|
where: { repositoryId: repository.id },
|
||||||
select: { lastCommitSha: true },
|
select: { lastCommitSha: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
prisma.repoSnapshot.upsert({
|
prisma.repoSnapshot.upsert({
|
||||||
where: { projectId },
|
where: { repositoryId: repository.id },
|
||||||
create: { projectId, ...payload },
|
create: { repositoryId: repository.id, ...payload },
|
||||||
update: payload,
|
update: payload,
|
||||||
}),
|
}),
|
||||||
prisma.project.update({
|
prisma.repository.update({
|
||||||
where: { id: projectId },
|
where: { id: repository.id },
|
||||||
data: {
|
data: {
|
||||||
lastSyncedAt: new Date(),
|
lastSyncedAt: new Date(),
|
||||||
syncError: null,
|
syncError: null,
|
||||||
@@ -199,7 +255,14 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) {
|
|||||||
repoUrl: data.htmlUrl ?? undefined,
|
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 { getAppSettings } from "@/lib/auth/settings";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { runStaleCheck } from "@/lib/notify/stale";
|
import { runStaleCheck } from "@/lib/notify/stale";
|
||||||
import { syncProject } from "@/lib/sync";
|
import { syncRepository } from "@/lib/sync";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Automatischer Abgleich mit den Git-Systemen.
|
* 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 batchSize = Math.max(1, Math.min(200, settings.autoSyncBatchSize));
|
||||||
const intervalMs = Math.max(5, settings.autoSyncIntervalMinutes) * 60_000;
|
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,
|
autoSync: true,
|
||||||
// Ohne Repository gibt es nichts abzugleichen.
|
// Ohne Angaben gibt es nichts abzugleichen.
|
||||||
OR: [
|
OR: [
|
||||||
{ AND: [{ repoOwner: { not: null } }, { repoName: { not: null } }] },
|
{ AND: [{ repoOwner: { not: null } }, { repoName: { not: null } }] },
|
||||||
{ repoUrl: { not: null } },
|
{ repoUrl: { not: null } },
|
||||||
],
|
],
|
||||||
...(settings.autoSyncActiveOnly
|
...(settings.autoSyncActiveOnly
|
||||||
? { status: { notIn: ["DONE", "ARCHIVED"] } }
|
? { project: { status: { notIn: ["DONE", "ARCHIVED"] } } }
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Grosszuegig holen: ein Teil faellt gleich der Zurueckhaltung zum Opfer.
|
// Grosszuegig holen: ein Teil faellt gleich der Zurueckhaltung zum Opfer.
|
||||||
const candidates = await prisma.project.findMany({
|
const candidates = await prisma.repository.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: [{ lastSyncedAt: { sort: "asc", nulls: "first" } }],
|
orderBy: [{ lastSyncedAt: { sort: "asc", nulls: "first" } }],
|
||||||
take: batchSize * 4,
|
take: batchSize * 4,
|
||||||
select: { id: true, name: true, lastSyncedAt: true, syncFailureCount: true },
|
select: { id: true, lastSyncedAt: true, syncFailureCount: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const due = candidates.filter((project) => {
|
const due = candidates.filter((repository) => {
|
||||||
if (project.syncFailureCount === 0) return true;
|
if (repository.syncFailureCount === 0) return true;
|
||||||
if (!project.lastSyncedAt) return true;
|
if (!repository.lastSyncedAt) return true;
|
||||||
|
|
||||||
// Nach jedem Fehlschlag doppelt so lange warten, gedeckelt.
|
// 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 wait = intervalMs * 2 ** steps;
|
||||||
const ready = Date.now() - project.lastSyncedAt.getTime() >= wait;
|
const ready = Date.now() - repository.lastSyncedAt.getTime() >= wait;
|
||||||
if (!ready) skipped += 1;
|
if (!ready) skipped += 1;
|
||||||
return ready;
|
return ready;
|
||||||
});
|
});
|
||||||
|
|
||||||
const batch = due.slice(0, batchSize);
|
const batch = due.slice(0, batchSize);
|
||||||
|
|
||||||
for (const project of batch) {
|
for (const repository of batch) {
|
||||||
const result = await syncProject(project.id);
|
const result = await syncRepository(repository.id);
|
||||||
if (result.ok) synced += 1;
|
if (result.ok) synced += 1;
|
||||||
else failed += 1;
|
else failed += 1;
|
||||||
if (PAUSE_MS > 0) await new Promise((resolve) => setTimeout(resolve, PAUSE_MS));
|
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"),
|
accent: z.enum(ACCENTS).default("violet"),
|
||||||
tags: z.array(z.string().trim().min(1).max(30)).max(12).default([]),
|
tags: z.array(z.string().trim().min(1).max(30)).max(12).default([]),
|
||||||
favorite: z.boolean().default(false),
|
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()),
|
repoUrl: z.preprocess(emptyToNull, z.string().trim().max(500).nullable().optional()),
|
||||||
repoOwner: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
|
repoOwner: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
|
||||||
repoName: 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();
|
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 ─────────────────────────────────────────────────
|
// ── Aufgaben ─────────────────────────────────────────────────
|
||||||
|
|
||||||
const TASK_STATUS = ["TODO", "DOING", "BLOCKED", "DONE"] as const;
|
const TASK_STATUS = ["TODO", "DOING", "BLOCKED", "DONE"] as const;
|
||||||
|
|||||||
+12
-5
@@ -5,6 +5,7 @@ import type {
|
|||||||
Project,
|
Project,
|
||||||
ProjectStatus,
|
ProjectStatus,
|
||||||
RepoSnapshot,
|
RepoSnapshot,
|
||||||
|
Repository,
|
||||||
Task,
|
Task,
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
} from "@prisma/client";
|
} from "@prisma/client";
|
||||||
@@ -33,12 +34,18 @@ export interface ConnectionSafe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ConnectionWithCount extends ConnectionSafe {
|
export interface ConnectionWithCount extends ConnectionSafe {
|
||||||
_count?: { projects: number };
|
_count?: { repositories: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Repository samt Verbindung und zuletzt geholtem Stand. */
|
||||||
|
export interface RepositoryWithSnapshot extends Repository {
|
||||||
|
connection: ConnectionSafe | null;
|
||||||
|
snapshot: RepoSnapshot | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectListItem extends Project {
|
export interface ProjectListItem extends Project {
|
||||||
connection: ConnectionSafe | null;
|
/** Das fuehrende zuerst – siehe repositoryInclude. */
|
||||||
snapshot: RepoSnapshot | null;
|
repositories: RepositoryWithSnapshot[];
|
||||||
_count: { notes: number; tasks: number };
|
_count: { notes: number; tasks: number };
|
||||||
/** Nur die erledigten – siehe projectListInclude. */
|
/** Nur die erledigten – siehe projectListInclude. */
|
||||||
tasks: { id: string }[];
|
tasks: { id: string }[];
|
||||||
@@ -55,8 +62,7 @@ export interface ActivityEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectDetail extends Project {
|
export interface ProjectDetail extends Project {
|
||||||
connection: ConnectionSafe | null;
|
repositories: RepositoryWithSnapshot[];
|
||||||
snapshot: RepoSnapshot | null;
|
|
||||||
notes: Note[];
|
notes: Note[];
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
activities: ActivityEntry[];
|
activities: ActivityEntry[];
|
||||||
@@ -83,6 +89,7 @@ export type {
|
|||||||
ProjectStatus,
|
ProjectStatus,
|
||||||
ReleaseInfo,
|
ReleaseInfo,
|
||||||
RepoSnapshot,
|
RepoSnapshot,
|
||||||
|
Repository,
|
||||||
TagInfo,
|
TagInfo,
|
||||||
Task,
|
Task,
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
|
|||||||
Reference in New Issue
Block a user