pr-size-check.yml

  1# PR Size Check
  2#
  3# Comments on PRs that exceed the 400 LOC soft limit with a friendly reminder
  4# to consider splitting. Does NOT block the PR — advisory only.
  5# Also adds size labels (size/S, size/M, size/L, size/XL) for tracking.
  6#
  7# Security note: Uses actions/github-script (JavaScript API) — no shell
  8# interpolation of untrusted input. PR body is accessed via the JS API,
  9# not via expression interpolation in run: blocks.
 10
 11name: PR Size Check
 12
 13on:
 14  pull_request:
 15    types: [opened, synchronize]
 16
 17jobs:
 18  check-size:
 19    if: github.repository_owner == 'zed-industries'
 20    permissions:
 21      contents: read
 22      pull-requests: write  # PR comments
 23      issues: write         # label management (GitHub routes labels through Issues API)
 24    runs-on: ubuntu-latest
 25    timeout-minutes: 5
 26    steps:
 27      - name: Calculate PR size and label
 28        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
 29        with:
 30          script: |
 31            const { data: files } = await github.rest.pulls.listFiles({
 32              owner: context.repo.owner,
 33              repo: context.repo.repo,
 34              pull_number: context.issue.number,
 35              per_page: 300,
 36            });
 37
 38            // Sum additions + deletions, excluding generated/lock files
 39            const IGNORED_PATTERNS = [
 40              /\.lock$/,
 41              /^Cargo\.lock$/,
 42              /pnpm-lock\.yaml$/,
 43              /\.generated\./,
 44              /\/fixtures\//,
 45              /\/snapshots\//,
 46            ];
 47
 48            let totalChanges = 0;
 49            for (const file of files) {
 50              const ignored = IGNORED_PATTERNS.some(p => p.test(file.filename));
 51              if (!ignored) {
 52                totalChanges += file.additions + file.deletions;
 53              }
 54            }
 55
 56            // Assign size label
 57            const SIZE_BRACKETS = [
 58              ['size/S',  0,       100,  '0e8a16'],
 59              ['size/M',  100,     400,  'fbca04'],
 60              ['size/L',  400,     800,  'e99695'],
 61              ['size/XL', 800, Infinity, 'b60205'],
 62            ];
 63
 64            let sizeLabel = 'size/S';
 65            let labelColor = '0e8a16';
 66            for (const [label, min, max, color] of SIZE_BRACKETS) {
 67              if (totalChanges >= min && totalChanges < max) {
 68                sizeLabel = label;
 69                labelColor = color;
 70                break;
 71              }
 72            }
 73
 74            // Update size label only if the classification changed
 75            const existingLabels = (await github.rest.issues.listLabelsOnIssue({
 76              owner: context.repo.owner,
 77              repo: context.repo.repo,
 78              issue_number: context.issue.number,
 79            })).data.map(l => l.name);
 80
 81            const existingSizeLabels = existingLabels.filter(l => l.startsWith('size/'));
 82            const alreadyCorrect = existingSizeLabels.length === 1 && existingSizeLabels[0] === sizeLabel;
 83
 84            if (!alreadyCorrect) {
 85              for (const label of existingSizeLabels) {
 86                await github.rest.issues.removeLabel({
 87                  owner: context.repo.owner,
 88                  repo: context.repo.repo,
 89                  issue_number: context.issue.number,
 90                  name: label,
 91                });
 92              }
 93
 94              // Create the label if it doesn't exist (ignore 422 = already exists)
 95              try {
 96                await github.rest.issues.createLabel({
 97                  owner: context.repo.owner,
 98                  repo: context.repo.repo,
 99                  name: sizeLabel,
100                  color: labelColor,
101                });
102              } catch (e) {
103                if (e.status !== 422) throw e;
104              }
105
106              await github.rest.issues.addLabels({
107                owner: context.repo.owner,
108                repo: context.repo.repo,
109                issue_number: context.issue.number,
110                labels: [sizeLabel],
111              });
112            }
113
114            // For large PRs (400+ LOC): auto-apply large-pr label and comment once
115            if (totalChanges >= 400) {
116              // Auto-apply the large-pr label
117              if (!existingLabels.includes('large-pr')) {
118                try {
119                  await github.rest.issues.createLabel({
120                    owner: context.repo.owner,
121                    repo: context.repo.repo,
122                    name: 'large-pr',
123                    color: 'e99695',
124                  });
125                } catch (e) {
126                  if (e.status !== 422) throw e;
127                }
128
129                await github.rest.issues.addLabels({
130                  owner: context.repo.owner,
131                  repo: context.repo.repo,
132                  issue_number: context.issue.number,
133                  labels: ['large-pr'],
134                });
135              }
136
137              // Comment once with guidance
138              const MARKER = '<!-- pr-size-check -->';
139              const { data: comments } = await github.rest.issues.listComments({
140                owner: context.repo.owner,
141                repo: context.repo.repo,
142                issue_number: context.issue.number,
143              });
144
145              const alreadyCommented = comments.some(c => c.body.includes(MARKER));
146              if (!alreadyCommented) {
147                const prBody = context.payload.pull_request.body || '';
148                const guidedTourPresent = /how to review|guided tour|read.*in.*order/i.test(prBody);
149
150                let body = `${MARKER}\n`;
151                body += `### :straight_ruler: PR Size: **${totalChanges} lines changed** (${sizeLabel})\n\n`;
152                body += `Please note: this PR exceeds the 400 LOC soft limit.\n`;
153                body += `- Consider **splitting** into separate PRs if the changes are separable\n`;
154                body += `- Ensure the PR description includes a **guided tour** in the "How to Review" section so reviewers know where to start\n`;
155
156                if (guidedTourPresent) {
157                  body += `\n:white_check_mark: Guided tour detected — thank you!\n`;
158                }
159
160                await github.rest.issues.createComment({
161                  owner: context.repo.owner,
162                  repo: context.repo.repo,
163                  issue_number: context.issue.number,
164                  body: body,
165                });
166              }
167            }
168
169            console.log(`PR #${context.issue.number}: ${totalChanges} LOC changed, labeled ${sizeLabel}`);
170defaults:
171  run:
172    shell: bash -euxo pipefail {0}