Umzug: Webhook-Adresse aus config.url, Aufräumen auch nach Selbstheilung
Fix round 1 (Review C1/I1/I2/I3, Controller-Ruling R11): - C1 (kritisch): Hooks haben in echtem Gitea kein Top-Level-url, nur config.url - isInternalRepo/deletePortalHooks lasen bisher ein Feld, das es in Produktion nicht gibt. - I1: Schritt-2-Namensvergleich jetzt ohne Ruecksicht auf Gross-/Kleinschreibung. - I2: fs.rm in Schritt 9 gegen EACCES/EBUSY/ENOTEMPTY & Co. abgesichert. - I3: Aufraeumen (Cache, alter Einzel-Webhook, Preview-Verzeichnis) laeuft jetzt ueber den neuen idempotenten finishMove()-Baustein in jedem Fall, in dem das Repo tatsaechlich in WEBklar liegt - auch nach einem Appwrite-Fehler, bei Selbstheilung und bei einem schon dort liegenden Repo (WARN.hookNotRemoved bei fehlgeschlagenem Loeschen). - Fake Gitea: Hook-Form ohne Top-Level-url, 301 mit relativer Location (auch fuer Unterpfade) wie im echten Gitea 1.25.2, failNext() fuer gezielte Fehler in Tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6e6c9a4c24
commit
405b2a0485
4 files changed
+277
-88
No files matched your search
+121
-44
@@ -6,7 +6,7 @@ import { listDocuments, updateDocument, Query } from './appwriteAdmin.js'
|
|||||||
import { invalidateProjectCache } from './projectInsights.js'
|
import { invalidateProjectCache } from './projectInsights.js'
|
||||||
import { previewWorkDir } from './previewDeploy.js'
|
import { previewWorkDir } from './previewDeploy.js'
|
||||||
|
|
||||||
/** Warnungen, wörtlich (siehe Plan, Abschnitt "Global Constraints"). */
|
/** Warnungen, wörtlich (siehe Plan, Abschnitt "Global Constraints", und Controller-Ruling R11). */
|
||||||
export const WARN = {
|
export const WARN = {
|
||||||
internal: 'Internes Repo, bleibt wo es ist.',
|
internal: 'Internes Repo, bleibt wo es ist.',
|
||||||
nameTaken: (name) =>
|
nameTaken: (name) =>
|
||||||
@@ -15,6 +15,8 @@ export const WARN = {
|
|||||||
`Gitea ist gerade nicht erreichbar (${reason}). Bitte später ‚Pushes nachtragen‘ klicken.`,
|
`Gitea ist gerade nicht erreichbar (${reason}). Bitte später ‚Pushes nachtragen‘ klicken.`,
|
||||||
docNotUpdated: (reason) =>
|
docNotUpdated: (reason) =>
|
||||||
`Das Repo liegt jetzt in WEBklar, aber die Projektdaten konnten nicht angepasst werden (${reason}). Bitte ‚Pushes nachtragen‘ klicken.`,
|
`Das Repo liegt jetzt in WEBklar, aber die Projektdaten konnten nicht angepasst werden (${reason}). Bitte ‚Pushes nachtragen‘ klicken.`,
|
||||||
|
hookNotRemoved: (reason) =>
|
||||||
|
`Der alte Einzel-Webhook ans Portal konnte nicht entfernt werden (${reason}). Bitte später ‚Pushes nachtragen‘ klicken.`,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Portal-Webhook-URL: ein Einzel-Webhook gilt als Portal-Webhook, wenn seine URL exakt damit beginnt. */
|
/** Portal-Webhook-URL: ein Einzel-Webhook gilt als Portal-Webhook, wenn seine URL exakt damit beginnt. */
|
||||||
@@ -22,6 +24,16 @@ function portalWebhookUrl() {
|
|||||||
return `${config.publicBaseUrl}/webhook/gitea`
|
return `${config.publicBaseUrl}/webhook/gitea`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ziel-URL eines Gitea-Hooks. Echtes Gitea (Produktions-Swagger von 1.25.2, `definitions.Hook`)
|
||||||
|
* hat dafuer KEIN Top-Level-`url`-Feld - das Ziel steht ausschliesslich unter `config.url`. Ein
|
||||||
|
* Top-Level-`url` zu lesen wuerde in Produktion immer `''` liefern und jedes Repo mit irgendeinem
|
||||||
|
* Hook faelschlich als intern einstufen (siehe Review C1).
|
||||||
|
*/
|
||||||
|
function hookUrl(hook) {
|
||||||
|
return hook?.config?.url || ''
|
||||||
|
}
|
||||||
|
|
||||||
/** true, wenn `owner/repo` (ohne Ruecksicht auf Gross-/Kleinschreibung) in der Ziel-Organisation liegt. */
|
/** true, wenn `owner/repo` (ohne Ruecksicht auf Gross-/Kleinschreibung) in der Ziel-Organisation liegt. */
|
||||||
export function isOrgRepo(repoFullName) {
|
export function isOrgRepo(repoFullName) {
|
||||||
const [owner] = splitRepo(repoFullName)
|
const [owner] = splitRepo(repoFullName)
|
||||||
@@ -33,6 +45,11 @@ export function repoLockKey(repoFullName) {
|
|||||||
return 'repo:' + repoFullName.toLowerCase()
|
return 'repo:' + repoFullName.toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** owner/repo ohne Ruecksicht auf Gross-/Kleinschreibung vergleichen (Gitea haelt es ebenso). */
|
||||||
|
function sameRepoFullName(a, b) {
|
||||||
|
return String(a || '').toLowerCase() === String(b || '').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* true, wenn das Repo in `GITEA_KEEP_IN_PLACE` steht (ohne Ruecksicht auf Gross-/Kleinschreibung)
|
* true, wenn das Repo in `GITEA_KEEP_IN_PLACE` steht (ohne Ruecksicht auf Gross-/Kleinschreibung)
|
||||||
* oder einen Webhook hat, dessen URL nicht mit der Portal-Webhook-URL beginnt (Deploy-verdrahtetes
|
* oder einen Webhook hat, dessen URL nicht mit der Portal-Webhook-URL beginnt (Deploy-verdrahtetes
|
||||||
@@ -45,7 +62,7 @@ export async function isInternalRepo(repoFullName) {
|
|||||||
|
|
||||||
const hooks = await giteaFetch(`/repos/${repoFullName}/hooks`)
|
const hooks = await giteaFetch(`/repos/${repoFullName}/hooks`)
|
||||||
const portalUrl = portalWebhookUrl()
|
const portalUrl = portalWebhookUrl()
|
||||||
return (hooks || []).some((hook) => !String(hook.url || '').startsWith(portalUrl))
|
return (hooks || []).some((hook) => !hookUrl(hook).startsWith(portalUrl))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,56 +112,66 @@ async function updateProjectDocument(project, to) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Schritt 8: alte Portal-Webhooks am neuen Ort loeschen. Fehler werden nur geloggt. */
|
/**
|
||||||
async function deletePortalHooks(to) {
|
* Loescht die alten Einzel-Webhooks ans Portal an einem (aktuellen) Repo-Ort - die Organisation
|
||||||
|
* hat inzwischen ihren eigenen Webhook. Ein fehlgeschlagenes Listen wird nur geloggt (kein
|
||||||
|
* Repo-Zustand haengt daran); ein fehlgeschlagenes Loeschen liefert eine Warnung (R11), damit
|
||||||
|
* "Pushes nachtragen" es beim naechsten Versuch erneut probieren kann - sonst bleibt der alte
|
||||||
|
* Webhook fuer immer bestehen und jeder Push wird doppelt zugestellt (siehe Review I3).
|
||||||
|
*/
|
||||||
|
async function deletePortalHooks(repoFullName) {
|
||||||
const portalUrl = portalWebhookUrl()
|
const portalUrl = portalWebhookUrl()
|
||||||
let hooks
|
let hooks
|
||||||
try {
|
try {
|
||||||
hooks = await giteaFetch(`/repos/${to}/hooks`)
|
hooks = await giteaFetch(`/repos/${repoFullName}/hooks`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[repoMove] Webhooks von ${to} konnten nicht gelesen werden:`, err.message)
|
console.error(`[repoMove] Webhooks von ${repoFullName} konnten nicht gelesen werden:`, err.message)
|
||||||
return
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const warnings = []
|
||||||
for (const hook of hooks || []) {
|
for (const hook of hooks || []) {
|
||||||
if (!String(hook.url || '').startsWith(portalUrl)) continue
|
if (!hookUrl(hook).startsWith(portalUrl)) continue
|
||||||
try {
|
try {
|
||||||
await giteaFetch(`/repos/${to}/hooks/${hook.id}`, { method: 'DELETE' })
|
await giteaFetch(`/repos/${repoFullName}/hooks/${hook.id}`, { method: 'DELETE' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[repoMove] Webhook ${hook.id} bei ${to} konnte nicht geloescht werden:`, err.message)
|
console.error(`[repoMove] Webhook ${hook.id} bei ${repoFullName} konnte nicht geloescht werden:`, err.message)
|
||||||
|
warnings.push(WARN.hookNotRemoved(err.message))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verschiebt das Repo eines zugewiesenen Projekts nach `config.gitea.org`, wenn noetig, und haelt
|
* Schritte 7-9, idempotent: Cache fuer alten und neuen Namen verwerfen, alte Portal-Webhooks am
|
||||||
* Projektdokument, Cache und Webhooks auf dem Laufenden. Wirft nie wegen eines Gitea- oder
|
* neuen Ort loeschen, Preview-Arbeitsverzeichnis des alten Namens aufraeumen. Laeuft unabhaengig
|
||||||
* Appwrite-Problems (siehe `classifyGiteaError`/`docNotUpdated`) - Task 6 verlaesst sich darauf,
|
* davon, ob das Projektdokument selbst erfolgreich aktualisiert wurde (R11) - der Repo-Zustand in
|
||||||
* dass eine Zuweisung auch bei einem Ausfall bestehen bleibt.
|
* Gitea ist in jedem Fall schon "in WEBklar", und genau das muss aufgeraeumt werden. `fs.rm` ist
|
||||||
|
* abgesichert (Review I2): ein EACCES/EBUSY/ENOTEMPTY & Co. nach dem schon erfolgten, nicht mehr
|
||||||
|
* rueckgaengig zu machenden Transfer darf `moveRepoToOrg` nicht mehr zum Werfen bringen.
|
||||||
*/
|
*/
|
||||||
export async function moveRepoToOrg(project) {
|
async function finishMove(from, to) {
|
||||||
const from = project.repoFullName
|
invalidateProjectCache(from)
|
||||||
|
invalidateProjectCache(to)
|
||||||
|
|
||||||
// Schritt 1: schon in der Organisation.
|
const warnings = await deletePortalHooks(to)
|
||||||
if (isOrgRepo(from)) return { moved: null, warnings: [] }
|
|
||||||
|
|
||||||
// Schritt 2: aktuellen Stand holen (fetch folgt Giteas 307-Weiterleitung von Hand verschobener Repos).
|
|
||||||
let current
|
|
||||||
try {
|
try {
|
||||||
current = await giteaFetch(`/repos/${from}`)
|
await fs.rm(previewWorkDir(from), { recursive: true, force: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return classifyGiteaError(err)
|
console.error(`[repoMove] Preview-Arbeitsverzeichnis von ${from} konnte nicht entfernt werden:`, err.message)
|
||||||
}
|
|
||||||
if (current.full_name !== from) {
|
|
||||||
const to = current.full_name
|
|
||||||
try {
|
|
||||||
await updateProjectDocument(project, to)
|
|
||||||
} catch (err) {
|
|
||||||
return { moved: { from, to }, warnings: [WARN.docNotUpdated(err.message)] }
|
|
||||||
}
|
|
||||||
return { moved: { from, to }, warnings: [] }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return warnings
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schritte 3-6 (+ Aufraeumen): internes Repo bleibt, Namenskonflikt wird gemeldet, sonst Transfer
|
||||||
|
* + Dokument-Update unter Sperre, danach `finishMove` - unabhaengig davon, ob `moveRepoToOrg` hier
|
||||||
|
* ueber den normalen Weg ankommt oder ueber die Selbstheilung mit dem kanonischen, aber
|
||||||
|
* Nicht-WEBklar-Namen als `from`.
|
||||||
|
*/
|
||||||
|
async function moveViaTransfer(project, from) {
|
||||||
// Schritt 3: internes (Deploy-verdrahtetes) Repo bleibt, wo es ist.
|
// Schritt 3: internes (Deploy-verdrahtetes) Repo bleibt, wo es ist.
|
||||||
let internal
|
let internal
|
||||||
try {
|
try {
|
||||||
@@ -180,29 +207,79 @@ export async function moveRepoToOrg(project) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const confirmedTo = transferResult.full_name
|
const confirmedTo = transferResult.full_name
|
||||||
|
let docErr = null
|
||||||
try {
|
try {
|
||||||
await updateProjectDocument(project, confirmedTo)
|
await updateProjectDocument(project, confirmedTo)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { failed: 'doc', to: confirmedTo, err }
|
docErr = err
|
||||||
}
|
}
|
||||||
return { to: confirmedTo }
|
return { to: confirmedTo, docErr }
|
||||||
})
|
})
|
||||||
|
|
||||||
if (outcome.failed === 'gitea') return classifyGiteaError(outcome.err)
|
if (outcome.failed === 'gitea') return classifyGiteaError(outcome.err)
|
||||||
if (outcome.failed === 'doc') {
|
|
||||||
return { moved: { from, to: outcome.to }, warnings: [WARN.docNotUpdated(outcome.err.message)] }
|
|
||||||
}
|
|
||||||
const confirmedTo = outcome.to
|
const confirmedTo = outcome.to
|
||||||
|
// Das Aufraeumen (Cache, alter Webhook, Preview-Verzeichnis) haengt nicht am Dokument-Update -
|
||||||
|
// das Repo liegt in Gitea so oder so schon in WEBklar (R11).
|
||||||
|
const finishWarnings = await finishMove(from, confirmedTo)
|
||||||
|
const warnings = outcome.docErr
|
||||||
|
? [WARN.docNotUpdated(outcome.docErr.message), ...finishWarnings]
|
||||||
|
: finishWarnings
|
||||||
|
return { moved: { from, to: confirmedTo }, warnings }
|
||||||
|
}
|
||||||
|
|
||||||
// Schritt 7: Cache fuer alten und neuen Namen verwerfen.
|
/**
|
||||||
invalidateProjectCache(from)
|
* Verschiebt das Repo eines zugewiesenen Projekts nach `config.gitea.org`, wenn noetig, und haelt
|
||||||
invalidateProjectCache(confirmedTo)
|
* Projektdokument, Cache und Webhooks auf dem Laufenden. Wirft nie wegen eines Gitea- oder
|
||||||
|
* Appwrite-Problems (siehe `classifyGiteaError`/`docNotUpdated`) - Task 6 verlaesst sich darauf,
|
||||||
|
* dass eine Zuweisung auch bei einem Ausfall bestehen bleibt.
|
||||||
|
*/
|
||||||
|
export async function moveRepoToOrg(project) {
|
||||||
|
const from = project.repoFullName
|
||||||
|
|
||||||
// Schritt 8: alten Portal-Webhook am neuen Ort loeschen (die Organisation hat einen eigenen).
|
// Schritt 1: schon in der Organisation - nur noch nach einem liegen gebliebenen Einzel-Webhook
|
||||||
await deletePortalHooks(confirmedTo)
|
// schauen (R11), damit "Pushes nachtragen" auch das noch nachholen kann.
|
||||||
|
if (isOrgRepo(from)) {
|
||||||
|
const warnings = await deletePortalHooks(from)
|
||||||
|
return { moved: null, warnings }
|
||||||
|
}
|
||||||
|
|
||||||
// Schritt 9: Preview-Arbeitsverzeichnis des alten Namens aufraeumen.
|
// Schritt 2: aktuellen Stand holen (fetch folgt Giteas 301-Weiterleitung von Hand verschobener
|
||||||
await fs.rm(previewWorkDir(from), { recursive: true, force: true })
|
// Repos). Der Vergleich ist ohne Ruecksicht auf Gross-/Kleinschreibung (Review I1) - Gitea
|
||||||
|
// liefert sonst z. B. "JUSN/Case-Test" fuer den gespeicherten Namen "jusn/case-test", ohne dass
|
||||||
|
// sich am Repo tatsaechlich etwas geaendert haette.
|
||||||
|
let current
|
||||||
|
try {
|
||||||
|
current = await giteaFetch(`/repos/${from}`)
|
||||||
|
} catch (err) {
|
||||||
|
return classifyGiteaError(err)
|
||||||
|
}
|
||||||
|
|
||||||
return { moved: { from, to: confirmedTo }, warnings: [] }
|
if (!sameRepoFullName(current.full_name, from)) {
|
||||||
|
// Das Repo wurde ausserhalb des Portals umbenannt/verschoben (z. B. von Hand). Den
|
||||||
|
// gespeicherten Namen dafuer korrigieren - unter derselben Sperre wie ein regulaerer Umzug.
|
||||||
|
const canonical = current.full_name
|
||||||
|
const renameOutcome = await withLocks([repoLockKey(from), repoLockKey(canonical)], async () => {
|
||||||
|
try {
|
||||||
|
await updateProjectDocument(project, canonical)
|
||||||
|
return { ok: true }
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, err }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const renameWarnings = renameOutcome.ok ? [] : [WARN.docNotUpdated(renameOutcome.err.message)]
|
||||||
|
|
||||||
|
if (isOrgRepo(canonical)) {
|
||||||
|
// Schon in WEBklar (z. B. von Hand verschoben): nur noch aufraeumen.
|
||||||
|
const finishWarnings = await finishMove(from, canonical)
|
||||||
|
return { moved: { from, to: canonical }, warnings: [...renameWarnings, ...finishWarnings] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Woanders hin verschoben, aber nicht nach WEBklar: mit dem kanonischen Namen als neuem
|
||||||
|
// Ausgangspunkt tatsaechlich umziehen (Schritte 3-5), statt den Umzug hier abzubrechen.
|
||||||
|
const result = await moveViaTransfer(project, canonical)
|
||||||
|
return { moved: result.moved, warnings: [...renameWarnings, ...result.warnings] }
|
||||||
|
}
|
||||||
|
|
||||||
|
return moveViaTransfer(project, from)
|
||||||
}
|
}
|
||||||
@@ -17,12 +17,17 @@ import { randomUUID } from 'node:crypto'
|
|||||||
* all: (collection: string) => object[],
|
* all: (collection: string) => object[],
|
||||||
* get: (collection: string, id: string) => (object|undefined),
|
* get: (collection: string, id: string) => (object|undefined),
|
||||||
* calls: Array<{method: string, path: string, query: Record<string, string>, headers: Record<string, string>, body: any}>,
|
* calls: Array<{method: string, path: string, query: Record<string, string>, headers: Record<string, string>, body: any}>,
|
||||||
|
* failNext: (spec: {method: string, pathIncludes: string, status: number}) => void,
|
||||||
* close: () => Promise<void>,
|
* close: () => Promise<void>,
|
||||||
* }>} `all`/`get` lesen den LIVE-Stand (nach create/patch/delete ueber HTTP). Die
|
* }>} `all`/`get` lesen den LIVE-Stand (nach create/patch/delete ueber HTTP). Die
|
||||||
* Datenbank-ID im Pfad wird nicht geprueft - jede wird akzeptiert.
|
* Datenbank-ID im Pfad wird nicht geprueft - jede wird akzeptiert.
|
||||||
|
* `failNext({method, pathIncludes, status})` laesst GENAU die naechste Anfrage, deren Methode
|
||||||
|
* passt und deren Pfad `pathIncludes` enthaelt, mit diesem Status und einer generischen
|
||||||
|
* JSON-Fehlermeldung scheitern (danach wieder normales Verhalten).
|
||||||
*/
|
*/
|
||||||
export async function startFakeAppwrite(options = {}) {
|
export async function startFakeAppwrite(options = {}) {
|
||||||
const calls = []
|
const calls = []
|
||||||
|
const pendingFailures = []
|
||||||
/** @type {Map<string, Map<string, object>>} Sammlungsname -> (Dokument-ID -> Dokument). */
|
/** @type {Map<string, Map<string, object>>} Sammlungsname -> (Dokument-ID -> Dokument). */
|
||||||
const store = new Map()
|
const store = new Map()
|
||||||
|
|
||||||
@@ -196,6 +201,14 @@ export async function startFakeAppwrite(options = {}) {
|
|||||||
body: parsedBody,
|
body: parsedBody,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const failIdx = pendingFailures.findIndex(
|
||||||
|
(f) => f.method === req.method && url.pathname.includes(f.pathIncludes)
|
||||||
|
)
|
||||||
|
if (failIdx !== -1) {
|
||||||
|
const [f] = pendingFailures.splice(failIdx, 1)
|
||||||
|
return send(res, f.status, { message: `fakeAppwrite: erzwungener Fehler (${f.status})` })
|
||||||
|
}
|
||||||
|
|
||||||
const route = ROUTES.find((r) => r.method === req.method && r.re.test(url.pathname))
|
const route = ROUTES.find((r) => r.method === req.method && r.re.test(url.pathname))
|
||||||
if (!route) return send(res, 404, { message: 'Not Found' })
|
if (!route) return send(res, 404, { message: 'Not Found' })
|
||||||
const match = route.re.exec(url.pathname)
|
const match = route.re.exec(url.pathname)
|
||||||
@@ -228,10 +241,14 @@ export async function startFakeAppwrite(options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function failNext({ method, pathIncludes, status }) {
|
||||||
|
pendingFailures.push({ method, pathIncludes, status })
|
||||||
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
for (const socket of sockets) socket.destroy()
|
for (const socket of sockets) socket.destroy()
|
||||||
return new Promise((resolve) => server.close(() => resolve()))
|
return new Promise((resolve) => server.close(() => resolve()))
|
||||||
}
|
}
|
||||||
|
|
||||||
return { endpoint, all, get, calls, close }
|
return { endpoint, all, get, calls, failNext, close }
|
||||||
}
|
}
|
||||||
+80
-39
@@ -10,11 +10,19 @@ import { createHash } from 'node:crypto'
|
|||||||
* nachgeschlagen, aber in der zuletzt gesetzten Schreibweise gespeichert - wie Gitea es
|
* nachgeschlagen, aber in der zuletzt gesetzten Schreibweise gespeichert - wie Gitea es
|
||||||
* auch haelt.
|
* auch haelt.
|
||||||
*
|
*
|
||||||
|
* Ein umgezogenes Repo antwortet unter seinem alten Namen (auch fuer Unterpfade wie
|
||||||
|
* `/hooks`, `/activities/feeds`, `/contents/...`, `/transfer`) mit `301` und einer RELATIVEN
|
||||||
|
* `Location` auf denselben (Unter-)Pfad unter dem neuen Namen - so wie echtes Gitea 1.25.2
|
||||||
|
* (live geprueft: `GET /api/v1/repos/<alt>` -> `301`, `location: /api/v1/repos/<neu>`).
|
||||||
|
*
|
||||||
* @param {object} [options]
|
* @param {object} [options]
|
||||||
* @param {Array<{full_name: string, archived?: boolean, private?: boolean, description?: string, default_branch?: string}>} [options.repos]
|
* @param {Array<{full_name: string, archived?: boolean, private?: boolean, description?: string, default_branch?: string}>} [options.repos]
|
||||||
* Anfangsbestand an Repos.
|
* Anfangsbestand an Repos.
|
||||||
* @param {Record<string, Array<{id: number|string, url: string, events?: string[], active?: boolean, type?: string}>>} [options.hooks]
|
* @param {Record<string, Array<{id: number|string, url?: string, config?: {url?: string, content_type?: string}, events?: string[], active?: boolean, type?: string}>>} [options.hooks]
|
||||||
* Repo-Webhooks je `"owner/repo"`.
|
* Repo-Webhooks je `"owner/repo"`. `url` ist nur eine Eingabe-Abkuerzung beim Seeden; gespeichert
|
||||||
|
* und ueber die API ausgeliefert wird das Hook-Objekt wie im echten Gitea 1.25 OHNE Top-Level-
|
||||||
|
* `url` (siehe `definitions.Hook` im Produktions-Swagger) - das Ziel steht ausschliesslich unter
|
||||||
|
* `config.url` (uebernimmt das Seed-`url`, wenn `config.url` nicht separat gesetzt wurde).
|
||||||
* @param {Record<string, object[]>} [options.feeds]
|
* @param {Record<string, object[]>} [options.feeds]
|
||||||
* Aktivitaeten-Feed je `"owner/repo"`, neueste zuerst (wie `GET .../activities/feeds` sie liefert).
|
* Aktivitaeten-Feed je `"owner/repo"`, neueste zuerst (wie `GET .../activities/feeds` sie liefert).
|
||||||
* @param {Record<string, string>} [options.contents]
|
* @param {Record<string, string>} [options.contents]
|
||||||
@@ -34,15 +42,21 @@ import { createHash } from 'node:crypto'
|
|||||||
* hooksOf: (fullName: string) => object[],
|
* hooksOf: (fullName: string) => object[],
|
||||||
* setDown: (down: boolean) => void,
|
* setDown: (down: boolean) => void,
|
||||||
* setTransferDelay: (ms: number) => void,
|
* setTransferDelay: (ms: number) => void,
|
||||||
|
* failNext: (spec: {method: string, pathIncludes: string, status: number}) => void,
|
||||||
* close: () => Promise<void>,
|
* close: () => Promise<void>,
|
||||||
* }>} `calls` waechst live, in der Reihenfolge, in der die Anfragen eintreffen - VOR einer
|
* }>} `calls` waechst live, in der Reihenfolge, in der die Anfragen eintreffen - VOR einer
|
||||||
* etwaigen `setTransferDelay`-Verzoegerung der Antwort. `repo()`/`hooksOf()` lesen den
|
* etwaigen `setTransferDelay`-Verzoegerung der Antwort. `repo()`/`hooksOf()` lesen den
|
||||||
* aktuellen Stand direkt (ohne Weiterleitung ueber einen umgezogenen alten Namen).
|
* aktuellen Stand direkt (ohne Weiterleitung ueber einen umgezogenen alten Namen).
|
||||||
|
* `failNext({method, pathIncludes, status})` laesst GENAU die naechste Anfrage, deren Methode
|
||||||
|
* passt und deren Pfad `pathIncludes` enthaelt, mit diesem Status und einer generischen
|
||||||
|
* JSON-Fehlermeldung scheitern (danach wieder normales Verhalten) - fuer Tests, die einen
|
||||||
|
* einzelnen Aufruf gezielt fehlschlagen lassen wollen, ohne den ganzen Server abzuschalten.
|
||||||
*/
|
*/
|
||||||
export async function startFakeGitea(options = {}) {
|
export async function startFakeGitea(options = {}) {
|
||||||
const calls = []
|
const calls = []
|
||||||
let isDown = false
|
let isDown = false
|
||||||
let transferDelayMs = 0
|
let transferDelayMs = 0
|
||||||
|
const pendingFailures = []
|
||||||
|
|
||||||
let nextRepoId = 1
|
let nextRepoId = 1
|
||||||
let nextHookId = 1
|
let nextHookId = 1
|
||||||
@@ -121,17 +135,27 @@ export async function startFakeGitea(options = {}) {
|
|||||||
return record
|
return record
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gitea-foermiges Hook-Objekt OHNE Top-Level-`url` (siehe JSDoc oben): das Ziel steht
|
||||||
|
* ausschliesslich in `config.url`. `h.url` ist nur eine Eingabe-Abkuerzung und landet dort,
|
||||||
|
* wenn die Eingabe kein `config.url` mitbringt.
|
||||||
|
*/
|
||||||
function normalizeHook(h) {
|
function normalizeHook(h) {
|
||||||
|
const config = { content_type: 'json', ...(h.config || {}) }
|
||||||
|
if (config.url === undefined) config.url = h.url
|
||||||
return {
|
return {
|
||||||
id: h.id,
|
id: h.id,
|
||||||
url: h.url,
|
|
||||||
events: h.events || ['push'],
|
|
||||||
active: h.active !== undefined ? h.active : true,
|
|
||||||
type: h.type || 'gitea',
|
type: h.type || 'gitea',
|
||||||
config: { url: h.url, content_type: 'json', ...(h.config || {}) },
|
active: h.active !== undefined ? h.active : true,
|
||||||
|
events: h.events || ['push'],
|
||||||
|
config,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeHook(h) {
|
||||||
|
return { ...h, config: { ...h.config } }
|
||||||
|
}
|
||||||
|
|
||||||
function createOrgRecord(username, full_name, visibility) {
|
function createOrgRecord(username, full_name, visibility) {
|
||||||
const org = {
|
const org = {
|
||||||
id: nextOrgId++,
|
id: nextOrgId++,
|
||||||
@@ -184,26 +208,23 @@ export async function startFakeGitea(options = {}) {
|
|||||||
send(res, 200, { ok: true, data: all.slice(start, start + limit).map(serializeRepo) })
|
send(res, 200, { ok: true, data: all.slice(start, start + limit).map(serializeRepo) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 301 mit RELATIVER Location auf denselben (Unter-)Pfad unter dem neuen Namen - wie echtes Gitea. */
|
||||||
|
function sendRepoRedirect(res, newFullName, suffix = '') {
|
||||||
|
res.writeHead(301, { Location: `/api/v1/repos/${newFullName}${suffix}` })
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
|
||||||
function handleGetRepo(match, searchParams, body, headers, res) {
|
function handleGetRepo(match, searchParams, body, headers, res) {
|
||||||
const found = findRepoOrRedirect(match[1], match[2])
|
const found = findRepoOrRedirect(match[1], match[2])
|
||||||
if (!found) return send(res, 404, { message: 'Not Found' })
|
if (!found) return send(res, 404, { message: 'Not Found' })
|
||||||
if (found.redirectTo) {
|
if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo)
|
||||||
return send304(res, found.redirectTo)
|
|
||||||
}
|
|
||||||
send(res, 200, serializeRepo(found.record))
|
send(res, 200, serializeRepo(found.record))
|
||||||
}
|
}
|
||||||
|
|
||||||
function send304(res, newFullName) {
|
|
||||||
res.writeHead(307, { Location: `${baseUrl}/api/v1/repos/${newFullName}` })
|
|
||||||
res.end()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePatchRepo(match, searchParams, body, headers, res) {
|
function handlePatchRepo(match, searchParams, body, headers, res) {
|
||||||
const found = findRepoOrRedirect(match[1], match[2])
|
const found = findRepoOrRedirect(match[1], match[2])
|
||||||
if (!found) return send(res, 404, { message: 'Not Found' })
|
if (!found) return send(res, 404, { message: 'Not Found' })
|
||||||
if (found.redirectTo) {
|
if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo)
|
||||||
return send304(res, found.redirectTo)
|
|
||||||
}
|
|
||||||
Object.assign(found.record, body || {})
|
Object.assign(found.record, body || {})
|
||||||
send(res, 200, serializeRepo(found.record))
|
send(res, 200, serializeRepo(found.record))
|
||||||
}
|
}
|
||||||
@@ -211,9 +232,11 @@ export async function startFakeGitea(options = {}) {
|
|||||||
async function handleTransfer(match, searchParams, body, headers, res) {
|
async function handleTransfer(match, searchParams, body, headers, res) {
|
||||||
if (transferDelayMs > 0) await sleep(transferDelayMs)
|
if (transferDelayMs > 0) await sleep(transferDelayMs)
|
||||||
|
|
||||||
const key = keyFrom(match[1], match[2])
|
const found = findRepoOrRedirect(match[1], match[2])
|
||||||
const record = repos.get(key)
|
if (!found) return send(res, 404, { message: 'Not Found' })
|
||||||
if (!record) return send(res, 404, { message: 'Not Found' })
|
if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo, '/transfer')
|
||||||
|
const key = found.key
|
||||||
|
const record = found.record
|
||||||
|
|
||||||
const newOwner = body?.new_owner
|
const newOwner = body?.new_owner
|
||||||
if (!newOwner) return send(res, 422, { message: 'new_owner erforderlich' })
|
if (!newOwner) return send(res, 422, { message: 'new_owner erforderlich' })
|
||||||
@@ -239,15 +262,18 @@ export async function startFakeGitea(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleGetRepoHooks(match, searchParams, body, headers, res) {
|
function handleGetRepoHooks(match, searchParams, body, headers, res) {
|
||||||
const key = keyFrom(match[1], match[2])
|
const found = findRepoOrRedirect(match[1], match[2])
|
||||||
if (!repos.has(key)) return send(res, 404, { message: 'Not Found' })
|
if (!found) return send(res, 404, { message: 'Not Found' })
|
||||||
send(res, 200, hooksByRepo.get(key) || [])
|
if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo, '/hooks')
|
||||||
|
send(res, 200, (hooksByRepo.get(found.key) || []).map(serializeHook))
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDeleteRepoHook(match, searchParams, body, headers, res) {
|
function handleDeleteRepoHook(match, searchParams, body, headers, res) {
|
||||||
const key = keyFrom(match[1], match[2])
|
const found = findRepoOrRedirect(match[1], match[2])
|
||||||
const list = hooksByRepo.get(key)
|
if (!found) return send(res, 404, { message: 'Not Found' })
|
||||||
if (!repos.has(key) || !list) return send(res, 404, { message: 'Not Found' })
|
if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo, `/hooks/${match[3]}`)
|
||||||
|
const list = hooksByRepo.get(found.key)
|
||||||
|
if (!list) return send(res, 404, { message: 'Not Found' })
|
||||||
const idx = list.findIndex((h) => String(h.id) === match[3])
|
const idx = list.findIndex((h) => String(h.id) === match[3])
|
||||||
if (idx === -1) return send(res, 404, { message: 'Not Found' })
|
if (idx === -1) return send(res, 404, { message: 'Not Found' })
|
||||||
list.splice(idx, 1)
|
list.splice(idx, 1)
|
||||||
@@ -255,9 +281,10 @@ export async function startFakeGitea(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleFeeds(match, searchParams, body, headers, res) {
|
function handleFeeds(match, searchParams, body, headers, res) {
|
||||||
const key = keyFrom(match[1], match[2])
|
const found = findRepoOrRedirect(match[1], match[2])
|
||||||
if (!repos.has(key)) return send(res, 404, { message: 'Not Found' })
|
if (!found) return send(res, 404, { message: 'Not Found' })
|
||||||
const all = feedsByRepo.get(key) || []
|
if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo, '/activities/feeds')
|
||||||
|
const all = feedsByRepo.get(found.key) || []
|
||||||
const limit = Number(searchParams.get('limit')) || 1000
|
const limit = Number(searchParams.get('limit')) || 1000
|
||||||
const page = Number(searchParams.get('page')) || 1
|
const page = Number(searchParams.get('page')) || 1
|
||||||
const start = (page - 1) * limit
|
const start = (page - 1) * limit
|
||||||
@@ -265,11 +292,12 @@ export async function startFakeGitea(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleGetContents(match, searchParams, body, headers, res) {
|
function handleGetContents(match, searchParams, body, headers, res) {
|
||||||
const key = keyFrom(match[1], match[2])
|
const found = findRepoOrRedirect(match[1], match[2])
|
||||||
const record = repos.get(key)
|
if (!found) return send(res, 404, { message: 'Not Found' })
|
||||||
if (!record) return send(res, 404, { message: 'Not Found' })
|
if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo, `/contents/${match[3]}`)
|
||||||
|
const record = found.record
|
||||||
const filePath = decodeURIComponent(match[3])
|
const filePath = decodeURIComponent(match[3])
|
||||||
const text = contents.get(`${key}:${filePath}`)
|
const text = contents.get(`${found.key}:${filePath}`)
|
||||||
if (text === undefined) return send(res, 404, { message: 'Not Found' })
|
if (text === undefined) return send(res, 404, { message: 'Not Found' })
|
||||||
const sha = createHash('sha1').update(text).digest('hex')
|
const sha = createHash('sha1').update(text).digest('hex')
|
||||||
send(res, 200, {
|
send(res, 200, {
|
||||||
@@ -283,11 +311,12 @@ export async function startFakeGitea(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleWriteContents(match, searchParams, body, headers, res) {
|
function handleWriteContents(match, searchParams, body, headers, res) {
|
||||||
const key = keyFrom(match[1], match[2])
|
const found = findRepoOrRedirect(match[1], match[2])
|
||||||
const record = repos.get(key)
|
if (!found) return send(res, 404, { message: 'Not Found' })
|
||||||
if (!record) return send(res, 404, { message: 'Not Found' })
|
if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo, `/contents/${match[3]}`)
|
||||||
|
const record = found.record
|
||||||
const filePath = decodeURIComponent(match[3])
|
const filePath = decodeURIComponent(match[3])
|
||||||
const contentKey = `${key}:${filePath}`
|
const contentKey = `${found.key}:${filePath}`
|
||||||
const existed = contents.has(contentKey)
|
const existed = contents.has(contentKey)
|
||||||
const decoded = Buffer.from(body?.content || '', 'base64').toString('utf8')
|
const decoded = Buffer.from(body?.content || '', 'base64').toString('utf8')
|
||||||
contents.set(contentKey, decoded)
|
contents.set(contentKey, decoded)
|
||||||
@@ -406,6 +435,14 @@ export async function startFakeGitea(options = {}) {
|
|||||||
body: parsedBody,
|
body: parsedBody,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const failIdx = pendingFailures.findIndex(
|
||||||
|
(f) => f.method === req.method && url.pathname.includes(f.pathIncludes)
|
||||||
|
)
|
||||||
|
if (failIdx !== -1) {
|
||||||
|
const [f] = pendingFailures.splice(failIdx, 1)
|
||||||
|
return send(res, f.status, { message: `fakeGitea: erzwungener Fehler (${f.status})` })
|
||||||
|
}
|
||||||
|
|
||||||
const route = ROUTES.find((r) => r.method === req.method && r.re.test(url.pathname))
|
const route = ROUTES.find((r) => r.method === req.method && r.re.test(url.pathname))
|
||||||
if (!route) return send(res, 404, { message: 'Not Found' })
|
if (!route) return send(res, 404, { message: 'Not Found' })
|
||||||
const match = route.re.exec(url.pathname)
|
const match = route.re.exec(url.pathname)
|
||||||
@@ -459,7 +496,7 @@ export async function startFakeGitea(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hooksOf(fullName) {
|
function hooksOf(fullName) {
|
||||||
return (hooksByRepo.get(keyFrom(fullName)) || []).map((h) => ({ ...h, config: { ...h.config } }))
|
return (hooksByRepo.get(keyFrom(fullName)) || []).map(serializeHook)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDown(down) {
|
function setDown(down) {
|
||||||
@@ -473,10 +510,14 @@ export async function startFakeGitea(options = {}) {
|
|||||||
transferDelayMs = Number(ms) || 0
|
transferDelayMs = Number(ms) || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function failNext({ method, pathIncludes, status }) {
|
||||||
|
pendingFailures.push({ method, pathIncludes, status })
|
||||||
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
for (const socket of sockets) socket.destroy()
|
for (const socket of sockets) socket.destroy()
|
||||||
return new Promise((resolve) => server.close(() => resolve()))
|
return new Promise((resolve) => server.close(() => resolve()))
|
||||||
}
|
}
|
||||||
|
|
||||||
return { baseUrl, calls, repo, addRepo, hooksOf, setDown, setTransferDelay, close }
|
return { baseUrl, calls, repo, addRepo, hooksOf, setDown, setTransferDelay, failNext, close }
|
||||||
}
|
}
|
||||||
+58
-4
@@ -28,6 +28,8 @@ function doc(name) {
|
|||||||
return aw.get('websiteProjects', idByRepo.get(name))
|
return aw.get('websiteProjects', idByRepo.get(name))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PORTAL_HOOK_URL = 'https://project.webklar.com/webhook/gitea'
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
gitea = await startFakeGitea({
|
gitea = await startFakeGitea({
|
||||||
repos: [
|
repos: [
|
||||||
@@ -39,10 +41,18 @@ before(async () => {
|
|||||||
{ full_name: 'JUSN/schon-da' },
|
{ full_name: 'JUSN/schon-da' },
|
||||||
{ full_name: 'WEBklar/von-hand' },
|
{ full_name: 'WEBklar/von-hand' },
|
||||||
{ full_name: 'ANDJ/unberuehrt' },
|
{ full_name: 'ANDJ/unberuehrt' },
|
||||||
|
{ full_name: 'JUSN/doc-fail-test' },
|
||||||
|
{ full_name: 'JUSN/verboten' },
|
||||||
|
{ full_name: 'JUSN/Case-Test' },
|
||||||
],
|
],
|
||||||
|
// 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`.
|
||||||
hooks: {
|
hooks: {
|
||||||
'knso/emmas-schatzkiste': [{ id: 110, url: 'https://project.webklar.com/webhook/gitea' }],
|
'knso/emmas-schatzkiste': [{ id: 110, url: PORTAL_HOOK_URL }],
|
||||||
'knso/hytail-ark': [{ id: 7, url: 'https://webklar.com/webhook/hytail-ark/deploy' }],
|
'knso/hytail-ark': [{ id: 7, url: 'https://webklar.com/webhook/hytail-ark/deploy' }],
|
||||||
|
'WEBklar/von-hand': [{ id: 210, url: PORTAL_HOOK_URL }],
|
||||||
|
'WEBklar/schon-da': [{ id: 310, url: PORTAL_HOOK_URL }],
|
||||||
|
'JUSN/doc-fail-test': [{ id: 410, url: PORTAL_HOOK_URL }],
|
||||||
},
|
},
|
||||||
// knso/von-hand wurde von Hand bereits nach WEBklar/von-hand verschoben (kein Transfer-Aufruf noetig).
|
// knso/von-hand wurde von Hand bereits nach WEBklar/von-hand verschoben (kein Transfer-Aufruf noetig).
|
||||||
redirects: {
|
redirects: {
|
||||||
@@ -59,6 +69,9 @@ before(async () => {
|
|||||||
'JUSN/schon-da',
|
'JUSN/schon-da',
|
||||||
'knso/von-hand', // Appwrite kennt noch den alten Namen (siehe Brief)
|
'knso/von-hand', // Appwrite kennt noch den alten Namen (siehe Brief)
|
||||||
'ANDJ/unberuehrt',
|
'ANDJ/unberuehrt',
|
||||||
|
'JUSN/doc-fail-test',
|
||||||
|
'JUSN/verboten',
|
||||||
|
'jusn/case-test', // Appwrite kennt eine andere Schreibweise als Gitea (dort: JUSN/Case-Test)
|
||||||
]
|
]
|
||||||
const websiteProjects = repoNames.map((repoFullName) => {
|
const websiteProjects = repoNames.map((repoFullName) => {
|
||||||
const $id = randomUUID()
|
const $id = randomUUID()
|
||||||
@@ -106,11 +119,29 @@ test('verschiebt Repo aus eigenem Konto', async () => {
|
|||||||
await assert.rejects(fs.access(previewWorkDir('JUSN/D-ner-Nation')))
|
await assert.rejects(fs.access(previewWorkDir('JUSN/D-ner-Nation')))
|
||||||
})
|
})
|
||||||
test('löscht den alten Portal-Webhook', async () => {
|
test('löscht den alten Portal-Webhook', async () => {
|
||||||
await moveRepoToOrg(doc('knso/emmas-schatzkiste'))
|
const r = await moveRepoToOrg(doc('knso/emmas-schatzkiste'))
|
||||||
|
assert.deepEqual(r.moved, { from: 'knso/emmas-schatzkiste', to: 'WEBklar/emmas-schatzkiste' })
|
||||||
assert.deepEqual(gitea.hooksOf('WEBklar/emmas-schatzkiste'), [])
|
assert.deepEqual(gitea.hooksOf('WEBklar/emmas-schatzkiste'), [])
|
||||||
|
assert.equal(
|
||||||
|
gitea.calls.some(
|
||||||
|
(c) => c.method === 'DELETE' && c.path === '/api/v1/repos/WEBklar/emmas-schatzkiste/hooks/110'
|
||||||
|
),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
test('docNotUpdated: Repo zieht trotzdem um, Portal-Webhook wird trotzdem geloescht', async () => {
|
||||||
|
aw.failNext({ method: 'PATCH', pathIncludes: doc('JUSN/doc-fail-test').$id, status: 500 })
|
||||||
|
const r = await moveRepoToOrg(doc('JUSN/doc-fail-test'))
|
||||||
|
assert.deepEqual(r.moved, { from: 'JUSN/doc-fail-test', to: 'WEBklar/doc-fail-test' })
|
||||||
|
assert.match(r.warnings[0], /^Das Repo liegt jetzt in WEBklar/)
|
||||||
|
assert.deepEqual(gitea.hooksOf('WEBklar/doc-fail-test'), [])
|
||||||
})
|
})
|
||||||
test('schon in WEBklar: nichts passiert', async () => {
|
test('schon in WEBklar: nichts passiert', async () => {
|
||||||
assert.deepEqual(await moveRepoToOrg(doc('WEBklar/schon-da')), { moved: null, warnings: [] })
|
const r = await moveRepoToOrg(doc('WEBklar/schon-da'))
|
||||||
|
assert.deepEqual(r, { moved: null, warnings: [] })
|
||||||
|
// R11: "Pushes nachtragen" raeumt auch bei einem schon in WEBklar liegenden Repo einen liegen
|
||||||
|
// gebliebenen Einzel-Webhook auf.
|
||||||
|
assert.deepEqual(gitea.hooksOf('WEBklar/schon-da'), [])
|
||||||
})
|
})
|
||||||
test('internes Repo per Liste und per fremdem Webhook', async () => {
|
test('internes Repo per Liste und per fremdem Webhook', async () => {
|
||||||
for (const name of ['ANDJ/Steuerungstechnik', 'knso/hytail-ark']) {
|
for (const name of ['ANDJ/Steuerungstechnik', 'knso/hytail-ark']) {
|
||||||
@@ -122,11 +153,34 @@ test('Name existiert in WEBklar', async () => {
|
|||||||
const r = await moveRepoToOrg(doc('JUSN/schon-da'))
|
const r = await moveRepoToOrg(doc('JUSN/schon-da'))
|
||||||
assert.deepEqual(r, { moved: null, warnings: ['In WEBklar gibt es schon ein Repo namens schon-da. Bitte in Gitea umbenennen und dann ‚Pushes nachtragen‘ klicken.'] })
|
assert.deepEqual(r, { moved: null, warnings: ['In WEBklar gibt es schon ein Repo namens schon-da. Bitte in Gitea umbenennen und dann ‚Pushes nachtragen‘ klicken.'] })
|
||||||
})
|
})
|
||||||
|
test('R10: 403 beim Transfer', async () => {
|
||||||
|
gitea.failNext({ method: 'POST', pathIncludes: '/transfer', status: 403 })
|
||||||
|
const r = await moveRepoToOrg(doc('JUSN/verboten'))
|
||||||
|
assert.equal(r.moved, null)
|
||||||
|
assert.match(r.warnings[0], /^Umzug nach WEBklar nicht möglich \(.+\)\.$/)
|
||||||
|
})
|
||||||
test('Weiterleitung korrigiert Projektdaten', async () => {
|
test('Weiterleitung korrigiert Projektdaten', async () => {
|
||||||
const r = await moveRepoToOrg(doc('knso/von-hand'))
|
const r = await moveRepoToOrg(doc('knso/von-hand'))
|
||||||
assert.deepEqual(r.moved, { from: 'knso/von-hand', to: 'WEBklar/von-hand' })
|
assert.deepEqual(r.moved, { from: 'knso/von-hand', to: 'WEBklar/von-hand' })
|
||||||
assert.equal(gitea.calls.filter((c) => c.path.endsWith('/transfer') && c.path.includes('von-hand')).length, 0)
|
assert.equal(gitea.calls.filter((c) => c.path.endsWith('/transfer') && c.path.includes('von-hand')).length, 0)
|
||||||
assert.equal(aw.get('websiteProjects', doc('knso/von-hand').$id).repoFullName, 'WEBklar/von-hand')
|
const d = aw.get('websiteProjects', doc('knso/von-hand').$id)
|
||||||
|
assert.equal(d.repoFullName, 'WEBklar/von-hand')
|
||||||
|
assert.equal(d.giteaRepoUrl, `${gitea.baseUrl}/WEBklar/von-hand`)
|
||||||
|
assert.equal(d.giteaRepoName, 'von-hand')
|
||||||
|
// R11/I3: der beim Hand-Umzug liegen gebliebene Einzel-Webhook wird jetzt mit aufgeraeumt.
|
||||||
|
assert.deepEqual(gitea.hooksOf('WEBklar/von-hand'), [])
|
||||||
|
})
|
||||||
|
test('nur Groß-/Kleinschreibung unterschiedlich: echter Umzug', async () => {
|
||||||
|
// I1: gespeichert ist "jusn/case-test", Gitea antwortet auf denselben Namen mit "JUSN/Case-Test"
|
||||||
|
// (reine Schreibweise, kein tatsaechlicher Umzug) - das darf den Selbstheilungs-Zweig NICHT
|
||||||
|
// ausloesen, sondern muss ganz normal weiter zum Transfer fuehren.
|
||||||
|
const r = await moveRepoToOrg(doc('jusn/case-test'))
|
||||||
|
assert.deepEqual(r, { moved: { from: 'jusn/case-test', to: 'WEBklar/Case-Test' }, warnings: [] })
|
||||||
|
assert.equal(
|
||||||
|
gitea.calls.some((c) => c.method === 'POST' && c.path === '/api/v1/repos/jusn/case-test/transfer'),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
assert.equal(aw.get('websiteProjects', doc('jusn/case-test').$id).repoFullName, 'WEBklar/Case-Test')
|
||||||
})
|
})
|
||||||
test('Erkennung ohne Groß-/Kleinschreibung', async () => {
|
test('Erkennung ohne Groß-/Kleinschreibung', async () => {
|
||||||
assert.equal(isOrgRepo('webklar/x'), true)
|
assert.equal(isOrgRepo('webklar/x'), true)
|
||||||
|
|||||||
Reference in new issue
Block a user