1name: PR Size
2
3on:
4 pull_request_target:
5 types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
6
7permissions:
8 contents: read
9
10jobs:
11 prepare-config:
12 name: Prepare PR size config
13 runs-on: ubuntu-24.04
14 outputs:
15 labels_json: ${{ steps.config.outputs.labels_json }}
16 steps:
17 - id: config
18 name: Build PR size label config
19 uses: actions/github-script@v8
20 with:
21 result-encoding: string
22 script: |
23 const managedLabels = [
24 {
25 name: "size:XS",
26 color: "0e8a16",
27 description: "0-9 effective changed lines (test files excluded in mixed PRs).",
28 },
29 {
30 name: "size:S",
31 color: "5ebd3e",
32 description: "10-29 effective changed lines (test files excluded in mixed PRs).",
33 },
34 {
35 name: "size:M",
36 color: "fbca04",
37 description: "30-99 effective changed lines (test files excluded in mixed PRs).",
38 },
39 {
40 name: "size:L",
41 color: "fe7d37",
42 description: "100-499 effective changed lines (test files excluded in mixed PRs).",
43 },
44 {
45 name: "size:XL",
46 color: "d93f0b",
47 description: "500-999 effective changed lines (test files excluded in mixed PRs).",
48 },
49 {
50 name: "size:XXL",
51 color: "b60205",
52 description: "1,000+ effective changed lines (test files excluded in mixed PRs).",
53 },
54 ];
55
56 core.setOutput("labels_json", JSON.stringify(managedLabels));
57 sync-label-definitions:
58 name: Sync PR size label definitions
59 needs: prepare-config
60 if: github.event_name != 'pull_request_target'
61 runs-on: ubuntu-24.04
62 permissions:
63 contents: read
64 issues: write
65 steps:
66 - name: Ensure PR size labels exist
67 uses: actions/github-script@v8
68 env:
69 PR_SIZE_LABELS_JSON: ${{ needs.prepare-config.outputs.labels_json }}
70 with:
71 script: |
72 const managedLabels = JSON.parse(process.env.PR_SIZE_LABELS_JSON ?? "[]");
73
74 for (const label of managedLabels) {
75 try {
76 const { data: existing } = await github.rest.issues.getLabel({
77 owner: context.repo.owner,
78 repo: context.repo.repo,
79 name: label.name,
80 });
81
82 if (
83 existing.color !== label.color ||
84 (existing.description ?? "") !== label.description
85 ) {
86 await github.rest.issues.updateLabel({
87 owner: context.repo.owner,
88 repo: context.repo.repo,
89 name: label.name,
90 color: label.color,
91 description: label.description,
92 });
93 }
94 } catch (error) {
95 if (error.status !== 404) {
96 throw error;
97 }
98
99 try {
100 await github.rest.issues.createLabel({
101 owner: context.repo.owner,
102 repo: context.repo.repo,
103 name: label.name,
104 color: label.color,
105 description: label.description,
106 });
107 } catch (createError) {
108 if (createError.status !== 422) {
109 throw createError;
110 }
111 }
112 }
113 }
114 label:
115 name: Label PR size
116 needs: prepare-config
117 if: github.event_name == 'pull_request_target'
118 runs-on: ubuntu-24.04
119 permissions:
120 contents: read
121 issues: read
122 pull-requests: write
123 concurrency:
124 group: pr-size-${{ github.event.pull_request.number }}
125 cancel-in-progress: true
126 steps:
127 # This pull_request_target job may fetch untrusted PR commits only as passive
128 # git data. Do not add dependency installs, build/test scripts, or cache
129 # actions here; use pull_request plus workflow_run for that pattern instead.
130 - name: Checkout base repository
131 uses: actions/checkout@v4
132 with:
133 fetch-depth: 0
134 - name: Sync PR size label
135 uses: actions/github-script@v8
136 env:
137 PR_SIZE_LABELS_JSON: ${{ needs.prepare-config.outputs.labels_json }}
138 with:
139 script: |
140 const { execFileSync } = require("node:child_process");
141
142 const issueNumber = context.payload.pull_request.number;
143 const baseSha = context.payload.pull_request.base.sha;
144 const headSha = context.payload.pull_request.head.sha;
145 const headTrackingRef = `refs/remotes/pr-size/${issueNumber}`;
146 const managedLabels = JSON.parse(process.env.PR_SIZE_LABELS_JSON ?? "[]");
147 const managedLabelNames = new Set(managedLabels.map((label) => label.name));
148 // Keep this aligned with the repo's test entrypoints and test-only support files.
149 const testExcludePathspecs = [
150 ":(glob,exclude)**/__tests__/**",
151 ":(glob,exclude)**/test/**",
152 ":(glob,exclude)**/tests/**",
153 ":(glob,exclude)apps/server/integration/**",
154 ":(glob,exclude)**/*.test.*",
155 ":(glob,exclude)**/*.spec.*",
156 ":(glob,exclude)**/*.browser.*",
157 ":(glob,exclude)**/*.integration.*",
158 ];
159
160 const sumNumstat = (text) =>
161 text
162 .split("\n")
163 .filter(Boolean)
164 .reduce((total, line) => {
165 const [insertionsRaw = "0", deletionsRaw = "0"] = line.split("\t");
166 const additions =
167 insertionsRaw === "-" ? 0 : Number.parseInt(insertionsRaw, 10) || 0;
168 const deletions =
169 deletionsRaw === "-" ? 0 : Number.parseInt(deletionsRaw, 10) || 0;
170
171 return total + additions + deletions;
172 }, 0);
173
174 const resolveSizeLabel = (totalChangedLines) => {
175 if (totalChangedLines < 10) {
176 return "size:XS";
177 }
178
179 if (totalChangedLines < 30) {
180 return "size:S";
181 }
182
183 if (totalChangedLines < 100) {
184 return "size:M";
185 }
186
187 if (totalChangedLines < 500) {
188 return "size:L";
189 }
190
191 if (totalChangedLines < 1000) {
192 return "size:XL";
193 }
194
195 return "size:XXL";
196 };
197
198 execFileSync("git", ["fetch", "--no-tags", "origin", baseSha], {
199 stdio: "inherit",
200 });
201
202 execFileSync(
203 "git",
204 ["fetch", "--no-tags", "origin", `+refs/pull/${issueNumber}/head:${headTrackingRef}`],
205 {
206 stdio: "inherit",
207 },
208 );
209
210 const resolvedHeadSha = execFileSync("git", ["rev-parse", headTrackingRef], {
211 encoding: "utf8",
212 }).trim();
213
214 if (resolvedHeadSha !== headSha) {
215 core.warning(
216 `Fetched head SHA ${resolvedHeadSha} does not match pull request head SHA ${headSha}; using fetched ref for sizing.`,
217 );
218 }
219
220 execFileSync("git", ["cat-file", "-e", `${baseSha}^{commit}`], {
221 stdio: "inherit",
222 });
223
224 const diffArgs = [
225 "diff",
226 "--numstat",
227 "--ignore-all-space",
228 "--ignore-blank-lines",
229 `${baseSha}...${resolvedHeadSha}`,
230 ];
231
232 const totalChangedLines = sumNumstat(
233 execFileSync(
234 "git",
235 diffArgs,
236 { encoding: "utf8" },
237 ),
238 );
239 const nonTestChangedLines = sumNumstat(
240 execFileSync("git", [...diffArgs, "--", ".", ...testExcludePathspecs], {
241 encoding: "utf8",
242 }),
243 );
244 const testChangedLines = Math.max(0, totalChangedLines - nonTestChangedLines);
245
246 const changedLines = nonTestChangedLines === 0 ? testChangedLines : nonTestChangedLines;
247 const nextLabelName = resolveSizeLabel(changedLines);
248
249 const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
250 owner: context.repo.owner,
251 repo: context.repo.repo,
252 issue_number: issueNumber,
253 per_page: 100,
254 });
255
256 for (const label of currentLabels) {
257 if (!managedLabelNames.has(label.name) || label.name === nextLabelName) {
258 continue;
259 }
260
261 try {
262 await github.rest.issues.removeLabel({
263 owner: context.repo.owner,
264 repo: context.repo.repo,
265 issue_number: issueNumber,
266 name: label.name,
267 });
268 } catch (removeError) {
269 if (removeError.status !== 404) {
270 throw removeError;
271 }
272 }
273 }
274
275 if (!currentLabels.some((label) => label.name === nextLabelName)) {
276 await github.rest.issues.addLabels({
277 owner: context.repo.owner,
278 repo: context.repo.repo,
279 issue_number: issueNumber,
280 labels: [nextLabelName],
281 });
282 }
283
284 const classification =
285 nonTestChangedLines === 0
286 ? testChangedLines > 0
287 ? "test-only PR"
288 : "no line changes"
289 : testChangedLines > 0
290 ? "test lines excluded"
291 : "all non-test changes";
292
293 core.info(
294 `PR #${issueNumber}: ${nonTestChangedLines} non-test lines, ${testChangedLines} test lines, ${changedLines} effective lines -> ${nextLabelName} (${classification})`,
295 );