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:
1 parent
017a9c22f2
commit
800630ea4b
2 files changed
+105
-49
No files matched your search
@@ -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 }
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user