import "dotenv/config"; import express from "express"; import axios from "axios"; import process from "process"; import crypto from "crypto"; const app = express(); app.use(express.json({ limit: "10mb" })); /* ====================================================== CONFIGURATION ====================================================== */ const CONFIG = { port: parseInt(process.env.PORT || "3002", 10), redmine: { apiUrl: process.env.REDMINE_API_URL, apiKey: process.env.REDMINE_API_KEY, timeout: parseInt(process.env.REDMINE_TIMEOUT || "30000", 10), }, gitea: { apiUrl: process.env.GITEA_API_URL, token: process.env.GITEA_TOKEN, owner: process.env.GITEA_OWNER, timeout: parseInt(process.env.GITEA_TIMEOUT || "30000", 10), }, cache: { ttl: parseInt(process.env.CACHE_TTL || "30000", 10), }, sync: { enableLabels: process.env.SYNC_LABELS !== "false", enableMilestones: process.env.SYNC_MILESTONES !== "false", enableAttachments: process.env.SYNC_ATTACHMENTS !== "false", enableCustomFields: process.env.SYNC_CUSTOM_FIELDS !== "false", retryAttempts: parseInt(process.env.RETRY_ATTEMPTS || "3", 10), retryDelay: parseInt(process.env.RETRY_DELAY || "1000", 10), }, logging: { level: process.env.LOG_LEVEL || "info", verbose: process.env.LOG_VERBOSE === "true", }, }; // Validate configuration if (!CONFIG.redmine.apiUrl || !CONFIG.redmine.apiKey) { console.error("FATAL: Missing Redmine configuration (REDMINE_API_URL, REDMINE_API_KEY)"); process.exit(1); } if (!CONFIG.gitea.apiUrl || !CONFIG.gitea.token || !CONFIG.gitea.owner) { console.error("FATAL: Missing Gitea configuration (GITEA_API_URL, GITEA_TOKEN, GITEA_OWNER)"); process.exit(1); } /* ====================================================== PROJECT AND STATUS MAPPING ====================================================== */ const PROJECT_MAPPING = JSON.parse(process.env.PROJECT_MAPPING || "{}"); const STATUS_MAPPING = { redmineToGitea: { 1: "open", // New 2: "open", // In Progress 3: "open", // Resolved 4: "open", // Feedback 5: "closed", // Closed 6: "closed", // Rejected }, giteaToRedmine: { open: 2, // In Progress closed: 5, // Closed }, }; const PRIORITY_MAPPING = { redmineToGitea: { 1: "Low Priority", 2: "Normal Priority", 3: "High Priority", 4: "Urgent", 5: "Immediate", }, giteaToRedmine: { "Low Priority": 1, "Normal Priority": 2, "High Priority": 3, "Urgent": 4, "Immediate": 5, }, }; const TRACKER_MAPPING = { redmineToGitea: { 1: "bug", 2: "feature", 3: "support", }, giteaToRedmine: { "bug": 1, "feature": 2, "support": 3, }, }; function getRedmineProjectId(giteaRepoName) { if (PROJECT_MAPPING[giteaRepoName]) { return PROJECT_MAPPING[giteaRepoName]; } return giteaRepoName.toLowerCase().replace(/[\s_]+/g, "-").replace(/[^a-z0-9-]/g, ""); } function getGiteaRepoName(redmineProjectId) { const entry = Object.entries(PROJECT_MAPPING).find(([_, v]) => v === redmineProjectId); if (entry) return entry[0]; return redmineProjectId.replace(/-/g, "_"); } /* ====================================================== HTTP CLIENTS WITH RETRY LOGIC ====================================================== */ async function retryRequest(fn, attempts = CONFIG.sync.retryAttempts) { for (let i = 0; i < attempts; i++) { try { return await fn(); } catch (error) { if (i === attempts - 1) throw error; const delay = CONFIG.sync.retryDelay * Math.pow(2, i); logger.warn(`Request failed, retrying in ${delay}ms... (attempt ${i + 1}/${attempts})`); await new Promise(resolve => setTimeout(resolve, delay)); } } } const redmineClient = axios.create({ baseURL: CONFIG.redmine.apiUrl, timeout: CONFIG.redmine.timeout, headers: { "X-Redmine-API-Key": CONFIG.redmine.apiKey, "Content-Type": "application/json", }, }); const giteaClient = axios.create({ baseURL: CONFIG.gitea.apiUrl, timeout: CONFIG.gitea.timeout, headers: { Authorization: `token ${CONFIG.gitea.token}`, "Content-Type": "application/json", }, }); /* ====================================================== LOGGING UTILITY ====================================================== */ const logger = { levels: { debug: 0, info: 1, warn: 2, error: 3 }, currentLevel: 1, log(level, ...args) { if (this.levels[level] >= this.currentLevel) { const timestamp = new Date().toISOString(); console.log(`[${timestamp}] [${level.toUpperCase()}]`, ...args); } }, debug(...args) { this.log("debug", ...args); }, info(...args) { this.log("info", ...args); }, warn(...args) { this.log("warn", ...args); }, error(...args) { this.log("error", ...args); }, }; logger.currentLevel = logger.levels[CONFIG.logging.level] || 1; /* ====================================================== SYNC CACHE ====================================================== */ class SyncCache { constructor(ttl) { this.cache = new Map(); this.ttl = ttl; } generateKey(type, id) { return `${type}:${id}`; } add(type, id) { const key = this.generateKey(type, id); this.cache.set(key, Date.now()); setTimeout(() => this.cache.delete(key), this.ttl); logger.debug(`Cache added: ${key}`); } has(type, id) { const key = this.generateKey(type, id); return this.cache.has(key); } clear() { this.cache.clear(); logger.info("Cache cleared"); } size() { return this.cache.size; } } const syncCache = new SyncCache(CONFIG.cache.ttl); /* ====================================================== REDMINE API UTILITIES ====================================================== */ class RedmineAPI { static async getIssue(issueId) { const response = await retryRequest(() => redmineClient.get(`/issues/${issueId}.json`, { params: { include: "attachments,journals,watchers" }, }) ); return response.data.issue; } static async createIssue(projectId, data) { const response = await retryRequest(() => redmineClient.post("/issues.json", { issue: data }) ); return response.data.issue; } static async updateIssue(issueId, data) { await retryRequest(() => redmineClient.put(`/issues/${issueId}.json`, { issue: data }) ); } static async searchIssues(projectId, subject) { const response = await retryRequest(() => redmineClient.get("/issues.json", { params: { project_id: projectId, subject: `~${subject}`, limit: 100, }, }) ); return response.data.issues || []; } static async addComment(issueId, notes) { await retryRequest(() => redmineClient.put(`/issues/${issueId}.json`, { issue: { notes }, }) ); } static async uploadAttachment(file) { const response = await retryRequest(() => redmineClient.post("/uploads.json", file.data, { headers: { "Content-Type": "application/octet-stream", }, }) ); return response.data.upload; } static async getProject(projectId) { const response = await retryRequest(() => redmineClient.get(`/projects/${projectId}.json`) ); return response.data.project; } static async getTrackers() { const response = await retryRequest(() => redmineClient.get("/trackers.json") ); return response.data.trackers || []; } static async getStatuses() { const response = await retryRequest(() => redmineClient.get("/issue_statuses.json") ); return response.data.issue_statuses || []; } static async getPriorities() { const response = await retryRequest(() => redmineClient.get("/enumerations/issue_priorities.json") ); return response.data.issue_priorities || []; } static async getVersions(projectId) { const response = await retryRequest(() => redmineClient.get(`/projects/${projectId}/versions.json`) ); return response.data.versions || []; } } /* ====================================================== GITEA API UTILITIES ====================================================== */ class GiteaAPI { static async getIssue(owner, repo, number) { const response = await retryRequest(() => giteaClient.get(`/api/v1/repos/${owner}/${repo}/issues/${number}`) ); return response.data; } static async searchIssues(owner, repo, params = {}) { const response = await retryRequest(() => giteaClient.get(`/api/v1/repos/${owner}/${repo}/issues`, { params: { state: "all", limit: 100, ...params }, }) ); return response.data; } static async createIssue(owner, repo, data) { const response = await retryRequest(() => giteaClient.post(`/api/v1/repos/${owner}/${repo}/issues`, data) ); return response.data; } static async updateIssue(owner, repo, number, data) { const response = await retryRequest(() => giteaClient.patch(`/api/v1/repos/${owner}/${repo}/issues/${number}`, data) ); return response.data; } static async getComments(owner, repo, number) { const response = await retryRequest(() => giteaClient.get(`/api/v1/repos/${owner}/${repo}/issues/${number}/comments`) ); return response.data; } static async addComment(owner, repo, number, body) { const response = await retryRequest(() => giteaClient.post(`/api/v1/repos/${owner}/${repo}/issues/${number}/comments`, { body }) ); return response.data; } static async getLabels(owner, repo) { const response = await retryRequest(() => giteaClient.get(`/api/v1/repos/${owner}/${repo}/labels`) ); return response.data; } static async createLabel(owner, repo, data) { const response = await retryRequest(() => giteaClient.post(`/api/v1/repos/${owner}/${repo}/labels`, data) ); return response.data; } static async setIssueLabels(owner, repo, number, labelNames) { // Get all labels to find IDs const allLabels = await this.getLabels(owner, repo); const labelIds = []; for (const labelName of labelNames) { const label = allLabels.find(l => l.name === labelName); if (label) { labelIds.push(label.id); } } if (labelIds.length > 0) { await retryRequest(() => giteaClient.put(`/api/v1/repos/${owner}/${repo}/issues/${number}/labels`, { labels: labelIds }) ); } } static async getMilestones(owner, repo) { const response = await retryRequest(() => giteaClient.get(`/api/v1/repos/${owner}/${repo}/milestones`, { params: { state: "all" }, }) ); return response.data; } static async createMilestone(owner, repo, data) { const response = await retryRequest(() => giteaClient.post(`/api/v1/repos/${owner}/${repo}/milestones`, data) ); return response.data; } static async getRepository(owner, repo) { const response = await retryRequest(() => giteaClient.get(`/api/v1/repos/${owner}/${repo}`) ); return response.data; } } /* ====================================================== SYNC UTILITIES ====================================================== */ class SyncUtilities { static extractRedmineIdFromTitle(title) { const match = title.match(/^Redmine #(\d+):/); return match ? parseInt(match[1], 10) : null; } static extractGiteaIdFromDescription(description) { const match = description.match(/\[Gitea Issue #(\d+)\]/); return match ? parseInt(match[1], 10) : null; } static formatRedmineDescription(issue) { let description = `[Gitea Issue #${issue.number}]\n`; description += `Author: ${issue.user.login}\n`; description += `Created at: ${issue.created_at}\n\n`; description += issue.body || "(No description)"; return description; } static formatGiteaTitle(issue) { return `Redmine #${issue.id}: ${issue.subject}`; } static formatGiteaDescription(issue) { let description = issue.description || "(No description)"; if (CONFIG.sync.enableCustomFields && issue.custom_fields?.length > 0) { description += "\n\n---\n**Custom Fields:**\n"; issue.custom_fields.forEach(cf => { description += `- ${cf.name}: ${cf.value}\n`; }); } return description; } static async syncLabels(owner, repo, issue, existingLabels) { if (!CONFIG.sync.enableLabels) return; const labels = []; // Add tracker as label if (issue.tracker && TRACKER_MAPPING.redmineToGitea[issue.tracker.id]) { labels.push(TRACKER_MAPPING.redmineToGitea[issue.tracker.id]); } // Add priority as label if (issue.priority && PRIORITY_MAPPING.redmineToGitea[issue.priority.id]) { labels.push(PRIORITY_MAPPING.redmineToGitea[issue.priority.id]); } // Add category as label if (issue.category) { labels.push(`category:${issue.category.name}`); } // Ensure labels exist const giteaLabels = await GiteaAPI.getLabels(owner, repo); for (const labelName of labels) { if (!giteaLabels.find(l => l.name === labelName)) { try { await GiteaAPI.createLabel(owner, repo, { name: labelName, color: this.generateColorForLabel(labelName), }); } catch (err) { logger.warn(`Failed to create label ${labelName}: ${err.message}`); } } } return labels; } static async syncMilestone(owner, repo, issue) { if (!CONFIG.sync.enableMilestones || !issue.fixed_version) return null; const milestones = await GiteaAPI.getMilestones(owner, repo); let milestone = milestones.find(m => m.title === issue.fixed_version.name); if (!milestone) { try { milestone = await GiteaAPI.createMilestone(owner, repo, { title: issue.fixed_version.name, description: issue.fixed_version.description || "", due_on: issue.fixed_version.due_date || null, }); } catch (err) { logger.warn(`Failed to create milestone ${issue.fixed_version.name}: ${err.message}`); } } return milestone?.id || null; } static generateColorForLabel(label) { const hash = crypto.createHash("md5").update(label).digest("hex"); return hash.substring(0, 6); } static mapRedmineStatusToGitea(statusId) { return STATUS_MAPPING.redmineToGitea[statusId] || "open"; } static mapGiteaStatusToRedmine(state) { return STATUS_MAPPING.giteaToRedmine[state] || 2; } static extractTrackerFromLabels(labels) { if (!labels || labels.length === 0) return 1; // Default to Bug for (const label of labels) { const labelName = typeof label === 'string' ? label : label.name; if (TRACKER_MAPPING.giteaToRedmine[labelName]) { return TRACKER_MAPPING.giteaToRedmine[labelName]; } } return 1; // Default to Bug } static extractPriorityFromLabels(labels) { if (!labels || labels.length === 0) return 2; // Default to Normal for (const label of labels) { const labelName = typeof label === 'string' ? label : label.name; if (PRIORITY_MAPPING.giteaToRedmine[labelName]) { return PRIORITY_MAPPING.giteaToRedmine[labelName]; } } return 2; // Default to Normal } static async processJournals(owner, repo, giteaIssueNumber, journals) { if (!journals || journals.length === 0) return; const existingComments = await GiteaAPI.getComments(owner, repo, giteaIssueNumber); for (const journal of journals) { if (!journal.notes || journal.notes.trim() === "") { logger.debug(`Skipping journal #${journal.id} - empty notes`); continue; } const journalMarker = `[Redmine Journal #${journal.id}]`; const alreadySynced = existingComments.some(c => c.body.includes(journalMarker)); if (alreadySynced) { logger.debug(`Skipping journal #${journal.id} - already synced`); continue; } if (syncCache.has("redmineJournal", journal.id)) { logger.debug(`Skipping journal #${journal.id} - in cache`); continue; } let commentBody = `${journalMarker}\n**${journal.user?.login || journal.author?.login || 'Unknown'}** commented:\n\n${journal.notes}`; if (journal.details && journal.details.length > 0) { commentBody += "\n\n**Changes:**\n"; journal.details.forEach(detail => { if (detail.property === "attr") { const fieldName = detail.name || detail.prop_key; commentBody += `- ${fieldName}: ${detail.old_value || "(none)"} → ${detail.new_value || detail.value}\n`; } }); } await GiteaAPI.addComment(owner, repo, giteaIssueNumber, commentBody); syncCache.add("redmineJournal", journal.id); logger.info(`Synced Redmine journal #${journal.id} to Gitea comment on issue #${giteaIssueNumber}`); } } static async processComments(issueId, comments) { if (!comments || comments.length === 0) return; for (const comment of comments) { const commentMarker = `[Gitea Comment #${comment.id}]`; if (!syncCache.has("giteaComment", comment.id)) { const notes = `${commentMarker}\n${comment.user.login} commented:\n\n${comment.body}`; await RedmineAPI.addComment(issueId, notes); syncCache.add("giteaComment", comment.id); logger.info(`Synced Gitea comment #${comment.id} to Redmine journal`); } } } } /* ====================================================== WEBHOOK HANDLERS ====================================================== */ app.post("/webhook/redmine", async (req, res) => { res.sendStatus(200); try { const payload = req.body?.payload; if (!payload?.issue) { logger.debug("Received Redmine webhook with no issue data"); return; } const issue = payload.issue; logger.info(`Processing Redmine issue #${issue.id}: ${issue.subject}`); // Check cache to prevent loops if (syncCache.has("redmineIssue", issue.id)) { logger.debug(`Skipping Redmine issue #${issue.id} - recently synced`); return; } const repoName = getGiteaRepoName(issue.project.identifier); if (!repoName) { logger.warn(`No Gitea repo mapping found for Redmine project ${issue.project.identifier}`); return; } // Verify Gitea repository exists try { await GiteaAPI.getRepository(CONFIG.gitea.owner, repoName); } catch (err) { logger.error(`Gitea repository ${CONFIG.gitea.owner}/${repoName} not found`); return; } // Search for existing Gitea issue const issues = await GiteaAPI.searchIssues(CONFIG.gitea.owner, repoName); let giteaIssue = issues.find(i => SyncUtilities.extractRedmineIdFromTitle(i.title) === issue.id ); const state = SyncUtilities.mapRedmineStatusToGitea(issue.status.id); const labels = await SyncUtilities.syncLabels(CONFIG.gitea.owner, repoName, issue); const milestoneId = await SyncUtilities.syncMilestone(CONFIG.gitea.owner, repoName, issue); const issueData = { title: SyncUtilities.formatGiteaTitle(issue), body: SyncUtilities.formatGiteaDescription(issue), state, }; // Only add optional fields if they have valid values if (issue.assignee?.login) { issueData.assignee = issue.assignee.login; } if (milestoneId) { issueData.milestone = milestoneId; } if (issue.due_date) { // Ensure date is in ISO format const dueDate = new Date(issue.due_date); if (!isNaN(dueDate.getTime())) { issueData.due_date = dueDate.toISOString(); } } if (giteaIssue) { logger.info(`Updating existing Gitea issue #${giteaIssue.number}`); await GiteaAPI.updateIssue(CONFIG.gitea.owner, repoName, giteaIssue.number, issueData); syncCache.add("giteaIssue", giteaIssue.number); // Set labels separately after update (if enabled) if (CONFIG.sync.enableLabels && labels.length > 0) { try { await GiteaAPI.setIssueLabels(CONFIG.gitea.owner, repoName, giteaIssue.number, labels); } catch (err) { logger.warn(`Failed to set labels: ${err.message}`); } } // Sync journals to comments if (payload.journal) { await SyncUtilities.processJournals( CONFIG.gitea.owner, repoName, giteaIssue.number, [payload.journal] ); } } else { logger.info(`Creating new Gitea issue for Redmine #${issue.id}`); giteaIssue = await GiteaAPI.createIssue(CONFIG.gitea.owner, repoName, issueData); syncCache.add("giteaIssue", giteaIssue.number); // Set labels separately after creation (if enabled) if (CONFIG.sync.enableLabels && labels.length > 0) { try { await GiteaAPI.setIssueLabels(CONFIG.gitea.owner, repoName, giteaIssue.number, labels); } catch (err) { logger.warn(`Failed to set labels: ${err.message}`); } } // Sync all journals if this is a new issue const fullIssue = await RedmineAPI.getIssue(issue.id); if (fullIssue.journals) { await SyncUtilities.processJournals( CONFIG.gitea.owner, repoName, giteaIssue.number, fullIssue.journals ); } } logger.info(`Successfully synced Redmine issue #${issue.id} to Gitea issue #${giteaIssue.number}`); } catch (err) { logger.error(`Redmine to Gitea sync failed: ${err.message}`); if (err.response?.status === 422) { logger.error(`Validation error details: ${JSON.stringify(err.response.data)}`); } if (CONFIG.logging.verbose) { logger.error(err.stack); } } }); app.post("/webhook/gitea", async (req, res) => { res.sendStatus(200); try { const event = req.body; if (!event.issue || !event.repository) { logger.debug("Received Gitea webhook with no issue data"); return; } const issue = event.issue; logger.info(`Processing Gitea issue #${issue.number}: ${issue.title}`); // Check cache to prevent loops if (syncCache.has("giteaIssue", issue.number)) { logger.debug(`Skipping Gitea issue #${issue.number} - recently synced`); return; } const repoName = event.repository.name; const projectId = getRedmineProjectId(repoName); // Verify Redmine project exists try { await RedmineAPI.getProject(projectId); } catch (err) { logger.error(`Redmine project ${projectId} not found`); return; } // Search for existing Redmine issue const existingRedmineId = SyncUtilities.extractRedmineIdFromTitle(issue.title); let redmineIssue = null; if (existingRedmineId) { try { redmineIssue = await RedmineAPI.getIssue(existingRedmineId); } catch (err) { logger.warn(`Redmine issue #${existingRedmineId} not found, will create new`); } } const statusId = SyncUtilities.mapGiteaStatusToRedmine(issue.state); const trackerId = SyncUtilities.extractTrackerFromLabels(issue.labels); const priorityId = SyncUtilities.extractPriorityFromLabels(issue.labels); const issueData = { project_id: projectId, subject: issue.title.replace(/^Redmine #\d+:\s*/, ""), description: SyncUtilities.formatRedmineDescription(issue), status_id: statusId, tracker_id: trackerId, priority_id: priorityId, }; // Only add optional fields if they have valid values if (event.issue.assignee?.id) { issueData.assigned_to_id = event.issue.assignee.id; } // Format dates for Redmine (YYYY-MM-DD only, no time) if (issue.due_date) { try { const dueDate = new Date(issue.due_date); if (!isNaN(dueDate.getTime())) { issueData.due_date = dueDate.toISOString().split('T')[0]; } } catch (err) { logger.warn(`Invalid due_date format: ${issue.due_date}`); } } if (issue.created_at) { try { const startDate = new Date(issue.created_at); if (!isNaN(startDate.getTime())) { issueData.start_date = startDate.toISOString().split('T')[0]; } } catch (err) { logger.warn(`Invalid created_at format: ${issue.created_at}`); } } if (issue.estimated_hours && !isNaN(issue.estimated_hours)) { issueData.estimated_hours = parseFloat(issue.estimated_hours); } let redmineIssueId; if (redmineIssue) { logger.info(`Updating existing Redmine issue #${redmineIssue.id}`); await RedmineAPI.updateIssue(redmineIssue.id, issueData); redmineIssueId = redmineIssue.id; syncCache.add("redmineIssue", redmineIssueId); // Sync new comment if (event.comment) { await SyncUtilities.processComments(redmineIssueId, [event.comment]); } } else { logger.info(`Creating new Redmine issue for Gitea #${issue.number}`); const newIssue = await RedmineAPI.createIssue(projectId, issueData); redmineIssueId = newIssue.id; syncCache.add("redmineIssue", redmineIssueId); // Sync all comments if this is a new issue const comments = await GiteaAPI.getComments(CONFIG.gitea.owner, repoName, issue.number); await SyncUtilities.processComments(redmineIssueId, comments); // Update Gitea issue title to include Redmine ID await GiteaAPI.updateIssue(CONFIG.gitea.owner, repoName, issue.number, { title: SyncUtilities.formatGiteaTitle({ id: redmineIssueId, subject: issue.title }), }); } logger.info(`Successfully synced Gitea issue #${issue.number} to Redmine issue #${redmineIssueId}`); } catch (err) { logger.error(`Gitea to Redmine sync failed: ${err.message}`); if (err.response?.status === 422) { logger.error(`Validation error details: ${JSON.stringify(err.response.data)}`); } if (CONFIG.logging.verbose) { logger.error(err.stack); } } }); /* ====================================================== HEALTH AND STATUS ENDPOINTS ====================================================== */ app.get("/health", (req, res) => { res.json({ status: "ok", uptime: process.uptime(), timestamp: new Date().toISOString(), cache: { size: syncCache.size(), ttl: CONFIG.cache.ttl, }, config: { redmine: { url: CONFIG.redmine.apiUrl, connected: true, }, gitea: { url: CONFIG.gitea.apiUrl, owner: CONFIG.gitea.owner, connected: true, }, sync: CONFIG.sync, }, }); }); app.get("/status", async (req, res) => { const status = { server: "running", uptime: process.uptime(), cache: { size: syncCache.size(), ttl: CONFIG.cache.ttl, }, connections: { redmine: "unknown", gitea: "unknown", }, }; // Test Redmine connection try { await RedmineAPI.getStatuses(); status.connections.redmine = "connected"; } catch (err) { status.connections.redmine = "disconnected"; logger.error(`Redmine connection test failed: ${err.message}`); } // Test Gitea connection try { await giteaClient.get("/api/v1/version"); status.connections.gitea = "connected"; } catch (err) { status.connections.gitea = "disconnected"; logger.error(`Gitea connection test failed: ${err.message}`); } res.json(status); }); app.post("/cache/clear", (req, res) => { syncCache.clear(); res.json({ message: "Cache cleared", timestamp: new Date().toISOString() }); }); /* ====================================================== ERROR HANDLERS ====================================================== */ app.use((err, req, res, next) => { logger.error(`Unhandled error: ${err.message}`); if (CONFIG.logging.verbose) { logger.error(err.stack); } res.status(500).json({ error: "Internal server error" }); }); process.on("unhandledRejection", (err) => { logger.error(`Unhandled promise rejection: ${err.message}`); if (CONFIG.logging.verbose && err.stack) { logger.error(err.stack); } }); process.on("uncaughtException", (err) => { logger.error(`Uncaught exception: ${err.message}`); if (CONFIG.logging.verbose && err.stack) { logger.error(err.stack); } process.exit(1); }); /* ====================================================== SERVER LIFECYCLE ====================================================== */ const server = app.listen(CONFIG.port, () => { logger.info(`Redmine-Gitea Sync Server started on port ${CONFIG.port}`); logger.info(`Redmine: ${CONFIG.redmine.apiUrl}`); logger.info(`Gitea: ${CONFIG.gitea.apiUrl} (owner: ${CONFIG.gitea.owner})`); logger.info(`Sync features: Labels=${CONFIG.sync.enableLabels}, Milestones=${CONFIG.sync.enableMilestones}, Attachments=${CONFIG.sync.enableAttachments}`); }); function shutdown() { logger.info("Shutting down gracefully..."); server.close(() => { logger.info("Server closed cleanly"); process.exit(0); }); setTimeout(() => { logger.error("Forcing shutdown after timeout"); process.exit(1); }, 10000); } process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown);