Files
Webklar-Kundenbereich/server/services/giteaAdmin.js
T
ClaudeandClaude Opus 5.5 e43cdb667b Projekt-Ablauf im Ticketsystem: Anlegen, Archivieren, Import repariert
- Neues Projekt ohne Subdomain: subdomain null statt '' (Unique-Index
  subdomain_unique, jedes weitere scheiterte mit "Document with the
  requested ID already exists"); ebenso Typ "Internes Projekt"
- Anlegen unter derselben Repo-Sperre wie der Webhook repository/created;
  upsertWebsiteProjectByRepo macht aus einem 409 ein Update
- Gitea-Beschreibung = Anzeigename; Art (kind) beim Anlegen aus dem Ticket
- Reaktivieren setzt status zurueck (vorher blieb 'archived' stehen)
- Import: archivierte Repos als archiviert, Archiv-Stand aus Gitea wird
  gespiegelt, ein fehlerhaftes Repo bricht den Lauf nicht mehr ab
- Abgleich ueberschreibt Projektnamen nicht mehr
- CORS erlaubt PUT (Preview-Variablen speichern)
- Vorlage zieht beim Zuweisen nie in die Org um
- Erinnerungs-Lauf ueberspringt archivierte Projekte
- kaputte Umlaute (U+FFFD) in drei Fehlermeldungen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:22:53 +00:00

818 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { config } from '../config.js'
import {
upsertWebsiteProjectByRepo,
listDocuments,
Query,
getDocument,
updateDocument,
} from './appwriteAdmin.js'
import {
runDeploy,
deployKeepsStatus,
buildPreviewUrl,
removeDeploy,
syncPreviewTls,
fetchPreviewConfig,
} from './previewDeploy.js'
import { giteaFetch } from './giteaClient.js'
import { slugify } from './slug.js'
import { withLocks } from './keyedLock.js'
import { repoLockKey } from './repoMove.js'
import { isKind } from './projectKinds.js'
// Sicherheitsnetz wie bei listRecentPushes (siehe pushHistory.js, Controller-Ruling R8): sollte
// Gitea "page" ignorieren und immer dieselbe volle Seite liefern, blaettert die Schleife nicht
// endlos weiter (40 x 50 = 2000 Repos).
const MAX_REPO_PAGES = 40
export async function listAllAccessibleRepos() {
const repos = []
const limit = 50
let page = 1
while (true) {
const batch = await giteaFetch(`/repos/search?limit=${limit}&page=${page}`)
const data = Array.isArray(batch?.data) ? batch.data : Array.isArray(batch) ? batch : []
if (data.length === 0) break
repos.push(...data)
if (data.length < limit) break
if (page >= MAX_REPO_PAGES) {
console.warn(
`[giteaAdmin] mehr als ${MAX_REPO_PAGES} Seiten Repos – weitere Repos werden nicht gelistet`
)
break
}
page += 1
}
return repos
}
/**
* Findet Repos, die in Gitea liegen, aber noch kein Appwrite-Projektdokument haben, und legt fuer
* jedes ein minimales Dokument an (status "synced", ohne Hosting/Subdomain; in Gitea archivierte
* als archiviertes Projekt) - ohne dass dafuer manuell "Abgleich" gedrueckt werden muesste. Bei
* vorhandenen Dokumenten wird nur der Archiv-Stand aus Gitea gespiegelt (Repo dort direkt
* archiviert oder reaktiviert). Wird vom Projekt-Dialog bei jedem
* Oeffnen aufgerufen, laeuft also oft: die aeussere `withLocks(['import'])`-Sperre serialisiert
* parallele Laeufe komplett (ein zweiter Aufruf sieht danach garantiert den Stand des ersten), die
* innere `withLocks([repoLockKey(...)])` je neuem Repo schuetzt zusaetzlich gegen andere
* gleichzeitige Schreiber desselben Projektdokuments (z. B. Umzug/Zuweisung).
* Ein Fehler bei einem Repo bricht den Lauf nicht ab (sonst fehlten alle Repos danach).
* `createdRepos` enthaelt nur neue, nicht archivierte Repos (nur sie bekommen Zuordnungs-Mails).
*/
export async function importNewRepos() {
return withLocks(['import'], async () => {
const [repos, docs] = await Promise.all([
listAllAccessibleRepos(),
listDocuments(config.collections.websiteProjects, [Query.limit(5000)]),
])
// Ohne Ruecksicht auf Gross-/Kleinschreibung vergleichen (Controller-Ruling).
const docsByName = new Map()
for (const d of docs) {
const key = String(d.repoFullName || '').toLowerCase()
if (key && !docsByName.has(key)) docsByName.set(key, d)
}
let created = 0
let archivedImported = 0
let archiveSynced = 0
const createdRepos = []
const failed = []
for (const repo of repos) {
const fullName = repo.full_name
if (!fullName) continue
const repoArchived = Boolean(repo.archived)
try {
const existing = docsByName.get(fullName.toLowerCase())
if (existing) {
if (Boolean(existing.archived) === repoArchived) continue
await withLocks([repoLockKey(fullName)], async () => {
const fresh = await getDocument(config.collections.websiteProjects, existing.$id)
if (!fresh || Boolean(fresh.archived) === repoArchived) return
await updateDocument(config.collections.websiteProjects, fresh.$id, {
archived: repoArchived,
...(repoArchived
? { status: 'archived' }
: fresh.status === 'archived' ? { status: 'synced' } : {}),
updatedAt: new Date().toISOString(),
})
archiveSynced += 1
})
continue
}
await withLocks([repoLockKey(fullName)], async () => {
const recheck = await listDocuments(config.collections.websiteProjects, [
Query.equal('repoFullName', fullName),
Query.limit(1),
])
if (recheck[0]) return
const doc = await upsertWebsiteProjectByRepo(fullName, {
projectName: repo.description || repo.name || fullName.split('/').pop(),
giteaRepoUrl: repo.html_url,
giteaRepoName: repo.name,
repoFullName: fullName,
archived: repoArchived,
status: repoArchived ? 'archived' : 'synced',
giteaSyncedAt: new Date().toISOString(),
})
if (repoArchived) {
archivedImported += 1
return
}
created += 1
createdRepos.push({ projectId: doc.$id, repo })
})
} catch (err) {
failed.push(fullName)
console.error(`[importNewRepos] ${fullName} übersprungen:`, err.message)
}
}
return { created, createdRepos, archivedImported, archiveSynced, failed }
})
}
export async function syncReposToAppwrite() {
const repos = await listAllAccessibleRepos()
let created = 0
let updated = 0
const failed = []
for (const repo of repos) {
const repoFullName = repo.full_name
if (!repoFullName) continue
const existing = await listDocuments(config.collections.websiteProjects, [
Query.equal('repoFullName', repoFullName),
Query.limit(1),
])
const payload = {
giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
giteaRepoName: repo.name || repoFullName.split('/').pop(),
repoFullName,
// In Gitea archivierte Repos werden im Ticketsystem ausgeblendet.
archived: Boolean(repo.archived),
}
const repoTitle = repo.description || repo.name || repoFullName.split('/').pop()
if (!existing[0]) {
payload.projectName = repoTitle
payload.status = repo.archived ? 'archived' : 'synced'
created += 1
} else {
// Der Name im Ticketsystem bleibt: frueher ueberschrieb der Abgleich ihn mit der
// Gitea-Beschreibung (aus "Döner Nation" wurde so "D-ner-Nation").
if (!existing[0].projectName) payload.projectName = repoTitle
// Archiv-Stand aus Gitea uebernehmen, ohne dass status auf 'archived' haengen bleibt
if (repo.archived && existing[0].status !== 'archived') payload.status = 'archived'
if (!repo.archived && existing[0].status === 'archived') payload.status = 'synced'
updated += 1
}
try {
await upsertWebsiteProjectByRepo(repoFullName, payload)
} catch (err) {
// Ein fehlerhaftes Repo darf den Abgleich aller folgenden nicht abbrechen
failed.push(repoFullName)
console.error(`[syncReposToAppwrite] ${repoFullName} übersprungen:`, err.message)
}
}
return { synced: repos.length, created, updated, failed }
}
export async function ensureSubdomainAvailable(subdomain) {
const docs = await listDocuments(config.collections.websiteProjects, [
Query.equal('subdomain', subdomain),
Query.limit(1),
])
if (docs[0]) {
const error = new Error(`Subdomain "${subdomain}" ist bereits vergeben`)
error.status = 409
throw error
}
}
export async function generateRepoFromTemplate({ owner, repoName, description }) {
const templateOwner = config.gitea.templateOwner
const templateRepo = config.gitea.templateRepo
const targetOwner = owner || config.gitea.defaultOwner
if (!targetOwner) {
throw new Error('GITEA_DEFAULT_OWNER fehlt in .env')
}
return giteaFetch(`/repos/${templateOwner}/${templateRepo}/generate`, {
method: 'POST',
body: JSON.stringify({
owner: targetOwner,
name: repoName,
private: false,
// Die Beschreibung ist der Projektname in Gitea (Abgleich und Webhook lesen ihn von dort)
description: description || `Website-Projekt ${repoName}`,
git_content: true,
}),
})
}
async function waitForRepoBranch(owner, repo, preferredBranch = 'main', maxAttempts = 15, delayMs = 400) {
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const repoInfo = await giteaFetch(`/repos/${owner}/${repo}`)
const branches = await giteaFetch(`/repos/${owner}/${repo}/branches?limit=50`)
const branchNames = Array.isArray(branches) ? branches.map((entry) => entry.name) : []
if (branchNames.includes(preferredBranch)) return preferredBranch
if (repoInfo?.default_branch && branchNames.includes(repoInfo.default_branch)) {
return repoInfo.default_branch
}
if (branchNames.length > 0) return branchNames[0]
} catch {
/* retry */
}
await new Promise((resolve) => setTimeout(resolve, delayMs))
}
throw new Error(`Branch "${preferredBranch}" existiert im Repository ${owner}/${repo} noch nicht`)
}
async function updatePreviewConfigFile(repoFullName, previewConfig, branch = 'main') {
const [owner, repo] = repoFullName.split('/')
const content = JSON.stringify(previewConfig, null, 2) + '\n'
const encoded = Buffer.from(content).toString('base64')
let sha = null
try {
const existing = await giteaFetch(
`/repos/${owner}/${repo}/contents/.webklar-preview.json?ref=${encodeURIComponent(branch)}`
)
sha = existing.sha
} catch {
/* file may not exist yet */
}
await giteaFetch(`/repos/${owner}/${repo}/contents/.webklar-preview.json`, {
method: sha ? 'PUT' : 'POST',
body: JSON.stringify({
message: 'Update preview config from ticket system',
content: encoded,
branch,
...(sha ? { sha } : {}),
}),
})
}
/**
* true, wenn zu diesem Projekt wirklich ein Hosting gehoert (Preview/Website/Vorlage, oder ein
* bereits deployter bzw. anstehender Stand) - im Unterschied zu einem nur importierten/
* abgeglichenen Repo ohne eigenes Hosting (z. B. status "synced" ohne Website). Bestimmt u. a.,
* ob die Selbstheilung (ensureRepoPreviewConfig) fuer dieses Projekt ueberhaupt aktiv werden darf -
* sonst wuerde mit dem organisationsweiten Webhook jeder Push in ein bloss abgeglichenes Repo eine
* Preview anlegen.
*/
export function isHostedProject(doc) {
if (['preview', 'website', 'template'].includes(doc?.projectType)) return true
if (['deployed', 'pending'].includes(doc?.status)) return true
return false
}
/**
* Selbstheilung fuer eine geloeschte/fehlende .webklar-preview.json:
* Rekonstruiert die Hosting-Config aus dem in Appwrite gespeicherten
* Projektstand (Subdomain, Typ, Name), schreibt sie ins Repo zurueck und liefert
* sie fuer einen sofortigen Deploy zurueck. Gibt null zurueck, wenn zu dem Repo
* kein gehostetes, aktives Projekt bekannt ist (dann bleibt es Repo-only).
*/
export async function ensureRepoPreviewConfig(repoFullName, branch = 'main') {
if (!repoFullName) return null
const docs = await listDocuments(config.collections.websiteProjects, [
Query.equal('repoFullName', repoFullName),
Query.limit(1),
])
const doc = docs[0]
if (!doc || doc.archived) return null
if (!isHostedProject(doc)) return null
const type = deriveProjectType(doc)
const subdomain = doc.subdomain || ''
if (!subdomain) return null
const previewConfig = {
enabled: true,
type: 'static',
branch,
displayName: doc.projectName || repoFullName.split('/').pop(),
templateName: doc.templateName || config.gitea.templateRepo,
projectType: type,
public: type === 'website',
template: type === 'template',
subdomain,
}
try {
await updatePreviewConfigFile(repoFullName, previewConfig, branch)
} catch (err) {
console.error('[ensureRepoPreviewConfig] .webklar-preview.json zurueckschreiben fehlgeschlagen:', err.message)
}
return previewConfig
}
export async function createProjectFromTemplate({
repoName,
displayName,
subdomain,
customerId = '',
ticketId = '',
owner,
kind = '',
staffName = '',
}) {
const targetOwner = owner || config.gitea.defaultOwner
const safeRepoName = slugify(repoName) || slugify(displayName) || slugify(subdomain)
// Subdomain ist optional: ohne Subdomain wird nur das Repo angelegt (normales
// Projekt ohne Website-Preview), mit Subdomain zusaetzlich deployed.
const safeSubdomain = slugify(subdomain)
const hasWebsite = Boolean(safeSubdomain)
if (!safeRepoName) {
throw new Error('repoName oder displayName ist erforderlich')
}
if (kind && !isKind(kind)) {
const error = new Error('Unbekannte Projektart.')
error.status = 400
throw error
}
if (hasWebsite) {
await ensureSubdomainAvailable(safeSubdomain)
}
const repo = await generateRepoFromTemplate({
owner: targetOwner,
repoName: safeRepoName,
description: displayName || safeRepoName,
})
const repoFullName = repo.full_name || `${targetOwner}/${safeRepoName}`
const [repoOwner, repoSlug] = repoFullName.split('/')
const branch = await waitForRepoBranch(repoOwner, repoSlug, 'main')
const previewConfig = {
enabled: hasWebsite,
type: 'static',
branch,
displayName: displayName || safeRepoName,
...(hasWebsite ? { subdomain: safeSubdomain } : {}),
templateName: config.gitea.templateRepo,
}
await updatePreviewConfigFile(repoFullName, previewConfig, branch)
let deployResult = { deployed: false, skipped: !hasWebsite }
if (hasWebsite) {
try {
deployResult = await runDeploy(repoFullName, branch, previewConfig)
} catch (deployErr) {
console.error('[giteaAdmin] deploy failed:', deployErr.message)
deployResult = { deployed: false, error: deployErr.message }
}
}
const previewUrl = hasWebsite ? buildPreviewUrl(safeSubdomain) : ''
// Gitea meldet das neue Repo sofort per Webhook (repository/created, dann der Push der Config).
// Der Webhook legt das Dokument unter dieser Repo-Sperre an; ohne dieselbe Sperre legen beide
// gleichzeitig an und der Unique-Index auf repoFullName lehnt dieses hier ab ("Document with
// the requested ID already exists") - das Repo gab es dann schon, Kunde und Ticket fehlten.
const project = await withLocks([repoLockKey(repoFullName)], () => upsertWebsiteProjectByRepo(repoFullName, {
customerId,
ticketId,
projectName: previewConfig.displayName,
templateName: config.gitea.templateRepo,
giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
giteaRepoName: repo.name || safeRepoName,
repoFullName,
// Ohne Website null statt '': auf subdomain liegt ein Unique-Index, und ein zweites
// Projekt mit '' scheiterte mit "Document with the requested ID already exists".
subdomain: safeSubdomain || null,
previewUrl,
// Login-geschuetzte Preview (isPublic bleibt aus) - wie deriveProjectType und das Ticketsystem
projectType: hasWebsite ? 'preview' : 'repo',
// 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
? {
kind,
forCustomer: Boolean(customerId),
kindSetBy: staffName || '',
kindSetAt: new Date().toISOString(),
previewMode: kind === 'website' || kind === 'webapp' ? 'auto' : 'off',
}
: {}),
...(hasWebsite && deployKeepsStatus(deployResult)
? {} // Preview-Runner (C1): Status setzt der Verfolger
: {
status: hasWebsite ? (deployResult.deployed ? 'deployed' : 'pending') : 'created',
provisioningStatus: hasWebsite
? (deployResult.deployed ? 'ready' : 'deploy_failed')
: 'ready',
}),
}), { waitMs: 10000 })
return {
projectId: project.$id,
repoFullName,
previewUrl,
subdomain: safeSubdomain,
projectType: hasWebsite ? 'preview' : 'repo',
deploy: deployResult,
}
}
/**
* Leitet den Projekt-Typ aus einem bestehenden Dokument ab, wenn im Request
* kein expliziter Typ mitgegeben wurde.
* preview -> Website mit Login-geschuetzter Preview (Standard)
* website -> oeffentliche Website ohne Login
* template -> Website-Vorlage (login-geschuetzt, nur Admin, in Vorlagen-Ansicht)
* project -> internes Projekt ohne Hosting/Subdomain
*/
function deriveProjectType(doc) {
if (doc?.isTemplate) return 'template'
if (doc?.isPublic) return 'website'
if (doc?.subdomain) return 'preview'
return 'project'
}
async function patchGiteaRepo(repoFullName, body) {
const [owner, repo] = String(repoFullName || '').split('/')
if (!owner || !repo) return null
return giteaFetch(`/repos/${owner}/${repo}`, {
method: 'PATCH',
body: JSON.stringify(body),
})
}
/**
* Bearbeitet ein bestehendes Website-Projekt (Name / Subdomain / Typ) und
* synchronisiert die Aenderung ueber ALLE Systeme:
* - Appwrite-Dokument (projectName, subdomain, previewUrl, projectType, isPublic, status)
* - Gitea: .webklar-preview.json (enabled/subdomain/displayName/public) + Repo-Beschreibung
* - Preview-Hosting: Redeploy in neues Verzeichnis, altes entfernen, Traefik-TLS neu
* Der Gitea-Repo-Slug/die Klon-URL bleiben bewusst stabil (kein Repo-Rename).
*/
export async function updateWebsiteProject(projectId, { projectName, subdomain, projectType } = {}) {
const doc = await getDocument(config.collections.websiteProjects, projectId)
if (!doc) {
const error = new Error('Projekt nicht gefunden')
error.status = 404
throw error
}
const repoFullName = doc.repoFullName || ''
const [owner, repoSlug] = repoFullName.split('/')
const nextName = String(projectName ?? doc.projectName ?? '').trim() || doc.projectName || repoSlug || ''
const nextType = projectType || deriveProjectType(doc)
// template = login-geschuetzte Vorlage: braucht Preview (wie 'preview'), aber
// nicht oeffentlich; wird als isTemplate markiert.
const hasWebsite = nextType === 'preview' || nextType === 'website' || nextType === 'template'
const isPublic = nextType === 'website'
const isTemplate = nextType === 'template'
const oldSubdomain = doc.subdomain || ''
let nextSubdomain = ''
if (hasWebsite) {
const requested = slugify(subdomain !== undefined ? subdomain : oldSubdomain)
nextSubdomain = requested || slugify(repoSlug)
}
const subdomainChanged = nextSubdomain !== oldSubdomain
// Subdomain-Eindeutigkeit pruefen (eigenes Dokument ignorieren)
if (hasWebsite && subdomainChanged && nextSubdomain) {
const conflicts = await listDocuments(config.collections.websiteProjects, [
Query.equal('subdomain', nextSubdomain),
Query.limit(5),
])
if (conflicts.some((c) => c.$id !== projectId)) {
const error = new Error(`Subdomain "${nextSubdomain}" ist bereits vergeben`)
error.status = 409
throw error
}
}
let branch = 'main'
try {
const info = await getRepoInfo(repoFullName)
branch = info?.defaultBranch || 'main'
} catch {
/* Default main */
}
// Deploy-Typ (z.B. node_build) und index-Unterordner aus der vorhandenen
// Repo-Config uebernehmen - sonst wuerde jedes Bearbeiten im Portal
// App-Projekte (Expo/Vite/React) wieder auf "static" zuruecksetzen.
let existingCfg = null
try {
existingCfg = await fetchPreviewConfig(repoFullName, branch)
} catch {
/* keine bestehende Config -> Defaults */
}
const previewConfig = {
enabled: hasWebsite,
type: existingCfg?.type || 'static',
...(existingCfg?.index ? { index: existingCfg.index } : {}),
branch,
displayName: nextName,
templateName: doc.templateName || config.gitea.templateRepo,
projectType: nextType,
public: isPublic,
template: isTemplate,
...(hasWebsite && nextSubdomain ? { subdomain: nextSubdomain } : {}),
}
if (owner && repoSlug) {
try {
await updatePreviewConfigFile(repoFullName, previewConfig, branch)
} catch (err) {
console.error('[updateWebsiteProject] preview-config schreiben fehlgeschlagen:', err.message)
}
try {
await patchGiteaRepo(repoFullName, { description: nextName })
} catch (err) {
console.error('[updateWebsiteProject] Gitea-Beschreibung fehlgeschlagen:', err.message)
}
}
// Deploy / Teardown
let deployResult = { deployed: false, skipped: true }
if (hasWebsite && nextSubdomain) {
if (subdomainChanged && oldSubdomain) {
try { await removeDeploy(oldSubdomain) } catch (err) {
console.error('[updateWebsiteProject] alten Deploy entfernen fehlgeschlagen:', err.message)
}
}
try {
deployResult = await runDeploy(repoFullName, branch, previewConfig, { project: doc })
} catch (err) {
console.error('[updateWebsiteProject] deploy fehlgeschlagen:', err.message)
deployResult = { deployed: false, error: err.message }
}
} else if (oldSubdomain) {
try { await removeDeploy(oldSubdomain) } catch (err) {
console.error('[updateWebsiteProject] Deploy entfernen fehlgeschlagen:', err.message)
}
}
const previewUrl = hasWebsite && nextSubdomain ? buildPreviewUrl(nextSubdomain) : ''
const updated = await updateDocument(config.collections.websiteProjects, projectId, {
projectName: nextName,
subdomain: nextSubdomain || null, // null statt '' (Unique-Index, siehe createProjectFromTemplate)
previewUrl,
projectType: nextType,
isPublic,
isTemplate,
...(hasWebsite && deployKeepsStatus(deployResult)
? {} // Preview-Runner (C1): Status setzt der Verfolger
: {
status: hasWebsite ? (deployResult.deployed ? 'deployed' : 'pending') : 'created',
provisioningStatus: hasWebsite
? (deployResult.deployed ? 'ready' : (deployResult.error ? 'deploy_failed' : 'pending'))
: 'ready',
}),
updatedAt: new Date().toISOString(),
})
// TLS-Router NACH dem Appwrite-Update synchronisieren, damit der public/auth-
// Zustand (isPublic) korrekt gelesen wird - auch wenn kein Redeploy stattfand
// (z.B. nur Typ preview <-> website getauscht).
try { await syncPreviewTls() } catch (err) {
console.error('[updateWebsiteProject] TLS-Sync fehlgeschlagen:', err.message)
}
// Website-Analyse: Berechtigung/Domains neu pruefen (dynamischer Import wegen Zyklus); Fehler aendern das Ergebnis nicht
try {
const { analyseOnProjectChanged } = await import('./analyse.js')
await analyseOnProjectChanged(projectId)
} catch (err) {
console.error('[updateWebsiteProject] Analyse-Abgleich fehlgeschlagen:', err.message)
}
return {
project: updated,
deploy: deployResult,
previewUrl,
subdomain: nextSubdomain,
projectType: nextType,
isPublic,
}
}
/**
* Archiviert (oder reaktiviert) ein Projekt: setzt das Gitea-Repo auf archived
* und pflegt das Appwrite-Flag. Beim Archivieren wird zusaetzlich der Preview-
* Deploy entfernt; beim Reaktivieren mit Subdomain neu deployt.
*/
export async function archiveRepo(projectId, archived = true) {
const doc = await getDocument(config.collections.websiteProjects, projectId)
if (!doc) {
const error = new Error('Projekt nicht gefunden')
error.status = 404
throw error
}
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
}
}
}
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',
updatedAt: new Date().toISOString(),
})
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) {
try {
const branch = (await getRepoInfo(repoFullName))?.defaultBranch || 'main'
const cfg = (await fetchPreviewConfig(repoFullName, branch)) || {
enabled: true,
type: 'static',
branch,
subdomain: doc.subdomain,
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',
})
}
} catch (err) {
console.error('[archiveRepo] Redeploy bei Reaktivierung fehlgeschlagen:', err.message)
}
}
return { project: updated, archived: false }
}
/** Repo-Basisdaten (Groesse in KB, Default-Branch). */
export async function getRepoInfo(repoFullName) {
const [owner, repo] = String(repoFullName || '').split('/')
if (!owner || !repo) return null
const data = await giteaFetch(`/repos/${owner}/${repo}`)
return {
sizeKb: Number(data.size || 0),
defaultBranch: data.default_branch || 'main',
updatedAt: data.updated_at || '',
htmlUrl: data.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
}
}
/** Letzte Commits eines Repos (volle Message inkl. Body). */
export async function listRepoCommits(repoFullName, { branch = 'main', limit = 20 } = {}) {
const [owner, repo] = String(repoFullName || '').split('/')
if (!owner || !repo) return []
// stat/verification/files abschalten - sonst berechnet Gitea Diff-Stats pro Commit
const data = await giteaFetch(
`/repos/${owner}/${repo}/commits?sha=${encodeURIComponent(branch)}&limit=${limit}&stat=false&verification=false&files=false`
)
return (Array.isArray(data) ? data : []).map((entry) => ({
sha: entry.sha || '',
shortSha: (entry.sha || '').slice(0, 7),
message: entry.commit?.message || '',
authorName:
entry.commit?.author?.name || entry.author?.login || entry.committer?.login || 'Unbekannt',
date: entry.commit?.author?.date || entry.created || '',
url: entry.html_url || `${config.gitea.baseUrl}/${repoFullName}/commit/${entry.sha}`,
}))
}
/**
* Datei-Uebersicht: aggregiert den Git-Baum serverseitig nach Top-Level-Eintraegen
* (nie den rohen Baum ans Frontend geben - kann tausende Eintraege haben).
*/
export async function getRepoTreeSummary(repoFullName, ref = 'main') {
const [owner, repo] = String(repoFullName || '').split('/')
if (!owner || !repo) return null
const data = await giteaFetch(
`/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=true&per_page=1000`
)
const entries = Array.isArray(data?.tree) ? data.tree : []
const topLevelMap = new Map()
let totalFiles = 0
let totalSizeBytes = 0
for (const entry of entries) {
const isBlob = entry.type === 'blob'
const size = isBlob ? Number(entry.size || 0) : 0
const path = String(entry.path || '')
const slash = path.indexOf('/')
const topSegment = slash === -1 ? path : path.slice(0, slash)
const isDir = slash !== -1 || entry.type === 'tree'
if (isBlob) {
totalFiles += 1
totalSizeBytes += size
}
if (!topSegment) continue
const existing = topLevelMap.get(topSegment) || {
path: topSegment,
type: isDir ? 'dir' : 'file',
fileCount: 0,
sizeBytes: 0,
}
if (isDir) existing.type = 'dir'
if (isBlob) {
existing.fileCount += 1
existing.sizeBytes += size
}
topLevelMap.set(topSegment, existing)
}
const topLevel = [...topLevelMap.values()]
.sort((a, b) => b.sizeBytes - a.sizeBytes)
.slice(0, 50)
return {
totalFiles,
totalSizeBytes,
truncated: Boolean(data?.truncated),
topLevel,
}
}
const README_CANDIDATES = ['README.md', 'readme.md', 'Readme.md', 'README.MD', 'README']
export async function fetchRepoReadme(repoFullName) {
const [owner, repo] = repoFullName.split('/')
if (!owner || !repo) {
const error = new Error('repoFullName muss owner/repo sein')
error.status = 400
throw error
}
for (const filename of README_CANDIDATES) {
try {
const data = await giteaFetch(
`/repos/${owner}/${repo}/contents/${encodeURIComponent(filename)}`
)
const encoding = data.encoding || 'base64'
const raw = encoding === 'base64'
? Buffer.from(data.content || '', 'base64').toString('utf8')
: String(data.content || '')
return {
filename: data.name || filename,
content: raw,
htmlUrl: data.html_url || `${config.gitea.baseUrl}/${repoFullName}/src/branch/main/${filename}`,
}
} catch (err) {
if (err.status === 404) continue
throw err
}
}
return null
}