Ein in Gitea geloeschtes Org-Repo liess sein unverknuepftes Projekt-Dokument liegen. Der Unique-Index auf repoFullName ignoriert Gross-/Kleinschreibung, darum scheiterte beim Zuweisen eines gleich benannten neuen Repos das Umbenennen des Projekts; alle Pushes landeten im Platzhalter ohne Ticket, also ohne Arbeitsblatt und ohne Push-Zeitmail (WEBklar/Schmacko-napf). - repoMove: vor dem Umbenennen ein unverknuepftes fremdes Dokument mit dem Zielnamen entfernen (verknuepfte bleiben unangetastet) - Webhook repository/deleted: unverknuepfte Platzhalter des geloeschten Repos entfernen, nur wenn Gitea das Repo wirklich nicht mehr kennt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
180 lines
7.7 KiB
JavaScript
180 lines
7.7 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 || 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
|
|
}
|