Projekt-Ablauf im Ticketsystem: Anlegen, Archivieren, Import repariert
- Neues Projekt ohne Subdomain: subdomain null statt '' (Unique-Index subdomain_unique, jedes weitere scheiterte mit "Document with the requested ID already exists"); ebenso Typ "Internes Projekt" - Anlegen unter derselben Repo-Sperre wie der Webhook repository/created; upsertWebsiteProjectByRepo macht aus einem 409 ein Update - Gitea-Beschreibung = Anzeigename; Art (kind) beim Anlegen aus dem Ticket - Reaktivieren setzt status zurueck (vorher blieb 'archived' stehen) - Import: archivierte Repos als archiviert, Archiv-Stand aus Gitea wird gespiegelt, ein fehlerhaftes Repo bricht den Lauf nicht mehr ab - Abgleich ueberschreibt Projektnamen nicht mehr - CORS erlaubt PUT (Preview-Variablen speichern) - Vorlage zieht beim Zuweisen nie in die Org um - Erinnerungs-Lauf ueberspringt archivierte Projekte - kaputte Umlaute (U+FFFD) in drei Fehlermeldungen Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3a0b0089dd
commit
e43cdb667b
13 files changed
+498
-83
No files matched your search
@@ -29,7 +29,7 @@ export async function requireAppwriteStaff(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return res.status(401).json({ error: data?.message || 'Ung�ltiges JWT' })
|
return res.status(401).json({ error: data?.message || 'Ungültiges JWT' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(data?.labels) || !data.labels.includes('admin')) {
|
if (!Array.isArray(data?.labels) || !data.labels.includes('admin')) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function corsMiddleware(allowedOrigins) {
|
|||||||
if (origin && allowedOrigins.includes(origin)) {
|
if (origin && allowedOrigins.includes(origin)) {
|
||||||
res.setHeader('Access-Control-Allow-Origin', origin)
|
res.setHeader('Access-Control-Allow-Origin', origin)
|
||||||
res.setHeader('Access-Control-Allow-Headers', ALLOW_HEADERS)
|
res.setHeader('Access-Control-Allow-Headers', ALLOW_HEADERS)
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
return res.sendStatus(204)
|
return res.sendStatus(204)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const router = Router()
|
|||||||
router.use(requireAppwriteStaff)
|
router.use(requireAppwriteStaff)
|
||||||
|
|
||||||
router.post('/create-from-template', async (req, res) => {
|
router.post('/create-from-template', async (req, res) => {
|
||||||
const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner } = req.body || {}
|
const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner, kind = '' } = req.body || {}
|
||||||
|
|
||||||
if (!repoName && !displayName) {
|
if (!repoName && !displayName) {
|
||||||
return res.status(400).json({ error: 'repoName oder displayName ist erforderlich' })
|
return res.status(400).json({ error: 'repoName oder displayName ist erforderlich' })
|
||||||
@@ -27,6 +27,8 @@ router.post('/create-from-template', async (req, res) => {
|
|||||||
customerId,
|
customerId,
|
||||||
ticketId,
|
ticketId,
|
||||||
owner,
|
owner,
|
||||||
|
kind,
|
||||||
|
staffName: req.appwriteUser?.name || req.appwriteUser?.email || '',
|
||||||
})
|
})
|
||||||
return res.status(201).json({ ok: true, ...result })
|
return res.status(201).json({ ok: true, ...result })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -300,19 +300,32 @@ export async function upsertWebsiteProjectByRepo(repoFullName, data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
return adminFetch(collectionPath(config.collections.websiteProjects), {
|
try {
|
||||||
method: 'POST',
|
return await adminFetch(collectionPath(config.collections.websiteProjects), {
|
||||||
body: {
|
method: 'POST',
|
||||||
...payload,
|
body: {
|
||||||
customerId: payload.customerId ?? '',
|
...payload,
|
||||||
ticketId: payload.ticketId ?? '',
|
customerId: payload.customerId ?? '',
|
||||||
status: payload.status ?? 'pending',
|
ticketId: payload.ticketId ?? '',
|
||||||
provisioningStatus: payload.provisioningStatus ?? 'pending',
|
status: payload.status ?? 'pending',
|
||||||
projectName: payload.projectName || repoFullName.split('/').pop() || repoFullName,
|
provisioningStatus: payload.provisioningStatus ?? 'pending',
|
||||||
createdAt: now,
|
projectName: payload.projectName || repoFullName.split('/').pop() || repoFullName,
|
||||||
documentId: ID.unique(),
|
createdAt: now,
|
||||||
},
|
documentId: ID.unique(),
|
||||||
})
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
// Ein anderer Schreiber ohne gemeinsame Sperre hat das Dokument zwischen Suche und Anlage
|
||||||
|
// angelegt: der Unique-Index auf repoFullName meldet 409. Dann das vorhandene Dokument
|
||||||
|
// aktualisieren, statt den Fehler bis in die Oberflaeche zu reichen.
|
||||||
|
if (err.status !== 409) throw err
|
||||||
|
const raced = await listDocuments(config.collections.websiteProjects, [
|
||||||
|
Query.equal('repoFullName', repoFullName),
|
||||||
|
Query.limit(1),
|
||||||
|
])
|
||||||
|
if (!raced[0]) throw err
|
||||||
|
return updateDocument(config.collections.websiteProjects, raced[0].$id, buildProjectPayload(raced[0], data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyDatabaseAccess() {
|
export async function verifyDatabaseAccess() {
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ export async function updateCustomerWithPortalAccess(customerId, payload) {
|
|||||||
portalPassword = normalizedPassword
|
portalPassword = normalizedPassword
|
||||||
}
|
}
|
||||||
} else if (normalizedPassword) {
|
} else if (normalizedPassword) {
|
||||||
const error = new Error('Portal-Login fehlt � bitte E-Mail setzen und Passwort erneut speichern')
|
const error = new Error('Portal-Login fehlt – bitte E-Mail setzen und Passwort erneut speichern')
|
||||||
error.status = 400
|
error.status = 400
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|||||||
+151
-54
@@ -18,6 +18,7 @@ import { giteaFetch } from './giteaClient.js'
|
|||||||
import { slugify } from './slug.js'
|
import { slugify } from './slug.js'
|
||||||
import { withLocks } from './keyedLock.js'
|
import { withLocks } from './keyedLock.js'
|
||||||
import { repoLockKey } from './repoMove.js'
|
import { repoLockKey } from './repoMove.js'
|
||||||
|
import { isKind } from './projectKinds.js'
|
||||||
|
|
||||||
// Sicherheitsnetz wie bei listRecentPushes (siehe pushHistory.js, Controller-Ruling R8): sollte
|
// Sicherheitsnetz wie bei listRecentPushes (siehe pushHistory.js, Controller-Ruling R8): sollte
|
||||||
// Gitea "page" ignorieren und immer dieselbe volle Seite liefern, blaettert die Schleife nicht
|
// Gitea "page" ignorieren und immer dieselbe volle Seite liefern, blaettert die Schleife nicht
|
||||||
@@ -49,12 +50,16 @@ export async function listAllAccessibleRepos() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Findet Repos, die in Gitea liegen, aber noch kein Appwrite-Projektdokument haben, und legt fuer
|
* 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) -
|
* jedes ein minimales Dokument an (status "synced", ohne Hosting/Subdomain; in Gitea archivierte
|
||||||
* ohne dass dafuer manuell "Abgleich" gedrueckt werden muesste. Wird vom Projekt-Dialog bei jedem
|
* als archiviertes Projekt) - ohne dass dafuer manuell "Abgleich" gedrueckt werden muesste. Bei
|
||||||
|
* vorhandenen Dokumenten wird nur der Archiv-Stand aus Gitea gespiegelt (Repo dort direkt
|
||||||
|
* archiviert oder reaktiviert). Wird vom Projekt-Dialog bei jedem
|
||||||
* Oeffnen aufgerufen, laeuft also oft: die aeussere `withLocks(['import'])`-Sperre serialisiert
|
* 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
|
* parallele Laeufe komplett (ein zweiter Aufruf sieht danach garantiert den Stand des ersten), die
|
||||||
* innere `withLocks([repoLockKey(...)])` je neuem Repo schuetzt zusaetzlich gegen andere
|
* innere `withLocks([repoLockKey(...)])` je neuem Repo schuetzt zusaetzlich gegen andere
|
||||||
* gleichzeitige Schreiber desselben Projektdokuments (z. B. Umzug/Zuweisung).
|
* gleichzeitige Schreiber desselben Projektdokuments (z. B. Umzug/Zuweisung).
|
||||||
|
* Ein Fehler bei einem Repo bricht den Lauf nicht ab (sonst fehlten alle Repos danach).
|
||||||
|
* `createdRepos` enthaelt nur neue, nicht archivierte Repos (nur sie bekommen Zuordnungs-Mails).
|
||||||
*/
|
*/
|
||||||
export async function importNewRepos() {
|
export async function importNewRepos() {
|
||||||
return withLocks(['import'], async () => {
|
return withLocks(['import'], async () => {
|
||||||
@@ -64,39 +69,71 @@ export async function importNewRepos() {
|
|||||||
])
|
])
|
||||||
|
|
||||||
// Ohne Ruecksicht auf Gross-/Kleinschreibung vergleichen (Controller-Ruling).
|
// Ohne Ruecksicht auf Gross-/Kleinschreibung vergleichen (Controller-Ruling).
|
||||||
const existingNames = new Set(
|
const docsByName = new Map()
|
||||||
docs.map((d) => String(d.repoFullName || '').toLowerCase()).filter(Boolean)
|
for (const d of docs) {
|
||||||
)
|
const key = String(d.repoFullName || '').toLowerCase()
|
||||||
|
if (key && !docsByName.has(key)) docsByName.set(key, d)
|
||||||
let created = 0
|
|
||||||
const createdRepos = []
|
|
||||||
for (const repo of repos) {
|
|
||||||
const fullName = repo.full_name
|
|
||||||
if (!fullName || repo.archived) continue
|
|
||||||
if (existingNames.has(fullName.toLowerCase())) continue
|
|
||||||
|
|
||||||
await withLocks([repoLockKey(fullName)], async () => {
|
|
||||||
const recheck = await listDocuments(config.collections.websiteProjects, [
|
|
||||||
Query.equal('repoFullName', fullName),
|
|
||||||
Query.limit(1),
|
|
||||||
])
|
|
||||||
if (recheck[0]) return
|
|
||||||
|
|
||||||
const doc = 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
|
|
||||||
createdRepos.push({ projectId: doc.$id, repo })
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { created, createdRepos }
|
let created = 0
|
||||||
|
let archivedImported = 0
|
||||||
|
let archiveSynced = 0
|
||||||
|
const createdRepos = []
|
||||||
|
const failed = []
|
||||||
|
for (const repo of repos) {
|
||||||
|
const fullName = repo.full_name
|
||||||
|
if (!fullName) continue
|
||||||
|
const repoArchived = Boolean(repo.archived)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = docsByName.get(fullName.toLowerCase())
|
||||||
|
if (existing) {
|
||||||
|
if (Boolean(existing.archived) === repoArchived) continue
|
||||||
|
await withLocks([repoLockKey(fullName)], async () => {
|
||||||
|
const fresh = await getDocument(config.collections.websiteProjects, existing.$id)
|
||||||
|
if (!fresh || Boolean(fresh.archived) === repoArchived) return
|
||||||
|
await updateDocument(config.collections.websiteProjects, fresh.$id, {
|
||||||
|
archived: repoArchived,
|
||||||
|
...(repoArchived
|
||||||
|
? { status: 'archived' }
|
||||||
|
: fresh.status === 'archived' ? { status: 'synced' } : {}),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
archiveSynced += 1
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await withLocks([repoLockKey(fullName)], async () => {
|
||||||
|
const recheck = await listDocuments(config.collections.websiteProjects, [
|
||||||
|
Query.equal('repoFullName', fullName),
|
||||||
|
Query.limit(1),
|
||||||
|
])
|
||||||
|
if (recheck[0]) return
|
||||||
|
|
||||||
|
const doc = await upsertWebsiteProjectByRepo(fullName, {
|
||||||
|
projectName: repo.description || repo.name || fullName.split('/').pop(),
|
||||||
|
giteaRepoUrl: repo.html_url,
|
||||||
|
giteaRepoName: repo.name,
|
||||||
|
repoFullName: fullName,
|
||||||
|
archived: repoArchived,
|
||||||
|
status: repoArchived ? 'archived' : 'synced',
|
||||||
|
giteaSyncedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
if (repoArchived) {
|
||||||
|
archivedImported += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created += 1
|
||||||
|
createdRepos.push({ projectId: doc.$id, repo })
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
failed.push(fullName)
|
||||||
|
console.error(`[importNewRepos] ${fullName} übersprungen:`, err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { created, createdRepos, archivedImported, archiveSynced, failed }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +141,7 @@ export async function syncReposToAppwrite() {
|
|||||||
const repos = await listAllAccessibleRepos()
|
const repos = await listAllAccessibleRepos()
|
||||||
let created = 0
|
let created = 0
|
||||||
let updated = 0
|
let updated = 0
|
||||||
|
const failed = []
|
||||||
|
|
||||||
for (const repo of repos) {
|
for (const repo of repos) {
|
||||||
const repoFullName = repo.full_name
|
const repoFullName = repo.full_name
|
||||||
@@ -115,25 +153,38 @@ export async function syncReposToAppwrite() {
|
|||||||
])
|
])
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
projectName: repo.description || repo.name || repoFullName.split('/').pop(),
|
|
||||||
giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
|
giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
|
||||||
giteaRepoName: repo.name || repoFullName.split('/').pop(),
|
giteaRepoName: repo.name || repoFullName.split('/').pop(),
|
||||||
repoFullName,
|
repoFullName,
|
||||||
// In Gitea archivierte Repos werden im Ticketsystem ausgeblendet.
|
// In Gitea archivierte Repos werden im Ticketsystem ausgeblendet.
|
||||||
archived: Boolean(repo.archived),
|
archived: Boolean(repo.archived),
|
||||||
}
|
}
|
||||||
|
const repoTitle = repo.description || repo.name || repoFullName.split('/').pop()
|
||||||
|
|
||||||
if (!existing[0]) {
|
if (!existing[0]) {
|
||||||
payload.status = 'synced'
|
payload.projectName = repoTitle
|
||||||
|
payload.status = repo.archived ? 'archived' : 'synced'
|
||||||
created += 1
|
created += 1
|
||||||
} else {
|
} else {
|
||||||
|
// Der Name im Ticketsystem bleibt: frueher ueberschrieb der Abgleich ihn mit der
|
||||||
|
// Gitea-Beschreibung (aus "Döner Nation" wurde so "D-ner-Nation").
|
||||||
|
if (!existing[0].projectName) payload.projectName = repoTitle
|
||||||
|
// Archiv-Stand aus Gitea uebernehmen, ohne dass status auf 'archived' haengen bleibt
|
||||||
|
if (repo.archived && existing[0].status !== 'archived') payload.status = 'archived'
|
||||||
|
if (!repo.archived && existing[0].status === 'archived') payload.status = 'synced'
|
||||||
updated += 1
|
updated += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
await upsertWebsiteProjectByRepo(repoFullName, payload)
|
try {
|
||||||
|
await upsertWebsiteProjectByRepo(repoFullName, payload)
|
||||||
|
} catch (err) {
|
||||||
|
// Ein fehlerhaftes Repo darf den Abgleich aller folgenden nicht abbrechen
|
||||||
|
failed.push(repoFullName)
|
||||||
|
console.error(`[syncReposToAppwrite] ${repoFullName} übersprungen:`, err.message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { synced: repos.length, created, updated }
|
return { synced: repos.length, created, updated, failed }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureSubdomainAvailable(subdomain) {
|
export async function ensureSubdomainAvailable(subdomain) {
|
||||||
@@ -148,7 +199,7 @@ export async function ensureSubdomainAvailable(subdomain) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateRepoFromTemplate({ owner, repoName }) {
|
export async function generateRepoFromTemplate({ owner, repoName, description }) {
|
||||||
const templateOwner = config.gitea.templateOwner
|
const templateOwner = config.gitea.templateOwner
|
||||||
const templateRepo = config.gitea.templateRepo
|
const templateRepo = config.gitea.templateRepo
|
||||||
const targetOwner = owner || config.gitea.defaultOwner
|
const targetOwner = owner || config.gitea.defaultOwner
|
||||||
@@ -163,7 +214,8 @@ export async function generateRepoFromTemplate({ owner, repoName }) {
|
|||||||
owner: targetOwner,
|
owner: targetOwner,
|
||||||
name: repoName,
|
name: repoName,
|
||||||
private: false,
|
private: false,
|
||||||
description: `Website-Projekt ${repoName}`,
|
// Die Beschreibung ist der Projektname in Gitea (Abgleich und Webhook lesen ihn von dort)
|
||||||
|
description: description || `Website-Projekt ${repoName}`,
|
||||||
git_content: true,
|
git_content: true,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -278,6 +330,8 @@ export async function createProjectFromTemplate({
|
|||||||
customerId = '',
|
customerId = '',
|
||||||
ticketId = '',
|
ticketId = '',
|
||||||
owner,
|
owner,
|
||||||
|
kind = '',
|
||||||
|
staffName = '',
|
||||||
}) {
|
}) {
|
||||||
const targetOwner = owner || config.gitea.defaultOwner
|
const targetOwner = owner || config.gitea.defaultOwner
|
||||||
const safeRepoName = slugify(repoName) || slugify(displayName) || slugify(subdomain)
|
const safeRepoName = slugify(repoName) || slugify(displayName) || slugify(subdomain)
|
||||||
@@ -289,6 +343,11 @@ export async function createProjectFromTemplate({
|
|||||||
if (!safeRepoName) {
|
if (!safeRepoName) {
|
||||||
throw new Error('repoName oder displayName ist erforderlich')
|
throw new Error('repoName oder displayName ist erforderlich')
|
||||||
}
|
}
|
||||||
|
if (kind && !isKind(kind)) {
|
||||||
|
const error = new Error('Unbekannte Projektart.')
|
||||||
|
error.status = 400
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
if (hasWebsite) {
|
if (hasWebsite) {
|
||||||
await ensureSubdomainAvailable(safeSubdomain)
|
await ensureSubdomainAvailable(safeSubdomain)
|
||||||
@@ -297,6 +356,7 @@ export async function createProjectFromTemplate({
|
|||||||
const repo = await generateRepoFromTemplate({
|
const repo = await generateRepoFromTemplate({
|
||||||
owner: targetOwner,
|
owner: targetOwner,
|
||||||
repoName: safeRepoName,
|
repoName: safeRepoName,
|
||||||
|
description: displayName || safeRepoName,
|
||||||
})
|
})
|
||||||
|
|
||||||
const repoFullName = repo.full_name || `${targetOwner}/${safeRepoName}`
|
const repoFullName = repo.full_name || `${targetOwner}/${safeRepoName}`
|
||||||
@@ -324,7 +384,11 @@ export async function createProjectFromTemplate({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const previewUrl = hasWebsite ? buildPreviewUrl(safeSubdomain) : ''
|
const previewUrl = hasWebsite ? buildPreviewUrl(safeSubdomain) : ''
|
||||||
const project = await upsertWebsiteProjectByRepo(repoFullName, {
|
// Gitea meldet das neue Repo sofort per Webhook (repository/created, dann der Push der Config).
|
||||||
|
// Der Webhook legt das Dokument unter dieser Repo-Sperre an; ohne dieselbe Sperre legen beide
|
||||||
|
// gleichzeitig an und der Unique-Index auf repoFullName lehnt dieses hier ab ("Document with
|
||||||
|
// the requested ID already exists") - das Repo gab es dann schon, Kunde und Ticket fehlten.
|
||||||
|
const project = await withLocks([repoLockKey(repoFullName)], () => upsertWebsiteProjectByRepo(repoFullName, {
|
||||||
customerId,
|
customerId,
|
||||||
ticketId,
|
ticketId,
|
||||||
projectName: previewConfig.displayName,
|
projectName: previewConfig.displayName,
|
||||||
@@ -332,9 +396,23 @@ export async function createProjectFromTemplate({
|
|||||||
giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
|
giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
|
||||||
giteaRepoName: repo.name || safeRepoName,
|
giteaRepoName: repo.name || safeRepoName,
|
||||||
repoFullName,
|
repoFullName,
|
||||||
subdomain: safeSubdomain,
|
// Ohne Website null statt '': auf subdomain liegt ein Unique-Index, und ein zweites
|
||||||
|
// Projekt mit '' scheiterte mit "Document with the requested ID already exists".
|
||||||
|
subdomain: safeSubdomain || null,
|
||||||
previewUrl,
|
previewUrl,
|
||||||
projectType: hasWebsite ? 'website' : 'repo',
|
// Login-geschuetzte Preview (isPublic bleibt aus) - wie deriveProjectType und das Ticketsystem
|
||||||
|
projectType: hasWebsite ? 'preview' : 'repo',
|
||||||
|
// Art direkt mitgeben (Ticket: "Neues Projekt" = Website) - sonst stand das Projekt auf
|
||||||
|
// "Art fehlt" und der Zuordnungs-Lauf mahnte es an. Felder wie completeAssignment.
|
||||||
|
...(kind
|
||||||
|
? {
|
||||||
|
kind,
|
||||||
|
forCustomer: Boolean(customerId),
|
||||||
|
kindSetBy: staffName || '',
|
||||||
|
kindSetAt: new Date().toISOString(),
|
||||||
|
previewMode: kind === 'website' || kind === 'webapp' ? 'auto' : 'off',
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(hasWebsite && deployKeepsStatus(deployResult)
|
...(hasWebsite && deployKeepsStatus(deployResult)
|
||||||
? {} // Preview-Runner (C1): Status setzt der Verfolger
|
? {} // Preview-Runner (C1): Status setzt der Verfolger
|
||||||
: {
|
: {
|
||||||
@@ -343,14 +421,14 @@ export async function createProjectFromTemplate({
|
|||||||
? (deployResult.deployed ? 'ready' : 'deploy_failed')
|
? (deployResult.deployed ? 'ready' : 'deploy_failed')
|
||||||
: 'ready',
|
: 'ready',
|
||||||
}),
|
}),
|
||||||
})
|
}), { waitMs: 10000 })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projectId: project.$id,
|
projectId: project.$id,
|
||||||
repoFullName,
|
repoFullName,
|
||||||
previewUrl,
|
previewUrl,
|
||||||
subdomain: safeSubdomain,
|
subdomain: safeSubdomain,
|
||||||
projectType: hasWebsite ? 'website' : 'repo',
|
projectType: hasWebsite ? 'preview' : 'repo',
|
||||||
deploy: deployResult,
|
deploy: deployResult,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -494,7 +572,7 @@ export async function updateWebsiteProject(projectId, { projectName, subdomain,
|
|||||||
const previewUrl = hasWebsite && nextSubdomain ? buildPreviewUrl(nextSubdomain) : ''
|
const previewUrl = hasWebsite && nextSubdomain ? buildPreviewUrl(nextSubdomain) : ''
|
||||||
const updated = await updateDocument(config.collections.websiteProjects, projectId, {
|
const updated = await updateDocument(config.collections.websiteProjects, projectId, {
|
||||||
projectName: nextName,
|
projectName: nextName,
|
||||||
subdomain: nextSubdomain,
|
subdomain: nextSubdomain || null, // null statt '' (Unique-Index, siehe createProjectFromTemplate)
|
||||||
previewUrl,
|
previewUrl,
|
||||||
projectType: nextType,
|
projectType: nextType,
|
||||||
isPublic,
|
isPublic,
|
||||||
@@ -573,7 +651,26 @@ export async function archiveRepo(projectId, archived = true) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!archived && !repoMissing && doc.subdomain) {
|
if (archived) {
|
||||||
|
const updated = await updateDocument(config.collections.websiteProjects, projectId, {
|
||||||
|
archived: true,
|
||||||
|
status: 'archived',
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
return { project: updated, archived: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reaktivieren: status 'archived' zuruecksetzen, und zwar VOR dem Redeploy - der Preview-Runner
|
||||||
|
// setzt nach dem Bau selbst 'deployed', das darf hier danach nicht ueberschrieben werden.
|
||||||
|
// Blieb 'archived' stehen, bot das Ticket das Projekt unter "Projekt wählen" nicht mehr an.
|
||||||
|
const redeploy = !repoMissing && Boolean(doc.subdomain)
|
||||||
|
let updated = await updateDocument(config.collections.websiteProjects, projectId, {
|
||||||
|
archived: false,
|
||||||
|
status: redeploy ? 'pending' : 'synced',
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (redeploy) {
|
||||||
try {
|
try {
|
||||||
const branch = (await getRepoInfo(repoFullName))?.defaultBranch || 'main'
|
const branch = (await getRepoInfo(repoFullName))?.defaultBranch || 'main'
|
||||||
const cfg = (await fetchPreviewConfig(repoFullName, branch)) || {
|
const cfg = (await fetchPreviewConfig(repoFullName, branch)) || {
|
||||||
@@ -583,19 +680,19 @@ export async function archiveRepo(projectId, archived = true) {
|
|||||||
subdomain: doc.subdomain,
|
subdomain: doc.subdomain,
|
||||||
displayName: doc.projectName,
|
displayName: doc.projectName,
|
||||||
}
|
}
|
||||||
await runDeploy(repoFullName, branch, cfg, { project: doc })
|
const deployResult = await runDeploy(repoFullName, branch, cfg, { project: doc })
|
||||||
|
if (deployResult?.deployed) {
|
||||||
|
updated = await updateDocument(config.collections.websiteProjects, projectId, {
|
||||||
|
status: 'deployed',
|
||||||
|
provisioningStatus: 'ready',
|
||||||
|
})
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[archiveRepo] Redeploy bei Reaktivierung fehlgeschlagen:', err.message)
|
console.error('[archiveRepo] Redeploy bei Reaktivierung fehlgeschlagen:', err.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await updateDocument(config.collections.websiteProjects, projectId, {
|
return { project: updated, archived: false }
|
||||||
archived: Boolean(archived),
|
|
||||||
...(archived ? { status: 'archived' } : {}),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
|
|
||||||
return { project: updated, archived: Boolean(archived) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Repo-Basisdaten (Groesse in KB, Default-Branch). */
|
/** Repo-Basisdaten (Groesse in KB, Default-Branch). */
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export async function giteaFetch(path, options = {}) {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let message = data?.message || `Gitea ${response.status}`
|
let message = data?.message || `Gitea ${response.status}`
|
||||||
if (response.status === 409 && /same name already exists/i.test(message)) {
|
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.'
|
message = 'Ein Gitea-Repository mit diesem Namen existiert bereits. Bitte einen anderen Subdomain-/Repo-Namen wählen.'
|
||||||
}
|
}
|
||||||
const error = new Error(message)
|
const error = new Error(message)
|
||||||
error.status = response.status
|
error.status = response.status
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export async function runRepoAssignJob({
|
|||||||
|
|
||||||
const docs = await listDocuments(collection(), [Query.limit(5000)])
|
const docs = await listDocuments(collection(), [Query.limit(5000)])
|
||||||
for (const p of docs) {
|
for (const p of docs) {
|
||||||
if (!p.createdByGitea || p.kind || handled.has(p.$id)) continue
|
if (!p.createdByGitea || p.kind || p.archived || handled.has(p.$id)) continue
|
||||||
// Wiederholung/Nachholen: erste Mail noch nicht raus (0-2 Versuche), Repo noch juenger als 24 h.
|
// Wiederholung/Nachholen: erste Mail noch nicht raus (0-2 Versuche), Repo noch juenger als 24 h.
|
||||||
const attempts = Number(p.assignMailAttempts || 0)
|
const attempts = Number(p.assignMailAttempts || 0)
|
||||||
if (!p.assignMailSentAt && attempts < 3) {
|
if (!p.assignMailSentAt && attempts < 3) {
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ function sameRepoFullName(a, b) {
|
|||||||
export async function isInternalRepo(repoFullName) {
|
export async function isInternalRepo(repoFullName) {
|
||||||
const lower = repoFullName.toLowerCase()
|
const lower = repoFullName.toLowerCase()
|
||||||
if (config.gitea.keepInPlace.some((entry) => entry.toLowerCase() === lower)) return true
|
if (config.gitea.keepInPlace.some((entry) => entry.toLowerCase() === lower)) return true
|
||||||
|
// Die Vorlage fuer "Neues Projekt" darf nie umziehen, sonst findet generate sie nicht mehr
|
||||||
|
if (`${config.gitea.templateOwner}/${config.gitea.templateRepo}`.toLowerCase() === lower) return true
|
||||||
|
|
||||||
const hooks = await giteaFetch(`/repos/${repoFullName}/hooks`)
|
const hooks = await giteaFetch(`/repos/${repoFullName}/hooks`)
|
||||||
const portalUrl = portalWebhookUrl()
|
const portalUrl = portalWebhookUrl()
|
||||||
|
|||||||
@@ -65,7 +65,11 @@ test('importNewRepos legt nur fehlende an', async () => {
|
|||||||
const neu = aw.all('websiteProjects').find((d) => d.repoFullName === 'JUSN/neu')
|
const neu = aw.all('websiteProjects').find((d) => d.repoFullName === 'JUSN/neu')
|
||||||
assert.deepEqual([neu.projectName, neu.status, neu.subdomain || '', neu.previewUrl || ''], ['Döner Nation', 'synced', '', ''])
|
assert.deepEqual([neu.projectName, neu.status, neu.subdomain || '', neu.previewUrl || ''], ['Döner Nation', 'synced', '', ''])
|
||||||
assert.ok(aw.all('websiteProjects').some((d) => d.repoFullName === 'knso/privat'))
|
assert.ok(aw.all('websiteProjects').some((d) => d.repoFullName === 'knso/privat'))
|
||||||
assert.ok(!aw.all('websiteProjects').some((d) => d.repoFullName === 'JUSN/archiv'))
|
// In Gitea archivierte Repos kommen als archiviertes Projekt an (sichtbar unter "Archiviert"),
|
||||||
|
// aber nicht in createdRepos (keine Zuordnungs-Mail)
|
||||||
|
const archiv = aw.all('websiteProjects').find((d) => d.repoFullName === 'JUSN/archiv')
|
||||||
|
assert.deepEqual([archiv.archived, archiv.status], [true, 'archived'])
|
||||||
|
assert.equal(r.archivedImported, 1)
|
||||||
assert.equal(aw.calls.filter((c) => c.method === 'PATCH').length, 0)
|
assert.equal(aw.calls.filter((c) => c.method === 'PATCH').length, 0)
|
||||||
})
|
})
|
||||||
test('importNewRepos läuft nie doppelt', async () => {
|
test('importNewRepos läuft nie doppelt', async () => {
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ import { randomUUID } from 'node:crypto'
|
|||||||
export async function startFakeAppwrite(options = {}) {
|
export async function startFakeAppwrite(options = {}) {
|
||||||
const calls = []
|
const calls = []
|
||||||
const pendingFailures = []
|
const pendingFailures = []
|
||||||
|
// Optionale Unique-Indizes wie in Appwrite/MariaDB: { websiteProjects: ['repoFullName', 'subdomain'] }.
|
||||||
|
// Strings vergleichen ohne Gross-/Kleinschreibung, null/undefined kollidieren nie, '' schon.
|
||||||
|
const uniqueAttrs = options.unique || {}
|
||||||
|
let beforeCreate = null
|
||||||
/** @type {Map<string, Map<string, object>>} Sammlungsname -> (Dokument-ID -> Dokument). */
|
/** @type {Map<string, Map<string, object>>} Sammlungsname -> (Dokument-ID -> Dokument). */
|
||||||
const store = new Map()
|
const store = new Map()
|
||||||
|
|
||||||
@@ -114,11 +118,24 @@ export async function startFakeAppwrite(options = {}) {
|
|||||||
send(res, 200, { total, documents: result })
|
send(res, 200, { total, documents: result })
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCreateDocument(match, searchParams, body, headers, res) {
|
function uniqueConflict(col, data, ownId) {
|
||||||
|
for (const attr of uniqueAttrs[col] || []) {
|
||||||
|
const value = data?.[attr]
|
||||||
|
if (value === null || value === undefined) continue
|
||||||
|
const norm = (v) => (typeof v === 'string' ? v.toLowerCase() : v)
|
||||||
|
for (const doc of collectionOf(col).values()) {
|
||||||
|
if (doc.$id !== ownId && doc[attr] !== null && doc[attr] !== undefined && norm(doc[attr]) === norm(value)) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateDocument(match, searchParams, body, headers, res) {
|
||||||
|
if (beforeCreate) await beforeCreate(match[2], body?.data || {})
|
||||||
const map = collectionOf(match[2])
|
const map = collectionOf(match[2])
|
||||||
const documentId = body?.documentId || randomUUID()
|
const documentId = body?.documentId || randomUUID()
|
||||||
if (map.has(documentId)) {
|
if (map.has(documentId) || uniqueConflict(match[2], body?.data, documentId)) {
|
||||||
return send(res, 409, { message: 'Document with the requested ID already exists.' })
|
return send(res, 409, { message: 'Document with the requested ID already exists.', type: 'document_already_exists' })
|
||||||
}
|
}
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const doc = { $id: documentId, $createdAt: now, $updatedAt: now, ...(body?.data || {}) }
|
const doc = { $id: documentId, $createdAt: now, $updatedAt: now, ...(body?.data || {}) }
|
||||||
@@ -135,6 +152,9 @@ export async function startFakeAppwrite(options = {}) {
|
|||||||
function handlePatchDocument(match, searchParams, body, headers, res) {
|
function handlePatchDocument(match, searchParams, body, headers, res) {
|
||||||
const doc = collectionOf(match[2]).get(match[3])
|
const doc = collectionOf(match[2]).get(match[3])
|
||||||
if (!doc) return send(res, 404, { message: 'Document not found' })
|
if (!doc) return send(res, 404, { message: 'Document not found' })
|
||||||
|
if (uniqueConflict(match[2], body?.data, doc.$id)) {
|
||||||
|
return send(res, 409, { message: 'Document with the requested ID already exists.', type: 'document_already_exists' })
|
||||||
|
}
|
||||||
Object.assign(doc, body?.data || {})
|
Object.assign(doc, body?.data || {})
|
||||||
doc.$updatedAt = new Date().toISOString()
|
doc.$updatedAt = new Date().toISOString()
|
||||||
send(res, 200, doc)
|
send(res, 200, doc)
|
||||||
@@ -212,11 +232,9 @@ export async function startFakeAppwrite(options = {}) {
|
|||||||
const route = ROUTES.find((r) => r.method === req.method && r.re.test(url.pathname))
|
const route = ROUTES.find((r) => r.method === req.method && r.re.test(url.pathname))
|
||||||
if (!route) return send(res, 404, { message: 'Not Found' })
|
if (!route) return send(res, 404, { message: 'Not Found' })
|
||||||
const match = route.re.exec(url.pathname)
|
const match = route.re.exec(url.pathname)
|
||||||
try {
|
Promise.resolve()
|
||||||
route.handler(match, url.searchParams, parsedBody, req.headers, res)
|
.then(() => route.handler(match, url.searchParams, parsedBody, req.headers, res))
|
||||||
} catch (err) {
|
.catch((err) => send(res, 500, { message: err?.message || 'fakeAppwrite: interner Fehler' }))
|
||||||
send(res, 500, { message: err?.message || 'fakeAppwrite: interner Fehler' })
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -245,10 +263,24 @@ export async function startFakeAppwrite(options = {}) {
|
|||||||
pendingFailures.push({ method, pathIncludes, status })
|
pendingFailures.push({ method, pathIncludes, status })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** fn(collection, data) laeuft vor jeder Anlage (z. B. um einen gleichzeitigen Schreiber nachzustellen). */
|
||||||
|
function setBeforeCreate(fn) {
|
||||||
|
beforeCreate = fn || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dokument direkt in den Speicher legen (ohne HTTP), z. B. aus einem setBeforeCreate-Haken. */
|
||||||
|
function insert(col, doc) {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const id = doc.$id || randomUUID()
|
||||||
|
const full = { $createdAt: now, $updatedAt: now, ...doc, $id: id }
|
||||||
|
collectionOf(col).set(id, full)
|
||||||
|
return full
|
||||||
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
for (const socket of sockets) socket.destroy()
|
for (const socket of sockets) socket.destroy()
|
||||||
return new Promise((resolve) => server.close(() => resolve()))
|
return new Promise((resolve) => server.close(() => resolve()))
|
||||||
}
|
}
|
||||||
|
|
||||||
return { endpoint, all, get, calls, failNext, close }
|
return { endpoint, all, get, calls, failNext, setBeforeCreate, insert, close }
|
||||||
}
|
}
|
||||||
@@ -63,6 +63,7 @@ export async function startFakeGitea(options = {}) {
|
|||||||
let transferDelayMs = 0
|
let transferDelayMs = 0
|
||||||
let responseDelayMs = 0
|
let responseDelayMs = 0
|
||||||
let transferNoopNext = false
|
let transferNoopNext = false
|
||||||
|
let onGenerate = null
|
||||||
const pendingFailures = []
|
const pendingFailures = []
|
||||||
|
|
||||||
let nextRepoId = 1
|
let nextRepoId = 1
|
||||||
@@ -229,6 +230,30 @@ export async function startFakeGitea(options = {}) {
|
|||||||
send(res, 200, serializeRepo(found.record))
|
send(res, 200, serializeRepo(found.record))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repo aus Vorlage erzeugen wie Gitea (`POST /repos/{tplOwner}/{tpl}/generate`): 409, wenn es das
|
||||||
|
* Ziel schon gibt. `setOnGenerate(fn)` ruft fn(repo) nach dem Anlegen auf, BEVOR geantwortet
|
||||||
|
* wird - so laesst sich der sofort feuernde Webhook `repository/created` nachstellen.
|
||||||
|
*/
|
||||||
|
async function handleGenerate(match, searchParams, body, headers, res) {
|
||||||
|
const owner = body?.owner || ''
|
||||||
|
const name = body?.name || ''
|
||||||
|
if (!owner || !name) return send(res, 422, { message: 'owner und name fehlen' })
|
||||||
|
if (repos.has(keyFrom(owner, name))) {
|
||||||
|
return send(res, 409, { message: 'The repository with the same name already exists.' })
|
||||||
|
}
|
||||||
|
const record = addRepoInternal({ full_name: `${owner}/${name}`, description: body.description || '', private: body.private })
|
||||||
|
if (onGenerate) await onGenerate(serializeRepo(record))
|
||||||
|
send(res, 201, serializeRepo(record))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetBranches(match, searchParams, body, headers, res) {
|
||||||
|
const found = findRepoOrRedirect(match[1], match[2])
|
||||||
|
if (!found) return send(res, 404, { message: 'Not Found' })
|
||||||
|
if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo, '/branches')
|
||||||
|
send(res, 200, [{ name: found.record.default_branch || 'main' }])
|
||||||
|
}
|
||||||
|
|
||||||
function handlePatchRepo(match, searchParams, body, headers, res) {
|
function handlePatchRepo(match, searchParams, body, headers, res) {
|
||||||
const found = findRepoOrRedirect(match[1], match[2])
|
const found = findRepoOrRedirect(match[1], match[2])
|
||||||
if (!found) return send(res, 404, { message: 'Not Found' })
|
if (!found) return send(res, 404, { message: 'Not Found' })
|
||||||
@@ -419,6 +444,8 @@ export async function startFakeGitea(options = {}) {
|
|||||||
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/hooks$/, handler: handleGetRepoHooks },
|
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/hooks$/, handler: handleGetRepoHooks },
|
||||||
{ method: 'DELETE', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/hooks\/([^/]+)$/, handler: handleDeleteRepoHook },
|
{ method: 'DELETE', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/hooks\/([^/]+)$/, handler: handleDeleteRepoHook },
|
||||||
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/activities\/feeds$/, handler: handleFeeds },
|
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/activities\/feeds$/, handler: handleFeeds },
|
||||||
|
{ method: 'POST', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/generate$/, handler: handleGenerate },
|
||||||
|
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/branches$/, handler: handleGetBranches },
|
||||||
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleGetContents },
|
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleGetContents },
|
||||||
{ method: 'POST', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleWriteContents },
|
{ method: 'POST', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleWriteContents },
|
||||||
{ method: 'PUT', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleWriteContents },
|
{ method: 'PUT', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleWriteContents },
|
||||||
@@ -542,6 +569,10 @@ export async function startFakeGitea(options = {}) {
|
|||||||
pendingFailures.push({ method, pathIncludes, status })
|
pendingFailures.push({ method, pathIncludes, status })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setOnGenerate(fn) {
|
||||||
|
onGenerate = fn || null
|
||||||
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
for (const socket of sockets) socket.destroy()
|
for (const socket of sockets) socket.destroy()
|
||||||
return new Promise((resolve) => server.close(() => resolve()))
|
return new Promise((resolve) => server.close(() => resolve()))
|
||||||
@@ -549,6 +580,7 @@ export async function startFakeGitea(options = {}) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
|
setOnGenerate,
|
||||||
calls,
|
calls,
|
||||||
repo,
|
repo,
|
||||||
addRepo,
|
addRepo,
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
// Ablauf Projekt anlegen / archivieren / Import im Ticketsystem (Fehler aus dem Live-Durchlauf 2026-10-04):
|
||||||
|
// - "Neues Projekt" ohne Subdomain schrieb subdomain '' -> Unique-Index -> "Document with the requested ID already exists"
|
||||||
|
// - Wettlauf mit dem Webhook repository/created beim Anlegen
|
||||||
|
// - Reaktivieren liess status 'archived' stehen
|
||||||
|
// - archivierte Repos kamen nie ins Ticketsystem, Archiv-Stand aus Gitea nie an
|
||||||
|
// - "Gitea-Repos synchronisieren" ueberschrieb Projektnamen
|
||||||
|
import { test, before, after } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { startFakeGitea } from './helpers/fakeGitea.js'
|
||||||
|
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
|
||||||
|
|
||||||
|
let gitea
|
||||||
|
let aw
|
||||||
|
let config
|
||||||
|
let svc
|
||||||
|
let handleRepositoryCreated
|
||||||
|
let upsertWebsiteProjectByRepo
|
||||||
|
|
||||||
|
const docs = () => aw.all('websiteProjects')
|
||||||
|
const docOf = (name) => docs().find((d) => String(d.repoFullName).toLowerCase() === name.toLowerCase())
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
gitea = await startFakeGitea({
|
||||||
|
repos: [
|
||||||
|
{ full_name: 'knso/webklar-preview-template' },
|
||||||
|
{ full_name: 'knso/gradient-component' },
|
||||||
|
{ full_name: 'WEBklar/ohne-site' },
|
||||||
|
{ full_name: 'WEBklar/mit-site' },
|
||||||
|
{ full_name: 'WEBklar/D-ner-Nation', description: 'D-ner-Nation' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
aw = await startFakeAppwrite({
|
||||||
|
unique: { websiteProjects: ['repoFullName', 'subdomain'] },
|
||||||
|
seed: {
|
||||||
|
websiteProjects: [
|
||||||
|
// wie live: ein altes Projekt traegt subdomain '' (danach scheiterte jedes weitere ohne Website)
|
||||||
|
{ repoFullName: 'knso/gradient-component', projectName: 'gradient component', subdomain: '' },
|
||||||
|
{ $id: 'p_ohne', repoFullName: 'WEBklar/ohne-site', projectName: 'Ohne Site', status: 'created' },
|
||||||
|
{ $id: 'p_mit', repoFullName: 'WEBklar/mit-site', projectName: 'Mit Site', subdomain: 'mit-site', status: 'deployed' },
|
||||||
|
{ $id: 'p_doener', repoFullName: 'WEBklar/D-ner-Nation', projectName: 'Döner Nation', subdomain: 'doener-nation' },
|
||||||
|
],
|
||||||
|
customers: [{ $id: 'c1', name: 'Kunde' }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.assign(process.env, {
|
||||||
|
GITEA_BASE_URL: gitea.baseUrl,
|
||||||
|
GITEA_API_TOKEN: 'test-token',
|
||||||
|
GITEA_DEFAULT_OWNER: 'WEBklar',
|
||||||
|
GITEA_TEMPLATE_OWNER: 'knso',
|
||||||
|
GITEA_TEMPLATE_REPO: 'webklar-preview-template',
|
||||||
|
APPWRITE_ENDPOINT: aw.endpoint,
|
||||||
|
APPWRITE_API_KEY: 'test-key',
|
||||||
|
PREVIEW_DEPLOY_ROOT: '',
|
||||||
|
TRAEFIK_DYNAMIC_DIR: '',
|
||||||
|
})
|
||||||
|
;({ config } = await import('../server/config.js'))
|
||||||
|
svc = await import('../server/services/giteaAdmin.js')
|
||||||
|
;({ handleRepositoryCreated } = await import('../server/services/repoAssignJob.js'))
|
||||||
|
;({ upsertWebsiteProjectByRepo } = await import('../server/services/appwriteAdmin.js'))
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await gitea.close()
|
||||||
|
await aw.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Neues Projekt ohne Subdomain: kein subdomain-Konflikt mehr, Kunde bleibt dran', async () => {
|
||||||
|
const r = await svc.createProjectFromTemplate({ displayName: 'Zweites ohne Website', customerId: 'c1' })
|
||||||
|
assert.equal(r.repoFullName, 'WEBklar/zweites-ohne-website')
|
||||||
|
assert.equal(r.projectType, 'repo')
|
||||||
|
const d = docOf('WEBklar/zweites-ohne-website')
|
||||||
|
assert.equal(d.subdomain, null)
|
||||||
|
assert.equal(d.customerId, 'c1')
|
||||||
|
assert.equal(d.projectName, 'Zweites ohne Website')
|
||||||
|
// Gitea-Beschreibung = Anzeigename (Abgleich/Webhook lesen den Namen von dort)
|
||||||
|
const gen = gitea.calls.find((c) => c.method === 'POST' && c.path.endsWith('/generate'))
|
||||||
|
assert.equal(gen.body.description, 'Zweites ohne Website')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Neues Projekt: Webhook repository/created legt das Dokument zuerst an -> eines, mit Kunde und Ticket', async () => {
|
||||||
|
gitea.setOnGenerate(async (repo) => {
|
||||||
|
await handleRepositoryCreated({ action: 'created', repository: repo, sender: { login: 'webklar-system' } }, {
|
||||||
|
sendMail: async () => ({ sent: false }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const r = await svc.createProjectFromTemplate({ displayName: 'Webhook zuerst', customerId: 'c1', ticketId: 't1' })
|
||||||
|
const all = docs().filter((d) => d.repoFullName === r.repoFullName)
|
||||||
|
assert.equal(all.length, 1)
|
||||||
|
assert.equal(all[0].customerId, 'c1')
|
||||||
|
assert.equal(all[0].ticketId, 't1')
|
||||||
|
assert.equal(all[0].projectType, 'repo')
|
||||||
|
assert.equal(all[0].createdByGitea, 'webklar-system')
|
||||||
|
} finally {
|
||||||
|
gitea.setOnGenerate(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Wettlauf zwischen Suche und Anlage: 409 wird zum Update statt zur Fehlermeldung', async () => {
|
||||||
|
let raced = false
|
||||||
|
aw.setBeforeCreate((col, data) => {
|
||||||
|
if (col !== 'websiteProjects' || raced || data.repoFullName !== 'WEBklar/wettlauf') return
|
||||||
|
raced = true
|
||||||
|
aw.insert('websiteProjects', { repoFullName: 'WEBklar/wettlauf', projectName: 'wettlauf', status: 'synced' })
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const d = await upsertWebsiteProjectByRepo('WEBklar/wettlauf', { repoFullName: 'WEBklar/wettlauf', customerId: 'c1', projectName: 'Wettlauf' })
|
||||||
|
assert.ok(raced)
|
||||||
|
assert.equal(d.customerId, 'c1')
|
||||||
|
assert.equal(docs().filter((x) => x.repoFullName === 'WEBklar/wettlauf').length, 1)
|
||||||
|
} finally {
|
||||||
|
aw.setBeforeCreate(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Neues Projekt mit Art (Ticket): kind gesetzt; unbekannte Art -> 400 ohne Repo', async () => {
|
||||||
|
await svc.createProjectFromTemplate({ displayName: 'Mit Art', customerId: 'c1', ticketId: 't2', kind: 'website', staffName: 'Kenso' })
|
||||||
|
const d = docOf('WEBklar/mit-art')
|
||||||
|
assert.deepEqual([d.kind, d.forCustomer, d.kindSetBy, d.previewMode], ['website', true, 'Kenso', 'auto'])
|
||||||
|
assert.ok(d.kindSetAt)
|
||||||
|
|
||||||
|
const before = gitea.calls.filter((c) => c.path.endsWith('/generate')).length
|
||||||
|
await assert.rejects(svc.createProjectFromTemplate({ displayName: 'Falsch', kind: 'quatsch' }), (err) => err.status === 400)
|
||||||
|
assert.equal(gitea.calls.filter((c) => c.path.endsWith('/generate')).length, before)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Typ "Internes Projekt" schreibt null statt leerer Subdomain', async () => {
|
||||||
|
const r = await svc.updateWebsiteProject('p_mit', { projectType: 'project' })
|
||||||
|
assert.equal(r.subdomain, '')
|
||||||
|
assert.equal(aw.get('websiteProjects', 'p_mit').subdomain, null)
|
||||||
|
// fuer die folgenden Tests wieder als Website
|
||||||
|
await svc.updateWebsiteProject('p_mit', { projectType: 'preview', subdomain: 'mit-site' })
|
||||||
|
assert.equal(aw.get('websiteProjects', 'p_mit').subdomain, 'mit-site')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Archivieren und Reaktivieren ohne Website: status geht zurueck auf synced', async () => {
|
||||||
|
await svc.archiveRepo('p_ohne', true)
|
||||||
|
assert.deepEqual([aw.get('websiteProjects', 'p_ohne').archived, aw.get('websiteProjects', 'p_ohne').status], [true, 'archived'])
|
||||||
|
assert.equal(gitea.repo('WEBklar/ohne-site').archived, true)
|
||||||
|
|
||||||
|
await svc.archiveRepo('p_ohne', false)
|
||||||
|
assert.deepEqual([aw.get('websiteProjects', 'p_ohne').archived, aw.get('websiteProjects', 'p_ohne').status], [false, 'synced'])
|
||||||
|
assert.equal(gitea.repo('WEBklar/ohne-site').archived, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Reaktivieren mit Website: status nicht mehr archived (pending bis der Deploy meldet)', async () => {
|
||||||
|
await svc.archiveRepo('p_mit', true)
|
||||||
|
assert.equal(aw.get('websiteProjects', 'p_mit').status, 'archived')
|
||||||
|
await svc.archiveRepo('p_mit', false)
|
||||||
|
const d = aw.get('websiteProjects', 'p_mit')
|
||||||
|
assert.equal(d.archived, false)
|
||||||
|
assert.equal(d.status, 'pending') // hier ohne PREVIEW_DEPLOY_ROOT: kein Deploy, also nicht 'deployed'
|
||||||
|
// Status vor dem Redeploy geschrieben, nicht danach (der Runner setzt spaeter selbst 'deployed')
|
||||||
|
const patches = aw.calls.filter((c) => c.method === 'PATCH' && c.path.endsWith('/p_mit'))
|
||||||
|
assert.equal(patches.at(-1).body.data.status, 'pending')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Abgleich ueberschreibt den Projektnamen nicht mehr', async () => {
|
||||||
|
await svc.syncReposToAppwrite()
|
||||||
|
assert.equal(aw.get('websiteProjects', 'p_doener').projectName, 'Döner Nation')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Import: archivierte Repos kommen als archiviert an (ohne Mail-Liste), Gitea-Archivstand wird gespiegelt, ein Fehler bricht nicht ab', async () => {
|
||||||
|
const g = await startFakeGitea({
|
||||||
|
repos: [
|
||||||
|
{ full_name: 'knso/neu-archiviert', archived: true, description: 'Alt' },
|
||||||
|
{ full_name: 'knso/kaputt' },
|
||||||
|
{ full_name: 'knso/neu-aktiv' },
|
||||||
|
{ full_name: 'knso/in-gitea-archiviert', archived: true },
|
||||||
|
{ full_name: 'knso/in-gitea-reaktiviert' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const a = await startFakeAppwrite({
|
||||||
|
seed: {
|
||||||
|
websiteProjects: [
|
||||||
|
{ $id: 'x1', repoFullName: 'knso/in-gitea-archiviert', projectName: 'A', status: 'synced', archived: false },
|
||||||
|
{ $id: 'x2', repoFullName: 'knso/in-gitea-reaktiviert', projectName: 'B', status: 'archived', archived: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const original = { gitea: config.gitea.baseUrl, appwrite: config.appwrite.endpoint }
|
||||||
|
config.gitea.baseUrl = g.baseUrl
|
||||||
|
config.appwrite.endpoint = a.endpoint
|
||||||
|
const originalError = console.error
|
||||||
|
console.error = () => {}
|
||||||
|
try {
|
||||||
|
// erste Anlage (knso/kaputt kommt nach dem archivierten) schlaegt fehl
|
||||||
|
a.failNext({ method: 'POST', pathIncludes: '/collections/websiteProjects/documents', status: 400 })
|
||||||
|
const r = await svc.importNewRepos()
|
||||||
|
const byName = (n) => a.all('websiteProjects').find((d) => d.repoFullName === n)
|
||||||
|
// der erzwungene Fehler trifft die erste Anlage: das archivierte neue Repo
|
||||||
|
assert.deepEqual(r.failed, ['knso/neu-archiviert'])
|
||||||
|
assert.ok(byName('knso/kaputt'))
|
||||||
|
assert.ok(byName('knso/neu-aktiv'))
|
||||||
|
assert.deepEqual(r.createdRepos.map((x) => x.repo.full_name).sort(), ['knso/kaputt', 'knso/neu-aktiv'])
|
||||||
|
assert.equal(r.archiveSynced, 2)
|
||||||
|
assert.deepEqual([a.get('websiteProjects', 'x1').archived, a.get('websiteProjects', 'x1').status], [true, 'archived'])
|
||||||
|
assert.deepEqual([a.get('websiteProjects', 'x2').archived, a.get('websiteProjects', 'x2').status], [false, 'synced'])
|
||||||
|
|
||||||
|
// naechster Lauf holt das archivierte Repo nach - als archiviert, ohne Mail-Liste
|
||||||
|
const r2 = await svc.importNewRepos()
|
||||||
|
assert.equal(r2.archivedImported, 1)
|
||||||
|
assert.deepEqual(r2.createdRepos, [])
|
||||||
|
const arch = byName('knso/neu-archiviert')
|
||||||
|
assert.deepEqual([arch.archived, arch.status, arch.projectName], [true, 'archived', 'Alt'])
|
||||||
|
// dritter Lauf: nichts mehr zu tun
|
||||||
|
const r3 = await svc.importNewRepos()
|
||||||
|
assert.deepEqual([r3.created, r3.archivedImported, r3.archiveSynced, r3.failed.length], [0, 0, 0, 0])
|
||||||
|
} finally {
|
||||||
|
config.gitea.baseUrl = original.gitea
|
||||||
|
config.appwrite.endpoint = original.appwrite
|
||||||
|
console.error = originalError
|
||||||
|
await g.close()
|
||||||
|
await a.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Vorlage fuer "Neues Projekt" zieht beim Zuweisen nie um', async () => {
|
||||||
|
const { isInternalRepo } = await import('../server/services/repoMove.js')
|
||||||
|
assert.equal(await isInternalRepo('knso/webklar-preview-template'), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('CORS erlaubt PUT (Preview-Variablen speichern)', async () => {
|
||||||
|
const { corsMiddleware } = await import('../server/middleware/cors.js')
|
||||||
|
const headers = {}
|
||||||
|
let status = 0
|
||||||
|
const req = { method: 'OPTIONS', get: (h) => (h === 'Origin' ? 'https://ticket.webklar.com' : undefined) }
|
||||||
|
const res = { setHeader: (k, v) => { headers[k] = v }, sendStatus: (s) => { status = s } }
|
||||||
|
corsMiddleware(['https://ticket.webklar.com'])(req, res, () => {})
|
||||||
|
assert.equal(status, 204)
|
||||||
|
assert.match(headers['Access-Control-Allow-Methods'], /\bPUT\b/)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user