1# PR Size Check — Label & Comment
2#
3# Triggered by workflow_run after pr-size-check.yml completes.
4# Downloads the size result artifact and applies labels + comments.
5#
6# This runs on the base repo with full GITHUB_TOKEN write access,
7# so it works for both same-repo and fork PRs.
8#
9# Security note: The artifact is treated as untrusted data — only
10# structured JSON fields (PR number, size label, color, boolean) are
11# read. No artifact content is executed or interpolated into shell.
12
13name: PR Size Label
14
15on:
16 workflow_run:
17 workflows: ["PR Size Check"]
18 types: [completed]
19
20jobs:
21 apply-labels:
22 if: >
23 github.repository_owner == 'zed-industries' &&
24 github.event.workflow_run.conclusion == 'success'
25 permissions:
26 contents: read
27 pull-requests: write
28 issues: write
29 runs-on: ubuntu-latest
30 timeout-minutes: 5
31 steps:
32 - name: Download size result artifact
33 id: download
34 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
35 with:
36 script: |
37 const fs = require('fs');
38 const path = require('path');
39
40 const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
41 owner: context.repo.owner,
42 repo: context.repo.repo,
43 run_id: context.payload.workflow_run.id,
44 });
45
46 const match = allArtifacts.data.artifacts.find(a => a.name === 'pr-size-result');
47 if (!match) {
48 console.log('No pr-size-result artifact found, skipping');
49 core.setOutput('found', 'false');
50 return;
51 }
52
53 const download = await github.rest.actions.downloadArtifact({
54 owner: context.repo.owner,
55 repo: context.repo.repo,
56 artifact_id: match.id,
57 archive_format: 'zip',
58 });
59
60 const temp = path.join(process.env.RUNNER_TEMP, 'pr-size');
61 fs.mkdirSync(temp, { recursive: true });
62 fs.writeFileSync(path.join(temp, 'result.zip'), Buffer.from(download.data));
63 core.setOutput('found', 'true');
64
65 - name: Unzip artifact
66 if: steps.download.outputs.found == 'true'
67 env:
68 ARTIFACT_DIR: ${{ runner.temp }}/pr-size
69 run: unzip "$ARTIFACT_DIR/result.zip" -d "$ARTIFACT_DIR"
70
71 - name: Apply labels and comment
72 if: steps.download.outputs.found == 'true'
73 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
74 with:
75 script: |
76 const fs = require('fs');
77 const path = require('path');
78
79 const temp = path.join(process.env.RUNNER_TEMP, 'pr-size');
80 const resultPath = path.join(temp, 'result.json');
81 if (!fs.existsSync(resultPath)) {
82 console.log('No result.json found, skipping');
83 return;
84 }
85
86 const result = JSON.parse(fs.readFileSync(resultPath, 'utf8'));
87
88 // Validate artifact data (treat as untrusted)
89 const prNumber = Number(result.pr_number);
90 const totalChanges = Number(result.total_changes);
91 const sizeLabel = String(result.size_label);
92 const labelColor = String(result.label_color);
93 const hasReviewGuidance = Boolean(result.has_review_guidance);
94
95 if (!prNumber || !sizeLabel.startsWith('Size ')) {
96 core.setFailed(`Invalid artifact data: pr=${prNumber}, label=${sizeLabel}`);
97 return;
98 }
99
100 console.log(`PR #${prNumber}: ${totalChanges} LOC, ${sizeLabel}`);
101
102 // --- Size label (idempotent) ---
103 const existingLabels = (await github.rest.issues.listLabelsOnIssue({
104 owner: context.repo.owner,
105 repo: context.repo.repo,
106 issue_number: prNumber,
107 })).data.map(l => l.name);
108
109 const existingSizeLabels = existingLabels.filter(l => l.startsWith('Size '));
110 const alreadyCorrect = existingSizeLabels.length === 1 && existingSizeLabels[0] === sizeLabel;
111
112 if (!alreadyCorrect) {
113 for (const label of existingSizeLabels) {
114 await github.rest.issues.removeLabel({
115 owner: context.repo.owner,
116 repo: context.repo.repo,
117 issue_number: prNumber,
118 name: label,
119 });
120 }
121
122 try {
123 await github.rest.issues.createLabel({
124 owner: context.repo.owner,
125 repo: context.repo.repo,
126 name: sizeLabel,
127 color: labelColor,
128 });
129 } catch (e) {
130 if (e.status !== 422) throw e;
131 }
132
133 await github.rest.issues.addLabels({
134 owner: context.repo.owner,
135 repo: context.repo.repo,
136 issue_number: prNumber,
137 labels: [sizeLabel],
138 });
139 }
140
141 // --- Large PR handling (400+ LOC) ---
142 if (totalChanges >= 400) {
143 if (!existingLabels.includes('large-pr')) {
144 try {
145 await github.rest.issues.createLabel({
146 owner: context.repo.owner,
147 repo: context.repo.repo,
148 name: 'large-pr',
149 color: 'e99695',
150 });
151 } catch (e) {
152 if (e.status !== 422) throw e;
153 }
154
155 await github.rest.issues.addLabels({
156 owner: context.repo.owner,
157 repo: context.repo.repo,
158 issue_number: prNumber,
159 labels: ['large-pr'],
160 });
161 }
162
163 // Comment once with guidance
164 const MARKER = '<!-- pr-size-check -->';
165 const { data: comments } = await github.rest.issues.listComments({
166 owner: context.repo.owner,
167 repo: context.repo.repo,
168 issue_number: prNumber,
169 });
170
171 const alreadyCommented = comments.some(c => c.body.includes(MARKER));
172 if (!alreadyCommented) {
173 let body = `${MARKER}\n`;
174 body += `### :straight_ruler: PR Size: **${totalChanges} lines changed** (${sizeLabel})\n\n`;
175 body += `Please note: this PR exceeds the 400 LOC soft limit.\n`;
176 body += `- Consider **splitting** into separate PRs if the changes are separable\n`;
177 body += `- Ensure the PR description includes a **guided tour** in the "How to Review" section so reviewers know where to start\n`;
178
179 if (hasReviewGuidance) {
180 body += `\n:white_check_mark: "How to Review" section appears to include guidance — thank you!\n`;
181 }
182
183 await github.rest.issues.createComment({
184 owner: context.repo.owner,
185 repo: context.repo.repo,
186 issue_number: prNumber,
187 body: body,
188 });
189 }
190 }
191
192 console.log(`PR #${prNumber}: labeled ${sizeLabel}, done`);
193defaults:
194 run:
195 shell: bash -euxo pipefail {0}