authorgravatar for crazywolf132@gmail.com0x1f99d <crazywolf132@gmail.com> 2026-03-13 03:10:07+11:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-12 09:10:07-07:00
log774cff9a74bbd937021e27da524b96dbb7319a7c
treec14196c9902cb9581ebc1c2e44563bdf1c01f93e
parente3d46b687ce2537b4761ab9ae1c50db6708d3948
signature Signed by PGP key B5690EEEBB952194

ci(github): add pull request size labels (#901)


3 files changed, 208 insertions(+), 18 deletions(-)

.github/workflows/pr-size.yml created+166
...@@ -0,0 +1,166 @@
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 issues: write
10 pull-requests: read
11
12jobs:
13 label:
14 name: Label PR size
15 runs-on: ubuntu-24.04
16 concurrency:
17 group: pr-size-${{ github.event.pull_request.number }}
18 cancel-in-progress: true
19 steps:
20 - name: Sync PR size label
21 uses: actions/github-script@v7
22 with:
23 script: |
24 const issueNumber = context.payload.pull_request.number;
25 const additions = context.payload.pull_request.additions ?? 0;
26 const deletions = context.payload.pull_request.deletions ?? 0;
27 const changedLines = additions + deletions;
28
29 const managedLabels = [
30 {
31 name: "size:XS",
32 color: "0e8a16",
33 description: "0-9 changed lines (additions + deletions).",
34 },
35 {
36 name: "size:S",
37 color: "5ebd3e",
38 description: "10-29 changed lines (additions + deletions).",
39 },
40 {
41 name: "size:M",
42 color: "fbca04",
43 description: "30-99 changed lines (additions + deletions).",
44 },
45 {
46 name: "size:L",
47 color: "fe7d37",
48 description: "100-499 changed lines (additions + deletions).",
49 },
50 {
51 name: "size:XL",
52 color: "d93f0b",
53 description: "500-999 changed lines (additions + deletions).",
54 },
55 {
56 name: "size:XXL",
57 color: "b60205",
58 description: "1,000+ changed lines (additions + deletions).",
59 },
60 ];
61
62 const managedLabelNames = new Set(managedLabels.map((label) => label.name));
63
64 const resolveSizeLabel = (totalChangedLines) => {
65 if (totalChangedLines < 10) {
66 return "size:XS";
67 }
68
69 if (totalChangedLines < 30) {
70 return "size:S";
71 }
72
73 if (totalChangedLines < 100) {
74 return "size:M";
75 }
76
77 if (totalChangedLines < 500) {
78 return "size:L";
79 }
80
81 if (totalChangedLines < 1000) {
82 return "size:XL";
83 }
84
85 return "size:XXL";
86 };
87
88 for (const label of managedLabels) {
89 try {
90 const { data: existing } = await github.rest.issues.getLabel({
91 owner: context.repo.owner,
92 repo: context.repo.repo,
93 name: label.name,
94 });
95
96 if (
97 existing.color !== label.color ||
98 (existing.description ?? "") !== label.description
99 ) {
100 await github.rest.issues.updateLabel({
101 owner: context.repo.owner,
102 repo: context.repo.repo,
103 name: label.name,
104 color: label.color,
105 description: label.description,
106 });
107 }
108 } catch (error) {
109 if (error.status !== 404) {
110 throw error;
111 }
112
113 try {
114 await github.rest.issues.createLabel({
115 owner: context.repo.owner,
116 repo: context.repo.repo,
117 name: label.name,
118 color: label.color,
119 description: label.description,
120 });
121 } catch (createError) {
122 if (createError.status !== 422) {
123 throw createError;
124 }
125 }
126 }
127 }
128
129 const nextLabelName = resolveSizeLabel(changedLines);
130
131 const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
132 owner: context.repo.owner,
133 repo: context.repo.repo,
134 issue_number: issueNumber,
135 per_page: 100,
136 });
137
138 for (const label of currentLabels) {
139 if (!managedLabelNames.has(label.name) || label.name === nextLabelName) {
140 continue;
141 }
142
143 try {
144 await github.rest.issues.removeLabel({
145 owner: context.repo.owner,
146 repo: context.repo.repo,
147 issue_number: issueNumber,
148 name: label.name,
149 });
150 } catch (removeError) {
151 if (removeError.status !== 404) {
152 throw removeError;
153 }
154 }
155 }
156
157 if (!currentLabels.some((label) => label.name === nextLabelName)) {
158 await github.rest.issues.addLabels({
159 owner: context.repo.owner,
160 repo: context.repo.repo,
161 issue_number: issueNumber,
162 labels: [nextLabelName],
163 });
164 }
165
166 core.info(`PR #${issueNumber}: ${changedLines} changed lines -> ${nextLabelName}`);
.github/workflows/pr-vouch.yml+41-17
...@@ -111,7 +111,7 @@ jobs:...@@ -111,7 +111,7 @@ jobs:
111 },111 },
112 ];112 ];
113113
114 const managedLabelNames = managedLabels.map((label) => label.name);114 const managedLabelNames = new Set(managedLabels.map((label) => label.name));
115115
116 for (const label of managedLabels) {116 for (const label of managedLabels) {
117 try {117 try {
...@@ -138,13 +138,19 @@ jobs:...@@ -138,13 +138,19 @@ jobs:
138 throw error;138 throw error;
139 }139 }
140140
141 await github.rest.issues.createLabel({141 try {
142 owner: context.repo.owner,142 await github.rest.issues.createLabel({
143 repo: context.repo.repo,143 owner: context.repo.owner,
144 name: label.name,144 repo: context.repo.repo,
145 color: label.color,145 name: label.name,
146 description: label.description,146 color: label.color,
147 });147 description: label.description,
148 });
149 } catch (createError) {
150 if (createError.status !== 422) {
151 throw createError;
152 }
153 }
148 }154 }
149 }155 }
150156
...@@ -159,17 +165,35 @@ jobs:...@@ -159,17 +165,35 @@ jobs:
159 owner: context.repo.owner,165 owner: context.repo.owner,
160 repo: context.repo.repo,166 repo: context.repo.repo,
161 issue_number: issueNumber,167 issue_number: issueNumber,
168 per_page: 100,
162 });169 });
163170
164 const preservedLabels = currentLabels171 for (const label of currentLabels) {
165 .map((label) => label.name)172 if (!managedLabelNames.has(label.name) || label.name === nextLabelName) {
166 .filter((name) => !managedLabelNames.includes(name));173 continue;
174 }
167175
168 await github.rest.issues.setLabels({176 try {
169 owner: context.repo.owner,177 await github.rest.issues.removeLabel({
170 repo: context.repo.repo,178 owner: context.repo.owner,
171 issue_number: issueNumber,179 repo: context.repo.repo,
172 labels: [...preservedLabels, nextLabelName],180 issue_number: issueNumber,
173 });181 name: label.name,
182 });
183 } catch (removeError) {
184 if (removeError.status !== 404) {
185 throw removeError;
186 }
187 }
188 }
189
190 if (!currentLabels.some((label) => label.name === nextLabelName)) {
191 await github.rest.issues.addLabels({
192 owner: context.repo.owner,
193 repo: context.repo.repo,
194 issue_number: issueNumber,
195 labels: [nextLabelName],
196 });
197 }
174198
175 core.info(`PR #${issueNumber}: ${status} -> ${nextLabelName}`);199 core.info(`PR #${issueNumber}: ${status} -> ${nextLabelName}`);
CONTRIBUTING.md+1-1
...@@ -8,7 +8,7 @@ You can still open an issue or PR, but please do so knowing there is a high chan...@@ -8,7 +8,7 @@ You can still open an issue or PR, but please do so knowing there is a high chan
88
9If that sounds annoying, that is because it is. This project is still early and we are trying to keep scope, quality, and direction under control.9If that sounds annoying, that is because it is. This project is still early and we are trying to keep scope, quality, and direction under control.
1010
11PRs are automatically labeled with a `vouch:*` trust status.11PRs are automatically labeled with a `vouch:*` trust status and a `size:*` diff size based on changed lines.
1212
13If you are an external contributor, expect `vouch:unvouched` until we explicitly add you to [.github/VOUCHED.td](.github/VOUCHED.td).13If you are an external contributor, expect `vouch:unvouched` until we explicitly add you to [.github/VOUCHED.td](.github/VOUCHED.td).
1414