Files
deployHook/x.js
sebseb7 693537f1bf upd
2025-11-12 23:14:44 +01:00

742 lines
25 KiB
JavaScript
Raw Permalink 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');
// Use built-in fetch (Node.js 18+) or fallback to node-fetch
const fetch = globalThis.fetch || require('node-fetch');
// AWS Bedrock SDK for Anthropic
const AnthropicBedrock = require("@anthropic-ai/bedrock-sdk");
// --- 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 [];
}
}
// Get unified diff for the latest commit (against its parent)
async function getLastCommitDiff({ repoDir = process.cwd(), contextLines = 3 } = {}) {
try {
// --pretty=format: to avoid commit message in diff header; use -1 for last commit
const cmd = `git show -1 --unified=${contextLines} --no-color`;
const { stdout } = await execCmd(cmd, { cwd: repoDir, timeoutMs: 8000 });
return stdout || '';
} catch (e) {
logMessage(`getLastCommitDiff 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. Erkläre nicht die Vorteile für Führungskräfte, erkläre nur was sich am Code geändert hat.',
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);
}
}
// AWS Bedrock client for Anthropic
async function callLLMBedrock({ awsAccessKey, awsSecretKey, awsRegion, model, system, user }) {
try {
const abclient = new AnthropicBedrock({
awsAccessKey,
awsSecretKey,
awsRegion
});
// Bedrock API uses a different format - system is a separate parameter
const stream = await abclient.messages.create({
model,
system: system,
stream: true,
messages: [
{ role: 'user', content: user }
],
max_tokens: 64000,
});
let result = "";
for await (const messageStreamEvent of stream) {
if (messageStreamEvent.type === "content_block_delta") {
if (messageStreamEvent.delta.type === "text_delta") {
result += messageStreamEvent.delta.text;
}
}
}
return result;
} catch (e) {
throw new Error(`Bedrock API error: ${e.message}`);
}
}
// Get commit diff from Gitea API
async function getCommitDiffFromGitea(giteaBaseUrl, repoOwner, repoName, commitHash) {
const giteaToken = process.env.GITEA_TOKEN;
if (!giteaToken) {
logMessage('GITEA_TOKEN not configured; cannot fetch diff from API', 'warn');
return null;
}
try {
const apiUrl = `${giteaBaseUrl}/api/v1/repos/${repoOwner}/${repoName}/git/commits/${commitHash}.diff`;
logMessage(`Fetching commit diff from: ${apiUrl}`);
const response = await fetch(apiUrl, {
headers: {
'Authorization': `token ${giteaToken}`,
'Accept': 'text/plain'
},
timeout: 10000
});
if (!response.ok) {
logMessage(`Gitea API request failed: ${response.status} ${response.statusText}`, 'warn');
return null;
}
const diffText = await response.text();
if (diffText && diffText.trim()) {
return diffText;
}
return null;
} catch (e) {
logMessage(`Failed to fetch commit diff from Gitea API: ${e.message}`, 'warn');
return null;
}
}
// Summarize the most recent commit in German using webhook payload data
async function summarizeMostRecentCommitDE(webhookPayload = null) {
// 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;
let awsAccessKey;
let awsSecretKey;
let awsRegion;
if (provider === 'bedrock') {
// AWS Bedrock configuration
awsAccessKey = process.env.AWS_ACCESS_KEY;
awsSecretKey = process.env.AWS_SECRET_KEY;
awsRegion = process.env.AWS_REGION || 'eu-central-1';
if (!awsAccessKey || !awsSecretKey) {
logMessage('AWS credentials not configured; skipping executive summary', 'warn');
return null;
}
} else if (provider === 'openai') {
baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com';
apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
logMessage('LLM API key not configured; skipping executive summary', 'warn');
return null;
}
} 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;
}
}
// Require webhook payload - no fallback to local git
if (!webhookPayload || !webhookPayload.commits || webhookPayload.commits.length === 0) {
logMessage('No webhook payload or commits provided for executive summary', 'warn');
return null;
}
// Use webhook payload data
const commits = webhookPayload.commits.map(commit => ({
hash: commit.id,
author: commit.author?.name || 'Unknown',
date: commit.timestamp || new Date().toISOString(),
subject: commit.message?.split('\n')[0] || 'No subject',
body: commit.message?.split('\n').slice(1).join('\n') || ''
}));
// Extract Gitea base URL from webhook payload
const giteaBaseUrl = webhookPayload.repository?.html_url?.match(/^https?:\/\/[^\/]+/)?.[0];
const repoOwner = webhookPayload.repository?.owner?.login;
const repoName = webhookPayload.repository?.name;
const latestCommitHash = commits[0]?.hash;
if (!giteaBaseUrl || !repoOwner || !repoName || !latestCommitHash) {
logMessage('Missing required repository information in webhook payload for executive summary', 'warn');
return null;
}
const diff = await getCommitDiffFromGitea(giteaBaseUrl, repoOwner, repoName, latestCommitHash);
const prompt = buildMostRecentCommitPromptGerman(commits);
if (!prompt) return null;
// Include the diff in the system prompt
const systemWithDiff =
`${prompt.system}\n\n` +
`Kontext (unified diff der neuesten Änderung):\n` +
`${diff ? diff.slice(0, 120000) : '(kein Diff verfügbar)'}`;
try {
let summary;
if (provider === 'bedrock') {
summary = await callLLMBedrock({
awsAccessKey,
awsSecretKey,
awsRegion,
model,
system: systemWithDiff,
user: prompt.user
});
} else {
// Use OpenAI-compatible API (openai, openrouter, etc.)
summary = await callLLMOpenAICompatible({
baseUrl,
apiKey,
model,
system: systemWithDiff,
user: prompt.user,
timeoutMs: 20000
});
}
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);
}
// Create failed webhooks directory if it doesn't exist
const failedWebhooksDir = path.join(__dirname, 'failed_webhooks');
if (!fs.existsSync(failedWebhooksDir)) {
fs.mkdirSync(failedWebhooksDir);
}
// 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');
}
// Function to dump failed webhooks for retry
function dumpFailedWebhook(payload, error, reason = 'unknown') {
try {
const timestamp = new Date().toISOString();
const filename = `failed_webhook_${timestamp.replace(/[:.]/g, '-')}_${payload?.repository?.name || 'unknown'}.json`;
const filepath = path.join(failedWebhooksDir, filename);
const failedWebhookData = {
timestamp,
reason,
error: error?.message || String(error),
payload
};
fs.writeFileSync(filepath, JSON.stringify(failedWebhookData, null, 2));
logMessage(`Failed webhook dumped to: ${filename}`, 'info');
return filepath;
} catch (dumpError) {
logMessage(`Failed to dump webhook: ${dumpError.message}`, 'error');
return null;
}
}
// 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`;
logMessage(`Sending Telegram message: ${message}`, 'info');
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');
logMessage(`Telegram API status: ${error.response.status}`, 'error');
}
// Create a more detailed error for webhook failure logging
const detailedError = new Error(`Telegram API error: ${error.message}`);
if (error.response && error.response.data) {
detailedError.message = `Telegram API error (${error.response.status}): ${JSON.stringify(error.response.data)}`;
}
// Re-throw the detailed error so it can be caught by the calling code
throw detailedError;
}
}
// 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: escape backticks, backslash, and periods
const escapeInlineCode = (s) => {
if (s == null) return '';
return String(s)
.replace(/\\/g, '\\\\')
.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 += `${escapeMdV2(`... 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 += `${escapeMdV2(`... 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 += `${escapeMdV2(`... 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(payload);
if (summary) {
const escapedSummary = escapeMdV2(summary);
summaryBlock = `\n\n———————————————\n\n📋 *Executive Summary*\n\n${escapedSummary}\n\n\n`;
}
} catch (e) {
// already logged inside summarizer; keep silent here
logMessage(`Executive summary generation failed: ${e.message}`, 'warn');
}
// 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', async (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 synchronously so we can return the error
try {
const telegramMessage = await Promise.resolve(formatCommitMessage(payload));
await sendTelegramMessage(telegramMessage);
logMessage('Telegram message sent successfully');
} catch (error) {
logMessage(`Error sending Telegram message: ${error.message}`, 'error');
dumpFailedWebhook(payload, error, 'telegram_send_error');
// Return error response instead of 200
return res.status(400).json({
error: 'Telegram send failed',
details: error.message
});
}
// 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(payload && payload.repository && payload.repository.name == 'dashApp'){
// Check if push was to 'prod' branch
const targetBranch = 'prod';
const ref = payload.ref || '';
const branchName = ref.replace('refs/heads/', '');
if (!responseSent) {
responseSent = true;
res.status(200).json({ success: true, repository: 'dashApp', message: 'No action taken - not prod branch' });
}
return;
}
// 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'));