From 52ed54f87f079c2466a687485cc105ab52cfe043 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Dec 2025 22:38:57 +0000 Subject: [PATCH] Add weekly PR statistics GitHub Action Implements a scheduled workflow that runs every Monday at 8am GMT to calculate and report PR statistics across the organization's repositories. Statistics reported: - Time to first review (median and 95th percentile) - PR lifespan from open to close/merge (median and 95th percentile) - Count of open PRs awaiting first review Results are sent to Slack via webhook (SLACK_PR_STATS_WEBHOOK_URL secret). --- .github/workflows/pr-statistics.yml | 55 ++++++ scripts/constants.js | 13 +- scripts/pr-statistics.js | 286 ++++++++++++++++++++++++++++ 3 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/pr-statistics.yml create mode 100644 scripts/pr-statistics.js diff --git a/.github/workflows/pr-statistics.yml b/.github/workflows/pr-statistics.yml new file mode 100644 index 0000000..cc80f7b --- /dev/null +++ b/.github/workflows/pr-statistics.yml @@ -0,0 +1,55 @@ +name: PR Statistics Report +run-name: Generate weekly PR statistics and send to Slack + +on: + schedule: + # Every Monday at 8am GMT + - cron: '0 8 * * 1' + workflow_dispatch: + # Allow manual triggering for testing + +jobs: + generate-pr-statistics: + runs-on: ubuntu-latest + steps: + - name: Generate App Token + id: generate-token + uses: tibdex/github-app-token@v2 + with: + app_id: ${{ secrets.LE_BOT_APP_ID }} + private_key: ${{ secrets.LE_BOT_PRIVATE_KEY }} + + - name: Checkout .github repository + uses: actions/checkout@v4 + with: + repository: learningequality/.github + ref: main + token: ${{ steps.generate-token.outputs.token }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: npm install + + - name: Generate PR statistics + id: stats + uses: actions/github-script@v7 + with: + github-token: ${{ steps.generate-token.outputs.token }} + script: | + const script = require('./scripts/pr-statistics.js'); + return await script({github, context, core}); + + - name: Send Slack notification + if: ${{ steps.stats.outputs.slack_message }} + uses: slackapi/slack-github-action@v2.0.0 + with: + webhook-type: incoming-webhook + webhook: ${{ secrets.SLACK_PR_STATS_WEBHOOK_URL }} + payload: | + { + "text": ${{ toJSON(steps.stats.outputs.slack_message) }} + } diff --git a/scripts/constants.js b/scripts/constants.js index a99af51..bccaecd 100644 --- a/scripts/constants.js +++ b/scripts/constants.js @@ -84,7 +84,17 @@ const BOT_MESSAGE_ALREADY_ASSIGNED = `Hi! πŸ‘‹ \n\n Thanks so much for your inte const BOT_MESSAGE_PULL_REQUEST = `πŸ‘‹ Thanks for contributing! \n\n We will assign a reviewer within the next two weeks. In the meantime, please ensure that:\n\n- [ ] **You ran \`pre-commit\` locally**\n- [ ] **All issue requirements are satisfied**\n- [ ] **The contribution is aligned with our [Contributing guidelines](https://learningequality.org/contributing-to-our-open-code-base). Pay extra attention to [Using generative AI](https://learningequality.org/contributing-to-our-open-code-base/#using-generative-ai). Pull requests that don't follow the guidelines will be closed.**\n\nWe'll be in touch! 😊`; -const HOLIDAY_MESSAGE = `Season’s greetings! πŸ‘‹ \n\n We’d like to thank everyone for another year of fruitful collaborations, engaging discussions, and for the continued support of our work. **Learning Equality will be on holidays from December 22 to January 5.** We look forward to much more in the new year and wish you a very happy holiday season!${GSOC_NOTE}`; +const HOLIDAY_MESSAGE = `Season's greetings! πŸ‘‹ \n\n We'd like to thank everyone for another year of fruitful collaborations, engaging discussions, and for the continued support of our work. **Learning Equality will be on holidays from December 22 to January 5.** We look forward to much more in the new year and wish you a very happy holiday season!${GSOC_NOTE}`; + +// Repositories to include in PR statistics reports +const PR_STATS_REPOS = [ + 'kolibri', + 'studio', + 'kolibri-design-system', + 'le-utils', + '.github', + 'ricecooker', +]; module.exports = { LE_BOT_USERNAME, @@ -98,4 +108,5 @@ module.exports = { BOT_MESSAGE_PULL_REQUEST, TEAMS_WITH_CLOSE_CONTRIBUTORS, HOLIDAY_MESSAGE, + PR_STATS_REPOS, }; diff --git a/scripts/pr-statistics.js b/scripts/pr-statistics.js new file mode 100644 index 0000000..7c8abd1 --- /dev/null +++ b/scripts/pr-statistics.js @@ -0,0 +1,286 @@ +const { PR_STATS_REPOS } = require('./constants'); + +const ORG = 'learningequality'; +const ROLLING_WINDOW_DAYS = 30; + +/** + * Calculate percentile value from a sorted array of numbers. + * Uses linear interpolation between closest ranks. + */ +function percentile(sortedArr, p) { + if (sortedArr.length === 0) return null; + if (sortedArr.length === 1) return sortedArr[0]; + + const index = (p / 100) * (sortedArr.length - 1); + const lower = Math.floor(index); + const upper = Math.ceil(index); + const weight = index - lower; + + if (upper >= sortedArr.length) return sortedArr[sortedArr.length - 1]; + return sortedArr[lower] * (1 - weight) + sortedArr[upper] * weight; +} + +/** + * Format milliseconds into human-readable duration. + */ +function formatDuration(ms) { + if (ms === null || ms === undefined) return 'N/A'; + + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + const remainingHours = hours % 24; + if (remainingHours > 0) { + return `${days}d ${remainingHours}h`; + } + return `${days}d`; + } + + if (hours > 0) { + const remainingMinutes = minutes % 60; + if (remainingMinutes > 0) { + return `${hours}h ${remainingMinutes}m`; + } + return `${hours}h`; + } + + if (minutes > 0) { + return `${minutes}m`; + } + + return '<1m'; +} + +/** + * Fetch all PRs for a repository updated within the rolling window. + */ +async function fetchPRsForRepo(github, owner, repo, sinceDate) { + const prs = []; + let page = 1; + const perPage = 100; + + // eslint-disable-next-line no-constant-condition + while (true) { + try { + const response = await github.rest.pulls.list({ + owner, + repo, + state: 'all', + sort: 'updated', + direction: 'desc', + per_page: perPage, + page, + }); + + if (response.data.length === 0) break; + + // Filter PRs updated within the rolling window + const relevantPRs = response.data.filter(pr => new Date(pr.updated_at) >= sinceDate); + + prs.push(...relevantPRs); + + // If we got fewer PRs than requested or all remaining PRs are outside window, stop + if (response.data.length < perPage) break; + + // If the last PR in this page is outside our window, we can stop + const lastPR = response.data[response.data.length - 1]; + if (new Date(lastPR.updated_at) < sinceDate) break; + + page++; + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Error fetching PRs for ${owner}/${repo} page ${page}:`, error.message); + break; + } + } + + return prs; +} + +/** + * Fetch reviews for a PR and return the first review timestamp. + */ +async function getFirstReviewTime(github, owner, repo, pullNumber) { + try { + const response = await github.rest.pulls.listReviews({ + owner, + repo, + pull_number: pullNumber, + per_page: 100, + }); + + if (response.data.length === 0) return null; + + // Find the earliest review + const reviewTimes = response.data + .filter(review => review.submitted_at) + .map(review => new Date(review.submitted_at).getTime()); + + if (reviewTimes.length === 0) return null; + + return Math.min(...reviewTimes); + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Error fetching reviews for ${owner}/${repo}#${pullNumber}:`, error.message); + return null; + } +} + +/** + * Main function to calculate PR statistics. + */ +module.exports = async ({ github, core }) => { + const now = new Date(); + const sinceDate = new Date(now.getTime() - ROLLING_WINDOW_DAYS * 24 * 60 * 60 * 1000); + + // eslint-disable-next-line no-console + console.log(`Calculating PR statistics for ${ROLLING_WINDOW_DAYS}-day rolling window`); + // eslint-disable-next-line no-console + console.log(`Since: ${sinceDate.toISOString()}`); + // eslint-disable-next-line no-console + console.log(`Repositories: ${PR_STATS_REPOS.join(', ')}`); + + // Collect all time-to-first-review values (in milliseconds) + const timeToFirstReviewValues = []; + + // Collect all PR lifespan values for closed/merged PRs (in milliseconds) + const lifespanValues = []; + + // Count open PRs without reviews + const openUnreviewedPRs = []; + + // Track totals for reporting + let totalPRsProcessed = 0; + let totalReviewedPRs = 0; + let totalClosedPRs = 0; + + for (const repo of PR_STATS_REPOS) { + // eslint-disable-next-line no-console + console.log(`\nProcessing ${ORG}/${repo}...`); + + const prs = await fetchPRsForRepo(github, ORG, repo, sinceDate); + // eslint-disable-next-line no-console + console.log(` Found ${prs.length} PRs updated in rolling window`); + + for (const pr of prs) { + totalPRsProcessed++; + const prCreatedAt = new Date(pr.created_at).getTime(); + + // Get first review time + const firstReviewTime = await getFirstReviewTime(github, ORG, repo, pr.number); + + if (pr.state === 'open') { + // Check if open PR has no reviews + if (firstReviewTime === null) { + openUnreviewedPRs.push({ + repo, + number: pr.number, + title: pr.title, + url: pr.html_url, + createdAt: pr.created_at, + }); + } else { + // Open PR with review - calculate time to first review + const timeToReview = firstReviewTime - prCreatedAt; + if (timeToReview >= 0) { + timeToFirstReviewValues.push(timeToReview); + totalReviewedPRs++; + } + } + } else { + // Closed or merged PR + const prClosedAt = new Date(pr.closed_at).getTime(); + const lifespan = prClosedAt - prCreatedAt; + + if (lifespan >= 0) { + lifespanValues.push(lifespan); + totalClosedPRs++; + } + + // If it had a review, calculate time to first review + if (firstReviewTime !== null) { + const timeToReview = firstReviewTime - prCreatedAt; + if (timeToReview >= 0) { + timeToFirstReviewValues.push(timeToReview); + totalReviewedPRs++; + } + } + } + } + } + + // Sort arrays for percentile calculations + timeToFirstReviewValues.sort((a, b) => a - b); + lifespanValues.sort((a, b) => a - b); + + // Calculate statistics + const timeToReviewMedian = percentile(timeToFirstReviewValues, 50); + const timeToReviewP95 = percentile(timeToFirstReviewValues, 95); + + const lifespanMedian = percentile(lifespanValues, 50); + const lifespanP95 = percentile(lifespanValues, 95); + + // eslint-disable-next-line no-console + console.log('\n--- Statistics ---'); + // eslint-disable-next-line no-console + console.log(`Total PRs processed: ${totalPRsProcessed}`); + // eslint-disable-next-line no-console + console.log(`Reviewed PRs: ${totalReviewedPRs}`); + // eslint-disable-next-line no-console + console.log(`Closed/Merged PRs: ${totalClosedPRs}`); + // eslint-disable-next-line no-console + console.log(`Open unreviewed PRs: ${openUnreviewedPRs.length}`); + // eslint-disable-next-line no-console + console.log(`Time to first review - Median: ${formatDuration(timeToReviewMedian)}, P95: ${formatDuration(timeToReviewP95)}`); + // eslint-disable-next-line no-console + console.log(`PR lifespan - Median: ${formatDuration(lifespanMedian)}, P95: ${formatDuration(lifespanP95)}`); + + // Format date for report + const reportDate = now.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + }); + + // Build Slack message + let slackMessage = `*Weekly PR Statistics Report*\n`; + slackMessage += `_${ROLLING_WINDOW_DAYS}-day rolling window | Generated ${reportDate}_\n\n`; + + slackMessage += `*Time to First Review*\n`; + if (timeToFirstReviewValues.length > 0) { + slackMessage += `Median: ${formatDuration(timeToReviewMedian)} | 95th percentile: ${formatDuration(timeToReviewP95)}\n`; + slackMessage += `_Based on ${totalReviewedPRs} reviewed PRs_\n\n`; + } else { + slackMessage += `_No reviewed PRs in this period_\n\n`; + } + + slackMessage += `*PR Lifespan (Open to Close/Merge)*\n`; + if (lifespanValues.length > 0) { + slackMessage += `Median: ${formatDuration(lifespanMedian)} | 95th percentile: ${formatDuration(lifespanP95)}\n`; + slackMessage += `_Based on ${totalClosedPRs} closed/merged PRs_\n\n`; + } else { + slackMessage += `_No closed PRs in this period_\n\n`; + } + + slackMessage += `*Open Unreviewed PRs*\n`; + if (openUnreviewedPRs.length > 0) { + slackMessage += `${openUnreviewedPRs.length} PR${openUnreviewedPRs.length === 1 ? '' : 's'} awaiting first review\n`; + } else { + slackMessage += `All open PRs have been reviewed\n`; + } + + slackMessage += `\n_Repos: ${PR_STATS_REPOS.join(', ')}_`; + + // Set outputs + core.setOutput('slack_message', slackMessage); + core.setOutput('total_prs', totalPRsProcessed); + core.setOutput('reviewed_prs', totalReviewedPRs); + core.setOutput('closed_prs', totalClosedPRs); + core.setOutput('open_unreviewed_prs', openUnreviewedPRs.length); + + return slackMessage; +};