Files
pikasTech-unidesk/scripts/src/platform-infra-gitea-github-sync-server.mjs
T

1407 lines
57 KiB
JavaScript

import { spawn } from "node:child_process";
import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { mkdir, open, readFile, readdir, rename, stat, unlink } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
export function createGithubSyncHandler(config) {
const controller = config.controller ?? createDurableSyncController(config);
if (!Number.isInteger(config.responseBudgetMs) || config.responseBudgetMs < 1) throw new Error("responseBudgetMs must be a positive integer");
return async (req, res) => {
const responseDeadlineMs = Date.now() + config.responseBudgetMs;
const requestContext = { deliveryId: null, repository: null, ref: null, requestedCommit: null };
try {
const url = new URL(req.url || "/", "http://gitea-github-sync.local");
if (req.method === "GET" && url.pathname === "/healthz") {
const readiness = controller.readiness();
writeJson(res, readiness.ready ? 200 : 503, { ok: readiness.ready, repos: config.repos.length, durableInbox: readiness, valuesPrinted: false });
return;
}
if (req.method === "GET" && url.pathname === "/status") {
const status = await controller.status();
writeJson(res, status.ready ? 200 : 503, { ok: status.ready, ...status, valuesPrinted: false });
return;
}
if (req.method !== "POST" || url.pathname !== config.path) {
writeJson(res, 404, { ok: false, error: "not-found", valuesPrinted: false });
return;
}
const deliveryId = singleHeader(req.headers["x-github-delivery"]);
requestContext.deliveryId = deliveryId || null;
const body = await waitWithinResponseBudget(
readBody(req, config.maxBodyBytes),
responseDeadlineMs,
"request-body-response-deadline-exhausted",
);
verifySignature(req.headers["x-hub-signature-256"], body, config.webhookSecret);
const event = String(req.headers["x-github-event"] || "");
if (!deliveryId) throw new HttpError(400, "missing-github-delivery-id");
if (event === "ping") {
writeJson(res, 200, { ok: true, event, deliveryId, disposition: "pong", valuesPrinted: false });
return;
}
if (event !== "push") {
writeJson(res, 202, { ok: true, event, deliveryId, disposition: "ignored-event", valuesPrinted: false });
return;
}
const payload = parsePayload(body);
const repository = String(payload?.repository?.full_name || "");
const ref = String(payload?.ref || "");
const requestedCommit = String(payload?.after || "");
Object.assign(requestContext, { repository, ref, requestedCommit });
const repo = config.repos.find((item) => item?.upstream?.repository === repository && ref === `refs/heads/${item?.upstream?.branch}`);
if (!repo || /^0+$/u.test(requestedCommit)) {
writeJson(res, 202, { ok: true, event, deliveryId, repository, ref, disposition: "ignored-repo-ref-or-delete", valuesPrinted: false });
return;
}
if (!/^[0-9a-f]{40,64}$/u.test(requestedCommit)) throw new HttpError(400, "invalid-push-after");
const delivery = {
deliveryId,
repository,
ref,
requestedCommit,
payloadSha: createHash("sha256").update(body).digest("hex"),
receivedAt: new Date().toISOString(),
};
const acceptPromise = Promise.resolve().then(() => controller.accept(repo, delivery));
let result;
try {
result = await waitWithinResponseBudget(
acceptPromise,
responseDeadlineMs,
"durable-acceptance-response-deadline-exhausted",
);
} catch (error) {
if (error instanceof HttpError && error.message === "durable-acceptance-response-deadline-exhausted") {
void acceptPromise.then(
(lateResult) => safeLog(config.log, deliveryLog("github-to-gitea-delivery-accepted-after-response-deadline", repo, delivery, {
ok: true,
state: lateResult.state,
duplicate: lateResult.duplicate,
})),
(lateError) => safeLog(config.log, deliveryLog("github-to-gitea-delivery-persist-failed-after-response-deadline", repo, delivery, {
ok: false,
errorType: "durable-acceptance-late-failure",
error: redact(String(lateError?.message || lateError)),
})),
);
}
throw error;
}
const refsCommitted = result.state === "committed";
writeJson(res, 202, {
ok: true,
event,
deliveryId,
repository,
ref,
repo: repo.key,
requestedCommit,
state: result.state,
disposition: result.duplicate ? "duplicate-durable-inbox-record" : "durable-inbox-accepted",
refsCommitted,
...(refsCommitted && result.result ? { result: result.result } : {}),
valuesPrinted: false,
});
} catch (error) {
const status = error instanceof HttpError ? error.status : 503;
safeLog(config.log, {
event: "github-to-gitea-request-rejected",
...requestContext,
ok: false,
status,
error: redact(String(error?.message || error)),
valuesPrinted: false,
});
writeJson(res, status, { ok: false, error: error instanceof HttpError ? error.message : "internal-sync-error", valuesPrinted: false });
}
};
}
export function createDurableInboxStore(options) {
const inboxDirectory = options.path;
const maxBytes = options.maxBytes;
const now = options.now ?? Date.now;
let totalBytes = 0;
let nextAcceptedSequence = 1;
let initialized = false;
let metadata = null;
let mutationTail = Promise.resolve();
function withMutationLock(task) {
const result = mutationTail.then(task);
mutationTail = result.catch(() => undefined);
return result;
}
async function initialize() {
await mkdir(inboxDirectory, { recursive: true, mode: 0o700 });
const probePath = join(inboxDirectory, `.readiness-${randomUUID()}`);
await durableWrite(probePath, '{"ok":true}\n');
await unlink(probePath);
await syncDirectory(inboxDirectory);
const metadataPath = join(inboxDirectory, ".journal-metadata");
try {
metadata = validateJournalMetadata(JSON.parse(await readFile(metadataPath, "utf8")));
} catch (error) {
if (error?.code !== "ENOENT") throw new DurableInboxError(503, "durable-inbox-metadata-invalid");
metadata = validateJournalMetadata({
version: 1,
kind: "GiteaGithubSyncDurableInbox",
createdAt: new Date(now()).toISOString(),
});
await durableWrite(metadataPath, serializeRecord(metadata));
}
totalBytes = 0;
nextAcceptedSequence = 1;
for (const entry of await readdir(inboxDirectory, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
const path = join(inboxDirectory, entry.name);
const record = await readRecordPath(path);
nextAcceptedSequence = Math.max(nextAcceptedSequence, record.acceptedSequence + 1);
totalBytes += (await stat(path)).size;
}
initialized = true;
return { totalBytes, maxBytes };
}
async function accept(repo, delivery) {
return await withMutationLock(async () => {
assertInitialized();
const path = recordPath(inboxDirectory, delivery.deliveryId);
const existing = await readRecordPath(path, true);
const fingerprint = deliveryFingerprint(repo, delivery);
if (existing) {
if (existing.fingerprint !== fingerprint) throw new DurableInboxError(409, "github-delivery-id-payload-conflict");
return { record: existing, duplicate: true };
}
const now = delivery.receivedAt || new Date().toISOString();
const record = {
version: 1,
deliveryId: delivery.deliveryId,
fingerprint,
repoKey: repo.key,
repository: delivery.repository,
ref: delivery.ref,
requestedCommit: delivery.requestedCommit,
payloadSha: delivery.payloadSha,
receivedAt: now,
acceptedAt: now,
acceptedSequence: nextAcceptedSequence,
updatedAt: now,
state: "accepted",
totalAttempts: 0,
cycleAttempt: 0,
nextAttemptAt: now,
result: null,
lastError: null,
};
const serialized = serializeRecord(validateInboxRecord(record));
if (totalBytes + Buffer.byteLength(serialized) > maxBytes) {
throw new DurableInboxError(503, "durable-inbox-capacity-exhausted");
}
await durableWrite(path, serialized);
totalBytes += Buffer.byteLength(serialized);
nextAcceptedSequence += 1;
return { record, duplicate: false };
});
}
async function get(deliveryId) {
assertInitialized();
return await readRecordPath(recordPath(inboxDirectory, deliveryId), true);
}
async function list() {
assertInitialized();
const records = [];
for (const entry of await readdir(inboxDirectory, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
records.push(await readRecordPath(join(inboxDirectory, entry.name)));
}
return sortInboxRecordsByAcceptedSequence(records);
}
async function update(deliveryId, updater) {
return await withMutationLock(async () => {
assertInitialized();
const path = recordPath(inboxDirectory, deliveryId);
const previous = await readRecordPath(path);
const previousSize = (await stat(path)).size;
const next = updater(structuredClone(previous));
const identityFields = ["version", "deliveryId", "fingerprint", "repoKey", "repository", "ref", "requestedCommit", "payloadSha", "receivedAt", "acceptedAt", "acceptedSequence"];
if (!next || identityFields.some((field) => next[field] !== previous[field])) {
throw new DurableInboxError(503, "durable-inbox-invalid-record-update");
}
const serialized = serializeRecord(validateInboxRecord(next));
await durableWrite(path, serialized);
totalBytes += Buffer.byteLength(serialized) - previousSize;
return next;
});
}
async function recoverProcessing(now) {
const records = await list();
for (const record of records) {
if (record.state !== "processing") continue;
await update(record.deliveryId, (current) => ({
...current,
state: "accepted",
updatedAt: now,
nextAttemptAt: now,
recoveredAfterRestart: true,
lastError: { errorType: "worker-restarted-during-processing", retryable: true },
}));
}
}
async function cleanupCommitted(cutoffMs) {
return await withMutationLock(async () => {
assertInitialized();
let removed = 0;
const entries = (await readdir(inboxDirectory, { withFileTypes: true }))
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"));
const records = [];
for (const entry of entries) {
const path = join(inboxDirectory, entry.name);
records.push({ entry, path, record: await readRecordPath(path) });
}
const latestCommittedByRepo = new Map();
for (const item of records) {
if (item.record.state !== "committed") continue;
const current = latestCommittedByRepo.get(item.record.repoKey);
if (!current || item.record.acceptedSequence > current.record.acceptedSequence) {
latestCommittedByRepo.set(item.record.repoKey, item);
}
}
for (const item of records) {
const { path, record } = item;
if (record.state !== "committed" || latestCommittedByRepo.get(record.repoKey) === item || Date.parse(record.committedAt) > cutoffMs) continue;
const size = (await stat(path)).size;
await unlink(path);
totalBytes = Math.max(0, totalBytes - size);
removed += 1;
}
if (removed > 0) await syncDirectory(inboxDirectory);
return removed;
});
}
function capacityAvailable() {
return initialized && totalBytes < maxBytes;
}
function usage() {
return { totalBytes, maxBytes, capacityAvailable: capacityAvailable() };
}
function journalMetadata() {
assertInitialized();
return structuredClone(metadata);
}
function assertInitialized() {
if (!initialized) throw new DurableInboxError(503, "durable-inbox-unavailable");
}
return { initialize, accept, get, list, update, recoverProcessing, cleanupCommitted, capacityAvailable, usage, journalMetadata };
}
export function createDurableSyncController(options) {
const now = options.now ?? Date.now;
const store = options.store ?? createDurableInboxStore(options.inbox);
const retry = options.workerRetry;
const workerAbort = new AbortController();
const executor = options.executor ?? createSyncCoordinator({
...options,
signal: workerAbort.signal,
responseBudgetMs: retry.attemptTimeoutMs,
retryMaxAttempts: 1,
retryInitialDelayMs: retry.initialDelayMs,
retryMaxDelayMs: retry.maxDelayMs,
});
const repoByKey = new Map(options.repos.map((repo) => [repo.key, repo]));
const repoTails = new Map();
const activeDeliveries = new Set();
const inFlight = new Set();
let storageReady = false;
let storageError = "durable-inbox-starting";
let capacityExhausted = false;
let stopped = true;
let scanTimer = null;
let scanRunning = false;
let lastCleanupAt = 0;
async function start() {
if (!stopped) return;
stopped = false;
try {
await store.initialize();
await store.recoverProcessing(new Date(now()).toISOString());
storageReady = true;
storageError = null;
capacityExhausted = false;
safeLog(options.log, { event: "github-to-gitea-inbox-ready", ...store.usage(), valuesPrinted: false });
wake(0);
} catch (error) {
storageReady = false;
storageError = typedStorageError(error);
safeLog(options.log, { event: "github-to-gitea-inbox-unavailable", ok: false, errorType: storageError, error: redact(String(error?.message || error)), valuesPrinted: false });
wake(retry.scanIntervalMs);
}
}
async function accept(repo, delivery) {
if (!storageReady) throw new HttpError(503, storageError || "durable-inbox-unavailable");
try {
const accepted = await store.accept(repo, delivery);
capacityExhausted = false;
safeLog(options.log, deliveryLog("github-to-gitea-delivery-accepted", repo, delivery, {
ok: true,
state: accepted.record.state,
duplicate: accepted.duplicate,
}));
wake(0);
return {
state: accepted.record.state,
duplicate: accepted.duplicate,
result: accepted.record.state === "committed" && accepted.record.result
? publicSyncResult(accepted.record.result)
: null,
};
} catch (error) {
if (error instanceof DurableInboxError && error.status === 409) throw new HttpError(409, error.message);
if (!(error instanceof DurableInboxError && error.message === "durable-inbox-capacity-exhausted")) {
storageReady = false;
storageError = typedStorageError(error);
} else {
capacityExhausted = true;
}
safeLog(options.log, deliveryLog("github-to-gitea-delivery-persist-failed", repo, delivery, {
ok: false,
state: "rejected",
errorType: error instanceof DurableInboxError ? error.message : "durable-inbox-write-failed",
error: redact(String(error?.message || error)),
}));
throw new HttpError(503, error instanceof DurableInboxError ? error.message : "durable-inbox-write-failed");
}
}
function readiness() {
const usage = store.usage();
return {
ready: storageReady && usage.capacityAvailable && !capacityExhausted,
storageReady,
capacityAvailable: usage.capacityAvailable && !capacityExhausted,
totalBytes: usage.totalBytes,
maxBytes: usage.maxBytes,
errorType: storageReady ? (usage.capacityAvailable && !capacityExhausted ? null : "durable-inbox-capacity-exhausted") : storageError,
};
}
async function status() {
let records = [];
if (storageReady) {
try {
records = await store.list();
} catch (error) {
storageReady = false;
storageError = typedStorageError(error);
}
}
const counts = { accepted: 0, processing: 0, committed: 0, failed: 0 };
for (const record of records) {
if (Object.hasOwn(counts, record.state)) counts[record.state] += 1;
}
return {
...readiness(),
journal: storageReady ? store.journalMetadata() : null,
counts,
deliveries: statusRecords(records).map(publicInboxRecord),
failedDeliveryRecovery: await readFailedDeliveryRecoveryStatus(options.failedDeliveryRecoveryStatePath),
};
}
function wake(delayMs) {
if (stopped) return;
if (scanTimer !== null) clearTimeout(scanTimer);
scanTimer = setTimeout(() => void scan(), Math.max(0, delayMs));
}
async function scan() {
if (stopped || scanRunning) return;
scanRunning = true;
try {
if (!storageReady) {
await store.initialize();
await store.recoverProcessing(new Date(now()).toISOString());
storageReady = true;
storageError = null;
}
if (now() - lastCleanupAt >= options.inbox.cleanupIntervalMs) {
const removed = await store.cleanupCommitted(now() - options.inbox.committedRetentionMs);
lastCleanupAt = now();
if (removed > 0) {
capacityExhausted = false;
safeLog(options.log, { event: "github-to-gitea-inbox-retention", removed, ...store.usage(), valuesPrinted: false });
}
}
const currentTime = now();
for (const record of sortInboxRecordsByAcceptedSequence(await store.list())) {
if (!["accepted", "failed"].includes(record.state)) continue;
if (Date.parse(record.nextAttemptAt || record.receivedAt) > currentTime) continue;
schedule(record);
}
} catch (error) {
storageReady = false;
storageError = typedStorageError(error);
safeLog(options.log, { event: "github-to-gitea-inbox-scan-failed", ok: false, errorType: storageError, error: redact(String(error?.message || error)), valuesPrinted: false });
} finally {
scanRunning = false;
wake(retry.scanIntervalMs);
}
}
function schedule(record) {
if (activeDeliveries.has(record.deliveryId) || stopped) return;
activeDeliveries.add(record.deliveryId);
const previous = repoTails.get(record.repoKey) ?? Promise.resolve();
const task = previous.catch(() => undefined).then(() => processRecord(record.deliveryId))
.catch((error) => {
if (error instanceof DurableInboxError) {
storageReady = false;
storageError = typedStorageError(error);
}
safeLog(options.log, {
event: "github-to-gitea-worker-exception",
deliveryId: record.deliveryId,
repo: record.repoKey,
ok: false,
errorType: "durable-worker-exception",
error: redact(String(error?.message || error)),
valuesPrinted: false,
});
})
.finally(() => {
activeDeliveries.delete(record.deliveryId);
inFlight.delete(task);
wake(0);
});
repoTails.set(record.repoKey, task.catch(() => undefined));
inFlight.add(task);
}
async function processRecord(deliveryId) {
let record = await store.get(deliveryId);
if (!record || record.state === "committed") return;
const repo = repoByKey.get(record.repoKey);
if (!repo) {
await markFailed(record, { ok: false, retryable: false, errorType: "repository-config-missing" });
return;
}
const attemptStartedAt = new Date(now()).toISOString();
record = await store.update(deliveryId, (current) => ({
...current,
state: "processing",
updatedAt: attemptStartedAt,
processingAt: attemptStartedAt,
totalAttempts: current.totalAttempts + 1,
cycleAttempt: current.cycleAttempt + 1,
}));
const delivery = recordDelivery(record);
safeLog(options.log, deliveryLog("github-to-gitea-delivery-processing", repo, delivery, {
ok: true,
state: "processing",
totalAttempts: record.totalAttempts,
cycleAttempt: record.cycleAttempt,
}));
let result;
try {
result = validateExecutorTerminalResult(await executor.execute(repo, delivery));
} catch (error) {
await markFailed(record, {
ok: false,
retryable: true,
errorType: workerAbort.signal.aborted || stopped
? "source-sync-executor-aborted"
: error instanceof ExecutorTerminalResultError
? "source-sync-executor-invalid-result"
: "source-sync-executor-threw",
error: redact(String(error?.message || error)),
});
return;
}
if (result.ok) {
const committedAt = new Date(now()).toISOString();
await store.update(deliveryId, (current) => ({
...current,
state: "committed",
updatedAt: committedAt,
committedAt,
nextAttemptAt: null,
result: publicSyncResult(result),
lastError: null,
}));
safeLog(options.log, deliveryLog("github-to-gitea-delivery-committed", repo, delivery, {
ok: true,
state: "committed",
sourceCommit: result.sourceCommit,
authorityCommit: result.authorityCommit,
snapshotRef: result.snapshotRef,
disposition: result.disposition,
totalAttempts: record.totalAttempts,
}));
return;
}
await markFailed(record, result);
}
async function markFailed(record, result) {
const repo = repoByKey.get(record.repoKey) ?? { key: record.repoKey };
const terminal = result.retryable === false || record.cycleAttempt >= retry.maxAttempts;
const delayMs = terminal
? retry.terminalRetryDelayMs
: retryDelayMs(record.cycleAttempt, retry.initialDelayMs, retry.maxDelayMs);
const failedAt = new Date(now()).toISOString();
const nextAttemptAt = new Date(now() + delayMs).toISOString();
await store.update(record.deliveryId, (current) => ({
...current,
state: "failed",
updatedAt: failedAt,
failedAt,
nextAttemptAt,
cycleAttempt: terminal ? 0 : current.cycleAttempt,
result: null,
lastError: {
errorType: result.errorType || "source-sync-failed",
retryable: result.retryable !== false,
terminal,
},
}));
safeLog(options.log, deliveryLog("github-to-gitea-delivery-failed", repo, recordDelivery(record), {
ok: false,
state: "failed",
errorType: result.errorType || "source-sync-failed",
error: result.error ? redact(result.error) : null,
terminal,
automaticRetryScheduled: true,
nextAttemptAt,
totalAttempts: record.totalAttempts,
cycleAttempt: record.cycleAttempt,
}));
}
async function stop() {
if (stopped && storageError === "durable-inbox-stopping") return;
stopped = true;
storageReady = false;
storageError = "durable-inbox-stopping";
if (scanTimer !== null) clearTimeout(scanTimer);
workerAbort.abort();
const settling = Promise.allSettled([...inFlight]);
await Promise.race([settling, sleep(options.shutdownGraceMs)]);
}
return { start, stop, accept, readiness, status, scan };
}
export function createSyncCoordinator(options) {
const states = new Map();
const deliveryOwners = new Map();
const sync = options.sync ?? ((repo, delivery, deadline) => syncRepository(repo, delivery, { ...options, ...deadline }));
const wait = options.sleep ?? sleep;
const now = options.now ?? Date.now;
function stateFor(key) {
let state = states.get(key);
if (!state) {
state = { tail: Promise.resolve(), active: null, lastSuccess: null };
states.set(key, state);
}
return state;
}
function execute(repo, delivery) {
const requestStartedAt = now();
const deadlineAt = requestStartedAt + options.responseBudgetMs;
const fingerprint = deliveryFingerprint(repo, delivery);
const owner = deliveryOwners.get(delivery.deliveryId);
if (owner && owner.fingerprint !== fingerprint) {
throw new HttpError(409, "github-delivery-id-payload-conflict");
}
if (owner?.promise) {
safeLog(options.log, deliveryLog("github-to-gitea-delivery-duplicate", repo, delivery, { disposition: "join-in-flight" }));
return owner.promise.then((result) => ({ ...result, duplicate: true }));
}
if (owner && owner.fingerprint === fingerprint && owner.result?.ok === true) {
safeLog(options.log, deliveryLog("github-to-gitea-delivery-duplicate", repo, delivery, { disposition: "durable-refs-already-committed" }));
return Promise.resolve({ ...owner.result, duplicate: true });
}
const state = stateFor(repo.key);
if (state.lastSuccess?.fingerprint === fingerprint) {
safeLog(options.log, deliveryLog("github-to-gitea-delivery-duplicate", repo, delivery, { disposition: "durable-refs-already-committed" }));
return Promise.resolve({ ...state.lastSuccess.result, duplicate: true });
}
const promise = state.tail.then(async () => {
state.active = { delivery, fingerprint };
const startedAt = requestStartedAt;
let terminal = null;
for (let syncAttempt = 1; syncAttempt <= options.retryMaxAttempts; syncAttempt += 1) {
if (now() >= deadlineAt) {
terminal = { ok: false, retryable: true, errorType: "response-deadline-exhausted" };
safeLog(options.log, deliveryLog("github-to-gitea-sync", repo, delivery, {
ok: false,
errorType: terminal.errorType,
syncAttempt,
maxSyncAttempts: options.retryMaxAttempts,
willRetry: false,
terminal: true,
elapsedMs: now() - startedAt,
}));
break;
}
let result;
try {
result = await sync(repo, delivery, { deadlineAt, now, signal: options.signal });
} catch (error) {
result = { ok: false, retryable: true, errorType: "sync-exception", error: String(error?.message || error) };
}
if (now() >= deadlineAt) {
result = {
ok: false,
retryable: true,
errorType: "response-deadline-exhausted",
sourceCommit: result.sourceCommit ?? null,
authorityCommit: result.authorityCommit ?? null,
snapshotRef: result.snapshotRef ?? null,
committedBeforeDeadlineUnknown: result.ok === true,
};
}
const retryable = result.retryable !== false;
const willRetry = !result.ok && retryable && syncAttempt < options.retryMaxAttempts;
terminal = result;
safeLog(options.log, deliveryLog("github-to-gitea-sync", repo, delivery, {
ok: result.ok,
sourceCommit: result.sourceCommit ?? null,
authorityCommit: result.authorityCommit ?? null,
snapshotRef: result.snapshotRef ?? null,
disposition: result.disposition ?? null,
errorType: result.errorType ?? null,
error: result.error ? redact(result.error) : null,
syncAttempt,
maxSyncAttempts: options.retryMaxAttempts,
willRetry,
terminal: !willRetry,
elapsedMs: now() - startedAt,
}));
if (!willRetry) break;
try {
const remainingMs = Math.max(0, deadlineAt - now());
await wait(Math.min(remainingMs, retryDelayMs(syncAttempt, options.retryInitialDelayMs, options.retryMaxDelayMs)));
} catch (error) {
safeLog(options.log, deliveryLog("github-to-gitea-retry-delay-error", repo, delivery, { error: redact(String(error?.message || error)) }));
}
}
if (terminal?.ok) state.lastSuccess = { fingerprint, result: terminal };
const previousDeliveryId = state.lastDeliveryId;
state.lastDeliveryId = delivery.deliveryId;
deliveryOwners.set(delivery.deliveryId, { fingerprint, promise: null, result: terminal });
if (previousDeliveryId && previousDeliveryId !== delivery.deliveryId) deliveryOwners.delete(previousDeliveryId);
return terminal ?? { ok: false, errorType: "sync-produced-no-result", retryable: true };
});
const settled = promise.finally(() => {
state.active = null;
const current = deliveryOwners.get(delivery.deliveryId);
if (current?.promise === settled) deliveryOwners.set(delivery.deliveryId, { fingerprint, promise: null, result: null });
});
state.tail = settled.catch(() => undefined);
deliveryOwners.set(delivery.deliveryId, { fingerprint, promise: settled });
return settled;
}
return { execute, stateFor };
}
export async function syncRepository(repo, delivery, runtime) {
const work = mkdtempSync(join(tmpdir(), `gitea-gh-sync-${repo.key}-`));
let sourceCommit = null;
let snapshotRef = null;
let authorityCommit = null;
const execute = (args) => runWithinDeadline(runtime, args);
const executeResult = (args) => runResultWithinDeadline(runtime, args);
try {
await execute(["git", "init", "--bare", work]);
const snapshotRefForRequest = `${repo.snapshot.prefix.replace(/\/+$/u, "")}/${delivery.requestedCommit}`;
const giteaUrl = runtime.giteaBaseUrl + new URL(repo.gitea.readUrl).pathname;
const branchRef = `refs/heads/${repo.upstream.branch}`;
const initialRemoteRefs = parseLsRemote((await execute([
"git", "-C", work, "-c", `http.extraHeader=${runtime.giteaAuthHeader}`,
"ls-remote", giteaUrl, branchRef, snapshotRefForRequest,
])).stdout);
const currentCommit = initialRemoteRefs.get(branchRef) ?? null;
const snapshotCommit = initialRemoteRefs.get(snapshotRefForRequest) ?? null;
if (currentCommit) {
await execute(["git", "-C", work, "-c", `http.extraHeader=${runtime.giteaAuthHeader}`, "fetch", "--no-tags", giteaUrl, `+${branchRef}:refs/remotes/gitea/${repo.upstream.branch}`]);
}
if (snapshotCommit && snapshotCommit !== currentCommit) {
await execute(["git", "-C", work, "-c", `http.extraHeader=${runtime.giteaAuthHeader}`, "fetch", "--no-tags", giteaUrl, `+${snapshotRefForRequest}:refs/remotes/gitea/requested-snapshot`]);
}
let requestedObject = await executeResult(["git", "-C", work, "cat-file", "-e", `${delivery.requestedCommit}^{commit}`]);
if (requestedObject.status !== 0) {
const branchFetch = await executeResult([
"git", "-C", work, "-c", `http.extraHeader=${runtime.githubAuthHeader}`,
"fetch", "--no-tags", repo.upstream.cloneUrl,
`+refs/heads/${repo.upstream.branch}:refs/remotes/github/${repo.upstream.branch}`,
]);
requestedObject = await executeResult(["git", "-C", work, "cat-file", "-e", `${delivery.requestedCommit}^{commit}`]);
if (requestedObject.status !== 0) {
const exactFetch = await executeResult(["git", "-C", work, "-c", `http.extraHeader=${runtime.githubAuthHeader}`, "fetch", "--no-tags", repo.upstream.cloneUrl, `${delivery.requestedCommit}:refs/remotes/github/delivery`]);
if (exactFetch.status !== 0) {
return {
ok: false,
retryable: true,
errorType: "requested-commit-temporarily-unavailable",
error: `${branchFetch.stderr}\n${exactFetch.stderr}`,
};
}
requestedObject = await executeResult(["git", "-C", work, "cat-file", "-e", `${delivery.requestedCommit}^{commit}`]);
}
}
if (requestedObject.status !== 0) return { ok: false, retryable: false, errorType: "requested-commit-not-a-commit" };
sourceCommit = (await execute(["git", "-C", work, "rev-parse", `${delivery.requestedCommit}^{commit}`])).stdout.trim();
if (sourceCommit !== delivery.requestedCommit) {
return {
ok: false,
retryable: false,
errorType: "source-commit-mismatch",
sourceCommit,
error: `fetched source commit ${sourceCommit} does not equal webhook after ${delivery.requestedCommit}`,
};
}
snapshotRef = snapshotRefForRequest;
if (snapshotCommit && snapshotCommit !== sourceCommit) {
return { ok: false, retryable: false, errorType: "immutable-snapshot-conflict", sourceCommit, authorityCommit: currentCommit, snapshotRef };
}
let branchDisposition = "created";
let updateBranch = true;
if (currentCommit) {
const currentIsAncestor = await ancestry(executeResult, work, currentCommit, sourceCommit);
const sourceIsAncestor = await ancestry(executeResult, work, sourceCommit, currentCommit);
if (currentCommit === sourceCommit) {
branchDisposition = "current";
updateBranch = false;
} else if (currentIsAncestor === true) {
branchDisposition = "fast-forwarded";
} else if (sourceIsAncestor === true) {
branchDisposition = "superseded";
updateBranch = false;
} else if (currentIsAncestor === null || sourceIsAncestor === null) {
return { ok: false, retryable: true, errorType: "ancestry-proof-failed", sourceCommit, authorityCommit: currentCommit, snapshotRef };
} else {
return { ok: false, retryable: false, errorType: "source-history-diverged", sourceCommit, authorityCommit: currentCommit, snapshotRef };
}
}
const refspecs = [];
const pushArgs = ["git", "-C", work, "-c", `http.extraHeader=${runtime.giteaAuthHeader}`, "push", "--atomic"];
if (updateBranch) {
pushArgs.push(`--force-with-lease=${branchRef}:${currentCommit ?? ""}`);
refspecs.push(`${sourceCommit}:${branchRef}`);
}
if (!snapshotCommit) refspecs.push(`${sourceCommit}:${snapshotRef}`);
if (refspecs.length > 0) {
const pushed = await executeResult([...pushArgs, giteaUrl, ...refspecs]);
if (pushed.status !== 0) {
return { ok: false, retryable: true, errorType: "atomic-push-race-or-failure", sourceCommit, authorityCommit: currentCommit, snapshotRef, error: pushed.stderr };
}
}
authorityCommit = branchDisposition === "superseded" ? currentCommit : sourceCommit;
const proofRefs = parseLsRemote((await execute([
"git", "-C", work, "-c", `http.extraHeader=${runtime.giteaAuthHeader}`,
"ls-remote", giteaUrl, branchRef, snapshotRef,
])).stdout);
const provenAuthorityCommit = proofRefs.get(branchRef) ?? null;
const provenSnapshotCommit = proofRefs.get(snapshotRef) ?? null;
if (provenAuthorityCommit !== authorityCommit || provenSnapshotCommit !== sourceCommit) {
return {
ok: false,
retryable: true,
errorType: "post-push-authority-proof-failed",
sourceCommit,
authorityCommit: provenAuthorityCommit,
snapshotRef,
};
}
return {
ok: true,
sourceCommit,
authorityCommit,
snapshotRef,
disposition: branchDisposition === "superseded" ? "superseded-with-immutable-snapshot" : "atomic-source-authority-committed",
};
} catch (error) {
return { ok: false, retryable: true, errorType: "git-source-sync-failed", sourceCommit, authorityCommit, snapshotRef, error: String(error?.message || error) };
} finally {
rmSync(work, { recursive: true, force: true });
}
}
export async function run(args, timeoutMs = 120000) {
const result = await runResult(args, timeoutMs);
if (result.status !== 0) {
throw new Error(`${args[0]} failed status=${result.status ?? "error"} stderr=${result.stderr}`);
}
return { stdout: result.stdout, stderr: result.stderr };
}
export function runResult(args, timeoutMs = 120000, signal = null) {
const boundedTimeoutMs = Math.max(1, Math.floor(timeoutMs));
if (signal?.aborted) return Promise.resolve({ status: null, stdout: "", stderr: "command aborted before spawn" });
return new Promise((resolve) => {
let stdout = "";
let stderr = "";
let timedOut = false;
let aborted = false;
let settled = false;
let deadlineTimer = null;
let forceKillTimer = null;
let abortCommand = () => {};
const child = spawn(args[0], args.slice(1), {
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
env: {
...process.env,
GIT_TERMINAL_PROMPT: "0",
GIT_CONFIG_NOSYSTEM: "1",
GIT_CONFIG_GLOBAL: "/dev/null",
},
});
const finish = (status, error = "") => {
if (settled) return;
settled = true;
if (deadlineTimer !== null) clearTimeout(deadlineTimer);
if (forceKillTimer !== null) clearTimeout(forceKillTimer);
signal?.removeEventListener("abort", abortCommand);
resolve({
status,
stdout,
stderr: appendTail(stderr, error, 1600),
});
};
child.stdout.on("data", (chunk) => {
stdout = appendTail(stdout, String(chunk), 65536);
});
child.stderr.on("data", (chunk) => {
stderr = appendTail(stderr, String(chunk), 1600);
});
child.once("error", (error) => finish(null, String(error?.message || error)));
child.once("close", (status, signal) => {
const terminalError = aborted
? `command aborted; signal=${signal || "none"}`
: timedOut
? `command deadline exceeded after ${boundedTimeoutMs}ms; signal=${signal || "none"}`
: "";
finish(aborted || timedOut ? null : status, terminalError);
});
abortCommand = () => {
aborted = true;
signalProcessTree(child, "SIGTERM");
forceKillTimer = setTimeout(() => signalProcessTree(child, "SIGKILL"), 50);
};
signal?.addEventListener("abort", abortCommand, { once: true });
if (signal?.aborted) abortCommand();
deadlineTimer = setTimeout(() => {
timedOut = true;
signalProcessTree(child, "SIGTERM");
forceKillTimer = setTimeout(() => signalProcessTree(child, "SIGKILL"), 50);
}, boundedTimeoutMs);
});
}
export function verifySignature(signatureHeader, body, webhookSecret) {
const signature = singleHeader(signatureHeader);
if (!signature || !signature.startsWith("sha256=")) throw new HttpError(401, "missing-github-signature");
const expected = `sha256=${createHmac("sha256", webhookSecret).update(body).digest("hex")}`;
if (!timingSafeEqualString(signature, expected)) throw new HttpError(401, "invalid-github-signature");
}
export function retryDelayMs(syncAttempt, initialDelayMs, maxDelayMs) {
return Math.min(maxDelayMs, initialDelayMs * (2 ** Math.max(0, syncAttempt - 1)));
}
export function loadRuntimeConfig() {
const githubToken = requiredEnv("GITHUB_TOKEN");
const giteaUsername = requiredEnv("GITEA_USERNAME");
const giteaPassword = requiredEnv("GITEA_PASSWORD");
return {
port: Number.parseInt(process.env.UNIDESK_GITEA_WEBHOOK_PORT || "8080", 10),
path: process.env.UNIDESK_GITEA_WEBHOOK_PATH || "/_unidesk/github-to-gitea",
responseBudgetMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_RESPONSE_BUDGET_MS"),
repos: JSON.parse(readFileSync(process.env.UNIDESK_GITEA_REPOS_PATH || "/etc/gitea-github-sync/repos.json", "utf8")),
webhookSecret: requiredEnv("GITHUB_WEBHOOK_SECRET"),
giteaBaseUrl: requiredEnv("UNIDESK_GITEA_SERVICE_BASE_URL").replace(/\/+$/u, ""),
inbox: {
path: requiredEnv("UNIDESK_GITEA_WEBHOOK_INBOX_PATH"),
maxBytes: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_INBOX_MAX_BYTES"),
committedRetentionMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_COMMITTED_RETENTION_SECONDS") * 1000,
cleanupIntervalMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_CLEANUP_INTERVAL_MS"),
},
failedDeliveryRecoveryStatePath: process.env.UNIDESK_GITEA_HOOK_RECOVERY_STATE_PATH || null,
workerRetry: {
maxAttempts: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_RETRY_MAX_ATTEMPTS"),
initialDelayMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_RETRY_INITIAL_DELAY_MS"),
maxDelayMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_RETRY_MAX_DELAY_MS"),
terminalRetryDelayMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_RETRY_TERMINAL_DELAY_MS"),
attemptTimeoutMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_WORKER_ATTEMPT_TIMEOUT_MS"),
scanIntervalMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_WORKER_SCAN_INTERVAL_MS"),
},
shutdownGraceMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_SHUTDOWN_GRACE_MS"),
maxBodyBytes: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_MAX_BODY_BYTES"),
githubAuthHeader: `Authorization: Basic ${Buffer.from(`x-access-token:${githubToken}`).toString("base64")}`,
giteaAuthHeader: `Authorization: Basic ${Buffer.from(`${giteaUsername}:${giteaPassword}`).toString("base64")}`,
log,
run,
runResult,
};
}
export async function readFailedDeliveryRecoveryStatus(statePath) {
if (!statePath) return { enabled: false, ready: true, state: "disabled", counts: {}, entries: [] };
let ledger;
try {
ledger = JSON.parse(await readFile(statePath, "utf8"));
} catch (error) {
if (error?.code === "ENOENT") return { enabled: true, ready: false, state: "not-initialized", counts: {}, entries: [] };
return { enabled: true, ready: false, state: "invalid-or-unreadable", errorType: "hook-recovery-ledger-unreadable", counts: {}, entries: [] };
}
if (ledger?.version !== 1 || ledger?.kind !== "GiteaGithubHookRecoveryLedger" || !ledger.records || typeof ledger.records !== "object" || Array.isArray(ledger.records)) {
return { enabled: true, ready: false, state: "invalid-or-unreadable", errorType: "hook-recovery-ledger-invalid", counts: {}, entries: [] };
}
const allowedStates = new Set(["pending", "attempted", "delivery-succeeded-awaiting-source", "source-committed", "terminal-exhausted"]);
const records = Object.values(ledger.records);
const validRecord = (item) => item && typeof item === "object"
&& allowedStates.has(item.state)
&& typeof item.repository === "string" && item.repository.length > 0
&& (typeof item.hookId === "number" || typeof item.hookId === "string")
&& typeof item.deliveryId === "string" && item.deliveryId.length > 0
&& Number.isInteger(item.attempts) && item.attempts >= 0
&& Number.isInteger(item.cooldownMs) && item.cooldownMs >= 0
&& typeof item.updatedAt === "string" && item.updatedAt.length > 0;
if (records.some((item) => !validRecord(item))) {
return { enabled: true, ready: false, state: "invalid-or-unreadable", errorType: "hook-recovery-ledger-record-invalid", counts: {}, entries: [] };
}
const counts = { pending: 0, attempted: 0, "delivery-succeeded-awaiting-source": 0, "source-committed": 0, "terminal-exhausted": 0 };
for (const item of records) counts[item.state] += 1;
const entries = records
.sort((left, right) => String(right.updatedAt || "").localeCompare(String(left.updatedAt || "")))
.slice(0, 50)
.map((item) => ({
repository: item.repository,
hookId: item.hookId,
deliveryId: item.deliveryId,
state: item.state,
attempts: item.attempts,
cooldownMs: item.cooldownMs,
nextAt: item.nextAt ?? null,
updatedAt: item.updatedAt,
lastStatusCode: item.lastStatusCode ?? null,
completionReason: item.completionReason ?? null,
}));
return { enabled: true, ready: true, state: "ready", counts, entries };
}
export function startServer(config = loadRuntimeConfig()) {
const controller = config.controller ?? createDurableSyncController(config);
const server = createServer(createGithubSyncHandler({ ...config, controller }));
void controller.start();
server.listen(config.port, "0.0.0.0", () => {
safeLog(config.log, { event: "github-to-gitea-sync-listening", port: config.port, path: config.path, repos: config.repos.map((repo) => repo.key), valuesPrinted: false });
});
const shutdown = async () => {
server.close();
await controller.stop();
};
server.once("close", () => void controller.stop());
process.once("SIGTERM", shutdown);
process.once("SIGINT", shutdown);
return server;
}
class HttpError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
class DurableInboxError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
class ExecutorTerminalResultError extends Error {}
function validateExecutorTerminalResult(result) {
if (!result || typeof result !== "object" || typeof result.ok !== "boolean") {
throw new ExecutorTerminalResultError("executor terminal result must be an object with boolean ok");
}
if (result.ok === false) {
if (typeof result.retryable !== "boolean" || !/^[a-z0-9][a-z0-9-]{0,127}$/u.test(result.errorType || "")) {
throw new ExecutorTerminalResultError("failed executor terminal result must include retryable and typed errorType");
}
return result;
}
if (!/^[0-9a-f]{40,64}$/u.test(result.sourceCommit || "")
|| !/^[0-9a-f]{40,64}$/u.test(result.authorityCommit || "")
|| !/^refs\/[A-Za-z0-9._/-]+$/u.test(result.snapshotRef || "")
|| !["atomic-source-authority-committed", "superseded-with-immutable-snapshot"].includes(result.disposition)) {
throw new ExecutorTerminalResultError("successful executor terminal result must include source, authority, snapshot and disposition proof");
}
return result;
}
function deliveryFingerprint(repo, delivery) {
return createHash("sha256")
.update([repo.key, delivery.deliveryId, delivery.repository, delivery.ref, delivery.requestedCommit, delivery.payloadSha].join("\0"))
.digest("hex");
}
function nonRetryableStatus(errorType) {
return [
"github-delivery-id-payload-conflict",
"source-commit-mismatch",
"immutable-snapshot-conflict",
"source-history-diverged",
].includes(errorType) ? 409 : 422;
}
function safeLog(logger, payload) {
try {
logger(payload);
} catch {
// Logging must never change delivery success or retry semantics.
}
}
function parseLsRemote(output) {
const refs = new Map();
for (const line of String(output || "").split("\n")) {
const [commit, ref] = line.trim().split(/\s+/u);
if (commit && ref) refs.set(ref, commit);
}
return refs;
}
async function ancestry(executeResult, work, ancestor, descendant) {
const result = await executeResult(["git", "-C", work, "merge-base", "--is-ancestor", ancestor, descendant]);
if (result.status === 0) return true;
if (result.status === 1) return false;
return null;
}
async function runWithinDeadline(runtime, args) {
const result = await runResultWithinDeadline(runtime, args);
if (result.status !== 0) throw new Error(`${args[0]} failed status=${result.status ?? "deadline"} stderr=${result.stderr}`);
return { stdout: result.stdout, stderr: result.stderr };
}
async function runResultWithinDeadline(runtime, args) {
const remainingMs = Math.floor(runtime.deadlineAt - runtime.now());
if (remainingMs < 1) return { status: null, stdout: "", stderr: "response deadline exhausted before command" };
if (runtime.signal?.aborted) return { status: null, stdout: "", stderr: "command aborted before spawn" };
return await runtime.runResult(args, remainingMs, runtime.signal);
}
function appendTail(previous, next, limit) {
return `${previous}${next}`.slice(-limit);
}
function signalProcessTree(child, signal) {
if (process.platform !== "win32" && child.pid) {
try {
process.kill(-child.pid, signal);
return;
} catch {
// Fall back to the direct child when the process group already exited.
}
}
child.kill(signal);
}
function recordPath(directory, deliveryId) {
return join(directory, `${createHash("sha256").update(String(deliveryId)).digest("hex")}.json`);
}
function serializeRecord(record) {
return `${JSON.stringify(record)}\n`;
}
async function readRecordPath(path, missingIsNull = false) {
try {
const parsed = JSON.parse(await readFile(path, "utf8"));
return validateInboxRecord(parsed);
} catch (error) {
if (missingIsNull && error?.code === "ENOENT") return null;
if (error instanceof DurableInboxError) throw error;
throw new DurableInboxError(503, `durable-inbox-read-failed:${error?.code || "unknown"}`);
}
}
async function durableWrite(path, content) {
const temporaryPath = `${path}.tmp-${randomUUID()}`;
let handle = null;
try {
handle = await open(temporaryPath, "wx", 0o600);
await handle.writeFile(content, "utf8");
await handle.sync();
await handle.close();
handle = null;
await rename(temporaryPath, path);
await syncDirectory(join(path, ".."));
} catch (error) {
if (handle) await handle.close().catch(() => undefined);
await unlink(temporaryPath).catch(() => undefined);
if (error instanceof DurableInboxError) throw error;
throw new DurableInboxError(503, `durable-inbox-write-failed:${error?.code || "unknown"}`);
}
}
async function syncDirectory(directory) {
const handle = await open(directory, "r");
try {
await handle.sync();
} finally {
await handle.close();
}
}
function typedStorageError(error) {
if (error instanceof DurableInboxError) return error.message.split(":", 1)[0];
return "durable-inbox-unavailable";
}
function recordDelivery(record) {
return {
deliveryId: record.deliveryId,
repository: record.repository,
ref: record.ref,
requestedCommit: record.requestedCommit,
payloadSha: record.payloadSha,
receivedAt: record.receivedAt,
};
}
function publicSyncResult(result) {
return {
sourceCommit: result.sourceCommit ?? null,
authorityCommit: result.authorityCommit ?? null,
snapshotRef: result.snapshotRef ?? null,
disposition: result.disposition ?? null,
};
}
function publicInboxRecord(record) {
return {
deliveryId: record.deliveryId,
repo: record.repoKey,
repository: record.repository,
ref: record.ref,
requestedCommit: record.requestedCommit,
payloadSha: record.payloadSha,
receivedAt: record.receivedAt,
state: record.state,
acceptedSequence: record.acceptedSequence,
totalAttempts: record.totalAttempts,
cycleAttempt: record.cycleAttempt,
nextAttemptAt: record.nextAttemptAt,
updatedAt: record.updatedAt,
result: record.result,
lastError: record.lastError,
};
}
function statusRecords(records) {
const selected = new Map();
for (const record of records.slice(-100)) selected.set(record.deliveryId, record);
const latestByRepo = new Map();
const latestCommittedByRepo = new Map();
for (const record of records) {
const current = latestByRepo.get(record.repoKey);
if (!current || record.acceptedSequence > current.acceptedSequence) latestByRepo.set(record.repoKey, record);
if (record.state === "committed") {
const committed = latestCommittedByRepo.get(record.repoKey);
if (!committed || record.acceptedSequence > committed.acceptedSequence) latestCommittedByRepo.set(record.repoKey, record);
}
}
for (const record of latestByRepo.values()) selected.set(record.deliveryId, record);
for (const record of latestCommittedByRepo.values()) selected.set(record.deliveryId, record);
return sortInboxRecordsByAcceptedSequence([...selected.values()]);
}
export function sortInboxRecordsByAcceptedSequence(records) {
return [...records].sort((left, right) => left.acceptedSequence - right.acceptedSequence);
}
function validateInboxRecord(record) {
const invalid = () => { throw new DurableInboxError(503, "durable-inbox-record-invalid"); };
if (!record || record.version !== 1) invalid();
if (typeof record.deliveryId !== "string" || record.deliveryId.length < 1 || record.deliveryId.length > 160) invalid();
if (!/^[a-zA-Z0-9._-]+$/u.test(record.repoKey || "")) invalid();
if (!/^[^/\s]+\/[^/\s]+$/u.test(record.repository || "")) invalid();
if (!/^refs\/heads\/[^\s]+$/u.test(record.ref || "")) invalid();
if (!/^[0-9a-f]{40,64}$/u.test(record.requestedCommit || "")) invalid();
if (!/^[0-9a-f]{64}$/u.test(record.payloadSha || "")) invalid();
if (!/^[0-9a-f]{64}$/u.test(record.fingerprint || "")) invalid();
if (!["accepted", "processing", "committed", "failed"].includes(record.state)) invalid();
if (!isTimestamp(record.receivedAt) || !isTimestamp(record.acceptedAt) || !isTimestamp(record.updatedAt)) invalid();
if (!Number.isInteger(record.acceptedSequence) || record.acceptedSequence < 1) invalid();
if (!Number.isInteger(record.totalAttempts) || record.totalAttempts < 0 || !Number.isInteger(record.cycleAttempt) || record.cycleAttempt < 0) invalid();
if (record.nextAttemptAt !== null && !isTimestamp(record.nextAttemptAt)) invalid();
if (record.lastError !== null) {
if (typeof record.lastError !== "object" || !/^[a-z0-9][a-z0-9-]{0,127}$/u.test(record.lastError.errorType || "")) invalid();
if (typeof record.lastError.retryable !== "boolean") invalid();
if (record.lastError.terminal !== undefined && typeof record.lastError.terminal !== "boolean") invalid();
}
const expectedFingerprint = createHash("sha256")
.update([record.repoKey, record.deliveryId, record.repository, record.ref, record.requestedCommit, record.payloadSha].join("\0"))
.digest("hex");
if (!timingSafeEqualString(record.fingerprint, expectedFingerprint)) invalid();
if (record.state === "accepted" && (!isTimestamp(record.nextAttemptAt) || record.result !== null)) invalid();
if (record.state === "processing" && (!isTimestamp(record.processingAt) || record.result !== null)) invalid();
if (record.state === "failed" && (!isTimestamp(record.failedAt) || !isTimestamp(record.nextAttemptAt) || record.lastError === null || record.result !== null)) invalid();
if (record.state === "committed") {
if (!isTimestamp(record.committedAt) || record.nextAttemptAt !== null || record.lastError !== null || !record.result || typeof record.result !== "object") invalid();
if (!/^[0-9a-f]{40,64}$/u.test(record.result.sourceCommit || "") || !/^[0-9a-f]{40,64}$/u.test(record.result.authorityCommit || "")) invalid();
if (record.result.sourceCommit !== record.requestedCommit) invalid();
if (!/^refs\/[a-zA-Z0-9._/-]+$/u.test(record.result.snapshotRef || "") || !record.result.snapshotRef.endsWith(`/${record.requestedCommit}`)) invalid();
if (!["atomic-source-authority-committed", "superseded-with-immutable-snapshot"].includes(record.result.disposition)) invalid();
}
return record;
}
function validateJournalMetadata(metadata) {
if (!metadata || metadata.version !== 1 || metadata.kind !== "GiteaGithubSyncDurableInbox" || !isTimestamp(metadata.createdAt)) {
throw new DurableInboxError(503, "durable-inbox-metadata-invalid");
}
return metadata;
}
function isTimestamp(value) {
return typeof value === "string" && Number.isFinite(Date.parse(value));
}
function deliveryLog(event, repo, delivery, extra = {}) {
return {
event,
repo: repo.key,
deliveryId: delivery.deliveryId,
repository: delivery.repository,
ref: delivery.ref,
requestedCommit: delivery.requestedCommit,
...extra,
valuesPrinted: false,
};
}
function parsePayload(body) {
try {
return JSON.parse(body.toString("utf8") || "{}");
} catch {
throw new HttpError(400, "invalid-json-payload");
}
}
function singleHeader(value) {
return String(Array.isArray(value) ? value[0] : value || "").trim();
}
function timingSafeEqualString(a, b) {
const left = Buffer.from(a);
const right = Buffer.from(b);
return left.length === right.length && timingSafeEqual(left, right);
}
function readBody(req, maxBodyBytes) {
return new Promise((resolve, reject) => {
const chunks = [];
let bytes = 0;
let rejected = false;
req.on("data", (chunk) => {
bytes += chunk.length;
if (!rejected && bytes > maxBodyBytes) {
rejected = true;
reject(new HttpError(413, "request-body-too-large"));
return;
}
if (!rejected) chunks.push(chunk);
});
req.on("end", () => {
if (!rejected) resolve(Buffer.concat(chunks));
});
req.on("error", reject);
});
}
export function waitWithinResponseBudget(promise, deadlineMs, errorType) {
const remainingMs = deadlineMs - Date.now();
if (remainingMs <= 0) return Promise.reject(new HttpError(503, errorType));
return new Promise((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
reject(new HttpError(503, errorType));
}, remainingMs);
Promise.resolve(promise).then(
(value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(value);
},
(error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(error);
},
);
});
}
function writeJson(res, status, payload) {
if (res.headersSent || res.destroyed) return;
res.writeHead(status, { "Content-Type": "application/json" });
res.end(`${JSON.stringify(payload)}\n`);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function log(payload) {
console.log(JSON.stringify(payload));
}
function redact(value) {
return String(value || "")
.replace(/Authorization: Basic [A-Za-z0-9+/=]+/gu, "Authorization: Basic <redacted>")
.replace(/x-access-token:[^@\s]+/gu, "x-access-token:<redacted>")
.slice(-1600);
}
function requiredEnv(name) {
const value = process.env[name];
if (!value) throw new Error(`missing env ${name}`);
return value;
}
function positiveIntegerEnv(name) {
const value = Number.parseInt(requiredEnv(name), 10);
if (!Number.isFinite(value) || value < 1) throw new Error(`invalid positive integer env ${name}`);
return value;
}