184 lines
9.1 KiB
JavaScript
184 lines
9.1 KiB
JavaScript
import { config } from '../config.js'
|
||
import { moveRepoToOrg, WARN } from './repoMove.js'
|
||
import { splitRepo } from './giteaClient.js'
|
||
import { listRecentPushes } from './pushHistory.js'
|
||
import { createGitPushWorksheet, isSystemPusher } from './gitWorksheet.js'
|
||
import { withLocks } from './keyedLock.js'
|
||
import { ensureProjectTicket } from './ticketAdmin.js'
|
||
import { getDocument, updateDocument } from './appwriteAdmin.js'
|
||
|
||
/**
|
||
* Warnungen dieses Moduls, woertlich (Controller-Ruling R14, Fix-Runde 1) - Anfuehrungszeichen
|
||
* wie die bestehenden Warnungen in repoMove.js (‚...‘ mit U+201A/U+2018).
|
||
*/
|
||
const WARN_BACKFILL_FAILED = (reason) =>
|
||
`Pushes konnten nicht nachgetragen werden (${reason}). Bitte später ‚Pushes nachtragen‘ klicken.`
|
||
const WARN_RECONCILE_FAILED = (reason) =>
|
||
`Zuweisung gespeichert, aber Umzug und Nachtragen sind fehlgeschlagen (${reason}). Bitte später ‚Pushes nachtragen‘ klicken.`
|
||
|
||
/**
|
||
* Commit-Nachrichten (erste Zeile), die frueher das System mit Kensos Schluessel geschrieben hat
|
||
* (Preview-Config, automatisch erstellte Preview-Seite) - siehe Plan, Abschnitt "Global
|
||
* Constraints". Beim Nachtragen zaehlt ein Push nicht, wenn die erste Zeile JEDER seiner
|
||
* Commit-Nachrichten hier steht - unabhaengig vom Pusher, denn diese alten Commits tragen oft
|
||
* "knso" als Pusher, nicht das heutige System-Konto (siehe `isSystemPusher`, das dafuer allein
|
||
* NICHT greift).
|
||
*/
|
||
export const SYSTEM_COMMIT_MESSAGES = ['Update preview config from ticket system', 'Automatisch erstellte Preview-Seite']
|
||
|
||
function firstLine(message) {
|
||
return String(message || '').split('\n')[0]
|
||
}
|
||
|
||
/** true, wenn jede Commit-Nachricht des Pushes (erste Zeile) ein alter System-Commit ist. */
|
||
function isOldSystemCommitPush(push) {
|
||
const commits = push.commits || []
|
||
if (commits.length === 0) return false
|
||
return commits.every((commit) => SYSTEM_COMMIT_MESSAGES.includes(firstLine(commit.message)))
|
||
}
|
||
|
||
/**
|
||
* Traegt die Pushes der letzten `GITEA_BACKFILL_DAYS` Tage eines Repos als Git-Arbeitsblaetter
|
||
* nach (fuer neu zugewiesene Projekte, deren bisherige Pushes noch nicht abgerechnet sind).
|
||
* Ueberspringt Systempushes: das aktuelle System-Konto (`isSystemPusher`) und alte, mit Kensos
|
||
* Schluessel gemachte System-Commits (`SYSTEM_COMMIT_MESSAGES`). Legt bei Bedarf automatisch ein
|
||
* Ticket an - aber erst, nachdem feststeht, dass tatsaechlich etwas nachzutragen ist, damit ein
|
||
* Projekt ohne (relevante) Pushes kein Ticket aufgedraengt bekommt. Wirft nie wegen eines
|
||
* Gitea-Problems (siehe `reconcileProject`, das sich - wie `moveRepoToOrg` - darauf verlaesst,
|
||
* dass eine Zuweisung in jedem Fall bestehen bleibt).
|
||
*/
|
||
export async function backfillPushes(project, { now = new Date() } = {}) {
|
||
let pushes
|
||
try {
|
||
pushes = await listRecentPushes(project.repoFullName, { now })
|
||
} catch (err) {
|
||
return { backfilled: 0, warnings: [WARN.giteaDown(err.message)], ticketId: project.ticketId || '' }
|
||
}
|
||
|
||
const relevant = pushes.filter((push) => !isSystemPusher(push.pusher) && !isOldSystemCommitPush(push))
|
||
if (relevant.length === 0) return { backfilled: 0, warnings: [], ticketId: project.ticketId || '' }
|
||
|
||
// `ensureProjectTicket` und die Arbeitsblatt-Schleife sind hier bewusst in EINEM try/catch
|
||
// (Controller-Ruling R14): schlaegt eine der beiden Appwrite-Schreiboperationen fehl (z. B.
|
||
// Ausfall waehrend POST workorders/worksheets), bleibt die Zuweisung trotzdem bestehen - siehe
|
||
// `assignProject`, das sich darauf verlaesst, dass `backfillPushes` nie wirft.
|
||
let ticketId = project.ticketId || ''
|
||
let backfilled = 0
|
||
try {
|
||
ticketId = ticketId || (await ensureProjectTicket(project, { repoFullName: project.repoFullName }))
|
||
if (!ticketId) return { backfilled: 0, warnings: [], ticketId: '' }
|
||
|
||
// Aelteste zuerst (siehe `listRecentPushes`), damit die Arbeitsblaetter in derselben
|
||
// Reihenfolge entstehen, in der die Pushes tatsaechlich passiert sind.
|
||
for (const push of relevant) {
|
||
const result = await createGitPushWorksheet({ ticketId, repoFullName: project.repoFullName, ...push })
|
||
if (result?.created) backfilled += 1
|
||
}
|
||
return { backfilled, warnings: [], ticketId }
|
||
} catch (err) {
|
||
return { backfilled, warnings: [WARN_BACKFILL_FAILED(err.message)], ticketId }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Kern von `reconcileProject`, OHNE selbst die Sperre zu nehmen - fuer `assignProject`, das
|
||
* seine eigene Lese-Pruef-Schreib-Folge und diesen Kern unter DERSELBEN Sperre laufen laesst
|
||
* (Controller-Ruling R15), statt die Sperre zweimal zu nehmen. `reconcileProject` (unten) bleibt
|
||
* der oeffentliche, selbst sperrende Einstieg (z. B. fuer die Migration, Task 9).
|
||
*
|
||
* Liest das Projekt, verschiebt sein Repo bei Bedarf nach `GITEA_ORG` und traegt danach dessen
|
||
* Pushes nach. Wirft nur noch, wenn schon das ERSTE Lesen des Projekts fehlschlaegt (dann gibt es
|
||
* nichts Sinnvolles zu verschieben/nachzutragen) - `assignProject` faengt das ab (R14).
|
||
*/
|
||
async function reconcileProjectLocked(projectId, { now } = {}) {
|
||
let project = await getDocument(config.collections.websiteProjects, projectId)
|
||
const { moved, warnings: moveWarnings } = await moveRepoToOrg(project)
|
||
|
||
try {
|
||
// Immer neu lesen, nicht nur wenn `moved` gesetzt ist: der Selbstheilungs-Zweig in
|
||
// `moveRepoToOrg` (von Hand verschobenes/umbenanntes Repo) kann das Dokument umbenennen und
|
||
// dabei trotzdem `moved: null` liefern (Controller-Ruling R13).
|
||
project = await getDocument(config.collections.websiteProjects, projectId)
|
||
} catch {
|
||
// R14: Schlaegt das Nachlesen fehl, mit dem VOR dem Umzug gelesenen Dokument weitermachen -
|
||
// ist tatsaechlich etwas verschoben worden, von Hand dessen `repoFullName`/`giteaRepoUrl`/
|
||
// `giteaRepoName` nachziehen (siehe `updateProjectDocument` in repoMove.js). Keine zusaetzliche
|
||
// Warnung - `moveWarnings` deckt einen etwaigen Gitea-Ausfall bereits ab.
|
||
if (moved) {
|
||
project = {
|
||
...project,
|
||
repoFullName: moved.to,
|
||
giteaRepoUrl: `${config.gitea.baseUrl}/${moved.to}`,
|
||
giteaRepoName: splitRepo(moved.to)[1],
|
||
}
|
||
}
|
||
}
|
||
|
||
const { backfilled, warnings: backfillWarnings, ticketId } = await backfillPushes(project, { now })
|
||
// R15: die Antwort soll die (evtl. gerade erst automatisch angelegte) ticketId zeigen, auch
|
||
// wenn `project` oben vor dem Anlegen gelesen wurde.
|
||
if (ticketId && project.ticketId !== ticketId) project = { ...project, ticketId }
|
||
|
||
return { project, moved, backfilled, warnings: [...moveWarnings, ...backfillWarnings] }
|
||
}
|
||
|
||
/**
|
||
* Oeffentlicher, selbst sperrender Einstieg fuer den Kern oben - damit ein Doppelklick auf
|
||
* "Zuweisen" (oder ein gleichzeitiger Aufruf ueber die Migration, Task 9) nicht zweimal umzieht
|
||
* oder doppelte Arbeitsblaetter erzeugt (Review Focus 3, Plan).
|
||
*/
|
||
export async function reconcileProject(projectId, { now } = {}) {
|
||
return withLocks(['project:' + projectId], () => reconcileProjectLocked(projectId, { now }))
|
||
}
|
||
|
||
/**
|
||
* Ticket-Id fuer die Zuweisung (Controller-Ruling R15): eine mitgegebene `ticketId` gewinnt immer.
|
||
* Ist sie leer UND bleibt die Kunden-Id gleich, bleibt die gespeicherte `ticketId` erhalten - ein
|
||
* Entfernen laeuft ueber "Zuweisung aufheben", nicht ueber diesen Endpunkt (sonst loescht ein
|
||
* zweiter Klick ohne `ticketId` ein zwischenzeitlich automatisch angelegtes Ticket, Review I3).
|
||
* Aendert sich die Kunden-Id und wird keine `ticketId` mitgegeben, wird sie geleert.
|
||
*/
|
||
function resolveTicketId(payloadTicketId, customerId, existing) {
|
||
if (payloadTicketId) return payloadTicketId
|
||
if (customerId === existing.customerId) return existing.ticketId || ''
|
||
return ''
|
||
}
|
||
|
||
/**
|
||
* Weist ein Projekt einem Kunden/Ticket zu und stoesst danach Umzug + Nachtragen an. Laeuft
|
||
* komplett unter der Projekt-Sperre (Controller-Ruling R15): Lesen (fuer die 404-Pruefung und die
|
||
* `ticketId`-Aufloesung), Schreiben der Zuweisung und der Reconcile-Kern teilen sich dieselbe
|
||
* Sperre, statt sie zweimal zu nehmen - ein zweiter, gleichzeitiger Aufruf sieht daher nie einen
|
||
* veralteten Stand. Ein unbekanntes Projekt wirft weiterhin (`.status = 404`) BEVOR irgendetwas
|
||
* geschrieben wird. Schlaegt danach der Reconcile-Kern (Umzug/Nachtragen) mit etwas anderem als
|
||
* einem 404 fehl, bleibt die Zuweisung trotzdem gespeichert - Ruling R14 verlangt eine Warnung
|
||
* statt eines Abbruchs, denn Spec 5.2 verlangt genau das.
|
||
*/
|
||
export async function assignProject(projectId, { customerId = '', ticketId = '' }, { now } = {}) {
|
||
return withLocks(['project:' + projectId], async () => {
|
||
// 404 fuer ein unbekanntes Projekt, bevor ueberhaupt geschrieben wird.
|
||
const existing = await getDocument(config.collections.websiteProjects, projectId)
|
||
|
||
const nextTicketId = resolveTicketId(ticketId, customerId, existing)
|
||
const saved = await updateDocument(config.collections.websiteProjects, projectId, {
|
||
customerId,
|
||
ticketId: nextTicketId,
|
||
updatedAt: new Date().toISOString(),
|
||
})
|
||
|
||
try {
|
||
const result = await reconcileProjectLocked(projectId, { now })
|
||
return { ok: true, ...result }
|
||
} catch (err) {
|
||
if (err.status === 404) throw err
|
||
return {
|
||
ok: true,
|
||
project: saved,
|
||
moved: null,
|
||
backfilled: 0,
|
||
warnings: [WARN_RECONCILE_FAILED(err.message)],
|
||
}
|
||
}
|
||
})
|
||
}
|