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,
|
Query,
|
||||||
getDocument,
|
getDocument,
|
||||||
updateDocument,
|
updateDocument,
|
||||||
|
deleteDocument,
|
||||||
} from './appwriteAdmin.js'
|
} from './appwriteAdmin.js'
|
||||||
import {
|
import {
|
||||||
runDeploy,
|
runDeploy,
|
||||||
@@ -17,7 +18,7 @@ import {
|
|||||||
import { giteaFetch } from './giteaClient.js'
|
import { giteaFetch } from './giteaClient.js'
|
||||||
import { slugify } from './slug.js'
|
import { slugify } from './slug.js'
|
||||||
import { withLocks } from './keyedLock.js'
|
import { withLocks } from './keyedLock.js'
|
||||||
import { repoLockKey } from './repoMove.js'
|
import { repoLockKey, isUnlinkedProject } from './repoMove.js'
|
||||||
import { isKind } from './projectKinds.js'
|
import { isKind } from './projectKinds.js'
|
||||||
|
|
||||||
// Sicherheitsnetz wie bei listRecentPushes (siehe pushHistory.js, Controller-Ruling R8): sollte
|
// Sicherheitsnetz wie bei listRecentPushes (siehe pushHistory.js, Controller-Ruling R8): sollte
|
||||||
@@ -90,17 +91,21 @@ export async function importNewRepos() {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
if (Boolean(existing.archived) === repoArchived) continue
|
if (Boolean(existing.archived) === repoArchived) continue
|
||||||
await withLocks([repoLockKey(fullName)], async () => {
|
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)
|
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, {
|
await updateDocument(config.collections.websiteProjects, fresh.$id, {
|
||||||
archived: repoArchived,
|
archived: liveArchived,
|
||||||
...(repoArchived
|
...(liveArchived
|
||||||
? { status: 'archived' }
|
? { status: 'archived' }
|
||||||
: fresh.status === 'archived' ? { status: 'synced' } : {}),
|
: fresh.status === 'archived' ? { status: 'synced' } : {}),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
archiveSynced += 1
|
archiveSynced += 1
|
||||||
})
|
}, { waitMs: 10000 })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +328,37 @@ export async function ensureRepoPreviewConfig(repoFullName, branch = 'main') {
|
|||||||
return previewConfig
|
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({
|
export async function createProjectFromTemplate({
|
||||||
repoName,
|
repoName,
|
||||||
displayName,
|
displayName,
|
||||||
@@ -353,6 +389,8 @@ export async function createProjectFromTemplate({
|
|||||||
await ensureSubdomainAvailable(safeSubdomain)
|
await ensureSubdomainAvailable(safeSubdomain)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await releaseLeftoverProjectName(`${targetOwner}/${safeRepoName}`)
|
||||||
|
|
||||||
const repo = await generateRepoFromTemplate({
|
const repo = await generateRepoFromTemplate({
|
||||||
owner: targetOwner,
|
owner: targetOwner,
|
||||||
repoName: safeRepoName,
|
repoName: safeRepoName,
|
||||||
@@ -402,6 +440,7 @@ export async function createProjectFromTemplate({
|
|||||||
previewUrl,
|
previewUrl,
|
||||||
// Login-geschuetzte Preview (isPublic bleibt aus) - wie deriveProjectType und das Ticketsystem
|
// Login-geschuetzte Preview (isPublic bleibt aus) - wie deriveProjectType und das Ticketsystem
|
||||||
projectType: hasWebsite ? 'preview' : 'repo',
|
projectType: hasWebsite ? 'preview' : 'repo',
|
||||||
|
archived: false,
|
||||||
// Art direkt mitgeben (Ticket: "Neues Projekt" = Website) - sonst stand das Projekt auf
|
// Art direkt mitgeben (Ticket: "Neues Projekt" = Website) - sonst stand das Projekt auf
|
||||||
// "Art fehlt" und der Zuordnungs-Lauf mahnte es an. Felder wie completeAssignment.
|
// "Art fehlt" und der Zuordnungs-Lauf mahnte es an. Felder wie completeAssignment.
|
||||||
...(kind
|
...(kind
|
||||||
@@ -628,49 +667,48 @@ export async function archiveRepo(projectId, archived = true) {
|
|||||||
|
|
||||||
const repoFullName = doc.repoFullName || ''
|
const repoFullName = doc.repoFullName || ''
|
||||||
let repoMissing = false
|
let repoMissing = false
|
||||||
if (repoFullName.includes('/')) {
|
// Gitea und Dokument unter der Repo-Sperre aendern - dieselbe nimmt der Import, der den
|
||||||
try {
|
// Archiv-Stand aus Gitea spiegelt; sonst koennte er einen alten Stand zurueckschreiben.
|
||||||
await patchGiteaRepo(repoFullName, { archived: Boolean(archived) })
|
const locks = repoFullName ? [repoLockKey(repoFullName)] : []
|
||||||
} catch (err) {
|
let updated = await withLocks(locks, async () => {
|
||||||
// Verwaistes Projekt: Repo existiert nicht mehr in Gitea (404). Dann trotzdem
|
if (repoFullName.includes('/')) {
|
||||||
// nur das Appwrite-Dokument (ent)archivieren, damit es aus der Liste verschwindet.
|
try {
|
||||||
if (err.status === 404) {
|
await patchGiteaRepo(repoFullName, { archived: Boolean(archived) })
|
||||||
repoMissing = true
|
} catch (err) {
|
||||||
console.warn(`[archiveRepo] Gitea-Repo ${repoFullName} nicht gefunden (404) - nur Appwrite wird aktualisiert`)
|
// Verwaistes Projekt: Repo existiert nicht mehr in Gitea (404). Dann trotzdem
|
||||||
} else {
|
// nur das Appwrite-Dokument (ent)archivieren, damit es aus der Liste verschwindet.
|
||||||
const error = new Error('Gitea-Archivierung fehlgeschlagen: ' + err.message)
|
if (err.status === 404) {
|
||||||
error.status = err.status || 500
|
repoMissing = true
|
||||||
throw error
|
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) {
|
// Reaktivieren: status 'archived' zuruecksetzen, und zwar VOR dem Redeploy - der Preview-Runner
|
||||||
try { await removeDeploy(doc.subdomain) } catch (err) {
|
// setzt nach dem Bau selbst 'deployed', das darf hier danach nicht ueberschrieben werden.
|
||||||
console.error('[archiveRepo] Deploy entfernen fehlgeschlagen:', err.message)
|
// 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'),
|
||||||
if (archived) {
|
|
||||||
const updated = await updateDocument(config.collections.websiteProjects, projectId, {
|
|
||||||
archived: true,
|
|
||||||
status: 'archived',
|
|
||||||
updatedAt: new Date().toISOString(),
|
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 }
|
return { project: updated, archived: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reaktivieren: status 'archived' zuruecksetzen, und zwar VOR dem Redeploy - der Preview-Runner
|
if (!repoMissing && doc.subdomain) {
|
||||||
// setzt nach dem Bau selbst 'deployed', das darf hier danach nicht ueberschrieben werden.
|
let after = null
|
||||||
// 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) {
|
|
||||||
try {
|
try {
|
||||||
const branch = (await getRepoInfo(repoFullName))?.defaultBranch || 'main'
|
const branch = (await getRepoInfo(repoFullName))?.defaultBranch || 'main'
|
||||||
const cfg = (await fetchPreviewConfig(repoFullName, branch)) || {
|
const cfg = (await fetchPreviewConfig(repoFullName, branch)) || {
|
||||||
@@ -681,15 +719,15 @@ export async function archiveRepo(projectId, archived = true) {
|
|||||||
displayName: doc.projectName,
|
displayName: doc.projectName,
|
||||||
}
|
}
|
||||||
const deployResult = await runDeploy(repoFullName, branch, cfg, { project: doc })
|
const deployResult = await runDeploy(repoFullName, branch, cfg, { project: doc })
|
||||||
if (deployResult?.deployed) {
|
if (deployResult?.deployed) after = { status: 'deployed', provisioningStatus: 'ready' }
|
||||||
updated = await updateDocument(config.collections.websiteProjects, projectId, {
|
else if (deployResult?.skipped) after = { status: 'synced' } // Preview ausgeschaltet
|
||||||
status: 'deployed',
|
else if (!deployResult?.queued) after = { provisioningStatus: 'deploy_failed' }
|
||||||
provisioningStatus: 'ready',
|
// queued: der Runner-Verfolger setzt den Status nach dem Bau
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[archiveRepo] Redeploy bei Reaktivierung fehlgeschlagen:', err.message)
|
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 }
|
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)
|
await svc.archiveRepo('p_mit', false)
|
||||||
const d = aw.get('websiteProjects', 'p_mit')
|
const d = aw.get('websiteProjects', 'p_mit')
|
||||||
assert.equal(d.archived, false)
|
assert.equal(d.archived, false)
|
||||||
assert.equal(d.status, 'pending') // hier ohne PREVIEW_DEPLOY_ROOT: kein Deploy, also nicht 'deployed'
|
// hier ohne PREVIEW_DEPLOY_ROOT: kein Deploy -> 'pending' und als fehlgeschlagen markiert
|
||||||
// Status vor dem Redeploy geschrieben, nicht danach (der Runner setzt spaeter selbst 'deployed')
|
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'))
|
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 () => {
|
test('Abgleich ueberschreibt den Projektnamen nicht mehr', async () => {
|
||||||
|
|||||||
Reference in new issue
Block a user