Skip to content

1335 new bot for monthly statistics #46

1335 new bot for monthly statistics

1335 new bot for monthly statistics #46

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