-
Notifications
You must be signed in to change notification settings - Fork 39
chore(dev): Add a script to analyze cross-team PR reviews #6919
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
3d8b42b to
af55e4a
Compare
| // Priority: draft > changesRequested > asked/commented > approved? | ||
| // User example: "_combined status: changesRequested" when one team is approved, one changesRequested. | ||
|
|
||
| let status = 'requested'; |
| ownerHandle, | ||
| teamMembersCache | ||
| ); | ||
| _isTeam = true; |
| start.setDate(start.getDate() + 1); | ||
| start.setHours(0, 0, 0, 0); | ||
| // Safety: don't pass end | ||
| if (start > end) return 0; |
af55e4a to
8fd4f46
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR introduces a comprehensive suite of analysis scripts to measure and report on pull request review times and cross-team collaboration patterns. The tooling is designed to fetch PR data from GitHub, calculate business-day-adjusted review durations, and generate aggregated reports across teams and confidence levels.
- Implements a modular architecture with separate concerns for data fetching, analysis, storage, and reporting
- Adds business hours calculation that excludes weekends and holidays for more accurate metrics
- Includes a GitHub Actions workflow to automatically label PRs requiring cross-team reviews
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 17 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/reports/pr-review-times/storage.mjs | Implements local filesystem caching for PR data with incremental update support |
| scripts/reports/pr-review-times/stats.mjs | Legacy script for fetching and analyzing PRs (appears to be superseded by newer modular approach) |
| scripts/reports/pr-review-times/run.sh | Shell script to orchestrate the fetch and report generation workflow |
| scripts/reports/pr-review-times/list-prs.mjs | GitHub API search wrapper for querying PRs |
| scripts/reports/pr-review-times/index.mjs | CLI entry point orchestrating fetch, report, and backfill commands |
| scripts/reports/pr-review-times/holidays.mjs | Business hours calculation logic excluding weekends and North American holidays |
| scripts/reports/pr-review-times/fetch-pr.mjs | Core PR analysis logic to calculate review durations and identify responsible teams |
| scripts/reports/pr-review-times/data/.keep | Placeholder for local data cache directory |
| scripts/reports/pr-review-times/constants.mjs | Shared configuration constants (labels, repo info, output paths) |
| scripts/reports/pr-review-times/codeowners.mjs | CODEOWNERS file parser with glob-to-regex conversion |
| scripts/reports/pr-review-times/backfill-label.mjs | Utility to retroactively add cross-team labels to existing PRs |
| scripts/reports/pr-review-times/aggregate.mjs | Aggregation engine that computes statistics and generates JSON/CSV reports |
| scripts/reports/pr-review-times/README.md | Comprehensive documentation of design decisions and usage |
| scripts/reports/pr-review-times/.gitignore | Excludes generated JSON and CSV files from version control |
| .github/workflows/label-cross-team.yml | Automated workflow to label PRs with multiple team reviewers |
| .cspell.json | Adds 'dxui' to the spell-check dictionary |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Try searching up from this script location | ||
| // This script is at scripts/reports/pr-review-times/ | ||
| // So root is ../../../ | ||
| const __dirname = path.dirname(new URL(import.meta.url).pathname); |
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On Windows systems, new URL(import.meta.url).pathname will return a path starting with a slash followed by the drive letter (e.g., '/C:/Users/...'). This can cause issues with path.resolve. Consider using fileURLToPath from 'node:url' instead, as shown in storage.mjs lines 4-7.
| ? readyEvents.sort( | ||
| (a, b) => new Date(b.created_at) - new Date(a.created_at) | ||
| )[0].created_at |
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function sorts the 'readyEvents' array in place on lines 241-245, which mutates the original 'events' array passed as a parameter. While this might not cause issues in the current implementation, it's better practice to avoid mutating function parameters. Consider using '.slice()' or the spread operator before sorting.
| ? readyEvents.sort( | |
| (a, b) => new Date(b.created_at) - new Date(a.created_at) | |
| )[0].created_at | |
| ? [...readyEvents] | |
| .sort((a, b) => new Date(b.created_at) - new Date(a.created_at))[0] | |
| .created_at |
| async function analyzeTeamStat( | ||
| octokit, | ||
| ownerHandle, | ||
| pr, | ||
| events, | ||
| reviews, | ||
| teamMembersCache | ||
| ) { | ||
| // 1. Determine Reviewers | ||
| let potentialReviewers = []; | ||
| let _isTeam = false; | ||
| // Heuristic for teams vs users | ||
| if (ownerHandle.includes('/')) { | ||
| potentialReviewers = await getTeamMembers( | ||
| octokit, | ||
| ownerHandle, | ||
| teamMembersCache | ||
| ); | ||
| _isTeam = true; | ||
| } else { | ||
| potentialReviewers = [ownerHandle.replace('@', '')]; | ||
| } | ||
|
|
||
| // 2. Find Start Time: Earliest review_requested event for this team/user | ||
| // If PR was draft, we take the later of (Request Time, Ready For Review Time) | ||
| const requestEvents = events.filter((e) => e.event === 'review_requested'); | ||
| const filteredRequests = idxRequestEvents( | ||
| requestEvents, | ||
| ownerHandle, | ||
| potentialReviewers | ||
| ); | ||
|
|
||
| let startTime = null; | ||
| if (filteredRequests.length > 0) { | ||
| // Sort by earliest | ||
| filteredRequests.sort( | ||
| (a, b) => new Date(a.created_at) - new Date(b.created_at) | ||
| ); | ||
| const firstRequest = filteredRequests[0].created_at; | ||
|
|
||
| // Check Draft Status | ||
| const readyEvents = events.filter((e) => e.event === 'ready_for_review'); | ||
| if (pr.draft) { | ||
| // It is CURRENTLY draft. | ||
| // If filteredRequests exists, it means we requested review while in draft? | ||
| // Or before it went back to draft? | ||
| // User says: "draft: PR in draft, do not count review as started". | ||
| // If currently draft, result should be "not started"? | ||
| // But we need to return a structure. | ||
| return { | ||
| startTime: null, | ||
| endTime: null, | ||
| durationHours: null, | ||
| status: 'draft', | ||
| }; | ||
| } else { | ||
| // It is NOT currently draft. | ||
| // Did the request happen while it WAS draft? | ||
| // Simple heuristic: If request < last ready_for_review, use valid ready_for_review time. | ||
| const latestReady = | ||
| readyEvents.length > 0 | ||
| ? readyEvents.sort( | ||
| (a, b) => new Date(b.created_at) - new Date(a.created_at) | ||
| )[0].created_at | ||
| : null; | ||
|
|
||
| startTime = firstRequest; | ||
| if (latestReady && new Date(latestReady) > new Date(firstRequest)) { | ||
| startTime = latestReady; | ||
| } | ||
| } | ||
| } else { | ||
| // No explicit request found. | ||
| // If it's a code owner, maybe the 'labeled' event (legacy heuristic)? | ||
| // Or if PR is open and older than some date? | ||
| // Default: If no request event, not started. | ||
| // UNLESS we fallback to PR creation if map is empty? | ||
| // Let's return nulls if no request found. | ||
| return { | ||
| startTime: null, | ||
| endTime: null, | ||
| durationHours: null, | ||
| status: 'requested', | ||
| }; | ||
| } | ||
|
|
||
| // 3. Status and End Time | ||
| // Calculate Status first | ||
| const relevantReviews = reviews.filter( | ||
| (r) => | ||
| potentialReviewers.includes(r.user.login) && | ||
| new Date(r.submitted_at) > new Date(startTime) | ||
| ); | ||
| // Sort reviews chronologically | ||
| relevantReviews.sort( | ||
| (a, b) => new Date(a.submitted_at) - new Date(b.submitted_at) | ||
| ); | ||
|
|
||
| // Latest review per user | ||
| const latestByUser = new Map(); | ||
| for (const r of relevantReviews) { | ||
| latestByUser.set(r.user.login, r); | ||
| } | ||
| const finalReviews = Array.from(latestByUser.values()); | ||
|
|
||
| let status = 'requested'; | ||
| let endTime = null; | ||
|
|
||
| const hasChangesRequested = finalReviews.some( | ||
| (r) => r.state === 'CHANGES_REQUESTED' | ||
| ); | ||
| const hasApproval = finalReviews.some((r) => r.state === 'APPROVED'); | ||
| const hasComment = finalReviews.some((r) => r.state === 'COMMENTED'); | ||
|
|
||
| // End Time Determination: | ||
| // If Approved: End Time is the timestamp of the *latest* necessary approval? | ||
| // For a Team, ONE approval is usually enough. | ||
| // So find the FIRST approval that happened? | ||
|
|
||
| if (hasChangesRequested) { | ||
| status = 'changesRequested'; | ||
| endTime = null; // Blocked | ||
| } else if (hasApproval) { | ||
| status = 'approved'; | ||
| // Find the first approval in the list | ||
| const approvals = relevantReviews.filter((r) => r.state === 'APPROVED'); | ||
| if (approvals.length > 0) { | ||
| endTime = approvals[0].submitted_at; | ||
| } | ||
| } else if (hasComment) { | ||
| status = 'commented'; | ||
| endTime = null; | ||
| } | ||
|
|
||
| // 4. Duration | ||
| let durationHours = null; | ||
| if (startTime) { | ||
| const start = new Date(startTime); | ||
| const end = endTime ? new Date(endTime) : new Date(); | ||
| const diffMs = end - start; | ||
| durationHours = diffMs / (1000 * 60 * 60); | ||
| } | ||
|
|
||
| return { | ||
| startTime, | ||
| endTime, | ||
| durationHours, | ||
| status, | ||
| }; | ||
| } |
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's duplicate logic between this function in stats.mjs and analyzeTeamStat in fetch-pr.mjs. The functions have nearly identical implementations but exist in different files. Consider extracting this shared logic into a common module to improve maintainability and reduce code duplication.
| - `high-confidence`: The system is blocking from merging due to CODEOWNERS, but the author rather confident about the change and thinks they should be able to merge without an additional review. | ||
| - `low-confidence`: The author is not that confident and actually wants another team to review the change before merging. |
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The description for 'high-confidence' on line 42 appears inverted. It states the author is "rather confident" and "should be able to merge without an additional review", but 'high-confidence' typically means high confidence in needing a review. Consider clarifying this terminology or swapping the descriptions for 'high-confidence' and 'low-confidence' to match conventional expectations.
| - `high-confidence`: The system is blocking from merging due to CODEOWNERS, but the author rather confident about the change and thinks they should be able to merge without an additional review. | |
| - `low-confidence`: The author is not that confident and actually wants another team to review the change before merging. | |
| - `high-confidence`: The system is blocking from merging due to CODEOWNERS, and the author is not that confident and actually wants another team to review the change before merging. | |
| - `low-confidence`: The author is rather confident about the change and thinks they should be able to merge without an additional review. |
|
|
||
| const p95Index = Math.floor(values.length * 0.95); | ||
| const p95 = sorted[p95Index]; | ||
|
|
||
| return { | ||
| count: values.length, | ||
| avg: parseFloat(avg.toFixed(2)), |
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The 'avg' calculation on line 193 uses a simple arithmetic mean which can be heavily skewed by outliers. For duration metrics, consider also calculating or primarily using the median, which provides a more robust measure of central tendency for distributions that may have extreme values.
| const p95Index = Math.floor(values.length * 0.95); | |
| const p95 = sorted[p95Index]; | |
| return { | |
| count: values.length, | |
| avg: parseFloat(avg.toFixed(2)), | |
| const midIndex = Math.floor(sorted.length / 2); | |
| const median = | |
| sorted.length % 2 === 0 | |
| ? (sorted[midIndex - 1] + sorted[midIndex]) / 2 | |
| : sorted[midIndex]; | |
| const p95Index = Math.floor(values.length * 0.95); | |
| const p95 = sorted[p95Index]; | |
| return { | |
| count: values.length, | |
| avg: parseFloat(avg.toFixed(2)), | |
| median: parseFloat(median.toFixed(2)), |
| } | ||
| } catch (error) { | ||
| console.error('Error in label-cross-team action:', error); | ||
| core.setFailed(error.message); |
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The 'core' object is referenced on line 53 but is never imported or defined in the script. This will cause a ReferenceError at runtime if an error occurs. The correct usage would be to throw the error or simply log it, since 'core.setFailed' is not available in this context.
| core.setFailed(error.message); | |
| throw error; |
|
|
||
| // Calculate Status | ||
| const isMerged = events.some((e) => e.event === 'merged'); | ||
| let status = 'unknown'; |
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The initial value of status is unused, since it is always overwritten.
| let status = 'unknown'; | |
| let status; |
| // Priority: draft > changesRequested > asked/commented > approved? | ||
| // User example: "_combined status: changesRequested" when one team is approved, one changesRequested. | ||
|
|
||
| let status = 'requested'; |
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The initial value of status is unused, since it is always overwritten.
| let status = 'requested'; | |
| let status; |
| ownerHandle, | ||
| teamMembersCache | ||
| ); | ||
| _isTeam = true; |
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The value assigned to _isTeam here is unused.
| start.setDate(start.getDate() + 1); | ||
| start.setHours(0, 0, 0, 0); | ||
| // Safety: don't pass end | ||
| if (start > end) return 0; |
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The condition 'start > end' is always false.
8fd4f46 to
878ccee
Compare
https://coveord.atlassian.net/browse/KIT-5371
See the README.md for detailed explanation.
Disclaimer: 99% is AI-generated.
I took a general look at the code to make sure it wasn't doing anything dangerous, especially with the API calls, but otherwise, I mainly tested it.
This is not intended to be long-lived or be related to production in any way, so I don't believe high scrutiny is necessary.