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, } } /** 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 }