Files
deployHook/x.js
sebseb7 ab4964815c fix(api): escape Markdown and adjust compare link formatting
Ensure parentheses are escaped in section headers, switch to en dash in
heading, and avoid Markdown links by outputting escaped text and URL.
Also improve line breaks for consistent layout.
2025-08-04 10:34:29 +02:00

521 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 13 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, 13 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);
// 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🧠 *Executive Summary \\(DE\\)*\n${escapedSummary}\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'));