pr-size-label.yml

  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}