1335 new bot for monthly statistics #34
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 with Charts | |
| 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: read | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '18' | |
| - name: Install dependencies | |
| run: | | |
| npm init -y | |
| npm install asciichart | |
| - name: Generate Monthly Statistics Report with Charts | |
| uses: actions/github-script@v7 | |
| env: | |
| MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK_URL }} | |
| with: | |
| script: | | |
| const asciichart = require('asciichart'); | |
| // Get language statistics | |
| 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) { | |
| console.log('Error getting language stats:', error.message); | |
| return []; | |
| } | |
| } | |
| // Cache for branch activity to avoid repeated API calls | |
| let branchActivityCache = null; | |
| // Get active branches for the entire year (cache this) | |
| async function getActiveBranchesForYear() { | |
| if (branchActivityCache) { | |
| console.log('*** USING CACHED BRANCH ACTIVITY ***'); | |
| return branchActivityCache; | |
| } | |
| console.log('*** BUILDING BRANCH ACTIVITY CACHE FOR FULL YEAR ***'); | |
| // Get all branches | |
| const branches = await github.paginate(github.rest.repos.listBranches, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| per_page: 100, | |
| }); | |
| console.log(`*** FOUND ${branches.length} BRANCHES ***`); | |
| // Get the date range for the entire year we're analyzing | |
| const now = new Date(); | |
| const yearStart = new Date(now.getFullYear(), now.getMonth() - 12, 1); | |
| const yearEnd = new Date(); | |
| console.log(`*** CHECKING ACTIVITY FROM ${yearStart.toISOString()} TO ${yearEnd.toISOString()} ***`); | |
| const activeBranches = []; | |
| let branchesChecked = 0; | |
| for (const branch of branches) { | |
| try { | |
| branchesChecked++; | |
| if (branchesChecked % 20 === 0) { | |
| console.log(`*** Checked ${branchesChecked}/${branches.length} branches for yearly activity ***`); | |
| } | |
| // Check if branch has ANY commits in the entire year | |
| const branchCommits = await github.rest.repos.listCommits({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| sha: branch.name, | |
| since: yearStart.toISOString(), | |
| until: yearEnd.toISOString(), | |
| per_page: 1, | |
| }); | |
| if (branchCommits.data.length > 0) { | |
| activeBranches.push(branch); | |
| console.log(`*** Branch ${branch.name} has commits in the year ***`); | |
| } | |
| } catch (error) { | |
| console.log(`*** Error checking ${branch.name}: ${error.message} ***`); | |
| } | |
| await new Promise(resolve => setTimeout(resolve, 50)); | |
| } | |
| console.log(`*** FOUND ${activeBranches.length} ACTIVE BRANCHES FOR THE YEAR ***`); | |
| branchActivityCache = activeBranches; | |
| return activeBranches; | |
| } | |
| // Get commit statistics using cached active branches | |
| async function getMonthCommitStats(since, until) { | |
| console.log(`=== GETTING COMMITS FROM CACHED ACTIVE BRANCHES ${since} TO ${until} ===`); | |
| try { | |
| // Get the list of active branches for the year (cached after first call) | |
| const activeBranches = await getActiveBranchesForYear(); | |
| // Get default branch info | |
| const repo = await github.rest.repos.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| }); | |
| const defaultBranch = repo.data.default_branch; | |
| console.log(`*** PROCESSING ${activeBranches.length} ACTIVE BRANCHES FOR THIS MONTH ***`); | |
| const allCommitShas = new Set(); | |
| const allAuthors = new Set(); | |
| let mainBranchCommits = []; | |
| // Now only check the active branches for this specific month | |
| for (const branch of activeBranches) { | |
| try { | |
| const branchCommits = await github.paginate(github.rest.repos.listCommits, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| sha: branch.name, | |
| since, | |
| until, | |
| per_page: 100, | |
| }); | |
| if (branchCommits.length > 0) { | |
| console.log(`*** Found ${branchCommits.length} commits on ${branch.name} for this month ***`); | |
| } | |
| // Add to our sets (automatically handles duplicates) | |
| branchCommits.forEach(commit => { | |
| allCommitShas.add(commit.sha); | |
| if (commit.author && commit.author.login) { | |
| allAuthors.add(commit.author.login); | |
| } | |
| }); | |
| // Keep main branch commits separate | |
| if (branch.name === defaultBranch) { | |
| mainBranchCommits = branchCommits; | |
| } | |
| } catch (error) { | |
| console.log(`*** Error fetching commits from ${branch.name}: ${error.message} ***`); | |
| } | |
| await new Promise(resolve => setTimeout(resolve, 100)); | |
| } | |
| console.log(`*** TOTAL UNIQUE COMMITS ACROSS ACTIVE BRANCHES: ${allCommitShas.size} ***`); | |
| console.log(`*** COMMITS ON MAIN BRANCH: ${mainBranchCommits.length} ***`); | |
| console.log(`*** TOTAL UNIQUE AUTHORS: ${allAuthors.size} ***`); | |
| console.log(`*** AUTHORS: ${Array.from(allAuthors).join(', ')} ***`); | |
| // Get detailed stats from main branch commits | |
| let totalAdditions = 0; | |
| let totalDeletions = 0; | |
| let mainFileChanges = 0; | |
| for (const commit of mainBranchCommits.slice(0, 50)) { | |
| try { | |
| const commitDetail = await github.rest.repos.getCommit({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| ref: commit.sha, | |
| }); | |
| mainFileChanges += commitDetail.data.files?.length || 0; | |
| totalAdditions += commitDetail.data.stats?.additions || 0; | |
| totalDeletions += commitDetail.data.stats?.deletions || 0; | |
| } catch (error) { | |
| console.log(`Error getting commit detail: ${error.message}`); | |
| } | |
| } | |
| const result = { | |
| totalCommitsAllBranches: allCommitShas.size, | |
| totalCommitsMain: mainBranchCommits.length, | |
| totalAuthors: allAuthors.size, | |
| authorsList: Array.from(allAuthors), | |
| filesChangedMain: mainFileChanges, | |
| linesAdded: totalAdditions, | |
| linesRemoved: totalDeletions, | |
| netLines: totalAdditions - totalDeletions, | |
| }; | |
| console.log('*** CACHED OPTIMIZED RESULT ***', JSON.stringify(result, null, 2)); | |
| return result; | |
| } catch (error) { | |
| console.log('*** ERROR WITH CACHED APPROACH ***', error.message); | |
| return { | |
| totalCommitsAllBranches: 0, | |
| totalCommitsMain: 0, | |
| totalAuthors: 0, | |
| authorsList: [], | |
| filesChangedMain: 0, | |
| linesAdded: 0, | |
| linesRemoved: 0, | |
| netLines: 0, | |
| }; | |
| } | |
| } | |
| // Get pull request statistics | |
| async function getMonthPRStats(since, until) { | |
| const allPRs = 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 = allPRs.filter(pr => | |
| pr.merged_at && | |
| new Date(pr.merged_at) >= new Date(since) && | |
| new Date(pr.merged_at) <= new Date(until) | |
| ); | |
| const openedPRs = allPRs.filter(pr => | |
| new Date(pr.created_at) >= new Date(since) && | |
| new Date(pr.created_at) <= new Date(until) | |
| ); | |
| return { | |
| merged: mergedPRs.length, | |
| opened: openedPRs.length, | |
| }; | |
| } | |
| // Create professional SVG charts with month labels | |
| function createSVGChart(data, title, color = '#22c55e', monthLabels = []) { | |
| const width = 600; | |
| const height = 200; | |
| const padding = 40; | |
| const chartWidth = width - 2 * padding; | |
| const chartHeight = height - 2 * padding - 20; // Extra space for labels | |
| 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`; | |
| // Create month labels (every 3rd month) | |
| const monthLabelElements = monthLabels.map((month, index) => { | |
| if (index % 3 === 0) { // Show every 3rd month | |
| 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('')} | |
| <!-- Month labels --> | |
| ${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>`; | |
| } | |
| // Commit charts to repository and create PR | |
| async function commitChartsToRepo(monthlyData, targetBranch) { | |
| const totalCommits = monthlyData.map(d => d.stats?.totalCommitsAllBranches || d.stats?.totalCommits || 0); | |
| const mergedPRs = monthlyData.map(d => d.prStats?.merged || 0); | |
| const linesAdded = monthlyData.map(d => d.stats?.linesAdded || 0); | |
| const activeContributors = monthlyData.map(d => d.stats?.totalAuthors || d.stats?.uniqueContributors || 0); | |
| // Extract month labels from the data | |
| const monthLabels = monthlyData.map(d => d.month); | |
| const totalCommitsSvg = createSVGChart(totalCommits, 'Total Commits (All Branches)', '#3b82f6', monthLabels); | |
| const mergedPRsSvg = createSVGChart(mergedPRs, 'Merged Pull Requests', '#10b981', monthLabels); | |
| const linesAddedSvg = createSVGChart(linesAdded, 'Total Lines Added', '#22c55e', monthLabels); | |
| const activeContributorsSvg = createSVGChart(activeContributors, 'Active Contributors', '#8b5cf6', monthLabels); | |
| // Create a new branch for the PR | |
| const now = new Date(); | |
| const prBranchName = `update-stats-charts-${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}`; | |
| console.log(`*** CREATING NEW BRANCH: ${prBranchName} ***`); | |
| try { | |
| // Get the default branch reference | |
| const defaultBranch = await github.rest.repos.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| }); | |
| const mainBranchRef = await github.rest.git.getRef({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| ref: `heads/${defaultBranch.data.default_branch}`, | |
| }); | |
| // Create new branch | |
| await github.rest.git.createRef({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| ref: `refs/heads/${prBranchName}`, | |
| sha: mainBranchRef.data.object.sha, | |
| }); | |
| console.log(`*** CREATED BRANCH: ${prBranchName} ***`); | |
| } catch (error) { | |
| if (error.message.includes('Reference already exists')) { | |
| console.log(`*** BRANCH ${prBranchName} ALREADY EXISTS, USING EXISTING BRANCH ***`); | |
| } else { | |
| throw error; | |
| } | |
| } | |
| const chartFiles = [ | |
| { path: 'docs/stats/total-commits-chart.svg', content: totalCommitsSvg }, | |
| { path: 'docs/stats/merged-prs-chart.svg', content: mergedPRsSvg }, | |
| { path: 'docs/stats/lines-added-chart.svg', content: linesAddedSvg }, | |
| { path: 'docs/stats/active-contributors-chart.svg', content: activeContributorsSvg } | |
| ]; | |
| // Commit files to the new branch | |
| for (const file of chartFiles) { | |
| try { | |
| const base64Content = Buffer.from(file.content).toString('base64'); | |
| // Try to get existing file SHA | |
| let existingFileSha = null; | |
| try { | |
| const existingFile = await github.rest.repos.getContent({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| path: file.path, | |
| ref: prBranchName | |
| }); | |
| existingFileSha = existingFile.data.sha; | |
| } catch (error) { | |
| console.log(`File ${file.path} doesn't exist yet, creating new file`); | |
| } | |
| const params = { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| path: file.path, | |
| message: `Update repository activity chart: ${file.path}`, | |
| content: base64Content, | |
| branch: prBranchName | |
| }; | |
| if (existingFileSha) { | |
| params.sha = existingFileSha; | |
| } | |
| await github.rest.repos.createOrUpdateFileContents(params); | |
| console.log(`Updated chart: ${file.path}`); | |
| } catch (error) { | |
| console.log(`Error updating ${file.path}:`, error.message); | |
| } | |
| } | |
| // Create the PR | |
| const latestMonth = monthlyData[monthlyData.length - 1]; | |
| const prTitle = `📊 Update Repository Statistics Charts - ${latestMonth.month}`; | |
| const prBody = `## 📊 Monthly Repository Statistics Update | |
| This PR automatically updates our repository activity charts with the latest data for **${latestMonth.month}**. | |
| ### 📈 Updated Charts | |
| - 🔵 **Total Commits (All Branches):** ${latestMonth.stats?.totalCommitsAllBranches || latestMonth.stats?.totalCommits || 0} commits | |
| - 🟢 **Merged Pull Requests:** ${latestMonth.prStats?.merged || 0} PRs | |
| - 🟣 **Total Lines Added:** ${(latestMonth.stats?.linesAdded || 0).toLocaleString()} lines | |
| - 🟠 **Active Contributors:** ${latestMonth.stats?.totalAuthors || latestMonth.stats?.uniqueContributors || 0} developers | |
| ### 📁 Files Updated | |
| - \`docs/stats/total-commits-chart.svg\` | |
| - \`docs/stats/merged-prs-chart.svg\` | |
| - \`docs/stats/lines-added-chart.svg\` | |
| - \`docs/stats/active-contributors-chart.svg\` | |
| ### 🤖 About | |
| This PR was automatically generated by our monthly statistics bot. The charts show 12 months of repository activity trends and can be embedded in documentation to showcase project health and development velocity. | |
| --- | |
| *Generated automatically on ${new Date().toLocaleDateString()}*`; | |
| try { | |
| const pullRequest = await github.rest.pulls.create({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| title: prTitle, | |
| head: prBranchName, | |
| base: defaultBranch.data.default_branch, | |
| body: prBody, | |
| }); | |
| console.log(`*** CREATED PR: ${pullRequest.data.html_url} ***`); | |
| return pullRequest.data.html_url; | |
| } catch (error) { | |
| if (error.message.includes('A pull request already exists')) { | |
| console.log(`*** PR ALREADY EXISTS FOR BRANCH ${prBranchName} ***`); | |
| return `PR already exists for ${prBranchName}`; | |
| } else { | |
| throw error; | |
| } | |
| } | |
| } | |
| // Main execution | |
| try { | |
| console.log('*** STARTING MONTHLY REPORT GENERATION ***'); | |
| // Get current branch | |
| let targetBranch; | |
| if (context.eventName === 'pull_request') { | |
| targetBranch = context.payload.pull_request.head.ref; | |
| console.log(`Using PR branch: ${targetBranch}`); | |
| } else { | |
| targetBranch = context.ref.replace('refs/heads/', '') || 'main'; | |
| console.log(`Using branch: ${targetBranch}`); | |
| } | |
| // Gather data for last 12 months | |
| const monthlyDataArray = []; | |
| 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 monthRange = { | |
| start: monthStart.toISOString(), | |
| end: monthEnd.toISOString(), | |
| monthName: monthStart.toLocaleString('default', { month: 'short', year: 'numeric' }) | |
| }; | |
| console.log(`Processing ${monthRange.monthName} (${monthRange.start} to ${monthRange.end})...`); | |
| const [stats, prStats] = await Promise.all([ | |
| getMonthCommitStats(monthRange.start, monthRange.end), | |
| getMonthPRStats(monthRange.start, monthRange.end) | |
| ]); | |
| monthlyDataArray.push({ | |
| month: monthRange.monthName, | |
| stats, | |
| prStats | |
| }); | |
| await new Promise(resolve => setTimeout(resolve, 500)); | |
| } | |
| // Create and commit SVG charts, then create PR | |
| console.log('Generating and committing SVG charts...'); | |
| const prUrl = await commitChartsToRepo(monthlyDataArray, targetBranch); | |
| // Get language stats | |
| const languageStats = await getLanguageStats(); | |
| // Use latest month for Mattermost | |
| const latestMonthData = monthlyDataArray[monthlyDataArray.length - 1]; | |
| console.log(`*** LATEST MONTH FOR MATTERMOST ***`, JSON.stringify(latestMonthData, null, 2)); | |
| // Post to Mattermost with PR link | |
| const mattermostPayload = { | |
| text: `📊 **${latestMonthData.month} Repository Activity**`, | |
| attachments: [{ | |
| color: '#22c55e', | |
| fields: [ | |
| { title: '📝 Commits to Main', value: (latestMonthData.stats?.totalCommitsMain || 0).toString(), short: true }, | |
| { title: '🌿 All Branch Commits', value: (latestMonthData.stats?.totalCommitsAllBranches || 0).toString(), short: true }, | |
| { title: '📁 Files Changed', value: (latestMonthData.stats?.filesChangedMain || 0).toString(), short: true }, | |
| { title: '🔀 PRs Merged', value: (latestMonthData.prStats?.merged || 0).toString(), short: true }, | |
| { title: '🆕 PRs Opened', value: (latestMonthData.prStats?.opened || 0).toString(), short: true }, | |
| { title: '👥 Authors', value: (latestMonthData.stats?.totalAuthors || 0).toString(), short: true } | |
| ], | |
| text: `**Code Changes:** +${(latestMonthData.stats?.linesAdded || 0).toLocaleString()} / -${(latestMonthData.stats?.linesRemoved || 0).toLocaleString()} lines\n` + | |
| `**Top Contributors:** ${(latestMonthData.stats?.authorsList || []).slice(0, 3).join(', ') || 'None'}\n` + | |
| `**Languages:** ${(languageStats || []).map(l => `${l.language} (${l.percentage}%)`).join(', ') || 'N/A'}\n\n` + | |
| `*Excluding merges, ${latestMonthData.stats?.totalAuthors || 0} authors pushed ${latestMonthData.stats?.totalCommitsMain || 0} commits to main and ${latestMonthData.stats?.totalCommitsAllBranches || 0} commits to all branches*\n\n` + | |
| `**📊 Charts Updated:** ${typeof prUrl === 'string' && prUrl.startsWith('http') ? `[View PR](${prUrl})` : 'Charts updated in docs/stats/'}\n` + | |
| `Repository: ${context.repo.owner}/${context.repo.repo}`, | |
| footer: 'Monthly statistics generated automatically' | |
| }] | |
| }; | |
| 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} ${response.statusText}`); | |
| } | |
| console.log('Monthly report with charts posted to Mattermost successfully'); | |
| } catch (error) { | |
| console.error('Error generating monthly report:', error); | |
| core.setFailed(`Monthly report generation failed: ${error.message}`); | |
| } |