// 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); 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); const heading = `🚀 *${repoEsc}* - ${totalCommits} ${commitWord} pushed`; let compare = ''; if (payload.compare_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🧠 *Executive Summary \\(DE\\)*\n${escapedSummary}\n`; } } catch (e) { // already logged inside summarizer; keep silent here } return `${heading} ${commitsText} ${filesList} ${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'));