From febac3c2a3dd1b985d666175d8ecde1946d22c86 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 10:02:07 +0000 Subject: [PATCH] Skripte: Organisation einrichten, Kundenprojekte umziehen Co-Authored-By: Claude Opus 5.5 --- scripts/gitea-organisation-migrate.mjs | 129 +++++++++++++++++++++ scripts/gitea-organisation-setup.mjs | 148 +++++++++++++++++++++++++ test/giteaScripts.test.js | 129 +++++++++++++++++++++ 3 files changed, 406 insertions(+) create mode 100644 scripts/gitea-organisation-migrate.mjs create mode 100644 scripts/gitea-organisation-setup.mjs create mode 100644 test/giteaScripts.test.js diff --git a/scripts/gitea-organisation-migrate.mjs b/scripts/gitea-organisation-migrate.mjs new file mode 100644 index 0000000..8ed8eed --- /dev/null +++ b/scripts/gitea-organisation-migrate.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node +/** + * Zieht die sechs Kundenprojekte nach "WEBklar" um: sichert vorher die Webhooks aller + * betroffenen Repos in eine JSON-Datei, ruft dann je Repo `reconcileProject` (Task 6) auf - + * das verschiebt das Repo und traegt liegen gebliebene Pushes nach - und stellt danach eine + * fehlende `.webklar-preview.json` fuer gehostete Projekte wieder her. + * + * Laeuft spaeter INNERHALB des Portal-Containers (nicht auf dem Host): nutzt server/config.js + * und die dort konfigurierten Appwrite-/Gitea-Zugaenge direkt. + * + * Usage (im Container): node scripts/gitea-organisation-migrate.mjs + */ +import fs from 'node:fs/promises' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { config } from '../server/config.js' +import { giteaFetch, splitRepo } from '../server/services/giteaClient.js' +import { listDocuments, Query } from '../server/services/appwriteAdmin.js' +import { reconcileProject } from '../server/services/projectAssignment.js' +import { isHostedProject, ensureRepoPreviewConfig, getRepoInfo } from '../server/services/giteaAdmin.js' +import { fetchPreviewConfig } from '../server/services/previewDeploy.js' + +export const CUSTOMER_REPOS = [ + 'knso/emmas-schatzkiste', + 'knso/Anelia-Wolf', + 'ANDJ/Unger-Bus-Unternehmen', + 'JUSN/D-ner-Nation', + 'knso/videogen', + 'knso/kfz', +] + +/** + * Schreibt `hooks-.json` mit den aktuellen Webhooks aller uebergebenen Repos + * (`{id, url, events}` je Hook - `url` kommt aus `config.url`, echtes Gitea traegt das Ziel + * NIRGENDS sonst). Ein Repo, dessen Hooks nicht gelesen werden koennen, bekommt eine leere + * Liste - die Sicherung soll nie an einem einzelnen kaputten Repo scheitern. + */ +async function backupHooks(repoFullNames, backupDir, now) { + const backup = {} + for (const repoFullName of repoFullNames) { + const [owner, repo] = splitRepo(repoFullName) + let hooks = [] + try { + const result = await giteaFetch(`/repos/${owner}/${repo}/hooks`) + hooks = Array.isArray(result) ? result : [] + } catch (err) { + console.error(`[gitea-organisation-migrate] Webhooks von ${repoFullName} konnten nicht gesichert werden:`, err.message) + } + backup[repoFullName] = hooks.map((h) => ({ id: h.id, url: h.config?.url, events: h.events })) + } + + await fs.mkdir(backupDir, { recursive: true }) + const fileName = `hooks-${now.toISOString().replace(/[:.]/g, '-')}.json` + await fs.writeFile(path.join(backupDir, fileName), JSON.stringify(backup, null, 2)) + return fileName +} + +export async function migrateRepos({ repoFullNames = CUSTOMER_REPOS, backupDir, now = new Date() } = {}) { + await backupHooks(repoFullNames, backupDir, now) + + const results = [] + for (const repoFullName of repoFullNames) { + const docs = await listDocuments(config.collections.websiteProjects, [ + Query.equal('repoFullName', repoFullName), + Query.limit(1), + ]) + const doc = docs[0] + if (!doc) { + results.push({ + from: repoFullName, + to: repoFullName, + backfilled: 0, + warnings: ['kein Projekt-Eintrag'], + previewConfigWritten: false, + }) + continue + } + + let reconciled + try { + // Bewusst OHNE eigene Sperre aufgerufen - `reconcileProject` sperrt sich selbst + // (Controller-Ruling): ein zweiter, gleichzeitiger Aufruf (z. B. aus dem Portal + // waehrend der Migration) darf nicht doppelt umziehen. + reconciled = await reconcileProject(doc.$id, { now }) + } catch (err) { + results.push({ + from: repoFullName, + to: repoFullName, + backfilled: 0, + warnings: [err.message], + previewConfigWritten: false, + }) + continue + } + + const to = reconciled.project.repoFullName + let previewConfigWritten = false + if (isHostedProject(reconciled.project)) { + const branch = (await getRepoInfo(to))?.defaultBranch || 'main' + const existingPreviewConfig = await fetchPreviewConfig(to, branch) + if (!existingPreviewConfig) { + await ensureRepoPreviewConfig(to, branch) + previewConfigWritten = true + } + } + + results.push({ + from: repoFullName, + to, + backfilled: reconciled.backfilled, + warnings: reconciled.warnings, + previewConfigWritten, + }) + } + + return results +} + +async function main() { + const results = await migrateRepos({ backupDir: '/app/upload-data/gitea-org' }) + console.log(JSON.stringify(results, null, 2)) +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((err) => { + console.error(err.message) + process.exit(1) + }) +} diff --git a/scripts/gitea-organisation-setup.mjs b/scripts/gitea-organisation-setup.mjs new file mode 100644 index 0000000..83aa303 --- /dev/null +++ b/scripts/gitea-organisation-setup.mjs @@ -0,0 +1,148 @@ +#!/usr/bin/env node +/** + * Richtet die Gitea-Organisation "WEBklar" fuer den Umzug der Kundenprojekte ein: legt die + * Organisation an (falls sie fehlt), fuegt die Mitglieder knso/jusn/andj zum Owners-Team hinzu + * und legt den organisationsweiten Webhook auf das Portal an. Idempotent - ein zweiter Aufruf + * aendert nichts mehr, wenn Organisation/Mitglieder/Hook bereits vorhanden sind. + * + * Haengt bewusst NICHT an server/config.js - laeuft spaeter auf dem HOST (nicht im Container), + * wo dessen Appwrite-/Preview-Umgebungsvariablen nicht gesetzt sind. baseUrl/token/secret kommen + * stattdessen als Parameter bzw. (im CLI-Teil) direkt aus der uebergebenen .env-Datei. + * + * Usage (auf dem Host): node scripts/gitea-organisation-setup.mjs --env /home/webklar/apps/.env + */ +import fs from 'node:fs' +import { pathToFileURL } from 'node:url' + +/** + * Liest eine simple .env-Datei (KEY=VALUE je Zeile, `#`-Kommentare und Leerzeilen werden + * ignoriert, umschliessende `"`/`'` werden entfernt) ein. Gibt NIE etwas auf der Konsole aus - + * die Datei kann Secrets (GITEA_SYSTEM_TOKEN, GITEA_WEBHOOK_TOKEN) enthalten. + */ +export function readEnvFile(filePath) { + const content = fs.readFileSync(filePath, 'utf8') + const result = {} + for (const rawLine of content.split('\n')) { + const line = rawLine.trim() + if (!line || line.startsWith('#')) continue + const eq = line.indexOf('=') + if (eq === -1) continue + const key = line.slice(0, eq).trim() + let value = line.slice(eq + 1).trim() + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1) + } + result[key] = value + } + return result +} + +/** Ruft `${baseUrl}/api/v1` mit `Authorization: token ` auf. Ein 404 wird NICHT geworfen (Aufrufer prueft `status`), alles andere Nicht-2xx schon. */ +async function giteaJson(baseUrl, token, path, { method = 'GET', body } = {}) { + const response = await fetch(`${baseUrl}/api/v1${path}`, { + method, + headers: { + Authorization: `token ${token}`, + 'Content-Type': 'application/json', + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + const text = await response.text() + let data = null + if (text) { + try { + data = JSON.parse(text) + } catch { + data = { message: text } + } + } + if (!response.ok && response.status !== 404) { + const error = new Error(data?.message || `Gitea ${response.status}`) + error.status = response.status + throw error + } + return { status: response.status, data } +} + +export async function setupOrganisation({ + baseUrl, + token, + org = 'WEBklar', + members = ['knso', 'jusn', 'andj'], + hookUrl = 'https://project.webklar.com/webhook/gitea', + secret, +} = {}) { + // 1. Organisation anlegen, falls sie noch nicht existiert. + let orgCreated = false + const orgGet = await giteaJson(baseUrl, token, `/orgs/${org}`) + if (orgGet.status === 404) { + await giteaJson(baseUrl, token, '/orgs', { + method: 'POST', + body: { username: org, full_name: org, visibility: 'public' }, + }) + orgCreated = true + } + + // 2. Fehlende Mitglieder im Owners-Team ergaenzen. + const membersAdded = [] + const teamsResult = await giteaJson(baseUrl, token, `/orgs/${org}/teams`) + const owners = (teamsResult.data || []).find((t) => t.name === 'Owners') + if (owners) { + const existingResult = await giteaJson(baseUrl, token, `/teams/${owners.id}/members`) + const existingLogins = new Set( + (existingResult.data || []).map((m) => String(m.login || '').toLowerCase()) + ) + for (const member of members) { + if (existingLogins.has(member.toLowerCase())) continue + await giteaJson(baseUrl, token, `/teams/${owners.id}/members/${member}`, { method: 'PUT' }) + membersAdded.push(member) + } + } + + // 3. Organisationsweiten Webhook aufs Portal anlegen, falls er noch fehlt. + let hookCreated = false + const hooksResult = await giteaJson(baseUrl, token, `/orgs/${org}/hooks`) + const hasHook = (hooksResult.data || []).some((h) => h.config?.url === hookUrl) + if (!hasHook) { + await giteaJson(baseUrl, token, `/orgs/${org}/hooks`, { + method: 'POST', + body: { + type: 'gitea', + active: true, + events: ['push'], + branch_filter: '*', + config: { url: hookUrl, content_type: 'json', secret }, + }, + }) + hookCreated = true + } + + return { orgCreated, membersAdded, hookCreated } +} + +async function main() { + const args = process.argv.slice(2) + const envIdx = args.indexOf('--env') + const envPath = envIdx !== -1 ? args[envIdx + 1] : undefined + if (!envPath) { + throw new Error('--env erforderlich') + } + const env = readEnvFile(envPath) + const result = await setupOrganisation({ + baseUrl: 'https://git.webklar.com', + token: env.GITEA_SYSTEM_TOKEN, + secret: env.GITEA_WEBHOOK_TOKEN, + }) + // Nur das Ergebnis - nie Token/Secret ausgeben. + console.log(JSON.stringify(result)) +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((err) => { + console.error(err.message) + process.exit(1) + }) +} diff --git a/test/giteaScripts.test.js b/test/giteaScripts.test.js new file mode 100644 index 0000000..5318de5 --- /dev/null +++ b/test/giteaScripts.test.js @@ -0,0 +1,129 @@ +import { test, before, after } from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { startFakeGitea } from './helpers/fakeGitea.js' +import { startFakeAppwrite } from './helpers/fakeAppwrite.js' +import { readEnvFile, setupOrganisation } from '../scripts/gitea-organisation-setup.mjs' + +let gitea +let aw +let tmp +let originalCwd +let envPath +let backupDir +let migrateRepos + +const now = new Date('2026-09-30T10:00:00Z') + +before(async () => { + gitea = await startFakeGitea({ + repos: [ + { full_name: 'knso/emmas-schatzkiste' }, + { full_name: 'JUSN/D-ner-Nation' }, + ], + hooks: { + 'knso/emmas-schatzkiste': [{ id: 110, url: 'https://project.webklar.com/webhook/gitea', events: ['push'] }], + }, + contents: { + 'knso/emmas-schatzkiste:.webklar-preview.json': JSON.stringify({ + enabled: true, + type: 'static', + branch: 'main', + displayName: 'Emmas Schatzkiste', + subdomain: 'emmas-schatzkiste', + }), + }, + }) + + aw = await startFakeAppwrite({ + seed: { + websiteProjects: [ + { + $id: 'p1', + repoFullName: 'knso/emmas-schatzkiste', + projectName: 'Emmas Schatzkiste', + projectType: 'website', + subdomain: 'emmas-schatzkiste', + customerId: '', + ticketId: '', + }, + { + $id: 'p2', + repoFullName: 'JUSN/D-ner-Nation', + projectName: 'D-ner-Nation', + projectType: 'preview', + subdomain: 'doener-nation', + customerId: '', + ticketId: '', + }, + ], + }, + }) + + originalCwd = process.cwd() + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'gitea-scripts-')) + process.chdir(tmp) + envPath = path.join(tmp, '.env') + backupDir = path.join(tmp, 'gitea-org') + + Object.assign(process.env, { + GITEA_BASE_URL: gitea.baseUrl, + GITEA_API_TOKEN: 'test-token', + APPWRITE_ENDPOINT: aw.endpoint, + APPWRITE_API_KEY: 'test-key', + PREVIEW_DEPLOY_ROOT: '', + TRAEFIK_DYNAMIC_DIR: '', + }) + // server/config.js liest die Umgebung beim Import - erst danach das Migrationsskript laden. + ;({ migrateRepos } = await import('../scripts/gitea-organisation-migrate.mjs')) +}) + +after(async () => { + process.chdir(originalCwd) + await gitea.close() + await aw.close() + await fs.rm(tmp, { recursive: true, force: true }) +}) + +test('Einrichtung legt Organisation, Mitglieder und Webhook an', async () => { + const r = await setupOrganisation({ baseUrl: gitea.baseUrl, token: 't', secret: 's3cret' }) + assert.deepEqual(r, { orgCreated: true, membersAdded: ['knso', 'jusn', 'andj'], hookCreated: true }) + assert.deepEqual( + gitea.calls.find((c) => c.method === 'POST' && c.path === '/api/v1/orgs').body, + { username: 'WEBklar', full_name: 'WEBklar', visibility: 'public' } + ) + assert.deepEqual( + gitea.calls.find((c) => c.path === '/api/v1/orgs/WEBklar/hooks' && c.method === 'POST').body, + { + type: 'gitea', + active: true, + events: ['push'], + branch_filter: '*', + config: { url: 'https://project.webklar.com/webhook/gitea', content_type: 'json', secret: 's3cret' }, + } + ) +}) + +test('Einrichtung ist wiederholbar', async () => { + assert.deepEqual( + await setupOrganisation({ baseUrl: gitea.baseUrl, token: 't', secret: 's3cret' }), + { orgCreated: false, membersAdded: [], hookCreated: false } + ) +}) + +test('readEnvFile', async () => { + await fs.writeFile(envPath, '# x\nGITEA_SYSTEM_TOKEN="abc"\nGITEA_WEBHOOK_TOKEN=def\n') + assert.deepEqual(readEnvFile(envPath), { GITEA_SYSTEM_TOKEN: 'abc', GITEA_WEBHOOK_TOKEN: 'def' }) +}) + +test('Umzug sichert Webhooks und schreibt fehlende Preview-Einstellung', async () => { + const r = await migrateRepos({ repoFullNames: ['knso/emmas-schatzkiste', 'JUSN/D-ner-Nation'], backupDir, now }) + assert.deepEqual(r.map((x) => [x.to, x.previewConfigWritten]), [ + ['WEBklar/emmas-schatzkiste', false], + ['WEBklar/D-ner-Nation', true], + ]) + const [file] = await fs.readdir(backupDir) + assert.equal(JSON.parse(await fs.readFile(path.join(backupDir, file), 'utf8'))['knso/emmas-schatzkiste'][0].id, 110) +})