1335 new bot for monthly statistics #46
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 * *' | |
| workflow_dispatch: | |
| jobs: | |
| generate-monthly-report: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| issues: write | |
| pull-requests: write | |
| actions: 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}`); | |
| 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`); | |
| const contributors = new Set(); | |
| 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); | |
| } | |
| }); | |
| 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; | |
| if (commitDetail.data.commit && commitDetail.data.commit.message) { | |
| const message = commitDetail.data.commit.message; | |
| const coAuthorRegex = /Co-authored-by:\s*([^<]+)\s*<([^>]+)>/gi; | |
| let match; | |
| while ((match = coAuthorRegex.exec(message)) !== null) { | |
| const coAuthorName = match[1].trim(); | |
| if (coAuthorName && coAuthorName.length > 0) { | |
| contributors.add(coAuthorName); | |
| console.log(`Found co-author: ${coAuthorName}`); | |
| } | |
| } | |
| } | |
| 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 (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, | |
| }); | |
| 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); | |
| } | |
| } | |
| } | |
| } catch (error) { | |
| console.log(`Error checking co-authors: ${error.message}`); | |
| } | |
| } | |
| console.log(`Total contributors: ${contributors.size}`); | |
| console.log(`Contributors: ${Array.from(contributors).join(', ')}`); | |
| return { | |
| totalCommits: commits.length, | |
| activeContributors: contributors.size, | |
| linesAdded: totalLinesAdded, | |
| filesChanged: filesChanged, | |
| contributorsList: Array.from(contributors) | |
| }; | |
| } | |
| 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 }; | |
| } | |
| 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 []; | |
| } | |
| } | |
| 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>`; | |
| } | |
| async function createChartsOnBranch(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); | |
| 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) } | |
| ]; | |
| // Get repo info | |
| const repoInfo = await github.rest.repos.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| }); | |
| // Create new branch | |
| const now = new Date(); | |
| const branchName = `update-stats-${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}`; | |
| try { | |
| const mainRef = await github.rest.git.getRef({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| ref: `heads/${repoInfo.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')) { | |
| console.log(`Branch ${branchName} already exists, using existing branch`); | |
| } else { | |
| throw error; | |
| } | |
| } | |
| // Commit charts to the new branch only | |
| 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) { | |
| console.log(`File ${chart.path} doesn't exist on branch, creating new file`); | |
| } | |
| 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 on branch: ${chart.path}`); | |
| } catch (error) { | |
| console.log(`Error updating ${chart.path} on branch: ${error.message}`); | |
| } | |
| } | |
| const latestMonth = monthlyData[monthlyData.length - 1]; | |
| return { | |
| branchName: branchName, | |
| message: `Charts updated on branch ${branchName} for ${latestMonth.month}`, | |
| branchUrl: `https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${branchName}` | |
| }; | |
| } | |
| try { | |
| console.log('Starting simple monthly report generation...'); | |
| const monthlyData = []; | |
| const now = new Date(); | |
| 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); | |
| console.log(`Month ${i}: ${monthStart.toLocaleDateString()} to ${monthEnd.toLocaleDateString()}`); | |
| const since = monthStart.toISOString(); | |
| const until = monthEnd.toISOString(); | |
| const monthName = monthStart.toLocaleString('default', { month: 'short', year: 'numeric' }); | |
| console.log(`Processing ${monthName} (${since} to ${until})...`); | |
| const [stats, prStats] = await Promise.all([ | |
| getSimpleMonthStats(since, until), | |
| getPRStats(since, until) | |
| ]); | |
| monthlyData.push({ month: monthName, stats, prStats }); | |
| } | |
| const branchResult = await createChartsOnBranch(monthlyData); | |
| const languages = await getLanguageStats(); | |
| 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:** [View on branch ${branchResult.branchName}](${branchResult.branchUrl})\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}`); | |
| } |