1335 new bot for monthly statistics #39
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Monthly Repository Statistics | |
| on: | |
| pull_request: | |
| types: [opened, synchronize] | |
| schedule: | |
| - cron: '0 9 1 * *' | |
| jobs: | |
| generate-monthly-report: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| issues: write | |
| pull-requests: read | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| - name: Generate Simple Monthly Statistics | |
| uses: actions/github-script@v7 | |
| env: | |
| MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK_URL }} | |
| with: | |
| script: | | |
| // Simple stats with minimal API calls - main branch only | |
| async function getSimpleMonthStats(since, until) { | |
| console.log(`Getting stats for ${since} to ${until}`); | |
| // Get commits from main branch only (1 API call per month) | |
| const commits = await github.paginate(github.rest.repos.listCommits, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| since, | |
| until, | |
| per_page: 100, | |
| }); | |
| console.log(`Found ${commits.length} commits on main branch`); | |
| // Get unique contributors including co-authors | |
| const contributors = new Set(); | |
| // First pass: add main commit authors | |
| commits.forEach(commit => { | |
| if (commit.author && commit.author.login) { | |
| contributors.add(commit.author.login); | |
| } | |
| if (commit.committer && commit.committer.login && commit.committer.login !== commit.author?.login) { | |
| contributors.add(commit.committer.login); | |
| } | |
| }); | |
| // Get detailed stats from commits and extract co-authors | |
| let totalLinesAdded = 0; | |
| let filesChanged = 0; | |
| for (const commit of commits.slice(0, 30)) { | |
| try { | |
| const commitDetail = await github.rest.repos.getCommit({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| ref: commit.sha, | |
| }); | |
| totalLinesAdded += commitDetail.data.stats?.additions || 0; | |
| filesChanged += commitDetail.data.files?.length || 0; | |
| // Extract co-authors from commit message | |
| if (commitDetail.data.commit && commitDetail.data.commit.message) { | |
| const message = commitDetail.data.commit.message; | |
| // Look for Co-authored-by: patterns | |
| const coAuthorRegex = /Co-authored-by:\s*([^<]+)\s*<([^>]+)>/gi; | |
| let match; | |
| while ((match = coAuthorRegex.exec(message)) !== null) { | |
| const coAuthorName = match[1].trim(); | |
| const coAuthorEmail = match[2].trim(); | |
| // Add co-author name to contributors | |
| if (coAuthorName && coAuthorName.length > 0) { | |
| contributors.add(coAuthorName); | |
| console.log(`Found co-author: ${coAuthorName} <${coAuthorEmail}>`); | |
| } | |
| } | |
| } | |
| // Also check if GitHub detected additional authors | |
| if (commitDetail.data.author && commitDetail.data.author.login) { | |
| contributors.add(commitDetail.data.author.login); | |
| } | |
| } catch (error) { | |
| console.log(`Error getting commit detail: ${error.message}`); | |
| } | |
| } | |
| // For remaining commits (beyond first 30), do a lighter check for co-authors | |
| for (const commit of commits.slice(30, 60)) { | |
| try { | |
| const commitDetail = await github.rest.repos.getCommit({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| ref: commit.sha, | |
| }); | |
| // Only extract co-authors, skip file/line stats to save API quota | |
| if (commitDetail.data.commit && commitDetail.data.commit.message) { | |
| const coAuthorRegex = /Co-authored-by:\s*([^<]+)\s*<([^>]+)>/gi; | |
| let match; | |
| while ((match = coAuthorRegex.exec(commitDetail.data.commit.message)) !== null) { | |
| const coAuthorName = match[1].trim(); | |
| if (coAuthorName && coAuthorName.length > 0) { | |
| contributors.add(coAuthorName); | |
| console.log(`Found co-author in commit ${commit.sha.substring(0, 7)}: ${coAuthorName}`); | |
| } | |
| } | |
| } | |
| } catch (error) { | |
| console.log(`Error checking co-authors for commit ${commit.sha}: ${error.message}`); | |
| } | |
| } | |
| console.log(`Total unique contributors (including co-authors): ${contributors.size}`); | |
| console.log(`Contributors: ${Array.from(contributors).join(', ')}`); | |
| return { | |
| totalCommits: commits.length, | |
| activeContributors: contributors.size, | |
| linesAdded: totalLinesAdded, | |
| filesChanged: filesChanged, | |
| contributorsList: Array.from(contributors) | |
| }; | |
| } | |
| // Get PR stats (1 API call per month) | |
| async function getPRStats(since, until) { | |
| const prs = await github.paginate(github.rest.pulls.list, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| state: 'all', | |
| sort: 'updated', | |
| direction: 'desc', | |
| per_page: 100, | |
| }); | |
| const mergedPRs = prs.filter(pr => | |
| pr.merged_at && | |
| new Date(pr.merged_at) >= new Date(since) && | |
| new Date(pr.merged_at) <= new Date(until) | |
| ); | |
| return { merged: mergedPRs.length }; | |
| } | |
| // Get languages (1 API call total) | |
| async function getLanguageStats() { | |
| try { | |
| const languages = await github.rest.repos.listLanguages({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| }); | |
| const total = Object.values(languages.data).reduce((sum, bytes) => sum + bytes, 0); | |
| return Object.entries(languages.data) | |
| .map(([lang, bytes]) => ({ | |
| language: lang, | |
| percentage: ((bytes / total) * 100).toFixed(1) | |
| })) | |
| .sort((a, b) => parseFloat(b.percentage) - parseFloat(a.percentage)) | |
| .slice(0, 3); | |
| } catch (error) { | |
| return []; | |
| } | |
| } | |
| // Create SVG chart | |
| function createSVGChart(data, title, color, monthLabels) { | |
| const width = 600; | |
| const height = 200; | |
| const padding = 40; | |
| const chartWidth = width - 2 * padding; | |
| const chartHeight = height - 2 * padding - 20; | |
| const maxValue = Math.max(...data); | |
| const minValue = Math.min(...data); | |
| const range = maxValue - minValue || 1; | |
| const points = data.map((value, index) => { | |
| const x = padding + (index / (data.length - 1)) * chartWidth; | |
| const y = padding + chartHeight - ((value - minValue) / range) * chartHeight; | |
| return `${x},${y}`; | |
| }).join(' L'); | |
| const areaPoints = `M${padding},${padding + chartHeight} L${points} L${padding + chartWidth},${padding + chartHeight} Z`; | |
| const monthLabelElements = monthLabels.map((month, index) => { | |
| if (index % 3 === 0) { | |
| const x = padding + (index / (data.length - 1)) * chartWidth; | |
| const y = padding + chartHeight + 15; | |
| return `<text x="${x}" y="${y}" text-anchor="middle" font-family="Arial, sans-serif" font-size="10" fill="#6b7280">${month}</text>`; | |
| } | |
| return ''; | |
| }).join(''); | |
| return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg"> | |
| <defs> | |
| <linearGradient id="gradient" x1="0%" y1="0%" x2="0%" y2="100%"> | |
| <stop offset="0%" style="stop-color:${color};stop-opacity:0.3" /> | |
| <stop offset="100%" style="stop-color:${color};stop-opacity:0.1" /> | |
| </linearGradient> | |
| </defs> | |
| <rect width="${width}" height="${height}" fill="#ffffff"/> | |
| ${[0, 0.25, 0.5, 0.75, 1].map(ratio => | |
| `<line x1="${padding}" y1="${padding + chartHeight * ratio}" x2="${padding + chartWidth}" y2="${padding + chartHeight * ratio}" stroke="#e5e7eb" stroke-width="1"/>` | |
| ).join('')} | |
| <path d="${areaPoints}" fill="url(#gradient)"/> | |
| <path d="M${points}" stroke="${color}" stroke-width="2" fill="none"/> | |
| ${data.map((value, index) => { | |
| const x = padding + (index / (data.length - 1)) * chartWidth; | |
| const y = padding + chartHeight - ((value - minValue) / range) * chartHeight; | |
| return `<circle cx="${x}" cy="${y}" r="3" fill="${color}"/>`; | |
| }).join('')} | |
| ${monthLabelElements} | |
| <text x="${width/2}" y="25" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="bold" fill="#374151">${title}</text> | |
| <text x="15" y="${padding + 5}" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" fill="#6b7280">${maxValue}</text> | |
| <text x="15" y="${padding + chartHeight + 5}" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" fill="#6b7280">${minValue}</text> | |
| </svg>`; | |
| } | |
| // Create PR and commit charts | |
| async function createChartsAndPR(monthlyData) { | |
| const totalCommits = monthlyData.map(d => d.stats.totalCommits); | |
| const mergedPRs = monthlyData.map(d => d.prStats.merged); | |
| const linesAdded = monthlyData.map(d => d.stats.linesAdded); | |
| const activeContributors = monthlyData.map(d => d.stats.activeContributors); | |
| const monthLabels = monthlyData.map(d => d.month); | |
| // Create the 4 required charts | |
| const charts = [ | |
| { path: 'docs/stats/total-commits-chart.svg', content: createSVGChart(totalCommits, 'Total Commits', '#3b82f6', monthLabels) }, | |
| { path: 'docs/stats/merged-prs-chart.svg', content: createSVGChart(mergedPRs, 'Merged Pull Requests', '#10b981', monthLabels) }, | |
| { path: 'docs/stats/lines-added-chart.svg', content: createSVGChart(linesAdded, 'Total Lines Added', '#22c55e', monthLabels) }, | |
| { path: 'docs/stats/active-contributors-chart.svg', content: createSVGChart(activeContributors, 'Active Contributors', '#8b5cf6', monthLabels) } | |
| ]; | |
| // Create branch for PR | |
| const now = new Date(); | |
| const branchName = `update-stats-${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}`; | |
| try { | |
| const repo = await github.rest.repos.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| }); | |
| const mainRef = await github.rest.git.getRef({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| ref: `heads/${repo.data.default_branch}`, | |
| }); | |
| await github.rest.git.createRef({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| ref: `refs/heads/${branchName}`, | |
| sha: mainRef.data.object.sha, | |
| }); | |
| console.log(`Created branch: ${branchName}`); | |
| } catch (error) { | |
| if (!error.message.includes('Reference already exists')) { | |
| throw error; | |
| } | |
| } | |
| // Commit charts | |
| for (const chart of charts) { | |
| try { | |
| let existingFileSha = null; | |
| try { | |
| const existing = await github.rest.repos.getContent({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| path: chart.path, | |
| ref: branchName | |
| }); | |
| existingFileSha = existing.data.sha; | |
| } catch (e) {} | |
| const params = { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| path: chart.path, | |
| message: `Update ${chart.path}`, | |
| content: Buffer.from(chart.content).toString('base64'), | |
| branch: branchName | |
| }; | |
| if (existingFileSha) params.sha = existingFileSha; | |
| await github.rest.repos.createOrUpdateFileContents(params); | |
| console.log(`Updated: ${chart.path}`); | |
| } catch (error) { | |
| console.log(`Error updating ${chart.path}: ${error.message}`); | |
| } | |
| } | |
| // Create PR | |
| const latestMonth = monthlyData[monthlyData.length - 1]; | |
| try { | |
| const pr = await github.rest.pulls.create({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| title: `📊 Update Repository Statistics - ${latestMonth.month}`, | |
| head: branchName, | |
| base: repo.data.default_branch, | |
| body: `## Repository Activity Charts Updated | |
| **${latestMonth.month} Summary:** | |
| - 📝 Total Commits: ${latestMonth.stats.totalCommits} | |
| - 🔀 Merged PRs: ${latestMonth.prStats.merged} | |
| - 📈 Lines Added: ${latestMonth.stats.linesAdded.toLocaleString()} | |
| - 👥 Active Contributors: ${latestMonth.stats.activeContributors} | |
| Updated charts in \`docs/stats/\` showing 12 months of repository activity.` | |
| }); | |
| return pr.data.html_url; | |
| } catch (error) { | |
| if (error.message.includes('A pull request already exists')) { | |
| return 'PR already exists'; | |
| } | |
| throw error; | |
| } | |
| } | |
| // Main execution | |
| try { | |
| console.log('Starting simple monthly report generation...'); | |
| const monthlyData = []; | |
| const now = new Date(); | |
| // Get last 12 months of data (simple approach) | |
| for (let i = 12; i >= 1; i--) { | |
| const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1); | |
| const monthEnd = new Date(now.getFullYear(), now.getMonth() - i + 1, 0); | |
| const since = monthStart.toISOString(); | |
| const until = monthEnd.toISOString(); | |
| const monthName = monthStart.toLocaleString('default', { month: 'short', year: 'numeric' }); | |
| console.log(`Processing ${monthName}...`); | |
| const [stats, prStats] = await Promise.all([ | |
| getSimpleMonthStats(since, until), | |
| getPRStats(since, until) | |
| ]); | |
| monthlyData.push({ month: monthName, stats, prStats }); | |
| } | |
| // Create charts and PR | |
| const prUrl = await createChartsAndPR(monthlyData); | |
| // Get languages | |
| const languages = await getLanguageStats(); | |
| // Post to Mattermost | |
| const latest = monthlyData[monthlyData.length - 1]; | |
| const mattermostPayload = { | |
| text: `📊 **${latest.month} Repository Activity**`, | |
| attachments: [{ | |
| color: '#22c55e', | |
| fields: [ | |
| { title: '📝 Total Commits', value: latest.stats.totalCommits.toString(), short: true }, | |
| { title: '🔀 Merged PRs', value: latest.prStats.merged.toString(), short: true }, | |
| { title: '📈 Lines Added', value: latest.stats.linesAdded.toLocaleString(), short: true }, | |
| { title: '👥 Contributors', value: latest.stats.activeContributors.toString(), short: true } | |
| ], | |
| text: `**Top Contributors:** ${latest.stats.contributorsList.slice(0, 3).join(', ') || 'None'}\n` + | |
| `**Languages:** ${languages.map(l => `${l.language} (${l.percentage}%)`).join(', ') || 'N/A'}\n\n` + | |
| `**Charts:** ${typeof prUrl === 'string' && prUrl.startsWith('http') ? `[View PR](${prUrl})` : 'Updated in docs/stats/'}\n` + | |
| `Repository: ${context.repo.owner}/${context.repo.repo}`, | |
| footer: 'Simple repository statistics' | |
| }] | |
| }; | |
| const response = await fetch(process.env.MATTERMOST_WEBHOOK_URL, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(mattermostPayload) | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`Mattermost webhook failed: ${response.status}`); | |
| } | |
| console.log('Simple monthly report completed successfully'); | |
| } catch (error) { | |
| console.error('Error:', error); | |
| core.setFailed(`Report generation failed: ${error.message}`); | |
| } |