Neue Repos importieren, Abgleich ohne Subdomains, Selbstheilung nur für gehostete Projekte

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-30 10:21:34 +00:00
1 parent 5ce8aa6fbb
commit 5c39640224
2 files changed
+183 -32

No files matched your search

+73 -32
View File
@@ -15,6 +15,13 @@ import {
} from './previewDeploy.js'
import { giteaFetch } from './giteaClient.js'
import { slugify } from './slug.js'
import { withLocks } from './keyedLock.js'
import { repoLockKey } from './repoMove.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 = []
@@ -27,40 +34,67 @@ export async function listAllAccessibleRepos() {
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
}
async function resolveSubdomainForSync(repo, existingDoc) {
if (existingDoc?.subdomain) return existingDoc.subdomain
/**
* Findet Repos, die in Gitea liegen, aber noch kein Appwrite-Projektdokument haben, und legt fuer
* jedes nicht archivierte ein minimales Dokument an (status "synced", ohne Hosting/Subdomain) -
* ohne dass dafuer manuell "Abgleich" gedrueckt werden muesste. 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).
*/
export async function importNewRepos() {
return withLocks(['import'], async () => {
const [repos, docs] = await Promise.all([
listAllAccessibleRepos(),
listDocuments(config.collections.websiteProjects, [Query.limit(5000)]),
])
const candidate = slugify(repo.name) || slugify(repo.full_name?.split('/').pop())
if (!candidate) return ''
// Ohne Ruecksicht auf Gross-/Kleinschreibung vergleichen (Controller-Ruling).
const existingNames = new Set(
docs.map((d) => String(d.repoFullName || '').toLowerCase()).filter(Boolean)
)
const conflict = await listDocuments(config.collections.websiteProjects, [
Query.equal('subdomain', candidate),
Query.limit(1),
])
let created = 0
for (const repo of repos) {
const fullName = repo.full_name
if (!fullName || repo.archived) continue
if (existingNames.has(fullName.toLowerCase())) continue
if (!conflict[0] || conflict[0].repoFullName === repo.full_name) {
return candidate
}
await withLocks([repoLockKey(fullName)], async () => {
const recheck = await listDocuments(config.collections.websiteProjects, [
Query.equal('repoFullName', fullName),
Query.limit(1),
])
if (recheck[0]) return
const ownerSlug = slugify(repo.full_name?.replace('/', '-'))
if (!ownerSlug) return ''
await upsertWebsiteProjectByRepo(fullName, {
projectName: repo.description || repo.name || fullName.split('/').pop(),
giteaRepoUrl: repo.html_url,
giteaRepoName: repo.name,
repoFullName: fullName,
archived: false,
status: 'synced',
giteaSyncedAt: new Date().toISOString(),
})
created += 1
})
}
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 ''
return { created }
})
}
export async function syncReposToAppwrite() {
@@ -77,10 +111,6 @@ export async function syncReposToAppwrite() {
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}`,
@@ -90,9 +120,6 @@ export async function syncReposToAppwrite() {
archived: Boolean(repo.archived),
}
if (subdomain) payload.subdomain = subdomain
if (previewUrl) payload.previewUrl = previewUrl
if (!existing[0]) {
payload.status = 'synced'
created += 1
@@ -186,6 +213,20 @@ async function updatePreviewConfigFile(repoFullName, previewConfig, branch = 'ma
})
}
/**
* 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
@@ -201,11 +242,11 @@ export async function ensureRepoPreviewConfig(repoFullName, branch = 'main') {
])
const doc = docs[0]
if (!doc || doc.archived) return null
if (!isHostedProject(doc)) return null
const type = deriveProjectType(doc)
const hasWebsite = type === 'preview' || type === 'website' || type === 'template'
const subdomain = doc.subdomain || ''
if (!hasWebsite || !subdomain) return null
if (!subdomain) return null
const previewConfig = {
enabled: true,