feat: GitHub Actions code review metrics framework #2
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: PR Quality Checks | |
| on: | |
| pull_request: | |
| types: [opened, synchronize, reopened, ready_for_review] | |
| pull_request_review: | |
| types: [submitted] | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| concurrency: | |
| group: pr-quality-checks-${{ github.event.pull_request.number }} | |
| cancel-in-progress: true | |
| jobs: | |
| diff_size_check: | |
| # Fails if diff exceeds threshold. Skip PRs targeting the production branch. | |
| if: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref != 'production' }} | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Check diff size | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const pr = context.payload.pull_request; | |
| const totalLines = pr.additions + pr.deletions; | |
| const threshold = 500; | |
| if (totalLines >= threshold) { | |
| core.setFailed( | |
| `PR diff is ${totalLines} lines (${pr.additions} additions, ${pr.deletions} deletions). ` + | |
| `Consider splitting this PR into smaller, focused changes (recommended: < ${threshold} lines).` | |
| ); | |
| } else { | |
| core.notice( | |
| `PR diff is ${totalLines} lines — within the ${threshold}-line guideline.`, | |
| { title: 'Diff size OK' } | |
| ); | |
| } | |
| review_count_check: | |
| # Fails if PR lacks 2 human approvals. Skip PRs targeting the production branch. | |
| if: ${{ github.event.pull_request.base.ref != 'production' }} | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Check human approval count | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const pr = context.payload.pull_request; | |
| const prNumber = pr.number; | |
| const prAuthor = pr.user.login; | |
| const requiredApprovals = 2; | |
| // Accounts to exclude from reviewer metrics (bots + automation). | |
| const isBotAccount = (login) => { | |
| if (!login) return true; | |
| if (login.endsWith('[bot]')) return true; | |
| const knownBots = ['dependabot', 'github-actions', 'copilot', 'renovate', 'snyk-bot']; | |
| return knownBots.some((b) => login.toLowerCase().startsWith(b)); | |
| }; | |
| const reviews = await github.paginate(github.rest.pulls.listReviews, { | |
| owner, | |
| repo, | |
| pull_number: prNumber, | |
| per_page: 100, | |
| }); | |
| // Collect the most recent state per human reviewer (later reviews supersede earlier ones). | |
| const latestStateByReviewer = new Map(); | |
| for (const review of reviews) { | |
| const login = review.user?.login; | |
| if (!login || isBotAccount(login) || login === prAuthor) continue; | |
| // Only track meaningful states; ignore COMMENTED (not an approval/rejection). | |
| if (['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state)) { | |
| latestStateByReviewer.set(login, review.state); | |
| } | |
| } | |
| const approvers = [...latestStateByReviewer.entries()] | |
| .filter(([, state]) => state === 'APPROVED') | |
| .map(([login]) => login); | |
| const count = approvers.length; | |
| if (count < requiredApprovals) { | |
| const needed = requiredApprovals - count; | |
| const approverList = count > 0 ? ` Current approvers: ${approvers.join(', ')}.` : ''; | |
| core.setFailed( | |
| `This PR has ${count} of ${requiredApprovals} required human approvals.` + | |
| ` ${needed} more approval${needed > 1 ? 's' : ''} needed.${approverList}` | |
| ); | |
| } else { | |
| core.notice( | |
| `This PR has ${count} human approval${count > 1 ? 's' : ''} (${approvers.join(', ')}).`, | |
| { title: 'Approval count met' } | |
| ); | |
| } |