Verwaiste Repo-Platzhalter blockieren keine Zuordnung mehr
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>
This commit is contained in:
1 parent
a64a996291
commit
6718018211
5 files changed
+132
-7
No files matched your search
@@ -16,7 +16,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'
|
||||
import { handleRepositoryCreated, handleRepositoryDeleted } from '../../services/repoAssignJob.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -48,9 +48,18 @@ 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.
|
||||
// Ereignis "repository": `created` (neues Repo -> Dokument + Zuordnungs-Mail) und `deleted`
|
||||
// (Platzhalter-Dokument entfernen), alles andere wird mit 200 ignoriert. Der Push-Pfad bleibt unberuehrt.
|
||||
if (req.get('X-Gitea-Event') === 'repository') {
|
||||
if (payload.action === 'deleted') {
|
||||
try {
|
||||
const r = await handleRepositoryDeleted(payload)
|
||||
return res.json({ ok: true, removed: r.removed })
|
||||
} catch (err) {
|
||||
console.error('[webhook] repository/deleted:', err)
|
||||
return res.status(500).json({ error: err.message || 'Webhook-Verarbeitung fehlgeschlagen' })
|
||||
}
|
||||
}
|
||||
if (payload.action !== 'created') return res.json({ ok: true, ignored: true })
|
||||
try {
|
||||
const r = await handleRepositoryCreated(payload)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { config } from '../config.js'
|
||||
import { listDocuments, updateDocument, getDocument, upsertWebsiteProjectByRepo, Query } from './appwriteAdmin.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 } from './repoMove.js'
|
||||
import { repoLockKey, isUnlinkedProject } from './repoMove.js'
|
||||
import { invalidateProjectCache } from './projectInsights.js'
|
||||
import { shouldSendAssignMail, dueReminder, sendRepoAssignMail } from './repoAssignMail.js'
|
||||
|
||||
/**
|
||||
@@ -84,6 +85,42 @@ export async function handleRepositoryCreated(payload, { now = new Date(), sendM
|
||||
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(),
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from 'node:fs/promises'
|
||||
import { config } from '../config.js'
|
||||
import { giteaFetch, splitRepo } from './giteaClient.js'
|
||||
import { withLocks } from './keyedLock.js'
|
||||
import { listDocuments, updateDocument, Query } from './appwriteAdmin.js'
|
||||
import { listDocuments, updateDocument, deleteDocument, Query } from './appwriteAdmin.js'
|
||||
import { invalidateProjectCache } from './projectInsights.js'
|
||||
import { previewWorkDir } from './previewDeploy.js'
|
||||
|
||||
@@ -101,8 +101,36 @@ function classifyGiteaError(err) {
|
||||
return { moved: null, warnings: [`Umzug nach WEBklar nicht möglich (${err.message}).`] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Projekt-Dokument ohne jede Verknuepfung (kein Ticket, kein Kunde, keine Art, keine Preview):
|
||||
* ein reiner Platzhalter, wie ihn der Webhook `repository/created` oder der Import anlegt.
|
||||
*/
|
||||
export function isUnlinkedProject(project) {
|
||||
return !project?.ticketId && !project?.customerId && !project?.kind && !project?.subdomain
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den Namen `to` fuer `projectId` frei, falls ihn ein anderes, unverknuepftes Dokument belegt -
|
||||
* z. B. das Ueberbleibsel eines in Gitea geloeschten Repos gleichen Namens. Der Unique-Index auf
|
||||
* `repoFullName` ignoriert Gross-/Kleinschreibung; ohne Freigabe scheitert das Umbenennen, und alle
|
||||
* Pushes landen danach im Platzhalter statt im zugewiesenen Projekt (keine Arbeitsblaetter, keine
|
||||
* Zeitmail). Ein verknuepftes fremdes Dokument bleibt unangetastet - dann scheitert das Update wie bisher.
|
||||
*/
|
||||
async function releaseRepoName(projectId, to) {
|
||||
const others = await listDocuments(config.collections.websiteProjects, [
|
||||
Query.equal('repoFullName', to),
|
||||
Query.limit(10),
|
||||
])
|
||||
for (const other of others) {
|
||||
if (other.$id === projectId || !isUnlinkedProject(other)) continue
|
||||
await deleteDocument(config.collections.websiteProjects, other.$id)
|
||||
console.warn(`[repoMove] Verwaistes Projekt-Dokument ${other.$id} (${other.repoFullName}) entfernt, Name frei für ${projectId}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Schritt 6: Projektdokument auf den neuen Repo-Namen nachziehen. */
|
||||
async function updateProjectDocument(project, to) {
|
||||
await releaseRepoName(project.$id, to)
|
||||
const name = splitRepo(to)[1]
|
||||
return updateDocument(config.collections.websiteProjects, project.$id, {
|
||||
repoFullName: to,
|
||||
|
||||
@@ -37,7 +37,11 @@ before(async () => {
|
||||
gitea = await startFakeGitea({ repos: [] })
|
||||
aw = await startFakeAppwrite({
|
||||
seed: {
|
||||
websiteProjects: [],
|
||||
websiteProjects: [
|
||||
{ $id: 'waise', repoFullName: 'WEBklar/schmacko-napf', projectName: 'schmacko-napf', ticketId: '', customerId: '' },
|
||||
{ $id: 'verknuepft', repoFullName: 'WEBklar/mit-ticket', projectName: 'mit-ticket', ticketId: 't-9' },
|
||||
{ $id: 'lebt', repoFullName: 'WEBklar/lebt-noch', projectName: 'lebt-noch' },
|
||||
],
|
||||
employees: [{ $id: 'k', displayName: 'Kenso Grimm', shortcode: 'KNSO', email: 'kenso@webklar.com' }],
|
||||
},
|
||||
})
|
||||
@@ -173,3 +177,19 @@ test('Job: Appwrite-Fehler bricht den Lauf ab, ohne zu werfen', async () => {
|
||||
} finally { console.error = silent }
|
||||
assert.ok(awUrl)
|
||||
})
|
||||
|
||||
test('repository/deleted entfernt das unverknuepfte Platzhalter-Dokument, verknuepfte und lebende bleiben', async () => {
|
||||
gitea.addRepo({ full_name: 'WEBklar/lebt-noch' })
|
||||
const del = (full) => hook('repository', { ...created(full), action: 'deleted', sender: { login: 'ANDJ' } })
|
||||
|
||||
const r = await del('WEBklar/schmacko-napf')
|
||||
assert.equal(r.status, 200)
|
||||
assert.equal(r.body.removed, 1)
|
||||
assert.equal(aw.get('websiteProjects', 'waise'), undefined)
|
||||
|
||||
assert.equal((await del('WEBklar/mit-ticket')).body.removed, 0)
|
||||
assert.ok(aw.get('websiteProjects', 'verknuepft'))
|
||||
|
||||
assert.equal((await del('WEBklar/lebt-noch')).body.removed, 0)
|
||||
assert.ok(aw.get('websiteProjects', 'lebt'))
|
||||
})
|
||||
@@ -45,6 +45,8 @@ before(async () => {
|
||||
{ full_name: 'JUSN/verboten' },
|
||||
{ full_name: 'JUSN/Case-Test' },
|
||||
{ full_name: 'knso/rausgezogen' },
|
||||
{ full_name: 'ANDJ/Schmacko-napf' },
|
||||
{ full_name: 'JUSN/belegt' },
|
||||
],
|
||||
// C1: Hooks kommen (wie im echten Gitea) OHNE Top-Level-`url` an - `url` hier ist nur die
|
||||
// Seed-Abkuerzung, gespeichert/ausgeliefert wird das Ziel ausschliesslich unter `config.url`.
|
||||
@@ -85,6 +87,20 @@ before(async () => {
|
||||
return { $id, repoFullName, projectName: repoFullName.split('/').pop() }
|
||||
})
|
||||
|
||||
// Andrejs Fall (2026-10-01): WEBklar/Schmacko-napf wurde angelegt und wieder geloescht, sein
|
||||
// Platzhalter-Dokument blieb liegen. Das neue, zugewiesene ANDJ/Schmacko-napf muss den Namen bekommen.
|
||||
const linked = { ticketId: 't-1', customerId: 'c-1', kind: 'website' }
|
||||
for (const [repoFullName, extra] of [
|
||||
['ANDJ/Schmacko-napf', linked],
|
||||
['WEBklar/Schmacko-napf', {}],
|
||||
['JUSN/belegt', linked],
|
||||
['WEBklar/belegt', { ticketId: 't-2' }],
|
||||
]) {
|
||||
const $id = randomUUID()
|
||||
idByRepo.set(repoFullName, $id)
|
||||
websiteProjects.push({ $id, repoFullName, projectName: repoFullName.split('/').pop(), ...extra })
|
||||
}
|
||||
|
||||
aw = await startFakeAppwrite({ seed: { websiteProjects } })
|
||||
|
||||
originalCwd = process.cwd()
|
||||
@@ -236,3 +252,18 @@ test('R10-Ein-Zeiler: Transfer meldet Erfolg, aber unveraenderten full_name -> k
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('verwaistes Platzhalter-Dokument mit dem Zielnamen wird entfernt, das zugewiesene Projekt uebernimmt den Namen', async () => {
|
||||
const orphanId = idByRepo.get('WEBklar/Schmacko-napf')
|
||||
const r = await moveRepoToOrg(doc('ANDJ/Schmacko-napf'))
|
||||
assert.deepEqual(r, { moved: { from: 'ANDJ/Schmacko-napf', to: 'WEBklar/Schmacko-napf' }, warnings: [] })
|
||||
assert.equal(aw.get('websiteProjects', orphanId), undefined)
|
||||
const d = doc('ANDJ/Schmacko-napf')
|
||||
assert.equal(d.repoFullName, 'WEBklar/Schmacko-napf')
|
||||
assert.equal(d.ticketId, 't-1')
|
||||
})
|
||||
|
||||
test('verknuepftes fremdes Dokument mit dem Zielnamen bleibt unangetastet', async () => {
|
||||
await moveRepoToOrg(doc('JUSN/belegt'))
|
||||
assert.equal(doc('WEBklar/belegt').ticketId, 't-2')
|
||||
})
|
||||
Reference in new issue
Block a user