From 1fac451ada8e35e989c1e1cd57d1f8c8acf68562 Mon Sep 17 00:00:00 2001 From: Untone Date: Wed, 19 Feb 2025 00:10:44 +0300 Subject: [PATCH] init --- .gitignore | 1 + api/formatters.js | 69 ++++++++++++++++++++++++++++++++++++++++++ api/gitea.js | 47 +++++++++++++++++++++++++++++ api/github.js | 47 +++++++++++++++++++++++++++++ api/index.js | 11 +++++++ api/webhook.js | 77 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 15 +++++++++ vercel.json | 12 ++++++++ 8 files changed, 279 insertions(+) create mode 100644 .gitignore create mode 100644 api/formatters.js create mode 100644 api/gitea.js create mode 100644 api/github.js create mode 100644 api/index.js create mode 100644 api/webhook.js create mode 100644 package.json create mode 100644 vercel.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e985853 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.vercel diff --git a/api/formatters.js b/api/formatters.js new file mode 100644 index 0000000..6aea13c --- /dev/null +++ b/api/formatters.js @@ -0,0 +1,69 @@ +/** + * Format commit message with emoji based on content + * @param {string} message - Commit message + * @param {Object} stats - Commit stats + * @returns {string} - Emoji for the commit + */ +const getCommitEmoji = (message, stats) => { + const msg = message.toLowerCase() + if (msg.includes('fix')) return '๐Ÿ”ง' + if (msg.includes('feat')) return 'โœจ' + if (msg.includes('break')) return '๐Ÿ’ฅ' + if (msg.includes('docs')) return '๐Ÿ“š' + if (msg.includes('test')) return '๐Ÿงช' + if (msg.includes('refactor')) return 'โ™ป๏ธ' + if (stats.additions > 100 || stats.deletions > 100) return '๐Ÿ”จ' + return '๐Ÿ“' +} + +/** + * Format commit stats + * @param {Object} stats - Commit stats object + * @returns {string} - Formatted stats string + */ +const formatStats = (stats = { additions: 0, deletions: 0 }) => + `+${stats.additions}/-${stats.deletions}` + +/** + * Format commit message for Telegram + * @param {Object} commit - Commit object + * @param {string} repoUrl - Repository URL + * @returns {string} - Formatted commit message + */ +const formatCommit = (commit, repoUrl) => { + const commitUrl = `${repoUrl}/commit/${commit.id}` + const emoji = getCommitEmoji(commit.message, commit.stats || {}) + const stats = formatStats(commit.stats) + return `${emoji} [${commit.id.substring(0, 7)}](${commitUrl}): ${commit.message} \`${stats}\`` +} + +/** + * Format webhook message for Telegram + * @param {Object} data - Webhook payload + * @param {Array} commits - Filtered commits + * @returns {string} - Formatted message + */ +const formatMessage = (data, commits) => { + const repoUrl = data.repository.html_url || data.repository.url + const repoName = data.repository.full_name + const branch = data.ref.split('/').pop() + const branchUrl = `${repoUrl}/tree/${branch}` + + const totalStats = commits.reduce((acc, commit) => ({ + additions: acc.additions + (commit.stats?.additions || 0), + deletions: acc.deletions + (commit.stats?.deletions || 0) + }), { additions: 0, deletions: 0 }) + + return [ + `๐Ÿ”„ [${repoName}](${repoUrl}):[${branch}](${branchUrl}) ${commits.length} new commit${commits.length === 1 ? '' : 's'}`, + commits.length > 1 ? `๐Ÿ“Š Changes: \`${formatStats(totalStats)}\`` : '', + commits.map(commit => formatCommit(commit, repoUrl)).join('\n') + ].filter(Boolean).join('\n') +} + +module.exports = { + formatMessage, + formatCommit, + formatStats, + getCommitEmoji +} \ No newline at end of file diff --git a/api/gitea.js b/api/gitea.js new file mode 100644 index 0000000..0a67838 --- /dev/null +++ b/api/gitea.js @@ -0,0 +1,47 @@ +const { formatMessage } = require('./formatters') + +/** + * Handle Gitea webhook + * @param {Object} payload - Gitea webhook payload + * @returns {Object} - Normalized webhook data + */ +const normalizeGiteaPayload = (payload) => ({ + repository: { + full_name: payload.repository.full_name, + html_url: payload.repository.html_url || payload.repository.url + }, + ref: payload.ref, + commits: payload.commits.map(commit => ({ + id: commit.id, + message: commit.message, + stats: { + additions: commit.added?.length || 0, + deletions: commit.removed?.length || 0 + } + })) +}) + +/** + * Handle Gitea webhook + * @param {Object} payload - Webhook payload + * @param {Set} reportedCommits - Set of reported commit hashes + * @param {Map} commitTimestamps - Map of commit timestamps + * @returns {Object} - Formatted message or null + */ +const handleGitea = (payload, reportedCommits, commitTimestamps) => { + const data = normalizeGiteaPayload(payload) + + // Filter new commits + const newCommits = data.commits.filter(commit => { + if (reportedCommits.has(commit.id)) return false + reportedCommits.add(commit.id) + commitTimestamps.set(commit.id, Date.now()) + return true + }) + + if (newCommits.length === 0) return null + + return formatMessage(data, newCommits) +} + +module.exports = { handleGitea } \ No newline at end of file diff --git a/api/github.js b/api/github.js new file mode 100644 index 0000000..f01bc4b --- /dev/null +++ b/api/github.js @@ -0,0 +1,47 @@ +const { formatMessage } = require('./formatters') + +/** + * Normalize GitHub webhook payload to common format + * @param {Object} payload - GitHub webhook payload + * @returns {Object} - Normalized webhook data + */ +const normalizeGithubPayload = (payload) => ({ + repository: { + full_name: payload.repository.full_name, + html_url: payload.repository.html_url + }, + ref: payload.ref, + commits: payload.commits.map(commit => ({ + id: commit.id, + message: commit.message, + stats: { + additions: commit.stats?.additions || 0, + deletions: commit.stats?.deletions || 0 + } + })) +}) + +/** + * Handle GitHub webhook + * @param {Object} payload - Webhook payload + * @param {Set} reportedCommits - Set of reported commit hashes + * @param {Map} commitTimestamps - Map of commit timestamps + * @returns {string|null} - Formatted message or null if no new commits + */ +const handleGithub = (payload, reportedCommits, commitTimestamps) => { + const data = normalizeGithubPayload(payload) + + // Filter new commits + const newCommits = data.commits.filter(commit => { + if (reportedCommits.has(commit.id)) return false + reportedCommits.add(commit.id) + commitTimestamps.set(commit.id, Date.now()) + return true + }) + + if (newCommits.length === 0) return null + + return formatMessage(data, newCommits) +} + +module.exports = { handleGithub } \ No newline at end of file diff --git a/api/index.js b/api/index.js new file mode 100644 index 0000000..4e7260f --- /dev/null +++ b/api/index.js @@ -0,0 +1,11 @@ +const { webhook, cleanup } = require('./webhook'); + +module.exports = async (req, res) => { + // Check if it's a cron cleanup request + if (req.headers['x-vercel-cron']) { + return cleanup(req, res); + } + + // Otherwise handle as webhook + return webhook(req, res); +}; \ No newline at end of file diff --git a/api/webhook.js b/api/webhook.js new file mode 100644 index 0000000..59f74a0 --- /dev/null +++ b/api/webhook.js @@ -0,0 +1,77 @@ +const { formatMessage } = require('./formatters') +const { handleGitea } = require('./gitea') +const { handleGithub } = require('./github') +// Store for already reported commit hashes (in-memory, resets on restart) +const reportedCommits = new Set(); +const commitTimestamps = new Map(); + +// TTL constants (used in cleanup) +const COMMIT_TTL = 30 * 24 * 60 * 60 * 1000; // 30 days + +/** + * Cleanup old commits (called by Vercel Cron) + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +const cleanup = (req, res) => { + const now = Date.now(); + let cleaned = 0; + + for (const [hash, timestamp] of commitTimestamps.entries()) { + if (now - timestamp > COMMIT_TTL) { + commitTimestamps.delete(hash); + reportedCommits.delete(hash); + cleaned++; + } + } + + res.status(200).json({ cleaned }); +}; + +/** + * Webhook handler that supports both JSON and x-www-form-urlencoded formats + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +const webhook = async (req, res) => { + try { + const payload = typeof req.body === 'string' ? JSON.parse(req.body) : req.body + + if (!payload || !payload.repository) { + return res.status(400).send('Invalid webhook payload') + } + + // Determine webhook type and handle accordingly + const message = payload.repository.owner?.username // Gitea specific field + ? handleGitea(payload, reportedCommits, commitTimestamps) + : handleGithub(payload, reportedCommits, commitTimestamps) + + if (!message) { + return res.status(200).send('No new commits to report') + } + + // Send to Telegram + const telegramUrl = `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage` + await fetch(telegramUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: `-100${process.env.TELEGRAM_CHAT_ID}`, + text: message, + parse_mode: 'Markdown', + disable_web_page_preview: true + }) + }) + + res.status(200).send('ok') + } catch (error) { + console.error('Error processing webhook:', error) + res.status(500).send(`Error: ${error.message}`) + } +} + +// Export both handlers +module.exports = { + webhook, + cleanup +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..63acaf9 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "gh2tg", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "vercel": "^41.1.0" + } +} diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..9916bd6 --- /dev/null +++ b/vercel.json @@ -0,0 +1,12 @@ +{ + "crons": [{ + "path": "/api", + "schedule": "0 0 * * *" + }], + "routes": [ + { + "src": "/api/webhook", + "dest": "/api/index.js" + } + ] +} \ No newline at end of file