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_target:
 15    types: [opened, synchronize]
 16
 17permissions:
 18  contents: read
 19
 20jobs:
 21  check-size:
 22    if: github.repository_owner == 'zed-industries'
 23    permissions:
 24      contents: read
 25      pull-requests: write  # PR comments
 26      issues: write         # label management (GitHub routes labels through Issues API)
 27    runs-on: ubuntu-latest
 28    timeout-minutes: 5
 29    steps:
 30      - name: Calculate PR size and label
 31        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
 32        with:
 33          script: |
 34            const { data: files } = await github.rest.pulls.listFiles({
 35              owner: context.repo.owner,
 36              repo: context.repo.repo,
 37              pull_number: context.issue.number,
 38              per_page: 300,
 39            });
 40
 41            // Sum additions + deletions, excluding generated/lock files
 42            const IGNORED_PATTERNS = [
 43              /\.lock$/,
 44              /^Cargo\.lock$/,
 45              /pnpm-lock\.yaml$/,
 46              /\.generated\./,
 47              /\/fixtures\//,
 48              /\/snapshots\//,
 49            ];
 50
 51            let totalChanges = 0;
 52            for (const file of files) {
 53              const ignored = IGNORED_PATTERNS.some(p => p.test(file.filename));
 54              if (!ignored) {
 55                totalChanges += file.additions + file.deletions;
 56              }
 57            }
 58
 59            // Assign size label
 60            const SIZE_BRACKETS = [
 61              ['size/S',  0,       100,  '0e8a16'],
 62              ['size/M',  100,     400,  'fbca04'],
 63              ['size/L',  400,     800,  'e99695'],
 64              ['size/XL', 800, Infinity, 'b60205'],
 65            ];
 66
 67            let sizeLabel = 'size/S';
 68            let labelColor = '0e8a16';
 69            for (const [label, min, max, color] of SIZE_BRACKETS) {
 70              if (totalChanges >= min && totalChanges < max) {
 71                sizeLabel = label;
 72                labelColor = color;
 73                break;
 74              }
 75            }
 76
 77            // Remove existing size labels, then apply the current one
 78            const existingLabels = (await github.rest.issues.listLabelsOnIssue({
 79              owner: context.repo.owner,
 80              repo: context.repo.repo,
 81              issue_number: context.issue.number,
 82            })).data.map(l => l.name);
 83
 84            for (const label of existingLabels) {
 85              if (label.startsWith('size/')) {
 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
 95            // Create the label if it doesn't exist (ignore 422 = already exists)
 96            try {
 97              await github.rest.issues.createLabel({
 98                owner: context.repo.owner,
 99                repo: context.repo.repo,
100                name: sizeLabel,
101                color: labelColor,
102              });
103            } catch (e) {
104              if (e.status !== 422) throw e;
105            }
106
107            await github.rest.issues.addLabels({
108              owner: context.repo.owner,
109              repo: context.repo.repo,
110              issue_number: context.issue.number,
111              labels: [sizeLabel],
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}