Gitea-Client herausgelöst, Konfiguration für Organisation und System-Konto, Test-Nachbauten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ea71b3f1c3
commit
531ee2a02b
6 files changed
+824
-47
No files matched your search
@@ -235,6 +235,20 @@ export const config = {
|
|||||||
templateOwner: process.env.GITEA_TEMPLATE_OWNER || 'knso',
|
templateOwner: process.env.GITEA_TEMPLATE_OWNER || 'knso',
|
||||||
templateRepo: process.env.GITEA_TEMPLATE_REPO || 'webklar-preview-template',
|
templateRepo: process.env.GITEA_TEMPLATE_REPO || 'webklar-preview-template',
|
||||||
defaultOwner: process.env.GITEA_DEFAULT_OWNER || 'knso',
|
defaultOwner: process.env.GITEA_DEFAULT_OWNER || 'knso',
|
||||||
|
// Ziel-Organisation des Umzugs (siehe docs/superpowers/plans/2026-09-27-gitea-organisation-1-kundenportal.md)
|
||||||
|
org: process.env.GITEA_ORG || 'WEBklar',
|
||||||
|
// Login des System-Kontos, das automatische Commits/Pushes ausfuehrt (kein Arbeitsblatt dafuer)
|
||||||
|
systemUser: process.env.GITEA_SYSTEM_USER || 'webklar-system',
|
||||||
|
// Interne Repos, die trotz Zuweisung nicht nach GITEA_ORG umziehen
|
||||||
|
keepInPlace: (
|
||||||
|
process.env.GITEA_KEEP_IN_PLACE ||
|
||||||
|
'ANDJ/Emailsorter,ANDJ/Steuerungstechnik,JUSN/tickte-system,JUSN/Webklar,knso/eship,knso/Webklar-Kundenbereich'
|
||||||
|
)
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
// Beim Zuweisen eines Projekts werden Pushes der letzten so-vielen Tage nachgetragen
|
||||||
|
backfillDays: Number(process.env.GITEA_BACKFILL_DAYS) || 30,
|
||||||
},
|
},
|
||||||
|
|
||||||
cors: {
|
cors: {
|
||||||
|
|||||||
@@ -13,53 +13,8 @@ import {
|
|||||||
syncPreviewTls,
|
syncPreviewTls,
|
||||||
fetchPreviewConfig,
|
fetchPreviewConfig,
|
||||||
} from './previewDeploy.js'
|
} from './previewDeploy.js'
|
||||||
|
import { giteaFetch } from './giteaClient.js'
|
||||||
function giteaHeaders() {
|
import { slugify } from './slug.js'
|
||||||
if (!config.gitea.apiToken) {
|
|
||||||
throw new Error('GITEA_API_TOKEN fehlt in .env')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
Authorization: `token ${config.gitea.apiToken}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function giteaFetch(path, options = {}) {
|
|
||||||
const response = await fetch(`${config.gitea.baseUrl}/api/v1${path}`, {
|
|
||||||
...options,
|
|
||||||
headers: { ...giteaHeaders(), ...options.headers },
|
|
||||||
})
|
|
||||||
|
|
||||||
const text = await response.text()
|
|
||||||
let data = null
|
|
||||||
if (text) {
|
|
||||||
try {
|
|
||||||
data = JSON.parse(text)
|
|
||||||
} catch {
|
|
||||||
data = { message: text }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
let message = data?.message || `Gitea ${response.status}`
|
|
||||||
if (response.status === 409 && /same name already exists/i.test(message)) {
|
|
||||||
message = 'Ein Gitea-Repository mit diesem Namen existiert bereits. Bitte einen anderen Subdomain-/Repo-Namen w�hlen.'
|
|
||||||
}
|
|
||||||
const error = new Error(message)
|
|
||||||
error.status = response.status
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
function slugify(value) {
|
|
||||||
return String(value || '')
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9-]+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '')
|
|
||||||
.slice(0, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listAllAccessibleRepos() {
|
export async function listAllAccessibleRepos() {
|
||||||
const repos = []
|
const repos = []
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { config } from '../config.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut die Authorization-/Content-Type-Header fuer einen Gitea-API-Aufruf.
|
||||||
|
* Wirft, wenn GITEA_API_TOKEN nicht gesetzt ist.
|
||||||
|
*/
|
||||||
|
function giteaHeaders() {
|
||||||
|
if (!config.gitea.apiToken) {
|
||||||
|
throw new Error('GITEA_API_TOKEN fehlt in .env')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
Authorization: `token ${config.gitea.apiToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruft `${config.gitea.baseUrl}/api/v1<path>` auf und liefert die geparste JSON-Antwort
|
||||||
|
* (oder null ohne Body). Eine Nicht-2xx-Antwort wirft eine Error mit .status; ein 409
|
||||||
|
* "same name already exists" wird in die deutsche Meldung uebersetzt (Subdomain-/Repo-Name).
|
||||||
|
*/
|
||||||
|
export async function giteaFetch(path, options = {}) {
|
||||||
|
const response = await fetch(`${config.gitea.baseUrl}/api/v1${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: { ...giteaHeaders(), ...options.headers },
|
||||||
|
})
|
||||||
|
|
||||||
|
const text = await response.text()
|
||||||
|
let data = null
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
data = { message: text }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = data?.message || `Gitea ${response.status}`
|
||||||
|
if (response.status === 409 && /same name already exists/i.test(message)) {
|
||||||
|
message = 'Ein Gitea-Repository mit diesem Namen existiert bereits. Bitte einen anderen Subdomain-/Repo-Namen w�hlen.'
|
||||||
|
}
|
||||||
|
const error = new Error(message)
|
||||||
|
error.status = response.status
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zerlegt "owner/repo" in [owner, repo]. Reines String-Splitting ohne Validierung -
|
||||||
|
* fuer nicht "owner/repo"-foermige Eingaben ist das Ergebnis entsprechend kurz/lang.
|
||||||
|
*/
|
||||||
|
export function splitRepo(fullName) {
|
||||||
|
return String(fullName || '').split('/')
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { test, before, after } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { startFakeGitea } from './helpers/fakeGitea.js'
|
||||||
|
|
||||||
|
let fake
|
||||||
|
let giteaFetch
|
||||||
|
let config
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
fake = await startFakeGitea({ repos: [{ full_name: 'JUSN/D-ner-Nation' }] })
|
||||||
|
Object.assign(process.env, {
|
||||||
|
GITEA_BASE_URL: fake.baseUrl,
|
||||||
|
GITEA_API_TOKEN: 'test-token',
|
||||||
|
})
|
||||||
|
// config.js liest die Umgebung beim Import
|
||||||
|
;({ giteaFetch } = await import('../server/services/giteaClient.js'))
|
||||||
|
;({ config } = await import('../server/config.js'))
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await fake.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sendet den Token', async () => {
|
||||||
|
await giteaFetch('/repos/JUSN/D-ner-Nation')
|
||||||
|
assert.equal(fake.calls.at(-1).headers.authorization, 'token test-token')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('404 hat status', async () => {
|
||||||
|
await assert.rejects(giteaFetch('/repos/x/y'), (e) => e.status === 404)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Voreinstellungen der Konfiguration', () => {
|
||||||
|
assert.equal(config.gitea.org, 'WEBklar')
|
||||||
|
assert.equal(config.gitea.systemUser, 'webklar-system')
|
||||||
|
assert.equal(config.gitea.backfillDays, 30)
|
||||||
|
assert.ok(config.gitea.keepInPlace.includes('JUSN/tickte-system'))
|
||||||
|
assert.equal(config.gitea.keepInPlace.length, 6)
|
||||||
|
})
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import http from 'node:http'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nachgebautes Appwrite fuer Tests: nur der REST-Ausschnitt, den `appwriteAdmin.js` und
|
||||||
|
* `appwriteStaff.js` nutzen (Dokumente einer Sammlung, `/account` per JWT). Laeuft auf
|
||||||
|
* 127.0.0.1 mit einem vom Betriebssystem vergebenen Port; `endpoint` endet auf `/v1`,
|
||||||
|
* genau wie `config.appwrite.endpoint`.
|
||||||
|
*
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {Record<string, object[]>} [options.seed]
|
||||||
|
* Anfangsbestand je Sammlung (Sammlungsname -> Dokumente). Jedes Dokument darf `$id`
|
||||||
|
* mitbringen (sonst wird eins erzeugt); `$createdAt`/`$updatedAt` werden sonst mit dem
|
||||||
|
* Startzeitpunkt gefuellt.
|
||||||
|
* @returns {Promise<{
|
||||||
|
* endpoint: string,
|
||||||
|
* all: (collection: string) => object[],
|
||||||
|
* get: (collection: string, id: string) => (object|undefined),
|
||||||
|
* calls: Array<{method: string, path: string, query: Record<string, string>, headers: Record<string, string>, body: any}>,
|
||||||
|
* close: () => Promise<void>,
|
||||||
|
* }>} `all`/`get` lesen den LIVE-Stand (nach create/patch/delete ueber HTTP). Die
|
||||||
|
* Datenbank-ID im Pfad wird nicht geprueft - jede wird akzeptiert.
|
||||||
|
*/
|
||||||
|
export async function startFakeAppwrite(options = {}) {
|
||||||
|
const calls = []
|
||||||
|
/** @type {Map<string, Map<string, object>>} Sammlungsname -> (Dokument-ID -> Dokument). */
|
||||||
|
const store = new Map()
|
||||||
|
|
||||||
|
function collectionOf(name) {
|
||||||
|
let map = store.get(name)
|
||||||
|
if (!map) {
|
||||||
|
map = new Map()
|
||||||
|
store.set(name, map)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
function all(col) {
|
||||||
|
return [...collectionOf(col).values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
function get(col, id) {
|
||||||
|
return collectionOf(col).get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(res, status, body) {
|
||||||
|
if (body === undefined) {
|
||||||
|
res.writeHead(status)
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = JSON.stringify(body)
|
||||||
|
res.writeHead(status, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(text),
|
||||||
|
})
|
||||||
|
res.end(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseQueries(searchParams) {
|
||||||
|
return searchParams
|
||||||
|
.getAll('queries[]')
|
||||||
|
.map((s) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(s)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- Routen --- */
|
||||||
|
|
||||||
|
function handleAccount(match, searchParams, body, headers, res) {
|
||||||
|
if (headers['x-appwrite-jwt'] === 'jwt-admin') {
|
||||||
|
return send(res, 200, { $id: 'staff1', labels: ['admin'] })
|
||||||
|
}
|
||||||
|
send(res, 401, { message: 'Unauthorized' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleListDocuments(match, searchParams, body, headers, res) {
|
||||||
|
const docs = [...collectionOf(match[2]).values()]
|
||||||
|
const queries = parseQueries(searchParams)
|
||||||
|
|
||||||
|
let result = docs.filter((doc) =>
|
||||||
|
queries.every((q) => {
|
||||||
|
if (q.method === 'equal') return q.values.includes(doc[q.attribute])
|
||||||
|
if (q.method === 'greaterThan') return doc[q.attribute] > q.values[0]
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const orderAsc = queries.find((q) => q.method === 'orderAsc')
|
||||||
|
const orderDesc = queries.find((q) => q.method === 'orderDesc')
|
||||||
|
const sortAttr = orderAsc?.attribute || orderDesc?.attribute
|
||||||
|
if (sortAttr) {
|
||||||
|
const dir = orderAsc ? 1 : -1
|
||||||
|
result = [...result].sort((a, b) => {
|
||||||
|
if (a[sortAttr] === b[sortAttr]) return 0
|
||||||
|
return a[sortAttr] > b[sortAttr] ? dir : -dir
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = result.length
|
||||||
|
const limit = queries.find((q) => q.method === 'limit')
|
||||||
|
if (limit) result = result.slice(0, Number(limit.values[0]))
|
||||||
|
|
||||||
|
send(res, 200, { total, documents: result })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateDocument(match, searchParams, body, headers, res) {
|
||||||
|
const map = collectionOf(match[2])
|
||||||
|
const documentId = body?.documentId || randomUUID()
|
||||||
|
if (map.has(documentId)) {
|
||||||
|
return send(res, 409, { message: 'Document with the requested ID already exists.' })
|
||||||
|
}
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const doc = { $id: documentId, $createdAt: now, $updatedAt: now, ...(body?.data || {}) }
|
||||||
|
map.set(documentId, doc)
|
||||||
|
send(res, 201, doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetDocument(match, searchParams, body, headers, res) {
|
||||||
|
const doc = collectionOf(match[2]).get(match[3])
|
||||||
|
if (!doc) return send(res, 404, { message: 'Document not found' })
|
||||||
|
send(res, 200, doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePatchDocument(match, searchParams, body, headers, res) {
|
||||||
|
const doc = collectionOf(match[2]).get(match[3])
|
||||||
|
if (!doc) return send(res, 404, { message: 'Document not found' })
|
||||||
|
Object.assign(doc, body?.data || {})
|
||||||
|
doc.$updatedAt = new Date().toISOString()
|
||||||
|
send(res, 200, doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteDocument(match, searchParams, body, headers, res) {
|
||||||
|
const map = collectionOf(match[2])
|
||||||
|
if (!map.has(match[3])) return send(res, 404, { message: 'Document not found' })
|
||||||
|
map.delete(match[3])
|
||||||
|
send(res, 204)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROUTES = [
|
||||||
|
{ method: 'GET', re: /^\/v1\/account$/, handler: handleAccount },
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
re: /^\/v1\/databases\/([^/]+)\/collections\/([^/]+)\/documents$/,
|
||||||
|
handler: handleListDocuments,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
re: /^\/v1\/databases\/([^/]+)\/collections\/([^/]+)\/documents$/,
|
||||||
|
handler: handleCreateDocument,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
re: /^\/v1\/databases\/([^/]+)\/collections\/([^/]+)\/documents\/([^/]+)$/,
|
||||||
|
handler: handleGetDocument,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
re: /^\/v1\/databases\/([^/]+)\/collections\/([^/]+)\/documents\/([^/]+)$/,
|
||||||
|
handler: handlePatchDocument,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
re: /^\/v1\/databases\/([^/]+)\/collections\/([^/]+)\/documents\/([^/]+)$/,
|
||||||
|
handler: handleDeleteDocument,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/* --------------------------------------------------------- Server --- */
|
||||||
|
|
||||||
|
const sockets = new Set()
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const chunks = []
|
||||||
|
req.on('data', (c) => chunks.push(c))
|
||||||
|
req.on('end', () => {
|
||||||
|
const raw = Buffer.concat(chunks).toString('utf8')
|
||||||
|
const url = new URL(req.url, 'http://fake-appwrite.local')
|
||||||
|
let parsedBody = null
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
parsedBody = JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
parsedBody = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
calls.push({
|
||||||
|
method: req.method,
|
||||||
|
path: url.pathname,
|
||||||
|
query: Object.fromEntries(url.searchParams),
|
||||||
|
headers: { ...req.headers },
|
||||||
|
body: parsedBody,
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = ROUTES.find((r) => r.method === req.method && r.re.test(url.pathname))
|
||||||
|
if (!route) return send(res, 404, { message: 'Not Found' })
|
||||||
|
const match = route.re.exec(url.pathname)
|
||||||
|
try {
|
||||||
|
route.handler(match, url.searchParams, parsedBody, req.headers, res)
|
||||||
|
} catch (err) {
|
||||||
|
send(res, 500, { message: err?.message || 'fakeAppwrite: interner Fehler' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
server.on('connection', (socket) => {
|
||||||
|
sockets.add(socket)
|
||||||
|
socket.on('close', () => sockets.delete(socket))
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||||
|
const { port } = server.address()
|
||||||
|
const endpoint = `http://127.0.0.1:${port}/v1`
|
||||||
|
|
||||||
|
/* --------------------------------------------------------- Seeding --- */
|
||||||
|
|
||||||
|
const seededAt = new Date().toISOString()
|
||||||
|
for (const [col, docs] of Object.entries(options.seed || {})) {
|
||||||
|
const map = collectionOf(col)
|
||||||
|
for (const doc of docs || []) {
|
||||||
|
const { $id, $createdAt, $updatedAt, ...fields } = doc
|
||||||
|
const id = $id || randomUUID()
|
||||||
|
map.set(id, { $id: id, $createdAt: $createdAt || seededAt, $updatedAt: $updatedAt || seededAt, ...fields })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
for (const socket of sockets) socket.destroy()
|
||||||
|
return new Promise((resolve) => server.close(() => resolve()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return { endpoint, all, get, calls, close }
|
||||||
|
}
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
import http from 'node:http'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nachgebautes Gitea 1.25 fuer Tests: nur der REST-Ausschnitt unter `/api/v1`, den das
|
||||||
|
* Kundenportal nutzt (Repos, Transfer, Hooks, Aktivitaeten-Feed, Contents, Orgs, Teams).
|
||||||
|
* Laeuft auf 127.0.0.1 mit einem vom Betriebssystem vergebenen Port.
|
||||||
|
*
|
||||||
|
* Besitzer-/Repo-Namen werden ueberall ohne Ruecksicht auf Gross-/Kleinschreibung
|
||||||
|
* nachgeschlagen, aber in der zuletzt gesetzten Schreibweise gespeichert - wie Gitea es
|
||||||
|
* auch haelt.
|
||||||
|
*
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {Array<{full_name: string, archived?: boolean, private?: boolean, description?: string, default_branch?: string}>} [options.repos]
|
||||||
|
* Anfangsbestand an Repos.
|
||||||
|
* @param {Record<string, Array<{id: number|string, url: string, events?: string[], active?: boolean, type?: string}>>} [options.hooks]
|
||||||
|
* Repo-Webhooks je `"owner/repo"`.
|
||||||
|
* @param {Record<string, object[]>} [options.feeds]
|
||||||
|
* Aktivitaeten-Feed je `"owner/repo"`, neueste zuerst (wie `GET .../activities/feeds` sie liefert).
|
||||||
|
* @param {Record<string, string>} [options.contents]
|
||||||
|
* Dateiinhalt (Klartext, keine Kodierung) je `"owner/repo:pfad"`, z. B. `"knso/x:.webklar-preview.json"`.
|
||||||
|
* @param {string[]} [options.orgs]
|
||||||
|
* Organisationen, die von Anfang an existieren (mit leerer Owners-Mitgliedschaft).
|
||||||
|
* @param {string} [options.user] Login, das `GET /user` meldet. Voreinstellung `'webklar-system'`.
|
||||||
|
* @returns {Promise<{
|
||||||
|
* baseUrl: string,
|
||||||
|
* calls: Array<{method: string, path: string, query: Record<string, string>, headers: Record<string, string>, body: any}>,
|
||||||
|
* repo: (fullName: string) => (object|undefined),
|
||||||
|
* addRepo: (repo: object) => object,
|
||||||
|
* hooksOf: (fullName: string) => object[],
|
||||||
|
* setDown: (down: boolean) => void,
|
||||||
|
* setTransferDelay: (ms: number) => void,
|
||||||
|
* close: () => Promise<void>,
|
||||||
|
* }>} `calls` waechst live, in der Reihenfolge, in der die Anfragen eintreffen - VOR einer
|
||||||
|
* etwaigen `setTransferDelay`-Verzoegerung der Antwort. `repo()`/`hooksOf()` lesen den
|
||||||
|
* aktuellen Stand direkt (ohne Weiterleitung ueber einen umgezogenen alten Namen).
|
||||||
|
*/
|
||||||
|
export async function startFakeGitea(options = {}) {
|
||||||
|
const calls = []
|
||||||
|
let isDown = false
|
||||||
|
let transferDelayMs = 0
|
||||||
|
|
||||||
|
let nextRepoId = 1
|
||||||
|
let nextHookId = 1
|
||||||
|
let nextOrgId = 1
|
||||||
|
let nextTeamId = 1
|
||||||
|
|
||||||
|
/** @type {Map<string, object>} Schluessel: `"owner/repo"` klein geschrieben. */
|
||||||
|
const repos = new Map()
|
||||||
|
/** @type {Map<string, string>} alter Schluessel -> neuer full_name (kanonische Schreibweise) nach Transfer. */
|
||||||
|
const redirects = new Map()
|
||||||
|
/** @type {Map<string, object[]>} Schluessel: `"owner/repo"` klein geschrieben. */
|
||||||
|
const hooksByRepo = new Map()
|
||||||
|
/** @type {Map<string, object[]>} Schluessel: `"owner/repo"` klein geschrieben. */
|
||||||
|
const feedsByRepo = new Map()
|
||||||
|
/** @type {Map<string, string>} Schluessel: `"owner/repo"` klein geschrieben + `:` + Pfad (Original-Gross-/Kleinschreibung). */
|
||||||
|
const contents = new Map()
|
||||||
|
/** @type {Map<string, {id: number, username: string, full_name: string, visibility: string, ownersTeamId: number}>} Schluessel: Login klein geschrieben. */
|
||||||
|
const orgs = new Map()
|
||||||
|
/** @type {Map<number, {id: number, name: string, org: string, members: string[]}>} */
|
||||||
|
const teams = new Map()
|
||||||
|
/** @type {Map<string, object[]>} Schluessel: Org-Login klein geschrieben. */
|
||||||
|
const orgHooks = new Map()
|
||||||
|
|
||||||
|
const userLogin = options.user || 'webklar-system'
|
||||||
|
|
||||||
|
function keyFrom(a, b) {
|
||||||
|
return (b === undefined ? a : `${a}/${b}`).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const t = setTimeout(resolve, ms)
|
||||||
|
t.unref?.()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(res, status, body) {
|
||||||
|
if (body === undefined) {
|
||||||
|
res.writeHead(status)
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = JSON.stringify(body)
|
||||||
|
res.writeHead(status, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(text),
|
||||||
|
})
|
||||||
|
res.end(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeRepo(record) {
|
||||||
|
return { ...record, owner: { ...record.owner } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRepoInternal(def) {
|
||||||
|
const fullName = def?.full_name || ''
|
||||||
|
const slash = fullName.indexOf('/')
|
||||||
|
if (slash < 1) {
|
||||||
|
throw new Error(`fakeGitea: repo full_name muss "owner/repo" sein, bekam "${fullName}"`)
|
||||||
|
}
|
||||||
|
const owner = fullName.slice(0, slash)
|
||||||
|
const name = fullName.slice(slash + 1)
|
||||||
|
const record = {
|
||||||
|
id: nextRepoId++,
|
||||||
|
name,
|
||||||
|
full_name: fullName,
|
||||||
|
owner: { login: owner },
|
||||||
|
html_url: `${baseUrl}/${fullName}`,
|
||||||
|
default_branch: def.default_branch || 'main',
|
||||||
|
archived: Boolean(def.archived),
|
||||||
|
private: Boolean(def.private),
|
||||||
|
description: def.description || '',
|
||||||
|
empty: false,
|
||||||
|
}
|
||||||
|
repos.set(keyFrom(fullName), record)
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHook(h) {
|
||||||
|
return {
|
||||||
|
id: h.id,
|
||||||
|
url: h.url,
|
||||||
|
events: h.events || ['push'],
|
||||||
|
active: h.active !== undefined ? h.active : true,
|
||||||
|
type: h.type || 'gitea',
|
||||||
|
config: { url: h.url, content_type: 'json', ...(h.config || {}) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOrgRecord(username, full_name, visibility) {
|
||||||
|
const org = {
|
||||||
|
id: nextOrgId++,
|
||||||
|
username,
|
||||||
|
full_name: full_name || username,
|
||||||
|
visibility: visibility || 'public',
|
||||||
|
ownersTeamId: nextTeamId++,
|
||||||
|
}
|
||||||
|
orgs.set(username.toLowerCase(), org)
|
||||||
|
teams.set(org.ownersTeamId, { id: org.ownersTeamId, name: 'Owners', org: username, members: [] })
|
||||||
|
return org
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveMapKey(map, oldKey, newKey) {
|
||||||
|
if (map.has(oldKey)) {
|
||||||
|
map.set(newKey, map.get(oldKey))
|
||||||
|
map.delete(oldKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveContentKeys(oldKey, newKey) {
|
||||||
|
const prefix = `${oldKey}:`
|
||||||
|
for (const [k, v] of [...contents.entries()]) {
|
||||||
|
if (k.startsWith(prefix)) {
|
||||||
|
contents.delete(k)
|
||||||
|
contents.set(`${newKey}${k.slice(oldKey.length)}`, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRepoOrRedirect(owner, name) {
|
||||||
|
const key = keyFrom(owner, name)
|
||||||
|
if (repos.has(key)) return { key, record: repos.get(key) }
|
||||||
|
const redirectTo = redirects.get(key)
|
||||||
|
if (redirectTo) return { key, redirectTo }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- Routen --- */
|
||||||
|
|
||||||
|
function handleGetUser(match, searchParams, body, headers, res) {
|
||||||
|
send(res, 200, { login: userLogin, is_admin: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearchRepos(match, searchParams, body, headers, res) {
|
||||||
|
const all = [...repos.values()]
|
||||||
|
const limit = Number(searchParams.get('limit')) || 50
|
||||||
|
const page = Number(searchParams.get('page')) || 1
|
||||||
|
const start = (page - 1) * limit
|
||||||
|
send(res, 200, { ok: true, data: all.slice(start, start + limit).map(serializeRepo) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetRepo(match, searchParams, body, headers, res) {
|
||||||
|
const found = findRepoOrRedirect(match[1], match[2])
|
||||||
|
if (!found) return send(res, 404, { message: 'Not Found' })
|
||||||
|
if (found.redirectTo) {
|
||||||
|
return send304(res, found.redirectTo)
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
const found = findRepoOrRedirect(match[1], match[2])
|
||||||
|
if (!found) return send(res, 404, { message: 'Not Found' })
|
||||||
|
if (found.redirectTo) {
|
||||||
|
return send304(res, found.redirectTo)
|
||||||
|
}
|
||||||
|
Object.assign(found.record, body || {})
|
||||||
|
send(res, 200, serializeRepo(found.record))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTransfer(match, searchParams, body, headers, res) {
|
||||||
|
if (transferDelayMs > 0) await sleep(transferDelayMs)
|
||||||
|
|
||||||
|
const key = keyFrom(match[1], match[2])
|
||||||
|
const record = repos.get(key)
|
||||||
|
if (!record) return send(res, 404, { message: 'Not Found' })
|
||||||
|
|
||||||
|
const newOwner = body?.new_owner
|
||||||
|
if (!newOwner) return send(res, 422, { message: 'new_owner erforderlich' })
|
||||||
|
|
||||||
|
const newFullName = `${newOwner}/${record.name}`
|
||||||
|
const newKey = keyFrom(newOwner, record.name)
|
||||||
|
if (repos.has(newKey)) {
|
||||||
|
return send(res, 422, {
|
||||||
|
message: `repository already exists [uname: ${newOwner}, name: ${record.name}]`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
repos.delete(key)
|
||||||
|
record.owner = { login: newOwner }
|
||||||
|
record.full_name = newFullName
|
||||||
|
repos.set(newKey, record)
|
||||||
|
redirects.set(key, newFullName)
|
||||||
|
moveMapKey(hooksByRepo, key, newKey)
|
||||||
|
moveMapKey(feedsByRepo, key, newKey)
|
||||||
|
moveContentKeys(key, newKey)
|
||||||
|
|
||||||
|
send(res, 202, serializeRepo(record))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetRepoHooks(match, searchParams, body, headers, res) {
|
||||||
|
const key = keyFrom(match[1], match[2])
|
||||||
|
if (!repos.has(key)) return send(res, 404, { message: 'Not Found' })
|
||||||
|
send(res, 200, hooksByRepo.get(key) || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteRepoHook(match, searchParams, body, headers, res) {
|
||||||
|
const key = keyFrom(match[1], match[2])
|
||||||
|
const list = hooksByRepo.get(key)
|
||||||
|
if (!repos.has(key) || !list) return send(res, 404, { message: 'Not Found' })
|
||||||
|
const idx = list.findIndex((h) => String(h.id) === match[3])
|
||||||
|
if (idx === -1) return send(res, 404, { message: 'Not Found' })
|
||||||
|
list.splice(idx, 1)
|
||||||
|
send(res, 204)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFeeds(match, searchParams, body, headers, res) {
|
||||||
|
const key = keyFrom(match[1], match[2])
|
||||||
|
if (!repos.has(key)) return send(res, 404, { message: 'Not Found' })
|
||||||
|
const all = feedsByRepo.get(key) || []
|
||||||
|
const limit = Number(searchParams.get('limit')) || 1000
|
||||||
|
const page = Number(searchParams.get('page')) || 1
|
||||||
|
const start = (page - 1) * limit
|
||||||
|
send(res, 200, all.slice(start, start + limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetContents(match, searchParams, body, headers, res) {
|
||||||
|
const key = keyFrom(match[1], match[2])
|
||||||
|
const record = repos.get(key)
|
||||||
|
if (!record) return send(res, 404, { message: 'Not Found' })
|
||||||
|
const filePath = decodeURIComponent(match[3])
|
||||||
|
const text = contents.get(`${key}:${filePath}`)
|
||||||
|
if (text === undefined) return send(res, 404, { message: 'Not Found' })
|
||||||
|
const sha = createHash('sha1').update(text).digest('hex')
|
||||||
|
send(res, 200, {
|
||||||
|
name: filePath.split('/').pop(),
|
||||||
|
path: filePath,
|
||||||
|
sha,
|
||||||
|
encoding: 'base64',
|
||||||
|
content: Buffer.from(text, 'utf8').toString('base64'),
|
||||||
|
html_url: `${baseUrl}/${record.full_name}/src/branch/${record.default_branch}/${filePath}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWriteContents(match, searchParams, body, headers, res) {
|
||||||
|
const key = keyFrom(match[1], match[2])
|
||||||
|
const record = repos.get(key)
|
||||||
|
if (!record) return send(res, 404, { message: 'Not Found' })
|
||||||
|
const filePath = decodeURIComponent(match[3])
|
||||||
|
const contentKey = `${key}:${filePath}`
|
||||||
|
const existed = contents.has(contentKey)
|
||||||
|
const decoded = Buffer.from(body?.content || '', 'base64').toString('utf8')
|
||||||
|
contents.set(contentKey, decoded)
|
||||||
|
const sha = createHash('sha1').update(decoded).digest('hex')
|
||||||
|
send(res, existed ? 200 : 201, {
|
||||||
|
content: {
|
||||||
|
name: filePath.split('/').pop(),
|
||||||
|
path: filePath,
|
||||||
|
sha,
|
||||||
|
encoding: 'base64',
|
||||||
|
content: body?.content || '',
|
||||||
|
},
|
||||||
|
commit: { sha, html_url: `${baseUrl}/${record.full_name}/commit/${sha}` },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetOrg(match, searchParams, body, headers, res) {
|
||||||
|
const org = orgs.get(match[1].toLowerCase())
|
||||||
|
if (!org) return send(res, 404, { message: 'Not Found' })
|
||||||
|
send(res, 200, { id: org.id, username: org.username, full_name: org.full_name, visibility: org.visibility })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateOrg(match, searchParams, body, headers, res) {
|
||||||
|
const username = body?.username
|
||||||
|
if (!username) return send(res, 422, { message: 'username erforderlich' })
|
||||||
|
if (orgs.has(username.toLowerCase())) {
|
||||||
|
return send(res, 422, { message: `user already exists [name: ${username}]` })
|
||||||
|
}
|
||||||
|
const org = createOrgRecord(username, body?.full_name, body?.visibility)
|
||||||
|
send(res, 201, { id: org.id, username: org.username, full_name: org.full_name, visibility: org.visibility })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOrgTeams(match, searchParams, body, headers, res) {
|
||||||
|
const org = orgs.get(match[1].toLowerCase())
|
||||||
|
if (!org) return send(res, 404, { message: 'Not Found' })
|
||||||
|
const team = teams.get(org.ownersTeamId)
|
||||||
|
send(res, 200, [{ id: team.id, name: team.name }])
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTeamMembers(match, searchParams, body, headers, res) {
|
||||||
|
const team = teams.get(Number(match[1]))
|
||||||
|
if (!team) return send(res, 404, { message: 'Not Found' })
|
||||||
|
send(res, 200, team.members.map((login) => ({ login })))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePutTeamMember(match, searchParams, body, headers, res) {
|
||||||
|
const team = teams.get(Number(match[1]))
|
||||||
|
if (!team) return send(res, 404, { message: 'Not Found' })
|
||||||
|
const user = match[2]
|
||||||
|
if (!team.members.some((m) => m.toLowerCase() === user.toLowerCase())) {
|
||||||
|
team.members.push(user)
|
||||||
|
}
|
||||||
|
send(res, 204)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetOrgHooks(match, searchParams, body, headers, res) {
|
||||||
|
const org = orgs.get(match[1].toLowerCase())
|
||||||
|
if (!org) return send(res, 404, { message: 'Not Found' })
|
||||||
|
send(res, 200, orgHooks.get(org.username.toLowerCase()) || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePostOrgHooks(match, searchParams, body, headers, res) {
|
||||||
|
const org = orgs.get(match[1].toLowerCase())
|
||||||
|
if (!org) return send(res, 404, { message: 'Not Found' })
|
||||||
|
const key = org.username.toLowerCase()
|
||||||
|
const list = orgHooks.get(key) || []
|
||||||
|
const hook = { id: nextHookId++, ...body }
|
||||||
|
list.push(hook)
|
||||||
|
orgHooks.set(key, list)
|
||||||
|
send(res, 201, hook)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROUTES = [
|
||||||
|
{ method: 'GET', re: /^\/api\/v1\/user$/, handler: handleGetUser },
|
||||||
|
{ method: 'GET', re: /^\/api\/v1\/repos\/search$/, handler: handleSearchRepos },
|
||||||
|
{ method: 'POST', re: /^\/api\/v1\/orgs$/, handler: handleCreateOrg },
|
||||||
|
{ method: 'GET', re: /^\/api\/v1\/orgs\/([^/]+)\/teams$/, handler: handleOrgTeams },
|
||||||
|
{ method: 'GET', re: /^\/api\/v1\/orgs\/([^/]+)\/hooks$/, handler: handleGetOrgHooks },
|
||||||
|
{ method: 'POST', re: /^\/api\/v1\/orgs\/([^/]+)\/hooks$/, handler: handlePostOrgHooks },
|
||||||
|
{ method: 'GET', re: /^\/api\/v1\/orgs\/([^/]+)$/, handler: handleGetOrg },
|
||||||
|
{ method: 'GET', re: /^\/api\/v1\/teams\/([^/]+)\/members$/, handler: handleTeamMembers },
|
||||||
|
{ method: 'PUT', re: /^\/api\/v1\/teams\/([^/]+)\/members\/([^/]+)$/, handler: handlePutTeamMember },
|
||||||
|
{ method: 'POST', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/transfer$/, handler: handleTransfer },
|
||||||
|
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/hooks$/, handler: handleGetRepoHooks },
|
||||||
|
{ method: 'DELETE', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/hooks\/([^/]+)$/, handler: handleDeleteRepoHook },
|
||||||
|
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/activities\/feeds$/, handler: handleFeeds },
|
||||||
|
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleGetContents },
|
||||||
|
{ method: 'POST', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleWriteContents },
|
||||||
|
{ method: 'PUT', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleWriteContents },
|
||||||
|
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)$/, handler: handleGetRepo },
|
||||||
|
{ method: 'PATCH', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)$/, handler: handlePatchRepo },
|
||||||
|
]
|
||||||
|
|
||||||
|
/* --------------------------------------------------------- Server --- */
|
||||||
|
|
||||||
|
const sockets = new Set()
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const chunks = []
|
||||||
|
req.on('data', (c) => chunks.push(c))
|
||||||
|
req.on('end', async () => {
|
||||||
|
const raw = Buffer.concat(chunks).toString('utf8')
|
||||||
|
const url = new URL(req.url, 'http://fake-gitea.local')
|
||||||
|
let parsedBody = null
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
parsedBody = JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
parsedBody = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
calls.push({
|
||||||
|
method: req.method,
|
||||||
|
path: url.pathname,
|
||||||
|
query: Object.fromEntries(url.searchParams),
|
||||||
|
headers: { ...req.headers },
|
||||||
|
body: parsedBody,
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = ROUTES.find((r) => r.method === req.method && r.re.test(url.pathname))
|
||||||
|
if (!route) return send(res, 404, { message: 'Not Found' })
|
||||||
|
const match = route.re.exec(url.pathname)
|
||||||
|
try {
|
||||||
|
await route.handler(match, url.searchParams, parsedBody, req.headers, res)
|
||||||
|
} catch (err) {
|
||||||
|
send(res, 500, { message: err?.message || 'fakeGitea: interner Fehler' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
server.on('connection', (socket) => {
|
||||||
|
sockets.add(socket)
|
||||||
|
socket.on('close', () => sockets.delete(socket))
|
||||||
|
if (isDown) socket.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||||
|
const { port } = server.address()
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`
|
||||||
|
|
||||||
|
/* --------------------------------------------------------- Seeding --- */
|
||||||
|
|
||||||
|
for (const def of options.repos || []) addRepoInternal(def)
|
||||||
|
for (const [fullName, list] of Object.entries(options.hooks || {})) {
|
||||||
|
hooksByRepo.set(keyFrom(fullName), (list || []).map(normalizeHook))
|
||||||
|
}
|
||||||
|
for (const [fullName, feed] of Object.entries(options.feeds || {})) {
|
||||||
|
feedsByRepo.set(keyFrom(fullName), feed || [])
|
||||||
|
}
|
||||||
|
for (const [seedKey, text] of Object.entries(options.contents || {})) {
|
||||||
|
const idx = seedKey.indexOf(':')
|
||||||
|
const fullName = idx === -1 ? seedKey : seedKey.slice(0, idx)
|
||||||
|
const filePath = idx === -1 ? '' : seedKey.slice(idx + 1)
|
||||||
|
contents.set(`${keyFrom(fullName)}:${filePath}`, text)
|
||||||
|
}
|
||||||
|
for (const name of options.orgs || []) createOrgRecord(name)
|
||||||
|
|
||||||
|
/* ------------------------------------------------------ oeffentlich --- */
|
||||||
|
|
||||||
|
function repo(fullName) {
|
||||||
|
const record = repos.get(keyFrom(fullName))
|
||||||
|
return record ? serializeRepo(record) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRepo(def) {
|
||||||
|
return serializeRepo(addRepoInternal(def))
|
||||||
|
}
|
||||||
|
|
||||||
|
function hooksOf(fullName) {
|
||||||
|
return (hooksByRepo.get(keyFrom(fullName)) || []).map((h) => ({ ...h, config: { ...h.config } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDown(down) {
|
||||||
|
isDown = Boolean(down)
|
||||||
|
if (isDown) {
|
||||||
|
for (const socket of sockets) socket.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTransferDelay(ms) {
|
||||||
|
transferDelayMs = Number(ms) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
for (const socket of sockets) socket.destroy()
|
||||||
|
return new Promise((resolve) => server.close(() => resolve()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return { baseUrl, calls, repo, addRepo, hooksOf, setDown, setTransferDelay, close }
|
||||||
|
}
|
||||||
Reference in new issue
Block a user