This commit is contained in:
Untone 2025-02-19 00:10:44 +03:00
commit 1fac451ada
8 changed files with 279 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.vercel

69
api/formatters.js Normal file
View File

@ -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
}

47
api/gitea.js Normal file
View File

@ -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 }

47
api/github.js Normal file
View File

@ -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 }

11
api/index.js Normal file
View File

@ -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);
};

77
api/webhook.js Normal file
View File

@ -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
};

15
package.json Normal file
View File

@ -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"
}
}

12
vercel.json Normal file
View File

@ -0,0 +1,12 @@
{
"crons": [{
"path": "/api",
"schedule": "0 0 * * *"
}],
"routes": [
{
"src": "/api/webhook",
"dest": "/api/index.js"
}
]
}