Schnittstellen für Import, Zuweisen, Nachtragen; Webhook wartet auf Umzug

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-30 10:21:34 +00:00
1 parent 5c39640224
commit a5da4849e5
4 files changed
+367 -49

No files matched your search

+13 -1
View File
@@ -1,5 +1,5 @@
import { Router } from 'express'
import { syncReposToAppwrite, fetchRepoReadme } from '../../services/giteaAdmin.js'
import { syncReposToAppwrite, fetchRepoReadme, importNewRepos } from '../../services/giteaAdmin.js'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
const router = Router()
@@ -16,6 +16,18 @@ router.post('/sync-repos', async (_req, res) => {
}
})
// Neue Repos aus Gitea als minimale Projektdokumente anlegen (vom Projekt-Dialog im
// Ticketsystem bei jedem Oeffnen aufgerufen).
router.post('/import-repos', async (_req, res) => {
try {
const result = await importNewRepos()
return res.json({ ok: true, ...result })
} catch (err) {
const status = err.status || 500
return res.status(status).json({ error: err.message || 'Import fehlgeschlagen' })
}
})
router.get('/repo-readme', async (req, res) => {
const repoFullName = String(req.query.repoFullName || '').trim()
if (!repoFullName) {
+37
View File
@@ -4,6 +4,7 @@ import {
updateWebsiteProject,
archiveRepo,
} from '../../services/giteaAdmin.js'
import { assignProject, reconcileProject } from '../../services/projectAssignment.js'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
const router = Router()
@@ -67,4 +68,40 @@ router.post('/:id/unarchive', async (req, res) => {
}
})
// Projekt einem Kunden/Ticket zuweisen -> stoesst Umzug nach GITEA_ORG + Nachtragen an
router.post('/:id/assign', async (req, res) => {
const { customerId, ticketId } = req.body || {}
if (!customerId && !ticketId) {
return res.status(400).json({ error: 'customerId oder ticketId erforderlich' })
}
try {
const result = await assignProject(req.params.id, {
customerId: String(customerId || ''),
ticketId: String(ticketId || ''),
})
return res.json({ ok: true, ...result })
} catch (err) {
if (err.status === 404) {
return res.status(404).json({ error: 'Projekt nicht gefunden' })
}
const status = err.status || 500
return res.status(status).json({ error: err.message })
}
})
// Umzug + Pushes eines bereits zugewiesenen Projekts erneut nachtragen
router.post('/:id/pushes', async (req, res) => {
try {
const result = await reconcileProject(req.params.id)
return res.json({ ok: true, ...result })
} catch (err) {
if (err.status === 404) {
return res.status(404).json({ error: 'Projekt nicht gefunden' })
}
const status = err.status || 500
return res.status(status).json({ error: err.message })
}
})
export default router
+89 -48
View File
@@ -1,7 +1,7 @@
import crypto from 'node:crypto'
import { Router } from 'express'
import { config } from '../../config.js'
import { upsertWebsiteProjectByRepo } from '../../services/appwriteAdmin.js'
import { upsertWebsiteProjectByRepo, getDocument } from '../../services/appwriteAdmin.js'
import {
fetchPreviewConfig,
defaultPreviewConfig,
@@ -12,6 +12,8 @@ import { ensureRepoPreviewConfig } from '../../services/giteaAdmin.js'
import { createGitPushWorksheet } from '../../services/gitWorksheet.js'
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'
const router = Router()
@@ -44,36 +46,45 @@ router.post('/gitea', async (req, res) => {
const payload = req.body || {}
const ref = payload.ref || ''
const repo = payload.repository || {}
const repoFullName = repo.full_name || ''
const payloadName = repo.full_name || ''
const commits = payload.commits || []
const pusher = payload.pusher || {}
const commitSha = payload.after || commits[commits.length - 1]?.id || ''
if (!repoFullName) {
if (!payloadName) {
return res.status(400).json({ error: 'repository.full_name fehlt' })
}
const branch = ref.replace('refs/heads/', '') || 'main'
try {
let previewConfigFile = await fetchPreviewConfig(repoFullName, branch)
// Phase 1: aktuellen Repo-Namen aufloesen - wartet auf einen laufenden Umzug DESSELBEN
// Repos (dieselbe Sperre wie moveRepoToOrg/importNewRepos, Controller-Ruling R17), damit
// der Webhook nie mit einem veralteten Namen weiterlaeuft. Keine Sperre bleibt darueber
// hinaus gehalten - ein Deploy kann lange dauern und darf sie nicht blockieren.
const name = await withLocks([repoLockKey(payloadName)], () => currentRepoFullName(payloadName), {
waitMs: 10000,
})
// Phase 2: Preview-Config + Deploy wie bisher, mit `name` - ohne gehaltene Sperre.
let previewConfigFile = await fetchPreviewConfig(name, branch)
// Selbstheilung: Wurde .webklar-preview.json im Repo geloescht (z.B. beim
// Leerraeumen/Ueberarbeiten der Seite), aber das Projekt ist in Appwrite als
// gehostet bekannt, dann Config aus dem gespeicherten Stand wiederherstellen
// (Datei ins Repo zurueckschreiben) und normal weiter-deployen.
if (!previewConfigFile) {
try {
const restored = await ensureRepoPreviewConfig(repoFullName, branch)
const restored = await ensureRepoPreviewConfig(name, branch)
if (restored) previewConfigFile = restored
} catch (err) {
console.error('[webhook] preview-config-Wiederherstellung fehlgeschlagen:', err.message)
}
}
const previewConfig = previewConfigFile || defaultPreviewConfig(repoFullName)
const previewConfig = previewConfigFile || defaultPreviewConfig(name)
const hasPreview = Boolean(previewConfigFile?.enabled !== false && previewConfigFile)
// Nur Projekte mit aktivierter Preview bekommen eine Subdomain zugewiesen -
// Repo-only-Projekte (enabled:false, keine Subdomain) bleiben ohne Website.
const subdomain = hasPreview ? previewConfig.subdomain || repoFullName.split('/').pop() : ''
const subdomain = hasPreview ? previewConfig.subdomain || name.split('/').pop() : ''
const previewUrl = subdomain ? buildPreviewUrl(subdomain) : ''
let deployResult = { deployed: false, skipped: true }
@@ -81,63 +92,93 @@ router.post('/gitea', async (req, res) => {
if (hasPreview && previewConfig.enabled !== false && branch === configBranch) {
try {
deployResult = await runDeploy(repoFullName, branch, previewConfig)
deployResult = await runDeploy(name, branch, previewConfig)
} catch (deployErr) {
console.error('[webhook] deploy failed:', deployErr.message)
deployResult = { deployed: false, error: deployErr.message }
}
}
const projectPayload = {
projectName: previewConfig.displayName || repo.name || subdomain,
templateName: previewConfig.templateName || config.gitea.templateRepo,
giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
giteaRepoName: repo.name || subdomain,
repoFullName,
}
// Phase 3: Dokument unter derselben Repo-Sperre wie Import/Umzug anlegen/aktualisieren.
// Namen dabei ERNEUT aufloesen - deckt einen Umzug ab, der waehrend des (unter Sperre
// laufenden) Deploys oben passiert ist. `current` ab hier fuer alle weiteren Schritte.
let current
const project = await withLocks(
[repoLockKey(name)],
async () => {
current = await currentRepoFullName(name)
if (subdomain) projectPayload.subdomain = subdomain
if (previewUrl) projectPayload.previewUrl = previewUrl
const projectPayload = {
projectName: previewConfig.displayName || repo.name || subdomain,
templateName: previewConfig.templateName || config.gitea.templateRepo,
giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${current}`,
giteaRepoName: repo.name || subdomain,
repoFullName: current,
}
if (deployResult.deployed) {
projectPayload.status = 'deployed'
projectPayload.provisioningStatus = 'ready'
} else if (hasPreview) {
projectPayload.status = 'pending'
projectPayload.provisioningStatus = deployResult.error ? 'deploy_failed' : 'pending'
} else {
projectPayload.status = 'synced'
}
if (subdomain) projectPayload.subdomain = subdomain
if (previewUrl) projectPayload.previewUrl = previewUrl
const project = await upsertWebsiteProjectByRepo(repoFullName, projectPayload)
invalidateProjectCache(repoFullName)
if (deployResult.deployed) {
projectPayload.status = 'deployed'
projectPayload.provisioningStatus = 'ready'
} else if (hasPreview) {
projectPayload.status = 'pending'
projectPayload.provisioningStatus = deployResult.error ? 'deploy_failed' : 'pending'
} else {
projectPayload.status = 'synced'
}
return upsertWebsiteProjectByRepo(current, projectPayload)
},
{ waitMs: 10000 }
)
// Phase 4
invalidateProjectCache(current)
// Phase 5: nur bei einem echten Commit - Ticket + Arbeitsblatt unter der Projekt-Sperre
// (derselben wie assignProject/reconcileProject), damit ein gleichzeitiges Zuweisen nicht
// dieselbe ticketId-Aufloesung doppelt anstoesst.
let gitWorksheet = null
if (commitSha && commitSha !== '0000000000000000000000000000000000000000') {
// Projekt ohne Ticket: automatisch ein Webpage-Ticket anlegen (nur mit Kunde)
let ticketId = project.ticketId
if (!ticketId) {
try {
ticketId = await ensureProjectTicket(project, { repoFullName })
} catch (err) {
console.error('[webhook] Auto-Ticket fehlgeschlagen:', err.message)
}
}
if (ticketId) {
gitWorksheet = await createGitPushWorksheet({
ticketId,
repoFullName,
branch,
commits,
pusher,
commitSha,
})
}
gitWorksheet = await withLocks(
['project:' + project.$id],
async () => {
let fresh = project
try {
fresh = await getDocument(config.collections.websiteProjects, project.$id)
} catch {
// Nachlesen fehlgeschlagen: mit dem Upsert-Ergebnis von oben weitermachen (R17).
}
// Projekt ohne Ticket: automatisch ein Webpage-Ticket anlegen (nur mit Kunde)
let ticketId = fresh.ticketId
if (!ticketId) {
try {
ticketId = await ensureProjectTicket(fresh, { repoFullName: current })
} catch (err) {
console.error('[webhook] Auto-Ticket fehlgeschlagen:', err.message)
}
}
if (!ticketId) return null
return createGitPushWorksheet({
ticketId,
repoFullName: current,
branch,
commits,
pusher,
commitSha,
})
},
{ waitMs: 10000 }
)
}
return res.json({
ok: true,
repoFullName,
repoFullName: current,
subdomain,
previewUrl,
deploy: deployResult,