Introduce helpers to fetch recent git commits and call an OpenAI-compatible LLM to generate a short German executive summary appended to Telegram notifications. Normalize async formatting in message builder and ensure Promise handling when sending messages. Also remove committed .env with secrets and add .env to .gitignore to prevent future leaks.
518 lines
17 KiB
JavaScript
518 lines
17 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);
|
||
|
||
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'));
|