1335 new bot for monthly statistics #26
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 []; | |
| } | |
| } | |
| // Get commit statistics using GitHub's actual Pulse API | |
| async function getMonthCommitStats(since, until) { | |
| console.log(`=== USING GITHUB PULSE API FOR ${since} TO ${until} ===`); | |
| try { | |
| // Use GitHub's Code Frequency API (shows additions/deletions over time) | |
| const codeFrequency = await github.rest.repos.getCodeFrequencyStats({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| }); | |
| console.log('*** CODE FREQUENCY DATA LENGTH ***', codeFrequency.data?.length); | |
| // Use GitHub's Commit Activity API (shows commits per week) | |
| const commitActivity = await github.rest.repos.getCommitActivityStats({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| }); | |
| console.log('*** COMMIT ACTIVITY DATA LENGTH ***', commitActivity.data?.length); | |
| // Use GitHub's Participation API (shows commit participation) | |
| const participation = await github.rest.repos.getParticipationStats({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| }); | |
| console.log('*** PARTICIPATION DATA ***', participation.data); | |
| // Use GitHub's Contributors API to get author info | |
| const contributors = await github.paginate(github.rest.repos.listContributors, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| per_page: 100, | |
| }); | |
| console.log('*** FOUND CONTRIBUTORS ***', contributors.length); | |
| // For the specific month, we still need to use listCommits but with better logic | |
| // Get all commits using GraphQL-style approach via REST | |
| // Method 1: Try to use search API to find commits in date range | |
| const searchQuery = `repo:${context.repo.owner}/${context.repo.repo} author-date:${since.split('T')[0]}..${until.split('T')[0]}`; | |
| console.log('*** SEARCHING WITH QUERY ***', searchQuery); | |
| let searchResults = null; | |
| try { | |
| searchResults = await github.rest.search.commits({ | |
| q: searchQuery, | |
| sort: 'author-date', | |
| order: 'desc', | |
| per_page: 100, | |
| }); | |
| console.log('*** SEARCH FOUND COMMITS ***', searchResults.data.total_count); | |
| } catch (searchError) { | |
| console.log('*** SEARCH API ERROR ***', searchError.message); | |
| } | |
| // Method 2: Fallback to regular commits API | |
| const mainCommits = await github.paginate(github.rest.repos.listCommits, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| sha: repo.data.default_branch, | |
| since, | |
| until, | |
| per_page: 100, | |
| }); | |
| console.log(`*** MAIN BRANCH COMMITS: ${mainCommits.length} ***`); | |
| // Use search results if available, otherwise use estimates | |
| const totalCommitsAllBranches = searchResults?.data.total_count || mainCommits.length; | |
| const totalCommitsMain = mainCommits.length; | |
| // Process main commits for detailed stats | |
| const authors = new Set(); | |
| let totalAdditions = 0; | |
| let totalDeletions = 0; | |
| let fileChanges = 0; | |
| for (const commit of mainCommits.slice(0, 50)) { | |
| if (commit.author && commit.author.login) { | |
| authors.add(commit.author.login); | |
| } | |
| try { | |
| const commitDetail = await github.rest.repos.getCommit({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| ref: commit.sha, | |
| }); | |
| fileChanges += 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}`); | |
| } | |
| } | |
| // If search worked, try to get all authors from search results | |
| if (searchResults && searchResults.data.items) { | |
| searchResults.data.items.forEach(commit => { | |
| if (commit.author && commit.author.login) { | |
| authors.add(commit.author.login); | |
| } | |
| }); | |
| } | |
| const result = { | |
| totalCommitsAllBranches: totalCommitsAllBranches, | |
| totalCommitsMain: totalCommitsMain, | |
| totalAuthors: authors.size, | |
| authorsList: Array.from(authors), | |
| filesChangedMain: fileChanges, | |
| linesAdded: totalAdditions, | |
| linesRemoved: totalDeletions, | |
| netLines: totalAdditions - totalDeletions, | |
| }; | |
| console.log('*** FINAL PULSE-BASED RESULT ***', JSON.stringify(result, null, 2)); | |
| return result; | |
| } catch (error) { | |
| console.log('*** ERROR WITH PULSE APIs ***', 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 | |
| function createSVGChart(data, title, color = '#22c55e') { | |
| const width = 600; | |
| const height = 200; | |
| const padding = 40; | |
| const chartWidth = width - 2 * padding; | |
| const chartHeight = height - 2 * padding; | |
| 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`; | |
| 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('')} | |
| <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 | |
| async function commitChartsToRepo(monthlyData, targetBranch) { | |
| const commits = monthlyData.map(d => d.stats?.totalCommitsAllBranches || d.stats?.totalCommits || 0); | |
| const linesAdded = monthlyData.map(d => d.stats?.linesAdded || 0); | |
| const prsOpened = monthlyData.map(d => d.prStats?.opened || 0); | |
| const commitsSvg = createSVGChart(commits, 'Monthly Commits', '#3b82f6'); | |
| const linesSvg = createSVGChart(linesAdded, 'Lines Added per Month', '#22c55e'); | |
| const prsSvg = createSVGChart(prsOpened, 'Pull Requests Opened', '#8b5cf6'); | |
| const chartFiles = [ | |
| { path: 'docs/assets/commits-chart.svg', content: commitsSvg }, | |
| { path: 'docs/assets/lines-chart.svg', content: linesSvg }, | |
| { path: 'docs/assets/prs-chart.svg', content: prsSvg } | |
| ]; | |
| 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: targetBranch | |
| }); | |
| 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: targetBranch | |
| }; | |
| 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); | |
| } | |
| } | |
| } | |
| // 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 | |
| console.log('Generating and committing SVG charts...'); | |
| 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 | |
| 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` + | |
| `Repository: ${context.repo.owner}/${context.repo.repo}`, | |
| footer: 'Charts updated in docs/assets/ for documentation' | |
| }] | |
| }; | |
| 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}`); | |
| } |