From 8161015b475e780bfb4530f137e0d57e607487fa Mon Sep 17 00:00:00 2001 From: Thomas Scott Date: Wed, 17 Dec 2025 11:47:32 +0000 Subject: [PATCH] Initial commit Signed-off-by: Thomas Scott --- server.mjs | 972 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 972 insertions(+) create mode 100644 server.mjs diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..869eeca --- /dev/null +++ b/server.mjs @@ -0,0 +1,972 @@ +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); \ No newline at end of file