From 6718018211c38930b18609d917f7adfc3cd5667f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 21:58:11 +0000 Subject: [PATCH] Verwaiste Repo-Platzhalter blockieren keine Zuordnung mehr Ein in Gitea geloeschtes Org-Repo liess sein unverknuepftes Projekt-Dokument liegen. Der Unique-Index auf repoFullName ignoriert Gross-/Kleinschreibung, darum scheiterte beim Zuweisen eines gleich benannten neuen Repos das Umbenennen des Projekts; alle Pushes landeten im Platzhalter ohne Ticket, also ohne Arbeitsblatt und ohne Push-Zeitmail (WEBklar/Schmacko-napf). - repoMove: vor dem Umbenennen ein unverknuepftes fremdes Dokument mit dem Zielnamen entfernen (verknuepfte bleiben unangetastet) - Webhook repository/deleted: unverknuepfte Platzhalter des geloeschten Repos entfernen, nur wenn Gitea das Repo wirklich nicht mehr kennt Co-Authored-By: Claude Opus 5.5 --- server/routes/webhook/gitea.js | 15 +++++++++--- server/services/repoAssignJob.js | 41 ++++++++++++++++++++++++++++++-- server/services/repoMove.js | 30 ++++++++++++++++++++++- test/repoAssignDetect.test.js | 22 ++++++++++++++++- test/repoMove.test.js | 31 ++++++++++++++++++++++++ 5 files changed, 132 insertions(+), 7 deletions(-) diff --git a/server/routes/webhook/gitea.js b/server/routes/webhook/gitea.js index 185dadc..dee6506 100644 --- a/server/routes/webhook/gitea.js +++ b/server/routes/webhook/gitea.js @@ -16,7 +16,7 @@ import { ensureProjectTicket } from '../../services/ticketAdmin.js' import { invalidateProjectCache } from '../../services/projectInsights.js' import { withLocks } from '../../services/keyedLock.js' import { repoLockKey, currentRepoFullName } from '../../services/repoMove.js' -import { handleRepositoryCreated } from '../../services/repoAssignJob.js' +import { handleRepositoryCreated, handleRepositoryDeleted } from '../../services/repoAssignJob.js' const router = Router() @@ -48,9 +48,18 @@ router.post('/gitea', async (req, res) => { const payload = req.body || {} - // Ereignis "repository": nur `created` wird verarbeitet (neues Repo -> Dokument + Zuordnungs-Mail), - // alles andere (deleted, transfer, ...) wird mit 200 ignoriert. Der Push-Pfad bleibt unberuehrt. + // Ereignis "repository": `created` (neues Repo -> Dokument + Zuordnungs-Mail) und `deleted` + // (Platzhalter-Dokument entfernen), alles andere wird mit 200 ignoriert. Der Push-Pfad bleibt unberuehrt. if (req.get('X-Gitea-Event') === 'repository') { + if (payload.action === 'deleted') { + try { + const r = await handleRepositoryDeleted(payload) + return res.json({ ok: true, removed: r.removed }) + } catch (err) { + console.error('[webhook] repository/deleted:', err) + return res.status(500).json({ error: err.message || 'Webhook-Verarbeitung fehlgeschlagen' }) + } + } if (payload.action !== 'created') return res.json({ ok: true, ignored: true }) try { const r = await handleRepositoryCreated(payload) diff --git a/server/services/repoAssignJob.js b/server/services/repoAssignJob.js index 95834b2..d1fadc0 100644 --- a/server/services/repoAssignJob.js +++ b/server/services/repoAssignJob.js @@ -1,9 +1,10 @@ import { config } from '../config.js' -import { listDocuments, updateDocument, getDocument, upsertWebsiteProjectByRepo, Query } from './appwriteAdmin.js' +import { listDocuments, updateDocument, getDocument, deleteDocument, upsertWebsiteProjectByRepo, Query } from './appwriteAdmin.js' import { importNewRepos } from './giteaAdmin.js' import { giteaFetch } from './giteaClient.js' import { withLocks } from './keyedLock.js' -import { repoLockKey } from './repoMove.js' +import { repoLockKey, isUnlinkedProject } from './repoMove.js' +import { invalidateProjectCache } from './projectInsights.js' import { shouldSendAssignMail, dueReminder, sendRepoAssignMail } from './repoAssignMail.js' /** @@ -84,6 +85,42 @@ export async function handleRepositoryCreated(payload, { now = new Date(), sendM return { created, mail } } +/** + * Webhook `repository` / `deleted`: unverknuepfte Platzhalter-Dokumente des geloeschten Repos + * entfernen. Sonst belegen sie den Namen (Unique-Index ohne Gross-/Kleinschreibung), und ein spaeter + * gleich benanntes Repo kann nach dem Zuweisen nicht auf ihn umbenannt werden. Verknuepfte Dokumente + * (Ticket, Kunde, Art, Preview) bleiben, ebenso alles, solange Gitea das Repo noch liefert. + */ +export async function handleRepositoryDeleted(payload) { + const fullName = payload?.repository?.full_name + if (!fullName) return { removed: 0 } + + return withLocks( + [repoLockKey(fullName)], + async () => { + try { + await giteaFetch(`/repos/${fullName}`) + return { removed: 0 } // Repo (oder eine Weiterleitung darauf) existiert noch + } catch (err) { + if (err.status !== 404) throw err + } + const docs = await listDocuments(collection(), [Query.equal('repoFullName', fullName), Query.limit(10)]) + let removed = 0 + for (const doc of docs) { + if (!isUnlinkedProject(doc)) { + console.warn(`[repoAssign] Repo ${fullName} gelöscht, Projekt ${doc.$id} ist verknüpft und bleibt`) + continue + } + await deleteDocument(collection(), doc.$id) + removed += 1 + } + invalidateProjectCache(fullName) + return { removed } + }, + { waitMs: 10000 } + ) +} + /** Ein Lauf: neue Repos importieren + Mail, Wiederholungen, Erinnerungen. Wirft bei Appwrite-Fehlern nicht. */ export async function runRepoAssignJob({ now = new Date(), diff --git a/server/services/repoMove.js b/server/services/repoMove.js index ce5191e..17d1ce0 100644 --- a/server/services/repoMove.js +++ b/server/services/repoMove.js @@ -2,7 +2,7 @@ import fs from 'node:fs/promises' import { config } from '../config.js' import { giteaFetch, splitRepo } from './giteaClient.js' import { withLocks } from './keyedLock.js' -import { listDocuments, updateDocument, Query } from './appwriteAdmin.js' +import { listDocuments, updateDocument, deleteDocument, Query } from './appwriteAdmin.js' import { invalidateProjectCache } from './projectInsights.js' import { previewWorkDir } from './previewDeploy.js' @@ -101,8 +101,36 @@ function classifyGiteaError(err) { return { moved: null, warnings: [`Umzug nach WEBklar nicht möglich (${err.message}).`] } } +/** + * Projekt-Dokument ohne jede Verknuepfung (kein Ticket, kein Kunde, keine Art, keine Preview): + * ein reiner Platzhalter, wie ihn der Webhook `repository/created` oder der Import anlegt. + */ +export function isUnlinkedProject(project) { + return !project?.ticketId && !project?.customerId && !project?.kind && !project?.subdomain +} + +/** + * Gibt den Namen `to` fuer `projectId` frei, falls ihn ein anderes, unverknuepftes Dokument belegt - + * z. B. das Ueberbleibsel eines in Gitea geloeschten Repos gleichen Namens. Der Unique-Index auf + * `repoFullName` ignoriert Gross-/Kleinschreibung; ohne Freigabe scheitert das Umbenennen, und alle + * Pushes landen danach im Platzhalter statt im zugewiesenen Projekt (keine Arbeitsblaetter, keine + * Zeitmail). Ein verknuepftes fremdes Dokument bleibt unangetastet - dann scheitert das Update wie bisher. + */ +async function releaseRepoName(projectId, to) { + const others = await listDocuments(config.collections.websiteProjects, [ + Query.equal('repoFullName', to), + Query.limit(10), + ]) + for (const other of others) { + if (other.$id === projectId || !isUnlinkedProject(other)) continue + await deleteDocument(config.collections.websiteProjects, other.$id) + console.warn(`[repoMove] Verwaistes Projekt-Dokument ${other.$id} (${other.repoFullName}) entfernt, Name frei für ${projectId}`) + } +} + /** Schritt 6: Projektdokument auf den neuen Repo-Namen nachziehen. */ async function updateProjectDocument(project, to) { + await releaseRepoName(project.$id, to) const name = splitRepo(to)[1] return updateDocument(config.collections.websiteProjects, project.$id, { repoFullName: to, diff --git a/test/repoAssignDetect.test.js b/test/repoAssignDetect.test.js index 9b1ce40..e930ba5 100644 --- a/test/repoAssignDetect.test.js +++ b/test/repoAssignDetect.test.js @@ -37,7 +37,11 @@ before(async () => { gitea = await startFakeGitea({ repos: [] }) aw = await startFakeAppwrite({ seed: { - websiteProjects: [], + websiteProjects: [ + { $id: 'waise', repoFullName: 'WEBklar/schmacko-napf', projectName: 'schmacko-napf', ticketId: '', customerId: '' }, + { $id: 'verknuepft', repoFullName: 'WEBklar/mit-ticket', projectName: 'mit-ticket', ticketId: 't-9' }, + { $id: 'lebt', repoFullName: 'WEBklar/lebt-noch', projectName: 'lebt-noch' }, + ], employees: [{ $id: 'k', displayName: 'Kenso Grimm', shortcode: 'KNSO', email: 'kenso@webklar.com' }], }, }) @@ -173,3 +177,19 @@ test('Job: Appwrite-Fehler bricht den Lauf ab, ohne zu werfen', async () => { } finally { console.error = silent } assert.ok(awUrl) }) + +test('repository/deleted entfernt das unverknuepfte Platzhalter-Dokument, verknuepfte und lebende bleiben', async () => { + gitea.addRepo({ full_name: 'WEBklar/lebt-noch' }) + const del = (full) => hook('repository', { ...created(full), action: 'deleted', sender: { login: 'ANDJ' } }) + + const r = await del('WEBklar/schmacko-napf') + assert.equal(r.status, 200) + assert.equal(r.body.removed, 1) + assert.equal(aw.get('websiteProjects', 'waise'), undefined) + + assert.equal((await del('WEBklar/mit-ticket')).body.removed, 0) + assert.ok(aw.get('websiteProjects', 'verknuepft')) + + assert.equal((await del('WEBklar/lebt-noch')).body.removed, 0) + assert.ok(aw.get('websiteProjects', 'lebt')) +}) diff --git a/test/repoMove.test.js b/test/repoMove.test.js index b64dd67..19b35aa 100644 --- a/test/repoMove.test.js +++ b/test/repoMove.test.js @@ -45,6 +45,8 @@ before(async () => { { full_name: 'JUSN/verboten' }, { full_name: 'JUSN/Case-Test' }, { full_name: 'knso/rausgezogen' }, + { full_name: 'ANDJ/Schmacko-napf' }, + { full_name: 'JUSN/belegt' }, ], // C1: Hooks kommen (wie im echten Gitea) OHNE Top-Level-`url` an - `url` hier ist nur die // Seed-Abkuerzung, gespeichert/ausgeliefert wird das Ziel ausschliesslich unter `config.url`. @@ -85,6 +87,20 @@ before(async () => { return { $id, repoFullName, projectName: repoFullName.split('/').pop() } }) + // Andrejs Fall (2026-10-01): WEBklar/Schmacko-napf wurde angelegt und wieder geloescht, sein + // Platzhalter-Dokument blieb liegen. Das neue, zugewiesene ANDJ/Schmacko-napf muss den Namen bekommen. + const linked = { ticketId: 't-1', customerId: 'c-1', kind: 'website' } + for (const [repoFullName, extra] of [ + ['ANDJ/Schmacko-napf', linked], + ['WEBklar/Schmacko-napf', {}], + ['JUSN/belegt', linked], + ['WEBklar/belegt', { ticketId: 't-2' }], + ]) { + const $id = randomUUID() + idByRepo.set(repoFullName, $id) + websiteProjects.push({ $id, repoFullName, projectName: repoFullName.split('/').pop(), ...extra }) + } + aw = await startFakeAppwrite({ seed: { websiteProjects } }) originalCwd = process.cwd() @@ -236,3 +252,18 @@ test('R10-Ein-Zeiler: Transfer meldet Erfolg, aber unveraenderten full_name -> k false ) }) + +test('verwaistes Platzhalter-Dokument mit dem Zielnamen wird entfernt, das zugewiesene Projekt uebernimmt den Namen', async () => { + const orphanId = idByRepo.get('WEBklar/Schmacko-napf') + const r = await moveRepoToOrg(doc('ANDJ/Schmacko-napf')) + assert.deepEqual(r, { moved: { from: 'ANDJ/Schmacko-napf', to: 'WEBklar/Schmacko-napf' }, warnings: [] }) + assert.equal(aw.get('websiteProjects', orphanId), undefined) + const d = doc('ANDJ/Schmacko-napf') + assert.equal(d.repoFullName, 'WEBklar/Schmacko-napf') + assert.equal(d.ticketId, 't-1') +}) + +test('verknuepftes fremdes Dokument mit dem Zielnamen bleibt unangetastet', async () => { + await moveRepoToOrg(doc('JUSN/belegt')) + assert.equal(doc('WEBklar/belegt').ticketId, 't-2') +})