Redmine-Gitea-Sync/server.mjs

972 lines
29 KiB
JavaScript

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);