From 800630ea4bbcf530542ad0dd66c9d2b0a78b2963 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 13:36:26 +0000 Subject: [PATCH] Projekt anlegen/archivieren: Review-Punkte - Anlegen uebernimmt keinen alten Eintrag gleichen Namens mehr (Repo in Gitea geloescht): Platzhalter werden entfernt, ein verknuepftes altes Projekt blockiert den Namen mit klarer Meldung, bevor ein Repo entsteht; neue Projekte starten mit archived=false - archiveRepo aendert Gitea und Dokument unter der Repo-Sperre; der Import liest den Archiv-Stand vor dem Spiegeln frisch aus Gitea - Reaktivieren: Preview aus -> synced, Deploy-Fehler -> deploy_failed statt dauerhaft 'pending' Co-Authored-By: Claude Opus 5.5 --- server/services/giteaAdmin.js | 130 ++++++++++++++++++++++------------ test/projektAblauf.test.js | 24 ++++++- 2 files changed, 105 insertions(+), 49 deletions(-) diff --git a/server/services/giteaAdmin.js b/server/services/giteaAdmin.js index 073ce05..0506862 100644 --- a/server/services/giteaAdmin.js +++ b/server/services/giteaAdmin.js @@ -5,6 +5,7 @@ import { Query, getDocument, updateDocument, + deleteDocument, } from './appwriteAdmin.js' import { runDeploy, @@ -17,7 +18,7 @@ import { import { giteaFetch } from './giteaClient.js' import { slugify } from './slug.js' import { withLocks } from './keyedLock.js' -import { repoLockKey } from './repoMove.js' +import { repoLockKey, isUnlinkedProject } from './repoMove.js' import { isKind } from './projectKinds.js' // Sicherheitsnetz wie bei listRecentPushes (siehe pushHistory.js, Controller-Ruling R8): sollte @@ -90,17 +91,21 @@ export async function importNewRepos() { if (existing) { if (Boolean(existing.archived) === repoArchived) continue await withLocks([repoLockKey(fullName)], async () => { + // Unter der Sperre frisch lesen (Dokument UND Gitea): die Repo-Liste oben kann aelter + // sein als ein gerade laufendes Archivieren/Reaktivieren im Ticketsystem. const fresh = await getDocument(config.collections.websiteProjects, existing.$id) - if (!fresh || Boolean(fresh.archived) === repoArchived) return + const live = await giteaFetch(`/repos/${fullName}`) + const liveArchived = Boolean(live?.archived) + if (!fresh || Boolean(fresh.archived) === liveArchived) return await updateDocument(config.collections.websiteProjects, fresh.$id, { - archived: repoArchived, - ...(repoArchived + archived: liveArchived, + ...(liveArchived ? { status: 'archived' } : fresh.status === 'archived' ? { status: 'synced' } : {}), updatedAt: new Date().toISOString(), }) archiveSynced += 1 - }) + }, { waitMs: 10000 }) continue } @@ -323,6 +328,37 @@ export async function ensureRepoPreviewConfig(repoFullName, branch = 'main') { return previewConfig } +/** + * Vor dem Anlegen: gibt es das Repo in Gitea nicht (mehr), aber noch ein Projekt-Dokument mit dem + * Namen, wuerde das neue Projekt in diesen alten Eintrag uebernommen - samt archived, Art, Preview- + * Stand (und waere z. B. als archiviert unsichtbar). Platzhalter ohne Bezug werden entfernt, ein + * verknuepftes altes Projekt blockiert den Namen mit einer klaren Meldung - bevor ein Repo entsteht. + */ +async function releaseLeftoverProjectName(fullName) { + try { + await giteaFetch(`/repos/${fullName}`) + return // Repo existiert: generate meldet gleich "existiert bereits" + } catch (err) { + if (err.status !== 404) return + } + const leftovers = await listDocuments(config.collections.websiteProjects, [ + Query.equal('repoFullName', fullName), + Query.limit(10), + ]) + for (const old of leftovers) { + if (isUnlinkedProject(old)) { + await deleteDocument(config.collections.websiteProjects, old.$id) + continue + } + const error = new Error( + `Der Name "${fullName}" gehört noch zu einem alten Projekt ("${old.projectName || fullName}"` + + `${old.archived ? ', archiviert' : ''}). Bitte einen anderen Namen wählen.` + ) + error.status = 409 + throw error + } +} + export async function createProjectFromTemplate({ repoName, displayName, @@ -353,6 +389,8 @@ export async function createProjectFromTemplate({ await ensureSubdomainAvailable(safeSubdomain) } + await releaseLeftoverProjectName(`${targetOwner}/${safeRepoName}`) + const repo = await generateRepoFromTemplate({ owner: targetOwner, repoName: safeRepoName, @@ -402,6 +440,7 @@ export async function createProjectFromTemplate({ previewUrl, // Login-geschuetzte Preview (isPublic bleibt aus) - wie deriveProjectType und das Ticketsystem projectType: hasWebsite ? 'preview' : 'repo', + archived: false, // Art direkt mitgeben (Ticket: "Neues Projekt" = Website) - sonst stand das Projekt auf // "Art fehlt" und der Zuordnungs-Lauf mahnte es an. Felder wie completeAssignment. ...(kind @@ -628,49 +667,48 @@ export async function archiveRepo(projectId, archived = true) { const repoFullName = doc.repoFullName || '' let repoMissing = false - if (repoFullName.includes('/')) { - try { - await patchGiteaRepo(repoFullName, { archived: Boolean(archived) }) - } catch (err) { - // Verwaistes Projekt: Repo existiert nicht mehr in Gitea (404). Dann trotzdem - // nur das Appwrite-Dokument (ent)archivieren, damit es aus der Liste verschwindet. - if (err.status === 404) { - repoMissing = true - console.warn(`[archiveRepo] Gitea-Repo ${repoFullName} nicht gefunden (404) - nur Appwrite wird aktualisiert`) - } else { - const error = new Error('Gitea-Archivierung fehlgeschlagen: ' + err.message) - error.status = err.status || 500 - throw error + // Gitea und Dokument unter der Repo-Sperre aendern - dieselbe nimmt der Import, der den + // Archiv-Stand aus Gitea spiegelt; sonst koennte er einen alten Stand zurueckschreiben. + const locks = repoFullName ? [repoLockKey(repoFullName)] : [] + let updated = await withLocks(locks, async () => { + if (repoFullName.includes('/')) { + try { + await patchGiteaRepo(repoFullName, { archived: Boolean(archived) }) + } catch (err) { + // Verwaistes Projekt: Repo existiert nicht mehr in Gitea (404). Dann trotzdem + // nur das Appwrite-Dokument (ent)archivieren, damit es aus der Liste verschwindet. + if (err.status === 404) { + repoMissing = true + console.warn(`[archiveRepo] Gitea-Repo ${repoFullName} nicht gefunden (404) - nur Appwrite wird aktualisiert`) + } else { + const error = new Error('Gitea-Archivierung fehlgeschlagen: ' + err.message) + error.status = err.status || 500 + throw error + } } } - } - if (archived && doc.subdomain) { - try { await removeDeploy(doc.subdomain) } catch (err) { - console.error('[archiveRepo] Deploy entfernen fehlgeschlagen:', err.message) - } - } - - if (archived) { - const updated = await updateDocument(config.collections.websiteProjects, projectId, { - archived: true, - status: 'archived', + // Reaktivieren: status 'archived' zuruecksetzen, und zwar VOR dem Redeploy - der Preview-Runner + // setzt nach dem Bau selbst 'deployed', das darf hier danach nicht ueberschrieben werden. + // Blieb 'archived' stehen, bot das Ticket das Projekt unter "Projekt wählen" nicht mehr an. + return updateDocument(config.collections.websiteProjects, projectId, { + archived: Boolean(archived), + status: archived ? 'archived' : (!repoMissing && doc.subdomain ? 'pending' : 'synced'), updatedAt: new Date().toISOString(), }) + }, { waitMs: 10000 }) + + if (archived) { + if (doc.subdomain) { + try { await removeDeploy(doc.subdomain) } catch (err) { + console.error('[archiveRepo] Deploy entfernen fehlgeschlagen:', err.message) + } + } return { project: updated, archived: true } } - // Reaktivieren: status 'archived' zuruecksetzen, und zwar VOR dem Redeploy - der Preview-Runner - // setzt nach dem Bau selbst 'deployed', das darf hier danach nicht ueberschrieben werden. - // Blieb 'archived' stehen, bot das Ticket das Projekt unter "Projekt wählen" nicht mehr an. - const redeploy = !repoMissing && Boolean(doc.subdomain) - let updated = await updateDocument(config.collections.websiteProjects, projectId, { - archived: false, - status: redeploy ? 'pending' : 'synced', - updatedAt: new Date().toISOString(), - }) - - if (redeploy) { + if (!repoMissing && doc.subdomain) { + let after = null try { const branch = (await getRepoInfo(repoFullName))?.defaultBranch || 'main' const cfg = (await fetchPreviewConfig(repoFullName, branch)) || { @@ -681,15 +719,15 @@ export async function archiveRepo(projectId, archived = true) { displayName: doc.projectName, } const deployResult = await runDeploy(repoFullName, branch, cfg, { project: doc }) - if (deployResult?.deployed) { - updated = await updateDocument(config.collections.websiteProjects, projectId, { - status: 'deployed', - provisioningStatus: 'ready', - }) - } + if (deployResult?.deployed) after = { status: 'deployed', provisioningStatus: 'ready' } + else if (deployResult?.skipped) after = { status: 'synced' } // Preview ausgeschaltet + else if (!deployResult?.queued) after = { provisioningStatus: 'deploy_failed' } + // queued: der Runner-Verfolger setzt den Status nach dem Bau } catch (err) { console.error('[archiveRepo] Redeploy bei Reaktivierung fehlgeschlagen:', err.message) + after = { provisioningStatus: 'deploy_failed' } } + if (after) updated = await updateDocument(config.collections.websiteProjects, projectId, after) } return { project: updated, archived: false } diff --git a/test/projektAblauf.test.js b/test/projektAblauf.test.js index 45b8c6d..a12032e 100644 --- a/test/projektAblauf.test.js +++ b/test/projektAblauf.test.js @@ -150,10 +150,28 @@ test('Reaktivieren mit Website: status nicht mehr archived (pending bis der Depl await svc.archiveRepo('p_mit', false) const d = aw.get('websiteProjects', 'p_mit') assert.equal(d.archived, false) - assert.equal(d.status, 'pending') // hier ohne PREVIEW_DEPLOY_ROOT: kein Deploy, also nicht 'deployed' - // Status vor dem Redeploy geschrieben, nicht danach (der Runner setzt spaeter selbst 'deployed') + // hier ohne PREVIEW_DEPLOY_ROOT: kein Deploy -> 'pending' und als fehlgeschlagen markiert + assert.deepEqual([d.status, d.provisioningStatus], ['pending', 'deploy_failed']) + // der Status wird vor dem Redeploy geschrieben, danach nicht mehr (der Runner setzt 'deployed') const patches = aw.calls.filter((c) => c.method === 'PATCH' && c.path.endsWith('/p_mit')) - assert.equal(patches.at(-1).body.data.status, 'pending') + assert.equal(patches.at(-1).body.data.status, undefined) +}) + +test('Neues Projekt: alter verknuepfter Eintrag mit demselben Namen blockiert mit klarer Meldung, bevor ein Repo entsteht', async () => { + aw.insert('websiteProjects', { repoFullName: 'WEBklar/alt-belegt', projectName: 'Alt', archived: true, status: 'archived', kind: 'website', customerId: 'cAlt' }) + const before = gitea.calls.filter((c) => c.path.endsWith('/generate')).length + await assert.rejects(svc.createProjectFromTemplate({ displayName: 'alt belegt', repoName: 'alt-belegt' }), (err) => + err.status === 409 && /alten Projekt/.test(err.message) && /archiviert/.test(err.message)) + assert.equal(gitea.calls.filter((c) => c.path.endsWith('/generate')).length, before) +}) + +test('Neues Projekt: alter Platzhalter ohne Bezug wird entfernt, das neue Projekt startet sauber', async () => { + aw.insert('websiteProjects', { $id: 'platzhalter', repoFullName: 'WEBklar/frei-machen', projectName: 'alt', archived: true, status: 'archived' }) + const r = await svc.createProjectFromTemplate({ displayName: 'Frei machen', customerId: 'c1' }) + assert.equal(aw.get('websiteProjects', 'platzhalter'), undefined) + const d = docOf(r.repoFullName) + assert.equal(d.archived, false) + assert.equal(d.customerId, 'c1') }) test('Abgleich ueberschreibt den Projektnamen nicht mehr', async () => {