661 lines
21 KiB
JavaScript
661 lines
21 KiB
JavaScript
import { config } from '../config.js'
|
|
import {
|
|
upsertWebsiteProjectByRepo,
|
|
listDocuments,
|
|
Query,
|
|
getDocument,
|
|
updateDocument,
|
|
} from './appwriteAdmin.js'
|
|
import {
|
|
runDeploy,
|
|
buildPreviewUrl,
|
|
removeDeploy,
|
|
syncPreviewTls,
|
|
fetchPreviewConfig,
|
|
} from './previewDeploy.js'
|
|
import { giteaFetch } from './giteaClient.js'
|
|
import { slugify } from './slug.js'
|
|
|
|
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,
|
|
// In Gitea archivierte Repos werden im Ticketsystem ausgeblendet.
|
|
archived: Boolean(repo.archived),
|
|
}
|
|
|
|
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 } : {}),
|
|
}),
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
|
|
const type = deriveProjectType(doc)
|
|
const hasWebsite = type === 'preview' || type === 'website' || type === 'template'
|
|
const subdomain = doc.subdomain || ''
|
|
if (!hasWebsite || !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,
|
|
}) {
|
|
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,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
} 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,
|
|
previewUrl,
|
|
projectType: nextType,
|
|
isPublic,
|
|
isTemplate,
|
|
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)
|
|
}
|
|
|
|
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 && !repoMissing && doc.subdomain) {
|
|
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,
|
|
}
|
|
await runDeploy(repoFullName, branch, cfg)
|
|
} catch (err) {
|
|
console.error('[archiveRepo] Redeploy bei Reaktivierung fehlgeschlagen:', err.message)
|
|
}
|
|
}
|
|
|
|
const updated = await updateDocument(config.collections.websiteProjects, projectId, {
|
|
archived: Boolean(archived),
|
|
...(archived ? { status: 'archived' } : {}),
|
|
updatedAt: new Date().toISOString(),
|
|
})
|
|
|
|
return { project: updated, archived: Boolean(archived) }
|
|
}
|
|
|
|
/** 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
|
|
}
|