pr-size-check.yml

  1# PR Size Check — Compute
  2#
  3# Calculates PR size and saves the result as an artifact. A companion
  4# workflow (pr-size-label.yml) picks up the artifact via workflow_run
  5# and applies labels + comments with write permissions.
  6#
  7# This two-workflow split is required because fork PRs receive a
  8# read-only GITHUB_TOKEN. The compute step needs no write access;
  9# the label/comment step runs via workflow_run on the base repo with
 10# full write permissions.
 11#
 12# Security note: This workflow only reads PR file data via the JS API
 13# and writes a JSON artifact. No untrusted input is interpolated into
 14# shell commands.
 15
 16name: PR Size Check
 17
 18on:
 19  pull_request:
 20    types: [opened, synchronize]
 21
 22permissions:
 23  contents: read
 24  pull-requests: read
 25
 26jobs:
 27  compute-size:
 28    if: github.repository_owner == 'zed-industries'
 29    runs-on: ubuntu-latest
 30    timeout-minutes: 5
 31    steps:
 32      - name: Calculate PR size
 33        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
 34        with:
 35          script: |
 36            const fs = require('fs');
 37
 38            const { data: files } = await github.rest.pulls.listFiles({
 39              owner: context.repo.owner,
 40              repo: context.repo.repo,
 41              pull_number: context.issue.number,
 42              per_page: 300,
 43            });
 44
 45            // Sum additions + deletions, excluding generated/lock files
 46            const IGNORED_PATTERNS = [
 47              /\.lock$/,
 48              /^Cargo\.lock$/,
 49              /pnpm-lock\.yaml$/,
 50              /\.generated\./,
 51              /\/fixtures\//,
 52              /\/snapshots\//,
 53            ];
 54
 55            let totalChanges = 0;
 56            for (const file of files) {
 57              const ignored = IGNORED_PATTERNS.some(p => p.test(file.filename));
 58              if (!ignored) {
 59                totalChanges += file.additions + file.deletions;
 60              }
 61            }
 62
 63            // Assign size bracket
 64            const SIZE_BRACKETS = [
 65              ['Size S',  0,       100,  '0e8a16'],
 66              ['Size M',  100,     400,  'fbca04'],
 67              ['Size L',  400,     800,  'e99695'],
 68              ['Size XL', 800, Infinity, 'b60205'],
 69            ];
 70
 71            let sizeLabel = 'Size S';
 72            let labelColor = '0e8a16';
 73            for (const [label, min, max, color] of SIZE_BRACKETS) {
 74              if (totalChanges >= min && totalChanges < max) {
 75                sizeLabel = label;
 76                labelColor = color;
 77                break;
 78              }
 79            }
 80
 81            // Check if the author wrote content in the "How to Review" section.
 82            const rawBody = context.payload.pull_request.body || '';
 83            const howToReview = rawBody.match(/## How to Review\s*\n([\s\S]*?)(?=\n## |$)/i);
 84            const hasReviewGuidance = howToReview
 85              ? howToReview[1].replace(/<!--[\s\S]*?-->/g, '').trim().length > 0
 86              : false;
 87
 88            const result = {
 89              pr_number: context.issue.number,
 90              total_changes: totalChanges,
 91              size_label: sizeLabel,
 92              label_color: labelColor,
 93              has_review_guidance: hasReviewGuidance,
 94            };
 95
 96            console.log(`PR #${result.pr_number}: ${totalChanges} LOC, ${sizeLabel}`);
 97
 98            fs.mkdirSync('pr-size', { recursive: true });
 99            fs.writeFileSync('pr-size/result.json', JSON.stringify(result));
100
101      - name: Upload size result
102        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
103        with:
104          name: pr-size-result
105          path: pr-size/
106          retention-days: 1
107defaults:
108  run:
109    shell: bash -euxo pipefail {0}