522 lines
18 KiB
JavaScript
522 lines
18 KiB
JavaScript
// server.js
|
||
require('dotenv').config();
|
||
const express = require('express');
|
||
const bodyParser = require('body-parser');
|
||
const axios = require('axios');
|
||
const app = express();
|
||
const { exec, spawn } = require('child_process');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
// --- Helpers: Git log + LLM summarization for Executive Summary (DE) ---
|
||
|
||
// Promise-based exec wrapper with timeout
|
||
function execCmd(cmd, { cwd = process.cwd(), timeoutMs = 8000 } = {}) {
|
||
return new Promise((resolve, reject) => {
|
||
const child = require('child_process').exec(cmd, { cwd, maxBuffer: 5 * 1024 * 1024, timeout: timeoutMs }, (error, stdout, stderr) => {
|
||
if (error) {
|
||
return reject(Object.assign(new Error(`exec failed: ${error.message}`), { stdout, stderr }));
|
||
}
|
||
resolve({ stdout, stderr });
|
||
});
|
||
// Best-effort: if process times out, Node will error; no extra handling here
|
||
});
|
||
}
|
||
|
||
// Retrieve and parse last N git commits (subject + body)
|
||
async function getLastCommits({ count = 10, repoDir = process.cwd() } = {}) {
|
||
try {
|
||
const format = '%H%x1f%an%x1f%ad%x1f%s%x1f%b';
|
||
const cmd = `git log -n ${count} --pretty=format:${format} --date=iso`;
|
||
const { stdout } = await execCmd(cmd, { cwd: repoDir, timeoutMs: 8000 });
|
||
const lines = stdout.split('\n').filter(Boolean);
|
||
return lines.map(line => {
|
||
const [hash, author, date, subject, body = ''] = line.split('\x1f');
|
||
return { hash, author, date, subject, body };
|
||
});
|
||
} catch (e) {
|
||
logMessage(`getLastCommits failed: ${e.message}`, 'warn');
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function buildMostRecentCommitPromptGerman(commits) {
|
||
if (!commits || commits.length === 0) return null;
|
||
const c0 = commits[0];
|
||
// Keep prompt compact but informative
|
||
const latest = [
|
||
`Commit: ${c0.hash}`,
|
||
`Autor: ${c0.author}`,
|
||
`Datum: ${c0.date}`,
|
||
`Betreff: ${c0.subject}`,
|
||
`Inhalt:\n${c0.body || '(kein Body)'}`
|
||
].join('\n');
|
||
|
||
const history = commits.slice(0, 10).map(c => `- ${c.hash.slice(0,7)} | ${c.subject}`).join('\n');
|
||
|
||
return {
|
||
system: 'Du bist ein erfahrener Software-Produktmanager. Erstelle eine kurze, laienverständliche, executive-taugliche Zusammenfassung der Auswirkungen der jüngsten Änderung. Vermeide Fachjargon, nenne das „Warum“ und den Nutzen. Antworte ausschließlich auf Deutsch in 1–3 Sätzen.',
|
||
user: `Hier ist der Git-Verlauf (letzte 10 Commits); fokussiere die jüngste Änderung:\n\nVerlauf:\n${history}\n\nDetail der neuesten Änderung:\n${latest}\n\nGib eine kurze Executive-Zusammenfassung (Deutsch, 1–3 Sätze).`
|
||
};
|
||
}
|
||
|
||
// Generic OpenAI-compatible client over fetch
|
||
async function callLLMOpenAICompatible({ baseUrl, apiKey, model, system, user, timeoutMs = 12000 }) {
|
||
const controller = new AbortController();
|
||
const t = setTimeout(() => controller.abort(), timeoutMs);
|
||
try {
|
||
const res = await fetch(`${baseUrl}/v1/chat/completions`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${apiKey}`
|
||
},
|
||
body: JSON.stringify({
|
||
model,
|
||
messages: [
|
||
{ role: 'system', content: system },
|
||
{ role: 'user', content: user }
|
||
],
|
||
temperature: 0.2
|
||
}),
|
||
signal: controller.signal
|
||
});
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '');
|
||
throw new Error(`LLM HTTP ${res.status}: ${text.slice(0, 500)}`);
|
||
}
|
||
const json = await res.json();
|
||
const msg = json.choices?.[0]?.message?.content?.trim();
|
||
return msg || '';
|
||
} finally {
|
||
clearTimeout(t);
|
||
}
|
||
}
|
||
|
||
// Summarize the most recent commit in German
|
||
async function summarizeMostRecentCommitDE() {
|
||
// Determine provider
|
||
const provider = (process.env.LLM_PROVIDER || 'openrouter').toLowerCase();
|
||
const model = process.env.LLM_MODEL || (provider === 'openrouter' ? 'openrouter/anthropic/claude-3.5-sonnet' : 'gpt-4o-mini');
|
||
let baseUrl;
|
||
let apiKey;
|
||
|
||
if (provider === 'openai') {
|
||
baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com';
|
||
apiKey = process.env.OPENAI_API_KEY;
|
||
} else {
|
||
// default openrouter
|
||
baseUrl = process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api';
|
||
apiKey = process.env.OPENROUTER_API_KEY;
|
||
}
|
||
|
||
if (!apiKey) {
|
||
logMessage('LLM API key not configured; skipping executive summary', 'warn');
|
||
return null;
|
||
}
|
||
|
||
// Pull commits from the current working directory (assumed repo root or subdir)
|
||
const commits = await getLastCommits({ count: 10, repoDir: process.cwd() });
|
||
if (!commits.length) return null;
|
||
|
||
const prompt = buildMostRecentCommitPromptGerman(commits);
|
||
if (!prompt) return null;
|
||
|
||
try {
|
||
const summary = await callLLMOpenAICompatible({
|
||
baseUrl,
|
||
apiKey,
|
||
model,
|
||
system: prompt.system,
|
||
user: prompt.user,
|
||
timeoutMs: 15000
|
||
});
|
||
if (!summary) return null;
|
||
return summary;
|
||
} catch (e) {
|
||
logMessage(`LLM summarization failed: ${e.message}`, 'warn');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Create logs directory if it doesn't exist
|
||
const logsDir = path.join(__dirname, 'logs');
|
||
if (!fs.existsSync(logsDir)) {
|
||
fs.mkdirSync(logsDir);
|
||
}
|
||
|
||
// Logger function
|
||
function logMessage(message, type = 'info') {
|
||
const timestamp = new Date().toISOString();
|
||
const logEntry = `[${timestamp}] [${type.toUpperCase()}] ${message}`;
|
||
|
||
console[type === 'error' ? 'error' : 'log'](logEntry);
|
||
|
||
// Also log to file
|
||
const logFile = path.join(logsDir, `webhook_${new Date().toISOString().split('T')[0]}.log`);
|
||
fs.appendFileSync(logFile, logEntry + '\n');
|
||
}
|
||
|
||
// Telegram bot function
|
||
async function sendTelegramMessage(message) {
|
||
const botToken = process.env.TELEGRAM_BOT_TOKEN;
|
||
const chatId = process.env.TELEGRAM_CHAT_ID;
|
||
|
||
if (!botToken || !chatId) {
|
||
logMessage('Telegram bot token or chat ID not configured', 'warn');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
|
||
const response = await axios.post(url, {
|
||
chat_id: chatId,
|
||
text: message,
|
||
parse_mode: 'MarkdownV2',
|
||
disable_web_page_preview: false
|
||
});
|
||
|
||
logMessage('Telegram message sent successfully');
|
||
return response.data;
|
||
} catch (error) {
|
||
logMessage(`Failed to send Telegram message: ${error.message}`, 'error');
|
||
if (error.response) {
|
||
logMessage(`Telegram API error: ${JSON.stringify(error.response.data)}`, 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
// Format commit message for Telegram
|
||
function formatCommitMessage(payload) {
|
||
const repo = payload.repository?.name || 'Unknown Repository';
|
||
const commits = payload.commits || [];
|
||
const pusher = payload.pusher?.username || payload.pusher?.login || 'Unknown';
|
||
|
||
if (!commits || commits.length === 0) {
|
||
return `🔄 *${repo}* - Push event received but no commit details available`;
|
||
}
|
||
|
||
// Aggregate all files across commits
|
||
const allAdded = new Set();
|
||
const allModified = new Set();
|
||
const allRemoved = new Set();
|
||
|
||
commits.forEach(commit => {
|
||
(commit.added || []).forEach(file => allAdded.add(file));
|
||
(commit.modified || []).forEach(file => allModified.add(file));
|
||
(commit.removed || []).forEach(file => allRemoved.add(file));
|
||
});
|
||
|
||
// Format commits list
|
||
let commitsText = '';
|
||
// MarkdownV2 full escaper for arbitrary text (not code)
|
||
const escapeMdV2 = (s) => {
|
||
if (s == null) return '';
|
||
return String(s)
|
||
// Order matters: escape backslash first
|
||
.replace(/\\/g, '\\\\')
|
||
// Telegram MarkdownV2 reserved characters (in text contexts)
|
||
.replace(/([_*[\]()~`>#+\-=|{}.!])/g, '\\$1');
|
||
};
|
||
// Escaper for inline code content inside backticks: only escape backticks and backslash
|
||
const escapeInlineCode = (s) => {
|
||
if (s == null) return '';
|
||
return String(s)
|
||
.replace(/\\/g, '\\\\')
|
||
.replace(/`/g, '\\`');
|
||
};
|
||
|
||
commits.forEach((commit) => {
|
||
const commitMessage = commit.message?.trim() || 'No commit message';
|
||
const escapedMessage = escapeMdV2(commitMessage);
|
||
|
||
const shortHash = commit.id?.substring(0, 7) || 'unknown';
|
||
const author = commit.author?.name || 'Unknown';
|
||
|
||
// backticked hash must use code-escaper rules
|
||
const hashInline = escapeInlineCode(shortHash);
|
||
|
||
// Replace raw hyphen with EN DASH between hash and message to avoid '-' entity errors
|
||
commitsText += `\n🔧 \`${hashInline}\` – ${escapedMessage}`;
|
||
if (commits.length > 1) {
|
||
commitsText += ` (${escapeMdV2(author)})`;
|
||
}
|
||
});
|
||
|
||
// Format aggregated file lists
|
||
let filesList = '';
|
||
|
||
if (allAdded.size > 0) {
|
||
filesList += `\n➕ *Added \\(${allAdded.size}\\):*\n`;
|
||
Array.from(allAdded).slice(0, 10).forEach(file => {
|
||
filesList += ` • \`${escapeInlineCode(file)}\`\n`;
|
||
});
|
||
if (allAdded.size > 10) {
|
||
filesList += ` • ... and ${allAdded.size - 10} more\n`;
|
||
}
|
||
}
|
||
|
||
if (allModified.size > 0) {
|
||
filesList += `\n📝 *Modified \\(${allModified.size}\\):*\n`;
|
||
Array.from(allModified).slice(0, 10).forEach(file => {
|
||
filesList += ` • \`${escapeInlineCode(file)}\`\n`;
|
||
});
|
||
if (allModified.size > 10) {
|
||
filesList += ` • ... and ${allModified.size - 10} more\n`;
|
||
}
|
||
}
|
||
|
||
if (allRemoved.size > 0) {
|
||
filesList += `\n❌ *Removed \\(${allRemoved.size}\\):*\n`;
|
||
Array.from(allRemoved).slice(0, 10).forEach(file => {
|
||
filesList += ` • \`${escapeInlineCode(file)}\`\n`;
|
||
});
|
||
if (allRemoved.size > 10) {
|
||
filesList += ` • ... and ${allRemoved.size - 10} more\n`;
|
||
}
|
||
}
|
||
|
||
if (!filesList) {
|
||
filesList = '\n📂 No files changed';
|
||
}
|
||
|
||
const totalCommits = commits.length;
|
||
const commitWord = totalCommits === 1 ? 'commit' : 'commits';
|
||
|
||
// Escape heading line content and link text/url appropriately
|
||
const repoEsc = escapeMdV2(repo);
|
||
// Use en dash and keep the rest escaped via escapeMdV2 for safety
|
||
const heading = `🚀 *${repoEsc}* – ${totalCommits} ${commitWord} pushed`;
|
||
|
||
let compare = '';
|
||
if (payload.compare_url) {
|
||
// Safer to avoid Markdown link syntax; print escaped text and URL
|
||
const linkText = escapeMdV2('Compare Changes');
|
||
const linkUrl = escapeMdV2(payload.compare_url);
|
||
compare = `${linkText}: ${linkUrl}`;
|
||
}
|
||
|
||
// Try to append a German executive summary of the most recent commit.
|
||
// Reuse escapeMdV2 defined above.
|
||
|
||
return (async () => {
|
||
let summaryBlock = '';
|
||
try {
|
||
const summary = await summarizeMostRecentCommitDE();
|
||
if (summary) {
|
||
const escapedSummary = escapeMdV2(summary);
|
||
summaryBlock = `\n\n———————————————\n\n📋 *Executive Summary*\n\n${escapedSummary}\n\n`;
|
||
}
|
||
} catch (e) {
|
||
// already logged inside summarizer; keep silent here
|
||
}
|
||
|
||
// Ensure each section starts on its own line; compare may be plain URL text.
|
||
return `${heading}
|
||
${commitsText}
|
||
${filesList}
|
||
${compare ? `\n${compare}` : ''}${summaryBlock}`;
|
||
})();
|
||
}
|
||
|
||
app.use(bodyParser.json());
|
||
|
||
app.post('/releasehook_kjfhdkf987987', (req, res) => {
|
||
try {
|
||
const payload = req.body;
|
||
logMessage(`Webhook received for repository: ${payload?.repository?.name || 'unknown'}`);
|
||
|
||
// Log the complete payload for analysis
|
||
logMessage(`Complete payload received: ${JSON.stringify(payload, null, 2)}`);
|
||
|
||
// Send Telegram notification
|
||
// formatCommitMessage may return a Promise; normalize before sending
|
||
Promise.resolve(formatCommitMessage(payload))
|
||
.then((telegramMessage) => sendTelegramMessage(telegramMessage))
|
||
.catch(error => {
|
||
logMessage(`Error sending Telegram message: ${error.message}`, 'error');
|
||
});
|
||
|
||
// Set a flag to track if we've sent a response
|
||
let responseSent = false;
|
||
|
||
if(payload && payload.repository && payload.repository.name == 'reactShop'){
|
||
// cd /home/seb/src/reactShop ; git pull ; npm run build
|
||
const repoPath = '/home/seb/src/growheads_de';
|
||
const command = `cd ${repoPath} && pkill -f "node.*prerender\\.cjs" || true && git pull && npm i . && npm run build:prerender`;
|
||
logMessage(`Executing command for reactShop: ${command}`);
|
||
|
||
// Use spawn to stream output line by line
|
||
// @note Try with explicit options to prevent early termination
|
||
const child = spawn('bash', ['-c', command], {
|
||
detached: false,
|
||
stdio: ['pipe', 'pipe', 'pipe']
|
||
});
|
||
|
||
logMessage(`Started process for reactShop with PID: ${child.pid}`);
|
||
|
||
// Stream stdout line by line
|
||
child.stdout.on('data', (data) => {
|
||
const lines = data.toString().split('\n');
|
||
lines.forEach(line => {
|
||
if (line.trim()) {
|
||
logMessage(`[reactShop stdout] ${line.trim()}`);
|
||
}
|
||
});
|
||
});
|
||
|
||
// Stream stderr line by line
|
||
child.stderr.on('data', (data) => {
|
||
const lines = data.toString().split('\n');
|
||
lines.forEach(line => {
|
||
if (line.trim()) {
|
||
logMessage(`[reactShop stderr] ${line.trim()}`, 'warn');
|
||
}
|
||
});
|
||
});
|
||
|
||
// Handle process completion
|
||
child.on('close', (code, signal) => {
|
||
if (signal) {
|
||
logMessage(`Command for reactShop was terminated by signal: ${signal}`, 'error');
|
||
if (!responseSent) {
|
||
responseSent = true;
|
||
return res.status(500).json({
|
||
error: 'Command execution failed',
|
||
details: `Process terminated by signal: ${signal}`
|
||
});
|
||
}
|
||
} else if (code !== 0) {
|
||
logMessage(`Command for reactShop exited with code ${code}`, 'error');
|
||
if (!responseSent) {
|
||
responseSent = true;
|
||
return res.status(500).json({
|
||
error: 'Command execution failed',
|
||
details: `Process exited with code ${code}`
|
||
});
|
||
}
|
||
} else {
|
||
logMessage(`Command for reactShop completed successfully (exit code: ${code})`);
|
||
if (!responseSent) {
|
||
responseSent = true;
|
||
res.status(200).json({ success: true, repository: 'reactShop' });
|
||
}
|
||
}
|
||
});
|
||
|
||
// Handle process errors
|
||
child.on('error', (error) => {
|
||
logMessage(`Error executing command for reactShop: ${error.message}`, 'error');
|
||
if (!responseSent) {
|
||
responseSent = true;
|
||
return res.status(500).json({
|
||
error: 'Command execution failed',
|
||
details: error.message
|
||
});
|
||
}
|
||
});
|
||
|
||
// Handle process exit (fired before close)
|
||
child.on('exit', (code, signal) => {
|
||
if (signal) {
|
||
logMessage(`Process for reactShop exited due to signal: ${signal}`, 'warn');
|
||
} else {
|
||
logMessage(`Process for reactShop exited with code: ${code}`, code === 0 ? 'info' : 'warn');
|
||
}
|
||
});
|
||
|
||
return; // Exit early to avoid double response
|
||
}
|
||
|
||
if(payload && payload.repository && payload.repository.name == 'shopApi'){
|
||
// cd /home/seb/src/shopApi ; git pull
|
||
const repoPath = '/home/seb/src/shopApi';
|
||
const command = `cd ${repoPath} && git pull`;
|
||
logMessage(`Executing command for shopApi: ${command}`);
|
||
|
||
exec(command, (error, stdout, stderr) => {
|
||
if (error) {
|
||
logMessage(`Error executing command for shopApi: ${error.message}`, 'error');
|
||
if (stderr) {
|
||
logMessage(`Command stderr: ${stderr}`, 'error');
|
||
}
|
||
if (!responseSent) {
|
||
responseSent = true;
|
||
return res.status(500).json({
|
||
error: 'Command execution failed',
|
||
details: error.message
|
||
});
|
||
}
|
||
}
|
||
|
||
if (stdout) {
|
||
logMessage(`Command output for shopApi: ${stdout.trim()}`);
|
||
}
|
||
|
||
if (stderr && !error) {
|
||
logMessage(`Command stderr (non-fatal) for shopApi: ${stderr}`, 'warn');
|
||
}
|
||
|
||
if (!responseSent) {
|
||
responseSent = true;
|
||
res.status(200).json({ success: true, repository: 'shopApi' });
|
||
}
|
||
});
|
||
return; // Exit early to avoid double response
|
||
}
|
||
|
||
if(payload && payload.repository && payload.repository.name == 'quickdhl'){
|
||
// cd /home/seb/src/shopApi ; git pull
|
||
const repoPath = '/home/seb/src/quickdhl';
|
||
const command = `cd ${repoPath} && git pull`;
|
||
logMessage(`Executing command for quickdhl: ${command}`);
|
||
|
||
exec(command, (error, stdout, stderr) => {
|
||
if (error) {
|
||
logMessage(`Error executing command for quickdhl: ${error.message}`, 'error');
|
||
if (stderr) {
|
||
logMessage(`Command stderr: ${stderr}`, 'error');
|
||
}
|
||
if (!responseSent) {
|
||
responseSent = true;
|
||
return res.status(500).json({
|
||
error: 'Command execution failed',
|
||
details: error.message
|
||
});
|
||
}
|
||
}
|
||
|
||
if (stdout) {
|
||
logMessage(`Command output for quickdhl: ${stdout.trim()}`);
|
||
}
|
||
|
||
if (stderr && !error) {
|
||
logMessage(`Command stderr (non-fatal) for quickdhl: ${stderr}`, 'warn');
|
||
}
|
||
|
||
if (!responseSent) {
|
||
responseSent = true;
|
||
res.status(200).json({ success: true, repository: 'shopApi' });
|
||
}
|
||
});
|
||
return; // Exit early to avoid double response
|
||
}
|
||
|
||
// If we get here, no repository matched
|
||
logMessage(`No handler found for repository: ${payload?.repository?.name || 'unknown'}`, 'warn');
|
||
res.status(200).json({ success: true, message: 'No action taken' });
|
||
|
||
} catch (err) {
|
||
logMessage(`Unhandled exception in webhook handler: ${err.message}`, 'error');
|
||
logMessage(err.stack, 'error');
|
||
res.status(500).json({ error: 'Internal server error', message: err.message });
|
||
}
|
||
});
|
||
|
||
// Add a simple health check endpoint
|
||
app.get('/health', (req, res) => {
|
||
res.status(200).json({ status: 'ok' });
|
||
});
|
||
|
||
app.listen(9304, () => logMessage('Webhook server listening on port 9304'));
|