Projektarten: neue Repos erkennen und Zuordnungs-Mail senden
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
de4b6f3a35
commit
1f1ffb5275
7 files changed
+332
-3
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { runRepoAssignJob } from './services/repoAssignJob.js'
|
||||
import express from 'express'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -161,6 +162,14 @@ const server = app.listen(config.port, '0.0.0.0', () => {
|
||||
.then((done) => done.length && console.log(`[funktionen] ${done.length} Funktion(en) freigeschaltet`))
|
||||
.catch((err) => console.warn('[funktionen] Freischalten fehlgeschlagen:', err.message))
|
||||
setInterval(activateFeatures, 5 * 60 * 1000).unref()
|
||||
|
||||
// Neue Gitea-Repos erkennen, Zuordnungs-Mail senden, Erinnerungen/Wiederholungen (alle 10 Minuten)
|
||||
const repoAssign = () =>
|
||||
runRepoAssignJob({ now: new Date() }).catch((err) =>
|
||||
console.warn('[repoAssign] Lauf fehlgeschlagen:', err.message)
|
||||
)
|
||||
setTimeout(repoAssign, 60 * 1000).unref()
|
||||
setInterval(repoAssign, 10 * 60 * 1000).unref()
|
||||
})
|
||||
|
||||
server.on('error', (err) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ensureProjectTicket } from '../../services/ticketAdmin.js'
|
||||
import { invalidateProjectCache } from '../../services/projectInsights.js'
|
||||
import { withLocks } from '../../services/keyedLock.js'
|
||||
import { repoLockKey, currentRepoFullName } from '../../services/repoMove.js'
|
||||
import { handleRepositoryCreated } from '../../services/repoAssignJob.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -45,6 +46,20 @@ router.post('/gitea', async (req, res) => {
|
||||
}
|
||||
|
||||
const payload = req.body || {}
|
||||
|
||||
// Ereignis "repository": nur `created` wird verarbeitet (neues Repo -> Dokument + Zuordnungs-Mail),
|
||||
// alles andere (deleted, transfer, ...) wird mit 200 ignoriert. Der Push-Pfad bleibt unberuehrt.
|
||||
if (req.get('X-Gitea-Event') === 'repository') {
|
||||
if (payload.action !== 'created') return res.json({ ok: true, ignored: true })
|
||||
try {
|
||||
const r = await handleRepositoryCreated(payload)
|
||||
return res.json({ ok: true, created: r.created })
|
||||
} catch (err) {
|
||||
console.error('[webhook] repository/created:', err)
|
||||
return res.status(500).json({ error: err.message || 'Webhook-Verarbeitung fehlgeschlagen' })
|
||||
}
|
||||
}
|
||||
|
||||
const ref = payload.ref || ''
|
||||
const repo = payload.repository || {}
|
||||
const payloadName = repo.full_name || ''
|
||||
|
||||
@@ -68,6 +68,7 @@ export async function importNewRepos() {
|
||||
)
|
||||
|
||||
let created = 0
|
||||
const createdRepos = []
|
||||
for (const repo of repos) {
|
||||
const fullName = repo.full_name
|
||||
if (!fullName || repo.archived) continue
|
||||
@@ -80,7 +81,7 @@ export async function importNewRepos() {
|
||||
])
|
||||
if (recheck[0]) return
|
||||
|
||||
await upsertWebsiteProjectByRepo(fullName, {
|
||||
const doc = await upsertWebsiteProjectByRepo(fullName, {
|
||||
projectName: repo.description || repo.name || fullName.split('/').pop(),
|
||||
giteaRepoUrl: repo.html_url,
|
||||
giteaRepoName: repo.name,
|
||||
@@ -90,10 +91,11 @@ export async function importNewRepos() {
|
||||
giteaSyncedAt: new Date().toISOString(),
|
||||
})
|
||||
created += 1
|
||||
createdRepos.push({ projectId: doc.$id, repo })
|
||||
})
|
||||
}
|
||||
|
||||
return { created }
|
||||
return { created, createdRepos }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { config } from '../config.js'
|
||||
import { listDocuments, updateDocument, getDocument, upsertWebsiteProjectByRepo, Query } from './appwriteAdmin.js'
|
||||
import { importNewRepos } from './giteaAdmin.js'
|
||||
import { giteaFetch } from './giteaClient.js'
|
||||
import { withLocks } from './keyedLock.js'
|
||||
import { repoLockKey } from './repoMove.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 }
|
||||
}
|
||||
|
||||
/** 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
|
||||
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: erste Mail fehlgeschlagen (1-2 Versuche), Repo noch juenger als 24 h.
|
||||
const attempts = Number(p.assignMailAttempts || 0)
|
||||
if (!p.assignMailSentAt && attempts > 0 && 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
|
||||
}
|
||||
Reference in new issue
Block a user