import dotenv from "dotenv"; dotenv.config(); import express from "express"; import morgan from "morgan"; import fs from "fs"; import path from "path"; import giteaWebhook from "./routes/giteaWebhook.js"; import issueProcessor, { isRunning as workerIsRunning } from "./workers/issueProcessor.js"; import errorHandler from "./middleware/errorHandler.js"; import logger from "./lib/logger.js"; import config from "./config/index.js"; import sheetsAdapter from "./adapters/sheetsAdapter.js"; const app = express(); // use morgan but route logs through structured logger app.use(morgan("combined", { stream: { write: (s) => logger.info(s.trim()) } })); // capture raw body for webhook signature verification on the gitea webhook route app.use( "/webhooks/gitea", express.json({ verify: (req, res, buf) => (req.rawBody = buf) }) ); // regular JSON parser for other routes app.use(express.json()); app.use("/webhooks/gitea", giteaWebhook); app.get("/health", (req, res) => { res.json({ status: "ok", uptime: process.uptime(), pid: process.pid, timestamp: new Date().toISOString() }); }); app.get("/ready", async (req, res) => { const missing = []; // config checks if (!config.giteaWebhookSecret) missing.push("GITEA_WEBHOOK_SECRET"); if (!config.googleSheetId) missing.push("GOOGLE_SHEET_ID"); const hasGoogleCreds = !!( process.env.GOOGLE_SERVICE_ACCOUNT_JSON || process.env.GOOGLE_APPLICATION_CREDENTIALS || (process.env.GOOGLE_PRIVATE_KEY && process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL) || fs.existsSync(path.resolve(process.cwd(), "service-account.json")) ); if (!hasGoogleCreds) missing.push("Google service account credentials"); if (missing.length) { logger.warn("Readiness check failed (missing config)", { missing }); return res.status(503).json({ ready: false, missing }); } // active adapter readiness probe try { const ok = await sheetsAdapter.readinessProbe(); if (!ok) { logger.warn("Readiness probe: sheets adapter not ready"); return res.status(503).json({ ready: false, missing: ["google_sheets_unreachable"] }); } } catch (err) { logger.warn("Readiness probe error", { err: err && err.message ? err.message : err }); return res.status(503).json({ ready: false, missing: ["google_sheets_unreachable"] }); } // worker status if (!workerIsRunning()) { logger.warn("Readiness check failed: worker not running"); return res.status(503).json({ ready: false, missing: ["worker_not_running"] }); } return res.json({ ready: true, uptime: process.uptime(), timestamp: new Date().toISOString() }); }); const PORT = config.port; function validateConfig() { const missing = []; if (!config.giteaWebhookSecret) missing.push("GITEA_WEBHOOK_SECRET"); if (!config.googleSheetId) missing.push("GOOGLE_SHEET_ID"); const hasGoogleCreds = !!( process.env.GOOGLE_SERVICE_ACCOUNT_JSON || process.env.GOOGLE_APPLICATION_CREDENTIALS || (process.env.GOOGLE_PRIVATE_KEY && process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL) || fs.existsSync(path.resolve(process.cwd(), "service-account.json")) ); if (!hasGoogleCreds) missing.push("Google service account credentials"); if (missing.length) { logger.warn("Startup config warning: missing env vars:", { missing }); logger.warn("See SECRETS.md for secure configuration instructions."); } } validateConfig(); // Start background workers (no business logic implemented yet) issueProcessor.start(); // Central error handler app.use(errorHandler); const server = app.listen(PORT, "0.0.0.0", () => { logger.info("Server running on port", { port: PORT }); }); // track active connections so we can force-close them on shutdown const connections = new Set(); server.on("connection", (socket) => { connections.add(socket); socket.on("close", () => connections.delete(socket)); }); const SHUTDOWN_GRACE_MS = process.env.SHUTDOWN_GRACE_MS ? Number(process.env.SHUTDOWN_GRACE_MS) : 10000; let shuttingDown = false; async function shutdown(signal) { if (shuttingDown) return; shuttingDown = true; logger.info("Shutdown initiated", { signal }); // stop accepting new connections const closeServer = new Promise((resolve) => { try { server.close((err) => { if (err) logger.warn("Error closing server", { err: err && err.message ? err.message : err }); else logger.info("HTTP server closed"); resolve(); }); } catch (e) { logger.warn("Error during server.close", { err: e && e.message ? e.message : e }); resolve(); } }); // schedule forced socket destruction after grace period const forceTimeout = setTimeout(() => { logger.warn("Forcing destruction of active connections after grace period"); for (const s of connections) { try { s.destroy(); } catch (e) { // ignore } } }, SHUTDOWN_GRACE_MS); await closeServer; // stop workers and drain adapter queues try { await issueProcessor.stop(SHUTDOWN_GRACE_MS); } catch (e) { logger.warn("Error stopping workers", { err: e && e.message ? e.message : e }); } // final attempt to wait for adapter try { await sheetsAdapter.waitForIdle(SHUTDOWN_GRACE_MS); } catch (e) { logger.warn("Timeout waiting for adapter to become idle", { err: e && e.message ? e.message : e }); } clearTimeout(forceTimeout); logger.info("Shutdown complete, exiting"); // give a moment for logs to flush setTimeout(() => process.exit(0), 100); } process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM")); process.on("unhandledRejection", (reason) => { logger.error("UnhandledRejection", { reason }); shutdown("unhandledRejection"); }); process.on("uncaughtException", (err) => { logger.error("UncaughtException", { err: err && err.stack ? err.stack : err }); shutdown("uncaughtException"); });