CI / pruefen (push) Canceled after 0s
Ein Push aktualisiert den Stand sofort, statt bis zum naechsten Durchlauf des Zeitgebers zu warten; ein geschlossenes Issue hakt seine Aufgabe ab. Der Zeitplan bleibt als Rueckfalllosung. Der Endpunkt ist der einzige ohne Anmeldung, der schreibt – entsprechend eng gefasst: - Ausgewiesen wird sich mit einer Signatur ueber den Rumpf. GitHub und Gitea rechnen HMAC-SHA256, GitLab schickt nur den Token im Klartext; dessen Verfahren ist schwaecher, mehr gibt die Plattform nicht her. Verglichen wird durchweg zeitkonstant. - Geprueft wird gegen den **rohen** Rumpf. Durch JSON.parse und JSON.stringify gedreht kaeme eine andere Zeichenfolge heraus und die Signatur passte nie. - Der Rumpf ist nie Quelle fuer Inhalte. Wer das Geheimnis kennt, kann eine Aufgabe auf- oder zumachen und einen Abgleich ausloesen – keine beliebigen Daten einschleusen. - Ratenbegrenzung je Repository, Deckel auf die Rumpfgroesse, und ein unbekanntes Repository antwortet wie ein nicht eingerichtetes: 404, damit die ID nicht bestaetigt wird. - Unbekannte Ereignisse werden angenommen und liegen gelassen. Ein Fehler brachte das Git-System zum Wiederholen und irgendwann zum Abschalten. Das Geheimnis liegt verschluesselt am Repository, wie die Zugangstoken, und ist genau einmal zu sehen. Damit es nicht doch nach draussen wandert, laedt repositoryInclude die Repositories nicht mehr per include, sondern ueber eine ausdrueckliche Feldliste: ein include haette das neue Feld still in jede Projektantwort und jede Seite mitgenommen. Ob ein Haken eingerichtet ist, holt die Projektseite getrennt. Die Migration legt zwei nullbare Spalten an, mehr nicht; der Gegenlauf von migrate diff kommt leer heraus. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
158 lines
4.6 KiB
TypeScript
158 lines
4.6 KiB
TypeScript
import { Prisma, type ProjectStatus } from "@prisma/client";
|
||
import { prisma } from "@/lib/db";
|
||
import { slugify } from "@/lib/utils";
|
||
|
||
/**
|
||
* Erzeugt einen je Besitzer eindeutigen Slug, notfalls mit angehaengtem
|
||
* Zaehler. Zwei Benutzer duerfen also beide ein Projekt "dashboard" haben.
|
||
*/
|
||
export async function uniqueSlug(
|
||
name: string,
|
||
ownerId: string,
|
||
ignoreId?: string,
|
||
): Promise<string> {
|
||
const base = slugify(name) || "projekt";
|
||
let candidate = base;
|
||
let counter = 2;
|
||
|
||
// In der Praxis endet die Schleife nach wenigen Runden.
|
||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||
const existing = await prisma.project.findFirst({
|
||
where: { slug: candidate, ownerId },
|
||
select: { id: true },
|
||
});
|
||
if (!existing || existing.id === ignoreId) return candidate;
|
||
candidate = `${base}-${counter}`;
|
||
counter += 1;
|
||
}
|
||
return `${base}-${Date.now()}`;
|
||
}
|
||
|
||
/**
|
||
* Naechste freie Position am Ende einer Statusspalte im Brett.
|
||
*
|
||
* Muss bei jedem Anlegen und bei jedem Statuswechsel gesetzt werden – sonst
|
||
* stehen alle auf 0 und das Brett zeigt eine Reihenfolge, die sich bei jedem
|
||
* Laden anders ergeben kann.
|
||
*/
|
||
export async function nextProjectPosition(
|
||
ownerId: string,
|
||
status: ProjectStatus,
|
||
): Promise<number> {
|
||
const last = await prisma.project.findFirst({
|
||
where: { ownerId, status },
|
||
orderBy: { position: "desc" },
|
||
select: { position: true },
|
||
});
|
||
return (last?.position ?? -1) + 1;
|
||
}
|
||
|
||
/**
|
||
* Verbindungsfelder, die an den Browser gehen duerfen.
|
||
* tokenCipher ist bewusst nicht dabei.
|
||
*/
|
||
export const connectionSafeSelect = Prisma.validator<Prisma.GitConnectionSelect>()({
|
||
id: true,
|
||
name: true,
|
||
provider: true,
|
||
baseUrl: true,
|
||
username: true,
|
||
tokenHint: true,
|
||
lastCheckedAt: true,
|
||
lastCheckOk: true,
|
||
lastCheckMessage: true,
|
||
createdAt: true,
|
||
updatedAt: true,
|
||
});
|
||
|
||
/**
|
||
* Felder eines Repositories, die nach aussen duerfen.
|
||
*
|
||
* Ausdruecklich als Liste und nicht als "include": ein include liefert jedes
|
||
* Skalarfeld mit, auch neu hinzugekommene. Das Webhook-Geheimnis waere so
|
||
* still in jede Projektantwort und in jede Seite gewandert – verschluesselt
|
||
* zwar, aber nach aussen gehoert es trotzdem nicht. Dieselbe Ueberlegung wie
|
||
* bei connectionSafeSelect.
|
||
*
|
||
* Ob ein Webhook eingerichtet ist, sagt webhookLastAt nicht zuverlaessig
|
||
* (frisch eingerichtet ist es null). Diese Angabe holt die Projektseite
|
||
* deshalb getrennt – siehe webhookRepositoryIds.
|
||
*/
|
||
export const repositorySafeSelect = Prisma.validator<Prisma.RepositorySelect>()({
|
||
id: true,
|
||
projectId: true,
|
||
label: true,
|
||
repoUrl: true,
|
||
repoOwner: true,
|
||
repoName: true,
|
||
defaultBranch: true,
|
||
connectionId: true,
|
||
position: true,
|
||
lastSyncedAt: true,
|
||
syncError: true,
|
||
autoSync: true,
|
||
syncFailureCount: true,
|
||
notifiedSyncError: true,
|
||
notifiedCiState: true,
|
||
notifiedRelease: true,
|
||
webhookLastAt: true,
|
||
createdAt: true,
|
||
updatedAt: true,
|
||
connection: { select: connectionSafeSelect },
|
||
snapshot: 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: {
|
||
select: repositorySafeSelect,
|
||
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||
},
|
||
});
|
||
|
||
/**
|
||
* IDs der Repositories eines Projekts, fuer die ein Webhook hinterlegt ist.
|
||
* Getrennt geholt, damit das Geheimnis selbst nirgends mitgereicht wird.
|
||
*/
|
||
export async function webhookRepositoryIds(projectId: string): Promise<string[]> {
|
||
const treffer = await prisma.repository.findMany({
|
||
where: { projectId, webhookSecret: { not: null } },
|
||
select: { id: true },
|
||
});
|
||
return treffer.map((eintrag) => eintrag.id);
|
||
}
|
||
|
||
export const projectListInclude = Prisma.validator<Prisma.ProjectInclude>()({
|
||
...repositoryInclude,
|
||
_count: { select: { notes: true, tasks: true } },
|
||
// Nur die offenen Aufgaben zaehlen – fuer die Anzeige "3 von 8" auf der
|
||
// Karte, ohne die Aufgaben selbst mitzuladen.
|
||
tasks: { where: { status: "DONE" }, select: { id: true } },
|
||
});
|
||
|
||
export const projectDetailInclude = Prisma.validator<Prisma.ProjectInclude>()({
|
||
...repositoryInclude,
|
||
notes: {
|
||
orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }],
|
||
},
|
||
tasks: {
|
||
orderBy: [{ status: "asc" }, { position: "asc" }],
|
||
},
|
||
// Erste Seite des Verlaufs; weitere holt die Anzeige bei Bedarf nach.
|
||
activities: {
|
||
orderBy: { createdAt: "desc" },
|
||
take: 20,
|
||
select: {
|
||
id: true,
|
||
kind: true,
|
||
summary: true,
|
||
createdAt: true,
|
||
user: { select: { username: true, displayName: true } },
|
||
},
|
||
},
|
||
_count: { select: { notes: true, tasks: true } },
|
||
});
|