Files
Webklar-Kundenbereich/server/services/repoAssignJob.js
T

183 lines
8.0 KiB
JavaScript

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 || p.archived || handled.has(p.$id)) continue
// Vom System-Konto angelegt (WOMS "Neues Projekt"): kein Mitarbeiter, den man erinnern
// koennte - sonst alle 10 Minuten Gitea-Abfrage und Log "keinem Mitarbeiter zugeordnet".
if (String(p.createdByGitea).toLowerCase() === String(config.gitea.systemUser || '').toLowerCase()) 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
}