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}