Zuweisen mit Umzug und Nachtragen der Pushes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
405b2a0485
commit
a960701d48
2 files changed
+263
No files matched your search
@@ -0,0 +1,106 @@
|
|||||||
|
import { config } from '../config.js'
|
||||||
|
import { moveRepoToOrg, WARN } from './repoMove.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'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const relevant = pushes.filter((push) => !isSystemPusher(push.pusher) && !isOldSystemCommitPush(push))
|
||||||
|
if (relevant.length === 0) return { backfilled: 0, warnings: [] }
|
||||||
|
|
||||||
|
const ticketId = project.ticketId || (await ensureProjectTicket(project, { repoFullName: project.repoFullName }))
|
||||||
|
if (!ticketId) return { backfilled: 0, warnings: [] }
|
||||||
|
|
||||||
|
let backfilled = 0
|
||||||
|
// 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: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest das Projekt, verschiebt sein Repo bei Bedarf nach `GITEA_ORG` und traegt danach dessen
|
||||||
|
* Pushes nach - alles unter der Projekt-Sperre, 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). Das Projektdokument wird ERST innerhalb der
|
||||||
|
* Sperre gelesen, damit ein zweiter, gleichzeitiger Aufruf nie mit einem inzwischen veralteten
|
||||||
|
* Objekt arbeitet.
|
||||||
|
*/
|
||||||
|
export async function reconcileProject(projectId, { now } = {}) {
|
||||||
|
return withLocks(['project:' + projectId], async () => {
|
||||||
|
let project = await getDocument(config.collections.websiteProjects, projectId)
|
||||||
|
const { moved, warnings: moveWarnings } = await moveRepoToOrg(project)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
const { backfilled, warnings: backfillWarnings } = await backfillPushes(project, { now })
|
||||||
|
|
||||||
|
return { project, moved, backfilled, warnings: [...moveWarnings, ...backfillWarnings] }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weist ein Projekt einem Kunden/Ticket zu und stoesst danach Umzug + Nachtragen an. Die
|
||||||
|
* Zuweisung selbst wird ZUERST und ausserhalb der Projekt-Sperre gespeichert (reines Appwrite,
|
||||||
|
* ohne Gitea) - sie bleibt deshalb bestehen, selbst wenn Gitea gerade nicht erreichbar ist
|
||||||
|
* (siehe `reconcileProject`/`moveRepoToOrg`, die deshalb nie wegen Gitea werfen duerfen).
|
||||||
|
*/
|
||||||
|
export async function assignProject(projectId, { customerId = '', ticketId = '' }, { now } = {}) {
|
||||||
|
// 404 fuer ein unbekanntes Projekt, bevor ueberhaupt geschrieben wird.
|
||||||
|
await getDocument(config.collections.websiteProjects, projectId)
|
||||||
|
|
||||||
|
await updateDocument(config.collections.websiteProjects, projectId, {
|
||||||
|
customerId,
|
||||||
|
ticketId,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { project, moved, backfilled, warnings } = await reconcileProject(projectId, { now })
|
||||||
|
return { ok: true, project, moved, backfilled, warnings }
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { test, before, after } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { startFakeGitea } from './helpers/fakeGitea.js'
|
||||||
|
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
|
||||||
|
|
||||||
|
let gitea
|
||||||
|
let aw
|
||||||
|
let tmp
|
||||||
|
let originalCwd
|
||||||
|
let assignProject
|
||||||
|
let reconcileProject
|
||||||
|
|
||||||
|
const now = new Date('2026-09-27T16:00:00Z')
|
||||||
|
|
||||||
|
let nextFeedId = 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut einen Gitea-1.25-Feed-Eintrag vom Typ `commit_repo` nach (ein Commit), siehe
|
||||||
|
* `test/pushHistory.test.js` fuer denselben Aufbau: `content` traegt das JSON
|
||||||
|
* `{Commits:[...], HeadCommit:{...}}` als String, wie im echten Aktivitaeten-Feed.
|
||||||
|
*/
|
||||||
|
function pushEntry({ sha, at, user, msg }) {
|
||||||
|
const login = user || 'JUSN'
|
||||||
|
const commit = {
|
||||||
|
Sha1: sha,
|
||||||
|
Message: msg || `Commit ${sha}`,
|
||||||
|
AuthorEmail: `${login.toLowerCase()}@example.com`,
|
||||||
|
AuthorName: login,
|
||||||
|
CommitterEmail: `${login.toLowerCase()}@example.com`,
|
||||||
|
CommitterName: login,
|
||||||
|
Timestamp: at,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: nextFeedId++,
|
||||||
|
op_type: 'commit_repo',
|
||||||
|
act_user: { login, full_name: '' },
|
||||||
|
ref_name: 'refs/heads/main',
|
||||||
|
created: at,
|
||||||
|
content: JSON.stringify({ Commits: [commit], HeadCommit: { Sha1: sha }, CompareURL: '', Len: 1 }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
gitea = await startFakeGitea({
|
||||||
|
repos: [
|
||||||
|
{ full_name: 'JUSN/D-ner-Nation' },
|
||||||
|
{ full_name: 'JUSN/leer' },
|
||||||
|
{ full_name: 'JUSN/nur-kunde' },
|
||||||
|
{ full_name: 'JUSN/doppelt' },
|
||||||
|
],
|
||||||
|
feeds: {
|
||||||
|
// Newest first, wie der echte Gitea-Feed.
|
||||||
|
'JUSN/D-ner-Nation': [
|
||||||
|
pushEntry({ sha: 's1', user: 'webklar-system', at: '2026-09-27T14:50:00Z', msg: 'System-Push' }),
|
||||||
|
pushEntry({ sha: 'k1', user: 'knso', at: '2026-09-27T14:48:00Z', msg: 'Update preview config from ticket system' }),
|
||||||
|
pushEntry({ sha: '26bc22ecf17e2e5a5a8794dcb4cf3892f802c51d', user: 'JUSN', at: '2026-09-27T14:46:34Z', msg: 'Bilder aktualisiert' }),
|
||||||
|
{ ...pushEntry({ sha: 'x1', at: '2026-09-27T14:46:33Z' }), content: '' },
|
||||||
|
{ id: nextFeedId++, op_type: 'create_repo', created: '2026-09-27T14:02:46Z', act_user: { login: 'JUSN' } },
|
||||||
|
],
|
||||||
|
'JUSN/nur-kunde': [pushEntry({ sha: 'n1', user: 'JUSN', at: '2026-09-27T12:00:00Z', msg: 'Kundenseite live' })],
|
||||||
|
'JUSN/doppelt': [pushEntry({ sha: 'd1', user: 'JUSN', at: '2026-09-27T11:00:00Z', msg: 'Doppelt-Push' })],
|
||||||
|
'JUSN/leer': [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
aw = await startFakeAppwrite({
|
||||||
|
seed: {
|
||||||
|
websiteProjects: [
|
||||||
|
{ $id: 'p1', repoFullName: 'JUSN/D-ner-Nation', projectName: 'D-ner-Nation', customerId: '', ticketId: '' },
|
||||||
|
{ $id: 'p2', repoFullName: 'JUSN/leer', projectName: 'leer', customerId: '', ticketId: '' },
|
||||||
|
{ $id: 'p3', repoFullName: 'JUSN/nur-kunde', projectName: 'nur-kunde', customerId: '', ticketId: '' },
|
||||||
|
{ $id: 'p4', repoFullName: 'JUSN/doppelt', projectName: 'doppelt', customerId: '', ticketId: '' },
|
||||||
|
],
|
||||||
|
workorders: [{ $id: 't1', woid: '100115' }],
|
||||||
|
customers: [{ $id: 'c1', name: 'Döner Nation' }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
originalCwd = process.cwd()
|
||||||
|
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'project-assignment-'))
|
||||||
|
process.chdir(tmp)
|
||||||
|
|
||||||
|
Object.assign(process.env, {
|
||||||
|
GITEA_BASE_URL: gitea.baseUrl,
|
||||||
|
GITEA_API_TOKEN: 'test-token',
|
||||||
|
APPWRITE_ENDPOINT: aw.endpoint,
|
||||||
|
APPWRITE_API_KEY: 'test-key',
|
||||||
|
PREVIEW_DEPLOY_ROOT: '',
|
||||||
|
TRAEFIK_DYNAMIC_DIR: '',
|
||||||
|
})
|
||||||
|
// config.js liest die Umgebung beim Import
|
||||||
|
;({ assignProject, reconcileProject } = await import('../server/services/projectAssignment.js'))
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
process.chdir(originalCwd)
|
||||||
|
await gitea.close()
|
||||||
|
await aw.close()
|
||||||
|
await fs.rm(tmp, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('assign speichert, verschiebt und trägt nach', async () => {
|
||||||
|
const r = await assignProject('p1', { customerId: 'c1', ticketId: 't1' }, { now })
|
||||||
|
assert.equal(r.ok, true)
|
||||||
|
assert.deepEqual(r.moved, { from: 'JUSN/D-ner-Nation', to: 'WEBklar/D-ner-Nation' })
|
||||||
|
assert.equal(r.backfilled, 1); assert.deepEqual(r.warnings, [])
|
||||||
|
const d = aw.get('websiteProjects', 'p1')
|
||||||
|
assert.deepEqual([d.customerId, d.ticketId], ['c1', 't1'])
|
||||||
|
const ws = aw.all('worksheets').filter((w) => w.workorderId === 't1')
|
||||||
|
assert.equal(ws.length, 1)
|
||||||
|
assert.deepEqual([ws[0].employeeName, ws[0].endDate, ws[0].endTime], ['JUSN', '27.09.2026', '1646'])
|
||||||
|
})
|
||||||
|
test('zweites Nachtragen erzeugt nichts Neues', async () => {
|
||||||
|
const r = await reconcileProject('p1', { now })
|
||||||
|
assert.deepEqual([r.moved, r.backfilled], [null, 0])
|
||||||
|
})
|
||||||
|
test('nur Kunde ohne Pushes: kein Ticket', async () => {
|
||||||
|
const before = aw.all('workorders').length
|
||||||
|
await assignProject('p2', { customerId: 'c1' }, { now })
|
||||||
|
assert.equal(aw.all('workorders').length, before)
|
||||||
|
})
|
||||||
|
test('nur Kunde mit Pushes: Webpage-Ticket entsteht', async () => {
|
||||||
|
const r = await assignProject('p3', { customerId: 'c1' }, { now })
|
||||||
|
const t = aw.all('workorders').find((w) => w.$id === aw.get('websiteProjects', 'p3').ticketId)
|
||||||
|
assert.equal(t.type, 'Webpage'); assert.equal(r.backfilled, 1)
|
||||||
|
})
|
||||||
|
test('Doppelklick: ein Umzug, keine doppelten Arbeitsblätter', async () => {
|
||||||
|
await Promise.all([assignProject('p4', { customerId: 'c1', ticketId: 't1' }, { now }), assignProject('p4', { customerId: 'c1', ticketId: 't1' }, { now })])
|
||||||
|
assert.equal(gitea.calls.filter((c) => c.path === '/api/v1/repos/JUSN/doppelt/transfer').length, 1)
|
||||||
|
assert.equal(aw.all('worksheets').filter((w) => w.details.includes('WEBklar/doppelt') || w.details.includes('JUSN/doppelt')).length, 1)
|
||||||
|
})
|
||||||
|
test('Gitea-Ausfall: Zuweisung bleibt', async () => {
|
||||||
|
// Diese Zuweisung loest bewusst einen Gitea-Ausfall aus (`deletePortalHooks` in `repoMove.js`
|
||||||
|
// loggt einen fehlgeschlagenen Hook-Abruf mit console.error) - Konsole hier abfangen, damit
|
||||||
|
// die Testausgabe sauber bleibt.
|
||||||
|
const originalError = console.error
|
||||||
|
const originalWarn = console.warn
|
||||||
|
console.error = () => {}
|
||||||
|
console.warn = () => {}
|
||||||
|
gitea.setDown(true)
|
||||||
|
let r
|
||||||
|
try {
|
||||||
|
r = await assignProject('p2', { customerId: 'c1', ticketId: 't1' }, { now })
|
||||||
|
} finally {
|
||||||
|
gitea.setDown(false)
|
||||||
|
console.error = originalError
|
||||||
|
console.warn = originalWarn
|
||||||
|
}
|
||||||
|
assert.equal(r.ok, true); assert.equal(aw.get('websiteProjects', 'p2').ticketId, 't1')
|
||||||
|
assert.match(r.warnings[0], /^Gitea ist gerade nicht erreichbar/)
|
||||||
|
})
|
||||||
|
test('unbekanntes Projekt', async () => {
|
||||||
|
await assert.rejects(assignProject('nope', { ticketId: 't1' }, { now }), (e) => e.status === 404)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user