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}