Initial commit
Signed-off-by: Thomas Scott <tombomb122@noreply.localhost>
This commit is contained in:
parent
bb51054a35
commit
8161015b47
|
|
@ -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);
|
||||
Loading…
Reference in New Issue