From 151680eef93cd0b9dd6930ae5bafef7ec7d78ae4 Mon Sep 17 00:00:00 2001 From: Hao Sun <131156805+surbadest@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:40:46 +0000 Subject: [PATCH] ci: add deterministic pull request size labels (#42615) Co-authored-by: surbadest --- .github/scripts/size-labeler.test.cjs | 94 +++++++++++++++++++++++++++ .github/workflows/size-labeler.yml | 55 ++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 .github/scripts/size-labeler.test.cjs create mode 100644 .github/workflows/size-labeler.yml diff --git a/.github/scripts/size-labeler.test.cjs b/.github/scripts/size-labeler.test.cjs new file mode 100644 index 00000000000..947df4dc996 --- /dev/null +++ b/.github/scripts/size-labeler.test.cjs @@ -0,0 +1,94 @@ +const fs = require('node:fs') +const assert = require('node:assert/strict') +const test = require('node:test') +const source = fs.readFileSync( + require('node:path').join(__dirname, '../workflows/size-labeler.yml'), + 'utf8', +) +const script = source + .replace(/\r\n/g, '\n') + .split(' script: |\n')[1] + .split('\n') + .map((l) => l.replace(/^ /, '')) + .join('\n') +const run = new (Object.getPrototypeOf(async function () {}).constructor)( + 'github', + 'context', + 'core', + script, +) +async function scenario(files, existing = [], changed = files.length) { + const calls = [] + const warnings = [] + const api = { + pulls: { + get: async () => ({ data: { changed_files: changed } }), + listFiles: 'files', + }, + issues: { + listLabelsOnIssue: 'labels', + addLabels: async (p) => calls.push(['add', p.labels[0]]), + removeLabel: async (p) => calls.push(['remove', p.name]), + }, + } + await run( + { + rest: api, + paginate: async (endpoint) => + endpoint === 'files' ? files : existing.map((name) => ({ name })), + }, + { + repo: { owner: 'test', repo: 'repo' }, + payload: { pull_request: { number: 1 } }, + }, + { warning: (s) => warnings.push(s) }, + ) + return { calls, warnings } +} +for (const [n, label] of [ + [0, 'XS'], + [9, 'XS'], + [10, 'S'], + [29, 'S'], + [30, 'M'], + [99, 'M'], + [100, 'L'], + [499, 'L'], + [500, 'XL'], + [999, 'XL'], + [1000, 'XXL'], +]) { + test(`threshold ${n}`, async () => + assert.deepEqual((await scenario([{ filename: 'a.py', additions: n, deletions: 0 }])).calls, [ + ['add', `size:${label}`], + ])) +} +test('count deletions', async () => + assert.deepEqual((await scenario([{ filename: 'a.py', additions: 0, deletions: 30 }])).calls, [ + ['add', 'size:M'], + ])) +test('ignore nested lockfiles', async () => + assert.deepEqual( + ( + await scenario([ + { filename: 'api/uv.lock', additions: 9000, deletions: 5 }, + { filename: 'web/pnpm-lock.yaml', additions: 5000, deletions: 0 }, + ]) + ).calls, + [['add', 'size:XS']], + )) +test('replace stale size only', async () => + assert.deepEqual( + (await scenario([{ filename: 'a.py', additions: 10, deletions: 0 }], ['size:L', 'bug', 'web'])) + .calls, + [ + ['add', 'size:S'], + ['remove', 'size:L'], + ], + )) +test('idempotent', async () => assert.deepEqual((await scenario([], ['size:XS', 'bug'])).calls, [])) +test('incomplete file list', async () => { + const r = await scenario([], ['size:L'], 3001) + assert.deepEqual(r.calls, []) + assert.equal(r.warnings.length, 1) +}) diff --git a/.github/workflows/size-labeler.yml b/.github/workflows/size-labeler.yml new file mode 100644 index 00000000000..b6807655b1f --- /dev/null +++ b/.github/workflows/size-labeler.yml @@ -0,0 +1,55 @@ +name: Pull Request Size Labeler + +on: + pull_request_target: + types: [opened, reopened, synchronize, ready_for_review, edited] + +permissions: + pull-requests: write + +concurrency: + group: size-labeler-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + label: + runs-on: depot-ubuntu-24.04 + steps: + # Only read PR metadata. Never check out or execute the PR's code here. + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo + const issue_number = context.payload.pull_request.number + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: issue_number }) + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, repo, pull_number: issue_number, per_page: 100, + }) + // GitHub caps this endpoint at 3,000 files; avoid labeling a partial count. + if (files.length !== pr.changed_files) { + core.warning('Skipping size label because the changed-file list is incomplete') + return + } + const ignoredNames = new Set(['uv.lock', 'pnpm-lock.yaml', 'package-lock.json', 'yarn.lock']) + const changes = files + .filter(file => !ignoredNames.has(file.filename.split('/').pop())) + .reduce((total, file) => total + file.additions + file.deletions, 0) + // Match the descriptions of the repository's existing size labels. + const buckets = [ + [10, 'size:XS'], [30, 'size:S'], [100, 'size:M'], + [500, 'size:L'], [1000, 'size:XL'], [Infinity, 'size:XXL'], + ] + const label = buckets.find(([limit]) => changes < limit)[1] + const sizeLabels = new Set(buckets.map(([, name]) => name)) + const labels = await github.paginate(github.rest.issues.listLabelsOnIssue, { + owner, repo, issue_number, per_page: 100, + }) + // Add first so an API error cannot leave the PR without a size label. + if (!labels.some(item => item.name === label)) { + await github.rest.issues.addLabels({ owner, repo, issue_number, labels: [label] }) + } + for (const item of labels) { + if (sizeLabels.has(item.name) && item.name !== label) { + await github.rest.issues.removeLabel({ owner, repo, issue_number, name: item.name }) + } + }