feat: add github to gitea webhook sync
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const port = Number.parseInt(process.env.UNIDESK_GITEA_WEBHOOK_PORT || "8080", 10);
|
||||
const path = process.env.UNIDESK_GITEA_WEBHOOK_PATH || "/_unidesk/github-to-gitea";
|
||||
const repos = JSON.parse(readFileSync(process.env.UNIDESK_GITEA_REPOS_PATH || "/etc/gitea-github-sync/repos.json", "utf8"));
|
||||
const githubToken = requiredEnv("GITHUB_TOKEN");
|
||||
const giteaUsername = requiredEnv("GITEA_USERNAME");
|
||||
const giteaPassword = requiredEnv("GITEA_PASSWORD");
|
||||
const webhookSecret = requiredEnv("GITHUB_WEBHOOK_SECRET");
|
||||
const giteaBaseUrl = requiredEnv("UNIDESK_GITEA_SERVICE_BASE_URL").replace(/\/+$/u, "");
|
||||
const githubAuthHeader = `Authorization: Basic ${Buffer.from(`x-access-token:${githubToken}`).toString("base64")}`;
|
||||
const giteaAuthHeader = `Authorization: Basic ${Buffer.from(`${giteaUsername}:${giteaPassword}`).toString("base64")}`;
|
||||
|
||||
const running = new Set();
|
||||
|
||||
createServer(async (req, res) => {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const url = new URL(req.url || "/", "http://gitea-github-sync.local");
|
||||
if (req.method === "GET" && url.pathname === "/healthz") {
|
||||
writeJson(res, 200, { ok: true, repos: repos.length, valuesPrinted: false });
|
||||
return;
|
||||
}
|
||||
if (req.method !== "POST" || url.pathname !== path) {
|
||||
writeJson(res, 404, { ok: false, error: "not-found", valuesPrinted: false });
|
||||
return;
|
||||
}
|
||||
const body = await readBody(req);
|
||||
verifySignature(req.headers["x-hub-signature-256"], body);
|
||||
const event = String(req.headers["x-github-event"] || "");
|
||||
if (event === "ping") {
|
||||
writeJson(res, 200, { ok: true, event, disposition: "pong", valuesPrinted: false });
|
||||
return;
|
||||
}
|
||||
if (event !== "push") {
|
||||
writeJson(res, 202, { ok: true, event, disposition: "ignored-event", valuesPrinted: false });
|
||||
return;
|
||||
}
|
||||
const payload = JSON.parse(body.toString("utf8") || "{}");
|
||||
const repository = payload?.repository?.full_name;
|
||||
const ref = payload?.ref;
|
||||
const repo = repos.find((item) => item?.upstream?.repository === repository && ref === `refs/heads/${item?.upstream?.branch}`);
|
||||
if (!repo) {
|
||||
writeJson(res, 202, { ok: true, event, repository, ref, disposition: "ignored-repo-or-ref", valuesPrinted: false });
|
||||
return;
|
||||
}
|
||||
if (running.has(repo.key)) {
|
||||
writeJson(res, 202, { ok: true, event, repository, ref, disposition: "already-running", repo: repo.key, valuesPrinted: false });
|
||||
return;
|
||||
}
|
||||
running.add(repo.key);
|
||||
try {
|
||||
const sync = syncRepository(repo);
|
||||
log({ event: "github-to-gitea-sync", repo: repo.key, ok: sync.ok, sourceCommit: sync.sourceCommit, snapshotRef: sync.snapshotRef, elapsedMs: Date.now() - startedAt, valuesPrinted: false });
|
||||
writeJson(res, sync.ok ? 200 : 500, { ok: sync.ok, event, repository, ref, repo: repo.key, ...sync, valuesPrinted: false });
|
||||
} finally {
|
||||
running.delete(repo.key);
|
||||
}
|
||||
} catch (error) {
|
||||
log({ event: "github-to-gitea-sync-error", ok: false, error: String(error?.message || error), valuesPrinted: false });
|
||||
writeJson(res, 500, { ok: false, error: String(error?.message || error), valuesPrinted: false });
|
||||
}
|
||||
}).listen(port, "0.0.0.0", () => {
|
||||
log({ event: "github-to-gitea-sync-listening", port, path, repos: repos.map((repo) => repo.key), valuesPrinted: false });
|
||||
});
|
||||
|
||||
function syncRepository(repo) {
|
||||
const work = mkdtempSync(join(tmpdir(), `gitea-gh-sync-${repo.key}-`));
|
||||
try {
|
||||
run(["git", "init", "--bare", work]);
|
||||
run(["git", "-C", work, "-c", `http.extraHeader=${githubAuthHeader}`, "fetch", "--prune", repo.upstream.cloneUrl, `+refs/heads/${repo.upstream.branch}:refs/remotes/github/${repo.upstream.branch}`]);
|
||||
const sourceCommit = run(["git", "-C", work, "rev-parse", `refs/remotes/github/${repo.upstream.branch}`]).stdout.trim();
|
||||
const snapshotRef = `${repo.snapshot.prefix.replace(/\/+$/u, "")}/${sourceCommit}`;
|
||||
const giteaUrl = giteaBaseUrl + new URL(repo.gitea.readUrl).pathname;
|
||||
run(["git", "-C", work, "-c", `http.extraHeader=${giteaAuthHeader}`, "push", "--force", giteaUrl, `${sourceCommit}:refs/heads/${repo.upstream.branch}`, `${sourceCommit}:${snapshotRef}`]);
|
||||
return { ok: true, sourceCommit, snapshotRef };
|
||||
} catch (error) {
|
||||
return { ok: false, error: String(error?.message || error) };
|
||||
} finally {
|
||||
rmSync(work, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function run(args) {
|
||||
const result = spawnSync(args[0], args.slice(1), {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: 120000,
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
GIT_CONFIG_NOSYSTEM: "1",
|
||||
GIT_CONFIG_GLOBAL: "/dev/null",
|
||||
},
|
||||
});
|
||||
if (result.error || result.status !== 0) {
|
||||
const stderr = String(result.stderr || result.error?.message || "").slice(-1600);
|
||||
throw new Error(`${args[0]} failed status=${result.status ?? "error"} stderr=${stderr}`);
|
||||
}
|
||||
return { stdout: result.stdout || "", stderr: result.stderr || "" };
|
||||
}
|
||||
|
||||
function verifySignature(signatureHeader, body) {
|
||||
const signature = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader;
|
||||
if (!signature || !signature.startsWith("sha256=")) throw new Error("missing-github-signature");
|
||||
const expected = `sha256=${createHmac("sha256", webhookSecret).update(body).digest("hex")}`;
|
||||
if (!timingSafeEqualString(signature, expected)) throw new Error("invalid-github-signature");
|
||||
}
|
||||
|
||||
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) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on("data", (chunk) => chunks.push(chunk));
|
||||
req.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function writeJson(res, status, payload) {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(`${JSON.stringify(payload)}\n`);
|
||||
}
|
||||
|
||||
function log(payload) {
|
||||
console.log(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function requiredEnv(name) {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`missing env ${name}`);
|
||||
return value;
|
||||
}
|
||||
Reference in New Issue
Block a user