Skip to content

Commit d1cf09e

Browse files
authored
Merge pull request #40 from learningequality/claude/github-action-pr-stats-4VFnD
Add weekly PR statistics GitHub Action
2 parents 1c84762 + 52ed54f commit d1cf09e

File tree

3 files changed

+353
-1
lines changed

3 files changed

+353
-1
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
name: PR Statistics Report
2+
run-name: Generate weekly PR statistics and send to Slack
3+
4+
on:
5+
schedule:
6+
# Every Monday at 8am GMT
7+
- cron: '0 8 * * 1'
8+
workflow_dispatch:
9+
# Allow manual triggering for testing
10+
11+
jobs:
12+
generate-pr-statistics:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- name: Generate App Token
16+
id: generate-token
17+
uses: tibdex/github-app-token@v2
18+
with:
19+
app_id: ${{ secrets.LE_BOT_APP_ID }}
20+
private_key: ${{ secrets.LE_BOT_PRIVATE_KEY }}
21+
22+
- name: Checkout .github repository
23+
uses: actions/checkout@v4
24+
with:
25+
repository: learningequality/.github
26+
ref: main
27+
token: ${{ steps.generate-token.outputs.token }}
28+
29+
- name: Setup Node.js
30+
uses: actions/setup-node@v4
31+
with:
32+
node-version: '20'
33+
34+
- name: Install dependencies
35+
run: npm install
36+
37+
- name: Generate PR statistics
38+
id: stats
39+
uses: actions/github-script@v7
40+
with:
41+
github-token: ${{ steps.generate-token.outputs.token }}
42+
script: |
43+
const script = require('./scripts/pr-statistics.js');
44+
return await script({github, context, core});
45+
46+
- name: Send Slack notification
47+
if: ${{ steps.stats.outputs.slack_message }}
48+
uses: slackapi/[email protected]
49+
with:
50+
webhook-type: incoming-webhook
51+
webhook: ${{ secrets.SLACK_PR_STATS_WEBHOOK_URL }}
52+
payload: |
53+
{
54+
"text": ${{ toJSON(steps.stats.outputs.slack_message) }}
55+
}

scripts/constants.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,17 @@ const BOT_MESSAGE_ALREADY_ASSIGNED = `Hi! 👋 \n\n Thanks so much for your inte
8484

8585
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! 😊`;
8686

87-
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}`;
87+
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}`;
88+
89+
// Repositories to include in PR statistics reports
90+
const PR_STATS_REPOS = [
91+
'kolibri',
92+
'studio',
93+
'kolibri-design-system',
94+
'le-utils',
95+
'.github',
96+
'ricecooker',
97+
];
8898

8999
module.exports = {
90100
LE_BOT_USERNAME,
@@ -98,4 +108,5 @@ module.exports = {
98108
BOT_MESSAGE_PULL_REQUEST,
99109
TEAMS_WITH_CLOSE_CONTRIBUTORS,
100110
HOLIDAY_MESSAGE,
111+
PR_STATS_REPOS,
101112
};

scripts/pr-statistics.js

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
const { PR_STATS_REPOS } = require('./constants');
2+
3+
const ORG = 'learningequality';
4+
const ROLLING_WINDOW_DAYS = 30;
5+
6+
/**
7+
* Calculate percentile value from a sorted array of numbers.
8+
* Uses linear interpolation between closest ranks.
9+
*/
10+
function percentile(sortedArr, p) {
11+
if (sortedArr.length === 0) return null;
12+
if (sortedArr.length === 1) return sortedArr[0];
13+
14+
const index = (p / 100) * (sortedArr.length - 1);
15+
const lower = Math.floor(index);
16+
const upper = Math.ceil(index);
17+
const weight = index - lower;
18+
19+
if (upper >= sortedArr.length) return sortedArr[sortedArr.length - 1];
20+
return sortedArr[lower] * (1 - weight) + sortedArr[upper] * weight;
21+
}
22+
23+
/**
24+
* Format milliseconds into human-readable duration.
25+
*/
26+
function formatDuration(ms) {
27+
if (ms === null || ms === undefined) return 'N/A';
28+
29+
const seconds = Math.floor(ms / 1000);
30+
const minutes = Math.floor(seconds / 60);
31+
const hours = Math.floor(minutes / 60);
32+
const days = Math.floor(hours / 24);
33+
34+
if (days > 0) {
35+
const remainingHours = hours % 24;
36+
if (remainingHours > 0) {
37+
return `${days}d ${remainingHours}h`;
38+
}
39+
return `${days}d`;
40+
}
41+
42+
if (hours > 0) {
43+
const remainingMinutes = minutes % 60;
44+
if (remainingMinutes > 0) {
45+
return `${hours}h ${remainingMinutes}m`;
46+
}
47+
return `${hours}h`;
48+
}
49+
50+
if (minutes > 0) {
51+
return `${minutes}m`;
52+
}
53+
54+
return '<1m';
55+
}
56+
57+
/**
58+
* Fetch all PRs for a repository updated within the rolling window.
59+
*/
60+
async function fetchPRsForRepo(github, owner, repo, sinceDate) {
61+
const prs = [];
62+
let page = 1;
63+
const perPage = 100;
64+
65+
// eslint-disable-next-line no-constant-condition
66+
while (true) {
67+
try {
68+
const response = await github.rest.pulls.list({
69+
owner,
70+
repo,
71+
state: 'all',
72+
sort: 'updated',
73+
direction: 'desc',
74+
per_page: perPage,
75+
page,
76+
});
77+
78+
if (response.data.length === 0) break;
79+
80+
// Filter PRs updated within the rolling window
81+
const relevantPRs = response.data.filter(pr => new Date(pr.updated_at) >= sinceDate);
82+
83+
prs.push(...relevantPRs);
84+
85+
// If we got fewer PRs than requested or all remaining PRs are outside window, stop
86+
if (response.data.length < perPage) break;
87+
88+
// If the last PR in this page is outside our window, we can stop
89+
const lastPR = response.data[response.data.length - 1];
90+
if (new Date(lastPR.updated_at) < sinceDate) break;
91+
92+
page++;
93+
} catch (error) {
94+
// eslint-disable-next-line no-console
95+
console.error(`Error fetching PRs for ${owner}/${repo} page ${page}:`, error.message);
96+
break;
97+
}
98+
}
99+
100+
return prs;
101+
}
102+
103+
/**
104+
* Fetch reviews for a PR and return the first review timestamp.
105+
*/
106+
async function getFirstReviewTime(github, owner, repo, pullNumber) {
107+
try {
108+
const response = await github.rest.pulls.listReviews({
109+
owner,
110+
repo,
111+
pull_number: pullNumber,
112+
per_page: 100,
113+
});
114+
115+
if (response.data.length === 0) return null;
116+
117+
// Find the earliest review
118+
const reviewTimes = response.data
119+
.filter(review => review.submitted_at)
120+
.map(review => new Date(review.submitted_at).getTime());
121+
122+
if (reviewTimes.length === 0) return null;
123+
124+
return Math.min(...reviewTimes);
125+
} catch (error) {
126+
// eslint-disable-next-line no-console
127+
console.error(`Error fetching reviews for ${owner}/${repo}#${pullNumber}:`, error.message);
128+
return null;
129+
}
130+
}
131+
132+
/**
133+
* Main function to calculate PR statistics.
134+
*/
135+
module.exports = async ({ github, core }) => {
136+
const now = new Date();
137+
const sinceDate = new Date(now.getTime() - ROLLING_WINDOW_DAYS * 24 * 60 * 60 * 1000);
138+
139+
// eslint-disable-next-line no-console
140+
console.log(`Calculating PR statistics for ${ROLLING_WINDOW_DAYS}-day rolling window`);
141+
// eslint-disable-next-line no-console
142+
console.log(`Since: ${sinceDate.toISOString()}`);
143+
// eslint-disable-next-line no-console
144+
console.log(`Repositories: ${PR_STATS_REPOS.join(', ')}`);
145+
146+
// Collect all time-to-first-review values (in milliseconds)
147+
const timeToFirstReviewValues = [];
148+
149+
// Collect all PR lifespan values for closed/merged PRs (in milliseconds)
150+
const lifespanValues = [];
151+
152+
// Count open PRs without reviews
153+
const openUnreviewedPRs = [];
154+
155+
// Track totals for reporting
156+
let totalPRsProcessed = 0;
157+
let totalReviewedPRs = 0;
158+
let totalClosedPRs = 0;
159+
160+
for (const repo of PR_STATS_REPOS) {
161+
// eslint-disable-next-line no-console
162+
console.log(`\nProcessing ${ORG}/${repo}...`);
163+
164+
const prs = await fetchPRsForRepo(github, ORG, repo, sinceDate);
165+
// eslint-disable-next-line no-console
166+
console.log(` Found ${prs.length} PRs updated in rolling window`);
167+
168+
for (const pr of prs) {
169+
totalPRsProcessed++;
170+
const prCreatedAt = new Date(pr.created_at).getTime();
171+
172+
// Get first review time
173+
const firstReviewTime = await getFirstReviewTime(github, ORG, repo, pr.number);
174+
175+
if (pr.state === 'open') {
176+
// Check if open PR has no reviews
177+
if (firstReviewTime === null) {
178+
openUnreviewedPRs.push({
179+
repo,
180+
number: pr.number,
181+
title: pr.title,
182+
url: pr.html_url,
183+
createdAt: pr.created_at,
184+
});
185+
} else {
186+
// Open PR with review - calculate time to first review
187+
const timeToReview = firstReviewTime - prCreatedAt;
188+
if (timeToReview >= 0) {
189+
timeToFirstReviewValues.push(timeToReview);
190+
totalReviewedPRs++;
191+
}
192+
}
193+
} else {
194+
// Closed or merged PR
195+
const prClosedAt = new Date(pr.closed_at).getTime();
196+
const lifespan = prClosedAt - prCreatedAt;
197+
198+
if (lifespan >= 0) {
199+
lifespanValues.push(lifespan);
200+
totalClosedPRs++;
201+
}
202+
203+
// If it had a review, calculate time to first review
204+
if (firstReviewTime !== null) {
205+
const timeToReview = firstReviewTime - prCreatedAt;
206+
if (timeToReview >= 0) {
207+
timeToFirstReviewValues.push(timeToReview);
208+
totalReviewedPRs++;
209+
}
210+
}
211+
}
212+
}
213+
}
214+
215+
// Sort arrays for percentile calculations
216+
timeToFirstReviewValues.sort((a, b) => a - b);
217+
lifespanValues.sort((a, b) => a - b);
218+
219+
// Calculate statistics
220+
const timeToReviewMedian = percentile(timeToFirstReviewValues, 50);
221+
const timeToReviewP95 = percentile(timeToFirstReviewValues, 95);
222+
223+
const lifespanMedian = percentile(lifespanValues, 50);
224+
const lifespanP95 = percentile(lifespanValues, 95);
225+
226+
// eslint-disable-next-line no-console
227+
console.log('\n--- Statistics ---');
228+
// eslint-disable-next-line no-console
229+
console.log(`Total PRs processed: ${totalPRsProcessed}`);
230+
// eslint-disable-next-line no-console
231+
console.log(`Reviewed PRs: ${totalReviewedPRs}`);
232+
// eslint-disable-next-line no-console
233+
console.log(`Closed/Merged PRs: ${totalClosedPRs}`);
234+
// eslint-disable-next-line no-console
235+
console.log(`Open unreviewed PRs: ${openUnreviewedPRs.length}`);
236+
// eslint-disable-next-line no-console
237+
console.log(`Time to first review - Median: ${formatDuration(timeToReviewMedian)}, P95: ${formatDuration(timeToReviewP95)}`);
238+
// eslint-disable-next-line no-console
239+
console.log(`PR lifespan - Median: ${formatDuration(lifespanMedian)}, P95: ${formatDuration(lifespanP95)}`);
240+
241+
// Format date for report
242+
const reportDate = now.toLocaleDateString('en-US', {
243+
weekday: 'short',
244+
month: 'short',
245+
day: 'numeric',
246+
year: 'numeric',
247+
});
248+
249+
// Build Slack message
250+
let slackMessage = `*Weekly PR Statistics Report*\n`;
251+
slackMessage += `_${ROLLING_WINDOW_DAYS}-day rolling window | Generated ${reportDate}_\n\n`;
252+
253+
slackMessage += `*Time to First Review*\n`;
254+
if (timeToFirstReviewValues.length > 0) {
255+
slackMessage += `Median: ${formatDuration(timeToReviewMedian)} | 95th percentile: ${formatDuration(timeToReviewP95)}\n`;
256+
slackMessage += `_Based on ${totalReviewedPRs} reviewed PRs_\n\n`;
257+
} else {
258+
slackMessage += `_No reviewed PRs in this period_\n\n`;
259+
}
260+
261+
slackMessage += `*PR Lifespan (Open to Close/Merge)*\n`;
262+
if (lifespanValues.length > 0) {
263+
slackMessage += `Median: ${formatDuration(lifespanMedian)} | 95th percentile: ${formatDuration(lifespanP95)}\n`;
264+
slackMessage += `_Based on ${totalClosedPRs} closed/merged PRs_\n\n`;
265+
} else {
266+
slackMessage += `_No closed PRs in this period_\n\n`;
267+
}
268+
269+
slackMessage += `*Open Unreviewed PRs*\n`;
270+
if (openUnreviewedPRs.length > 0) {
271+
slackMessage += `${openUnreviewedPRs.length} PR${openUnreviewedPRs.length === 1 ? '' : 's'} awaiting first review\n`;
272+
} else {
273+
slackMessage += `All open PRs have been reviewed\n`;
274+
}
275+
276+
slackMessage += `\n_Repos: ${PR_STATS_REPOS.join(', ')}_`;
277+
278+
// Set outputs
279+
core.setOutput('slack_message', slackMessage);
280+
core.setOutput('total_prs', totalPRsProcessed);
281+
core.setOutput('reviewed_prs', totalReviewedPRs);
282+
core.setOutput('closed_prs', totalClosedPRs);
283+
core.setOutput('open_unreviewed_prs', openUnreviewedPRs.length);
284+
285+
return slackMessage;
286+
};

0 commit comments

Comments
 (0)