Files
Webklar-Kundenbereich/server/services/giteaAdmin.js
Kenso Grimm cf4b3ce4d1 chore: aktuellen Live-Stand sichern (Admin-API, Services, Auth-Erweiterungen)
Sichert den bisher uncommitteten Produktionsstand des Kundenbereich-Servers,
damit kuenftige Deploys (git reset --hard) nichts mehr verwerfen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 16:24:53 +00:00

324 lines
9.2 KiB
JavaScript
Raw Blame History

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(subdomain)
const safeSubdomain = slugify(subdomain) || safeRepoName
if (!safeRepoName || !safeSubdomain) {
throw new Error('repoName und subdomain sind erforderlich')
}
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: true,
type: 'static',
branch,
displayName: displayName || safeRepoName,
subdomain: safeSubdomain,
templateName: config.gitea.templateRepo,
}
await updatePreviewConfigFile(repoFullName, previewConfig, branch)
let deployResult = { deployed: false }
try {
deployResult = await runDeploy(repoFullName, branch, previewConfig)
} catch (deployErr) {
console.error('[giteaAdmin] deploy failed:', deployErr.message)
deployResult = { deployed: false, error: deployErr.message }
}
const previewUrl = 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,
status: deployResult.deployed ? 'deployed' : 'pending',
provisioningStatus: deployResult.deployed ? 'ready' : 'deploy_failed',
})
return {
projectId: project.$id,
repoFullName,
previewUrl,
subdomain: safeSubdomain,
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
}