UNPKG

git-contribution-stats

Version:

Library to generate GitHub contribution reports for GitHub App installations

447 lines (446 loc) • 19.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.generateGitHubReport = generateGitHubReport; const octokit_1 = require("octokit"); const logger_1 = require("./logger"); async function generateGitHubReport(config) { const { app_id, private_key, days_to_look_back = 7 } = config; const logger = config.logger || new logger_1.Logger({ level: 'info', scope: ['GitHubReport'] }); logger.start(`Initializing GitHub App with ID: ${app_id}`); const app = await initializeGitHubApp(app_id, private_key); try { const installations = await getInstallations(app); logger.info(`Found ${installations.length} installations`); const results = await processInstallations(app, installations, days_to_look_back, logger); logger.info(`Generating report for ${results.length} installations`); const report = generateReport(results); logger.success(`GitHub activity report generated successfully`); return { summary: report, detailed_results: results }; } catch (error) { logger.error(`Error accessing GitHub API: ${error.message}`); throw error; } } async function initializeGitHubApp(app_id, private_key) { return new octokit_1.App({ appId: app_id, privateKey: private_key, }); } async function getInstallations(app) { const response = await app.octokit.request('GET /app/installations', { headers: { 'X-GitHub-Api-Version': '2022-11-28' } }); return response.data; } async function processInstallations(app, installations, days_to_look_back, logger) { const results = []; logger.start(`Processing ${installations.length} installations`); for (const [index, installation] of installations.entries()) { logger.pending(`Processing installation ${index + 1}/${installations.length}`); try { const octokit = await app.getInstallationOctokit(installation.id); const repos = await getRepositories(octokit); logger.info(`Has access to ${repos.length} repositories`); const cutoff_date = calculateCutoffDate(days_to_look_back); const updated_repos = filterUpdatedRepositories(repos, cutoff_date, logger); logger.info(`${updated_repos.length} of ${repos.length} repositories were updated in the last ${days_to_look_back} days`); if (updated_repos.length > 0) { logger.start(`Processing repositories for ${installation.account.login}`); } const user_stats = await collectUserStats(octokit, updated_repos, cutoff_date, logger); const installation_stats = formatInstallationStats(installation, days_to_look_back, user_stats); logger.complete(`Finished processing for ${installation.account.login}`); results.push(installation_stats); } catch (error) { logger.error(`Error processing installation: ${error.message}`); results.push({ id: installation.id, account: installation.account.login, error: error.message }); } } logger.complete(`Processed all ${installations.length} installations`); return results; } async function getRepositories(octokit) { const { data: { repositories } } = await octokit.request('GET /installation/repositories', { per_page: 100, headers: { 'X-GitHub-Api-Version': '2022-11-28' } }); return repositories; } function calculateCutoffDate(days_to_look_back) { const cutoff_date = new Date(); cutoff_date.setDate(cutoff_date.getDate() - days_to_look_back); return cutoff_date; } function filterUpdatedRepositories(repos, cutoff_date, logger) { logger.start(`Filtering ${repos.length} repositories by update date`); const filtered = repos.filter(repo => { const last_pushed = new Date(repo.pushed_at || 0); const last_updated = new Date(repo.updated_at || 0); const latest_activity = last_pushed > last_updated ? last_pushed : last_updated; const was_updated_in_period = latest_activity >= cutoff_date; if (!was_updated_in_period) { logger.debug(`Skipping repository ${repo.full_name} - no updates since ${cutoff_date.toISOString()}`); } return was_updated_in_period; }); logger.complete(`Filtered repositories: ${filtered.length} matched criteria`); return filtered; } async function collectUserStats(octokit, updated_repos, cutoff_date, logger) { const user_stats = {}; const since = cutoff_date.toISOString(); logger.start(`Collecting stats for ${updated_repos.length} repositories`); for (const [index, repo] of updated_repos.entries()) { try { const processed_commits = new Set(); logger.pending(`Processing repository ${index + 1}/${updated_repos.length}: ${repo.full_name}`); await processRepositoryBranches(octokit, repo, since, cutoff_date, processed_commits, user_stats, logger); await processRepositoryPullRequests(octokit, repo, cutoff_date, user_stats, logger); logger.complete(`Found ${processed_commits.size} commits in ${repo.name}`); } catch (repo_error) { logger.error(`Error processing repository: ${repo_error.message}`); } } logger.complete(`Collected stats for all repositories`); return user_stats; } async function processRepositoryBranches(octokit, repo, since, cutoff_date, processed_commits, user_stats, logger) { const owner_login = repo.owner.login; const repo_name = repo.name; const branches = await getBranches(octokit, owner_login, repo_name); logger.info(`Repository has ${branches.length} branches`); for (const branch of branches) { await processBranchCommits(octokit, owner_login, repo_name, branch, since, processed_commits, user_stats, logger); } } async function getBranches(octokit, owner_login, repo_name) { const branches_query = ` query($owner: String!, $repo: String!) { repository(owner: $owner, name: $repo) { refs(refPrefix: "refs/heads/", first: 100) { nodes { name target { oid } } } } } `; const branches_result = await octokit.graphql(branches_query, { owner: owner_login, repo: repo_name }); return branches_result.repository.refs.nodes || []; } async function processBranchCommits(octokit, owner_login, repo_name, branch, since, processed_commits, user_stats, logger) { const commits_query = ` query($owner: String!, $repo: String!, $branchName: String!, $since: GitTimestamp!) { repository(owner: $owner, name: $repo) { ref(qualifiedName: $branchName) { target { ... on Commit { history(since: $since, first: 100) { nodes { oid message committedDate author { user { login id databaseId } name email } } } } } } } } `; const commits_result = await octokit.graphql(commits_query, { owner: owner_login, repo: repo_name, branchName: `refs/heads/${branch.name}`, since: since }); const branch_commits = commits_result.repository.ref?.target?.history?.nodes || []; logger.debug(`Found ${branch_commits.length} commits in the period`); for (const commit of branch_commits) { if (!processed_commits.has(commit.oid)) { processed_commits.add(commit.oid); const author_login = commit.author.user?.login || commit.author.name || commit.author.email || "Unknown"; const user_id = commit.author.user?.databaseId || null; if (!user_stats[author_login]) { user_stats[author_login] = { user_id: user_id, commits_by_repo: {}, pull_requests_by_repo: {}, commit_details_by_repo: {}, total_commits: 0, total_prs_opened: 0, total_prs_closed: 0 }; } else if (user_id && !user_stats[author_login].user_id) { user_stats[author_login].user_id = user_id; } if (!user_stats[author_login].commits_by_repo[repo_name]) { user_stats[author_login].commits_by_repo[repo_name] = 0; user_stats[author_login].commit_details_by_repo[repo_name] = []; } user_stats[author_login].commits_by_repo[repo_name]++; user_stats[author_login].total_commits++; user_stats[author_login].commit_details_by_repo[repo_name].push({ id: commit.oid, message: commit.message, date: commit.committedDate }); } } } async function processRepositoryPullRequests(octokit, repo, cutoff_date, user_stats, logger) { const owner_login = repo.owner.login; const repo_name = repo.name; const since_date_str = cutoff_date.toISOString().split('T')[0]; try { const search_query = `repo:${owner_login}/${repo_name} is:pr updated:>=${since_date_str}`; logger.debug(`Searching PRs with query: ${search_query}`); const search_prs_response = await octokit.request('GET /search/issues', { q: search_query, per_page: 100, headers: { 'X-GitHub-Api-Version': '2022-11-28' } }); logger.info(`Search API found ${search_prs_response.data.total_count} PRs for ${repo_name}`); await processPullRequestsFromSearch(search_prs_response.data.items, repo, cutoff_date, user_stats, logger); if (search_prs_response.data.total_count === 0) { logger.note(`Using fallback for pulls endpoint for ${repo_name}`); await fetchPRsWithPagination(octokit, repo, cutoff_date, user_stats, logger); } } catch (search_error) { logger.error(`Error searching PRs for ${repo.full_name}: ${search_error.message}`); logger.note(`Using fallback for pulls endpoint for ${repo_name}`); await fetchPRsWithPagination(octokit, repo, cutoff_date, user_stats, logger); } } async function processPullRequestsFromSearch(search_prs, repo, cutoff_date, user_stats, logger) { const owner_login = repo.owner.login; const repo_name = repo.name; for (const pr of search_prs || []) { try { const pr_repo_regex = /\/repos\/([^\/]+)\/([^\/]+)\/pulls\/\d+/; const pr_repo_match = pr.pull_request.url.match(pr_repo_regex); if (pr_repo_match && pr_repo_match[1] === owner_login && pr_repo_match[2] === repo_name) { await processPullRequest(pr, repo_name, cutoff_date, user_stats, logger); } } catch (pr_error) { logger.error(`Error processing individual PR: ${pr_error.message}`); } } } async function processPullRequest(pr, repo_name, cutoff_date, user_stats, logger) { const author_login = pr.user ? pr.user.login : "Unknown"; const user_id = pr.user?.id || pr.user?.node_id || null; if (!user_stats[author_login]) { user_stats[author_login] = { user_id: user_id, commits_by_repo: {}, pull_requests_by_repo: {}, commit_details_by_repo: {}, total_commits: 0, total_prs_opened: 0, total_prs_closed: 0 }; } else if (user_id && !user_stats[author_login].user_id) { user_stats[author_login].user_id = user_id; } if (!user_stats[author_login].pull_requests_by_repo[repo_name]) { user_stats[author_login].pull_requests_by_repo[repo_name] = { opened: 0, closed: 0 }; } const created_at = new Date(pr.created_at); if (created_at >= cutoff_date) { user_stats[author_login].pull_requests_by_repo[repo_name].opened++; user_stats[author_login].total_prs_opened++; logger.debug(`PR #${pr.number} by ${author_login} counted as opened`); } if (pr.closed_at) { const closed_at = new Date(pr.closed_at); if (closed_at >= cutoff_date) { user_stats[author_login].pull_requests_by_repo[repo_name].closed++; user_stats[author_login].total_prs_closed++; logger.debug(`PR #${pr.number} by ${author_login} counted as closed`); } } } async function fetchPRsWithPagination(octokit, repo, cutoff_date, user_stats, logger) { let page = 1; let has_more_pages = true; let total_prs = 0; while (has_more_pages) { try { if (page > 1) { await new Promise(resolve => setTimeout(resolve, 1000)); } logger.pending(`Fetching PRs for ${repo.name} - page ${page}`); const pull_requests_response = await octokit.request('GET /repos/{owner}/{repo}/pulls', { owner: repo.owner.login, repo: repo.name, state: 'all', sort: 'updated', direction: 'desc', per_page: 100, page: page, headers: { 'X-GitHub-Api-Version': '2022-11-28' } }); const pull_requests = pull_requests_response.data; logger.info(`Page ${page}: Found ${pull_requests.length} PRs for ${repo.name}`); has_more_pages = pull_requests.length === 100; page++; const filtered_prs = pull_requests.filter((pr) => { try { const updated_at = new Date(pr.updated_at); return updated_at >= cutoff_date; } catch (err) { logger.error(`Error filtering PR: ${err}`); return false; } }); logger.info(`${filtered_prs.length} PRs updated in the specified period`); total_prs += filtered_prs.length; for (const pr of filtered_prs) { try { await processPullRequest(pr, repo.name, cutoff_date, user_stats, logger); } catch (pr_error) { logger.error(`Error processing PR: ${pr_error.message}`); } } if (filtered_prs.length === 0 && page > 2) { logger.note(`No recent PRs found on page ${page - 1}, stopping search`); has_more_pages = false; } } catch (error) { logger.error(`Error fetching PRs (page ${page}): ${error.message}`); if (error.status === 403 && error.message.includes('rate limit')) { logger.warn('Rate limit reached, waiting 60 seconds...'); await new Promise(resolve => setTimeout(resolve, 60000)); page--; } else { has_more_pages = false; } } } logger.complete(`Total of ${total_prs} recent PRs found for ${repo.name}`); return total_prs; } function formatInstallationStats(installation, days_to_look_back, user_stats) { const installation_stats = { installation_id: installation.id, account: installation.account.login, user_id: installation.account.id, account_type: installation.account.type, period_days: days_to_look_back, user_stats: [] }; for (const [user_name, stats] of Object.entries(user_stats)) { const user_stat = { name: user_name, user_id: stats.user_id, total_commits: stats.total_commits, total_prs_opened: stats.total_prs_opened, total_prs_closed: stats.total_prs_closed, repo_contributions: [] }; const repo_set = new Set([ ...Object.keys(stats.commits_by_repo), ...Object.keys(stats.pull_requests_by_repo) ]); for (const repo_name of repo_set) { const repo_stat = { repo_name: repo_name, commits: stats.commits_by_repo[repo_name] || 0, prs_opened: (stats.pull_requests_by_repo[repo_name] && stats.pull_requests_by_repo[repo_name].opened) || 0, prs_closed: (stats.pull_requests_by_repo[repo_name] && stats.pull_requests_by_repo[repo_name].closed) || 0, commit_details: stats.commit_details_by_repo[repo_name] || [] }; user_stat.repo_contributions.push(repo_stat); } installation_stats.user_stats.push(user_stat); } return installation_stats; } function generateReport(results) { let report_lines = []; for (const installation of results) { if ('error' in installation) { report_lines.push(`āš ļø Installation ${installation.account}: ${installation.error}`); continue; } const period_label = installation.period_days === 7 ? "week" : (installation.period_days === 30 ? "month" : `${installation.period_days} days`); report_lines.push(`šŸ“Š Statistics for ${installation.account} (${installation.account_type}) - Last ${period_label}:`); if (installation.user_stats && installation.user_stats.length > 0) { installation.user_stats.sort((a, b) => { const total_a = a.total_commits + a.total_prs_opened + a.total_prs_closed; const total_b = b.total_commits + b.total_prs_opened + b.total_prs_closed; return total_b - total_a; }); for (const user of installation.user_stats) { const user_id_info = user.user_id ? ` (ID: ${user.user_id})` : ''; report_lines.push(`\nšŸ‘¤ ${user.name}${user_id_info}:`); report_lines.push(` Total: ${user.total_commits} commits, ${user.total_prs_opened} PRs opened, ${user.total_prs_closed} PRs closed`); user.repo_contributions.sort((a, b) => { const total_a = a.commits + a.prs_opened + a.prs_closed; const total_b = b.commits + b.prs_opened + b.prs_closed; return total_b - total_a; }); report_lines.push(` Contributions by repository:`); for (const repo of user.repo_contributions) { report_lines.push(` - ${repo.repo_name}: ${repo.commits} commits, ${repo.prs_opened} PRs opened, ${repo.prs_closed} PRs closed`); } } } else { report_lines.push(` No contributions found in the period.`); } report_lines.push(''); } return report_lines.join('\n'); }