Skip to content

feat: GitHub Actions code review metrics framework #2

feat: GitHub Actions code review metrics framework

feat: GitHub Actions code review metrics framework #2

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' }
);
}