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 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 13:36:26 +00:00
1 parent 017a9c22f2
commit 800630ea4b
2 files changed
+105 -49

No files matched your search

+84 -46
View File
@@ -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 }
+21 -3
View File
@@ -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 () => {