import { config } from '../config.js' import { upsertWebsiteProjectByRepo, listDocuments, Query } from './appwriteAdmin.js' import { runDeploy, buildPreviewUrl, } from './previewDeploy.js' function giteaHeaders() { if (!config.gitea.apiToken) { throw new Error('GITEA_API_TOKEN fehlt in .env') } return { Authorization: `token ${config.gitea.apiToken}`, 'Content-Type': 'application/json', } } async function giteaFetch(path, options = {}) { const response = await fetch(`${config.gitea.baseUrl}/api/v1${path}`, { ...options, headers: { ...giteaHeaders(), ...options.headers }, }) const text = await response.text() let data = null if (text) { try { data = JSON.parse(text) } catch { data = { message: text } } } if (!response.ok) { let message = data?.message || `Gitea ${response.status}` if (response.status === 409 && /same name already exists/i.test(message)) { message = 'Ein Gitea-Repository mit diesem Namen existiert bereits. Bitte einen anderen Subdomain-/Repo-Namen w�hlen.' } const error = new Error(message) error.status = response.status throw error } return data } function slugify(value) { return String(value || '') .toLowerCase() .replace(/[^a-z0-9-]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 100) } 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 page += 1 } return repos } async function resolveSubdomainForSync(repo, existingDoc) { if (existingDoc?.subdomain) return existingDoc.subdomain const candidate = slugify(repo.name) || slugify(repo.full_name?.split('/').pop()) if (!candidate) return '' const conflict = await listDocuments(config.collections.websiteProjects, [ Query.equal('subdomain', candidate), Query.limit(1), ]) if (!conflict[0] || conflict[0].repoFullName === repo.full_name) { return candidate } const ownerSlug = slugify(repo.full_name?.replace('/', '-')) if (!ownerSlug) return '' const ownerConflict = await listDocuments(config.collections.websiteProjects, [ Query.equal('subdomain', ownerSlug), Query.limit(1), ]) if (!ownerConflict[0] || ownerConflict[0].repoFullName === repo.full_name) { return ownerSlug } return '' } export async function syncReposToAppwrite() { const repos = await listAllAccessibleRepos() let created = 0 let updated = 0 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 branch = repo.default_branch || 'main' const subdomain = await resolveSubdomainForSync(repo, existing[0]) const previewUrl = subdomain ? buildPreviewUrl(subdomain) : '' const payload = { projectName: repo.description || repo.name || repoFullName.split('/').pop(), giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`, giteaRepoName: repo.name || repoFullName.split('/').pop(), repoFullName, } if (subdomain) payload.subdomain = subdomain if (previewUrl) payload.previewUrl = previewUrl if (!existing[0]) { payload.status = 'synced' created += 1 } else { updated += 1 } await upsertWebsiteProjectByRepo(repoFullName, payload) } return { synced: repos.length, created, updated } } 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 }) { 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, 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 } : {}), }), }) } export async function createProjectFromTemplate({ repoName, displayName, subdomain, customerId = '', ticketId = '', owner, }) { 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 (hasWebsite) { await ensureSubdomainAvailable(safeSubdomain) } const repo = await generateRepoFromTemplate({ owner: targetOwner, repoName: 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) : '' const project = await upsertWebsiteProjectByRepo(repoFullName, { customerId, ticketId, projectName: previewConfig.displayName, templateName: config.gitea.templateRepo, giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`, giteaRepoName: repo.name || safeRepoName, repoFullName, subdomain: safeSubdomain, previewUrl, projectType: hasWebsite ? 'website' : 'repo', status: hasWebsite ? (deployResult.deployed ? 'deployed' : 'pending') : 'created', provisioningStatus: hasWebsite ? (deployResult.deployed ? 'ready' : 'deploy_failed') : 'ready', }) return { projectId: project.$id, repoFullName, previewUrl, subdomain: safeSubdomain, projectType: hasWebsite ? 'website' : 'repo', deploy: deployResult, } } 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 }