import { config } from '../config.js' import { listDocuments, updateDocument, getDocument, deleteDocument, upsertWebsiteProjectByRepo, Query } from './appwriteAdmin.js' import { importNewRepos } from './giteaAdmin.js' import { giteaFetch } from './giteaClient.js' import { withLocks } from './keyedLock.js' import { repoLockKey, isUnlinkedProject } from './repoMove.js' import { invalidateProjectCache } from './projectInsights.js' import { shouldSendAssignMail, dueReminder, sendRepoAssignMail } from './repoAssignMail.js' /** * Erkennung neuer Repos (Task 7): der Webhook `repository/created` und der 10-Minuten-Job laufen * ueber dieselbe Repo-Sperre (repoLockKey) und legen das Projekt-Dokument hoechstens einmal an. * Die Mail geht ausserhalb dieser Sperre raus, unter einer eigenen Sperre je Projekt, die vor dem * Senden den Stand frisch liest - so kann kein zweiter Pfad dieselbe Mail doppelt verschicken (R7). */ // Ein vom Job angelegtes Org-Dokument, das der Webhook kurz danach meldet, gilt noch als "neu". const RACE_WINDOW_MS = 15 * 60 * 1000 export const isOrgOwner = (repo) => String(repo?.owner?.login || '').toLowerCase() === String(config.gitea.org || '').toLowerCase() const collection = () => config.collections.websiteProjects /** * Mail senden, falls nach frischem Stand faellig. `createdNow` bedeutet: dies ist der Pfad, der das * Dokument gerade angelegt hat (oder die Wiederholung einer fehlgeschlagenen ersten Mail). */ async function sendFirstMailIfDue(projectId, repo, giteaLogin, { now, createdNow, sendMail }) { return withLocks(['assignmail:' + projectId], async () => { const fresh = await getDocument(collection(), projectId) if (!shouldSendAssignMail(fresh, repo, { now, createdNow })) return { sent: false, reason: 'not_due' } return sendMail(fresh, { giteaLogin, reminder: false, repoCreatedAt: repo.created_at }) }) } /** * Webhook `repository` / `created`: Dokument anlegen (falls fehlt), Ersteller merken, Mail senden. * Gibt { created, mail } zurueck; `mail` ist das Ergebnis von sendRepoAssignMail oder null. */ export async function handleRepositoryCreated(payload, { now = new Date(), sendMail = sendRepoAssignMail } = {}) { const repo = payload?.repository || {} const fullName = repo.full_name const login = payload?.sender?.login || '' if (!fullName || repo.archived) return { created: false, mail: null } let projectId = '' let created = false let takeOver = false await withLocks( [repoLockKey(fullName)], async () => { const existing = ( await listDocuments(collection(), [Query.equal('repoFullName', fullName), Query.limit(1)]) )[0] if (existing) { projectId = existing.$id // Vom Job Sekunden zuvor angelegtes Org-Repo ohne Ersteller: der Webhook kennt den Ersteller. const young = now.getTime() - new Date(existing.$createdAt || 0).getTime() < RACE_WINDOW_MS if (young && login && !existing.createdByGitea && !existing.kind && !existing.assignMailSentAt && !existing.customerId && !existing.ticketId && !Number(existing.assignMailAttempts || 0)) { await updateDocument(collection(), existing.$id, { createdByGitea: login }) takeOver = true } return } const doc = await upsertWebsiteProjectByRepo(fullName, { projectName: repo.description || repo.name || fullName.split('/').pop(), giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${fullName}`, giteaRepoName: repo.name || fullName.split('/').pop(), repoFullName: fullName, archived: false, status: 'synced', giteaSyncedAt: new Date().toISOString(), createdByGitea: login, }) projectId = doc.$id created = true }, { waitMs: 10000 } ) if (!created && !takeOver) return { created, mail: null } const mail = await sendFirstMailIfDue(projectId, repo, login, { now, createdNow: true, sendMail }) return { created, mail } } /** * Webhook `repository` / `deleted`: unverknuepfte Platzhalter-Dokumente des geloeschten Repos * entfernen. Sonst belegen sie den Namen (Unique-Index ohne Gross-/Kleinschreibung), und ein spaeter * gleich benanntes Repo kann nach dem Zuweisen nicht auf ihn umbenannt werden. Verknuepfte Dokumente * (Ticket, Kunde, Art, Preview) bleiben, ebenso alles, solange Gitea das Repo noch liefert. */ export async function handleRepositoryDeleted(payload) { const fullName = payload?.repository?.full_name if (!fullName) return { removed: 0 } return withLocks( [repoLockKey(fullName)], async () => { try { await giteaFetch(`/repos/${fullName}`) return { removed: 0 } // Repo (oder eine Weiterleitung darauf) existiert noch } catch (err) { if (err.status !== 404) throw err } const docs = await listDocuments(collection(), [Query.equal('repoFullName', fullName), Query.limit(10)]) let removed = 0 for (const doc of docs) { if (!isUnlinkedProject(doc)) { console.warn(`[repoAssign] Repo ${fullName} gelöscht, Projekt ${doc.$id} ist verknüpft und bleibt`) continue } await deleteDocument(collection(), doc.$id) removed += 1 } invalidateProjectCache(fullName) return { removed } }, { waitMs: 10000 } ) } /** Ein Lauf: neue Repos importieren + Mail, Wiederholungen, Erinnerungen. Wirft bei Appwrite-Fehlern nicht. */ export async function runRepoAssignJob({ now = new Date(), sendMail = sendRepoAssignMail, fetchRepo = (fullName) => giteaFetch(`/repos/${fullName}`), } = {}) { const counts = { imported: 0, mails: 0, retries: 0, reminders: 0, error: null } try { const { createdRepos = [] } = await importNewRepos() counts.imported = createdRepos.length const handled = new Set() // in diesem Lauf schon versucht: nicht sofort wiederholen for (const { projectId, repo } of createdRepos) { // Org-Repos: Ersteller unbekannt, der Webhook-Sender deckt sie ab. if (isOrgOwner(repo)) continue const login = repo.owner?.login || '' if (!login) continue // Alte Repos (>= 24 h) bekommen keinen Ersteller: sie werden nie gemailt und nie erinnert. const age = now.getTime() - new Date(repo.created_at || 0).getTime() if (!(age < 24 * 3600_000)) continue handled.add(projectId) await updateDocument(collection(), projectId, { createdByGitea: login }) const r = await sendFirstMailIfDue(projectId, repo, login, { now, createdNow: true, sendMail }) if (r.sent) counts.mails += 1 } const docs = await listDocuments(collection(), [Query.limit(5000)]) for (const p of docs) { if (!p.createdByGitea || p.kind || handled.has(p.$id)) continue // Wiederholung/Nachholen: erste Mail noch nicht raus (0-2 Versuche), Repo noch juenger als 24 h. const attempts = Number(p.assignMailAttempts || 0) if (!p.assignMailSentAt && attempts < 3) { let repo try { repo = await fetchRepo(p.repoFullName) } catch (err) { console.warn(`[repoAssign] Repo ${p.repoFullName} nicht lesbar:`, err.message) continue } const r = await sendFirstMailIfDue(p.$id, repo, p.createdByGitea, { now, createdNow: true, sendMail }) if (r.sent) counts.retries += 1 } else if (dueReminder(p, { now })) { const r = await withLocks(['assignmail:' + p.$id], async () => { const fresh = await getDocument(collection(), p.$id) if (!dueReminder(fresh, { now })) return { sent: false } return sendMail(fresh, { giteaLogin: fresh.createdByGitea, reminder: true }) }) if (r.sent) counts.reminders += 1 } } } catch (err) { counts.error = err.message console.error('[repoAssign] Lauf abgebrochen:', err.message) } return counts }