name: PR Size Check on: pull_request: types: [opened, synchronize, reopened] permissions: contents: read pull-requests: write jobs: check-pr-size: runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Harden the runner uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 - name: Check PR size id: check-size run: | set -euo pipefail # Fetch the base branch git fetch origin "${{ github.base_ref }}" # Get diff stats diff_output=$(git diff --numstat "origin/${{ github.base_ref }}"...HEAD) # Count lines, excluding: # - Test files (*.test.ts, *.spec.tsx, etc.) # - Locale files (locales/*.json, i18n/*.json) # - Lock files (pnpm-lock.yaml, package-lock.json, yarn.lock) # - Generated files (dist/, coverage/, build/, .next/) # - Storybook stories (*.stories.tsx) total_additions=0 total_deletions=0 counted_files=0 excluded_files=0 while IFS=$'\t' read -r additions deletions file; do # Skip if additions or deletions are "-" (binary files) if [ "$additions" = "-" ] || [ "$deletions" = "-" ]; then continue fi # Check if file should be excluded case "$file" in *.test.ts|*.test.tsx|*.spec.ts|*.spec.tsx|*.test.js|*.test.jsx|*.spec.js|*.spec.jsx) excluded_files=$((excluded_files + 1)) continue ;; */locales/*.json|*/i18n/*.json) excluded_files=$((excluded_files + 1)) continue ;; pnpm-lock.yaml|package-lock.json|yarn.lock) excluded_files=$((excluded_files + 1)) continue ;; dist/*|coverage/*|build/*|node_modules/*|test-results/*|playwright-report/*|.next/*|*.tsbuildinfo) excluded_files=$((excluded_files + 1)) continue ;; *.stories.ts|*.stories.tsx|*.stories.js|*.stories.jsx) excluded_files=$((excluded_files + 1)) continue ;; esac total_additions=$((total_additions + additions)) total_deletions=$((total_deletions + deletions)) counted_files=$((counted_files + 1)) done <> "${GITHUB_OUTPUT}" echo "excluded_files=${excluded_files}" >> "${GITHUB_OUTPUT}" echo "total_additions=${total_additions}" >> "${GITHUB_OUTPUT}" echo "total_deletions=${total_deletions}" >> "${GITHUB_OUTPUT}" echo "total_changes=${total_changes}" >> "${GITHUB_OUTPUT}" # Set flag if PR is too large (> 800 lines) if [ ${total_changes} -gt 800 ]; then echo "is_too_large=true" >> "${GITHUB_OUTPUT}" else echo "is_too_large=false" >> "${GITHUB_OUTPUT}" fi - name: Comment on PR if too large if: steps.check-size.outputs.is_too_large == 'true' uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const totalChanges = ${{ steps.check-size.outputs.total_changes }}; const countedFiles = ${{ steps.check-size.outputs.counted_files }}; const excludedFiles = ${{ steps.check-size.outputs.excluded_files }}; const additions = ${{ steps.check-size.outputs.total_additions }}; const deletions = ${{ steps.check-size.outputs.total_deletions }}; const body = '## 🚨 PR Size Warning\n\n' + 'This PR has approximately **' + totalChanges + ' lines** of changes (' + additions + ' additions, ' + deletions + ' deletions across ' + countedFiles + ' files).\n\n' + 'Large PRs (>800 lines) are significantly harder to review and increase the chance of merge conflicts. Consider splitting this into smaller, self-contained PRs.\n\n' + '### 💡 Suggestions:\n' + '- **Split by feature or module** - Break down into logical, independent pieces\n' + '- **Create a sequence of PRs** - Each building on the previous one\n' + '- **Branch off PR branches** - Don\'t wait for reviews to continue dependent work\n\n' + '### 📊 What was counted:\n' + '- ✅ Source files, stylesheets, configuration files\n' + '- ❌ Excluded ' + excludedFiles + ' files (tests, locales, locks, generated files)\n\n' + '### 📚 Guidelines:\n' + '- **Ideal:** 300-500 lines per PR\n' + '- **Warning:** 500-800 lines\n' + '- **Critical:** 800+ lines ⚠️\n\n' + 'If this large PR is unavoidable (e.g., migration, dependency update, major refactor), please explain in the PR description why it couldn\'t be split.'; // Check if we already commented const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, }); const botComment = comments.find(comment => comment.user.type === 'Bot' && comment.body.includes('🚨 PR Size Warning') ); if (botComment) { // Update existing comment await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: botComment.id, body: body }); } else { // Create new comment await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body: body }); }