-
Notifications
You must be signed in to change notification settings - Fork 1.2k
ci: add PR size labeler with XXL gate #9055
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
ede48f8
6eff6d3
67222c2
061dc11
1b48933
dffd5de
294f08e
f4d4572
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,206 @@ | ||
| # PR Size Labeler | ||
| # | ||
| # Labels every PR with one of size/XS, size/S, size/M, size/L, size/XL, size/XXL | ||
| # based on additions+deletions across non-excluded files, and fails the check on | ||
| # size/XXL unless a maintainer applies size/override. | ||
| # | ||
| # ONE-TIME SETUP (a maintainer must run these once after this workflow merges): | ||
| # gh label create size/XS --color ededed --description "PR size: 0-9 changed lines" | ||
| # gh label create size/S --color c2e0c6 --description "PR size: 10-29 changed lines" | ||
| # gh label create size/M --color fef2c0 --description "PR size: 30-99 changed lines" | ||
| # gh label create size/L --color fbca04 --description "PR size: 100-499 changed lines" | ||
| # gh label create size/XL --color e99695 --description "PR size: 500-999 changed lines" | ||
| # gh label create size/XXL --color b60205 --description "PR size: 1000+ changed lines" | ||
| # gh label create size/override --color 0e8a16 --description "Maintainer override for size/XXL gate" | ||
| # | ||
| # Spec: docs/superpowers/specs/2026-06-01-pr-size-labeler-design.md | ||
|
|
||
| name: "PR Size Labeler" | ||
|
|
||
| on: | ||
| pull_request_target: | ||
| types: [opened, synchronize, reopened, labeled, unlabeled] | ||
|
|
||
| # Cancel in-flight runs on rapid pushes; the latest push is what matters. | ||
| concurrency: | ||
| group: pr-size-labeler-${{ github.event.pull_request.number }} | ||
| cancel-in-progress: true | ||
|
|
||
| jobs: | ||
| size-label: | ||
| name: Size label | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| pull-requests: write | ||
| contents: read | ||
| steps: | ||
| - name: Compute size, label, and gate | ||
| uses: actions/github-script@v9 | ||
| with: | ||
| github-token: ${{ secrets.GITHUB_TOKEN }} | ||
| script: | | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. let's use a standalone script. |
||
| // ---- Buckets (sorted ascending by lower bound). ---- | ||
| const BUCKETS = [ | ||
| { label: 'size/XS', min: 0, max: 9 }, | ||
| { label: 'size/S', min: 10, max: 29 }, | ||
| { label: 'size/M', min: 30, max: 99 }, | ||
| { label: 'size/L', min: 100, max: 499 }, | ||
| { label: 'size/XL', min: 500, max: 999 }, | ||
| { label: 'size/XXL', min: 1000, max: Infinity }, | ||
| ]; | ||
| const SIZE_LABELS = BUCKETS.map(b => b.label); | ||
| const OVERRIDE_LABEL = 'size/override'; | ||
| const STICKY_MARKER = '<!-- pr-size-labeler -->'; | ||
|
|
||
| // ---- Exclusion globs. Files matching ANY glob are excluded | ||
| // from the size count. Edit this list to tune. ---- | ||
| const EXCLUDE_GLOBS = [ | ||
| '**/*.lock', | ||
| '**/package-lock.json', | ||
| '**/poetry.lock', | ||
| '**/yarn.lock', | ||
| '**/Pipfile.lock', | ||
| '**/uv.lock', | ||
| '**/requirements*.txt', | ||
| 'schema/**', | ||
| '**/__snapshots__/**', | ||
| '**/*.snap', | ||
| '**/*.svg', | ||
| '**/*.png', | ||
| '**/*.jpg', | ||
| '**/*.jpeg', | ||
| '**/*.gif', | ||
| '**/*.ico', | ||
| '**/*.pdf', | ||
| ]; | ||
|
Comment on lines
+57
to
+75
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do we have any of these except
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A more useful list would be ignore the dep/test/document changes. Could discuss |
||
|
|
||
| const minimatch = require('minimatch'); | ||
| const { owner, repo } = context.repo; | ||
| const pull_number = context.payload.pull_request.number; | ||
|
|
||
| function shouldExclude(filename) { | ||
| return EXCLUDE_GLOBS.some(g => minimatch(filename, g, { dot: true })); | ||
| } | ||
| function pickBucket(n) { | ||
| return BUCKETS.find(b => n >= b.min && n <= b.max); | ||
| } | ||
|
|
||
| async function upsertStickyComment(body) { | ||
| const fullBody = `${STICKY_MARKER}\n${body}`; | ||
| const comments = await github.paginate(github.rest.issues.listComments, { | ||
| owner, | ||
| repo, | ||
| issue_number: pull_number, | ||
| per_page: 100, | ||
| }); | ||
| const existing = comments.find(c => c.body && c.body.startsWith(STICKY_MARKER)); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can we just create a check that success & fail on each commit so we can throw away all these find/update in place logic?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. wait, this workflow itself is already a check. So we can just fail the workflow if it's more than 1000 useful lines |
||
| if (existing) { | ||
| await github.rest.issues.updateComment({ | ||
| owner, | ||
| repo, | ||
| comment_id: existing.id, | ||
| body: fullBody, | ||
| }); | ||
| } else { | ||
| await github.rest.issues.createComment({ | ||
| owner, | ||
| repo, | ||
| issue_number: pull_number, | ||
| body: fullBody, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| let bucket; | ||
| let total = 0; | ||
| let currentLabels = []; | ||
|
|
||
| try { | ||
| const files = await github.paginate(github.rest.pulls.listFiles, { | ||
| owner, | ||
| repo, | ||
| pull_number, | ||
| per_page: 100, | ||
| }); | ||
| const truncated = files.length === 3000; | ||
| if (truncated) { | ||
| core.warning('Pull-request files response hit GitHub\'s 3000-file cap; counting partial sum. The PR is XXL regardless.'); | ||
| } | ||
|
|
||
| let excludedFiles = 0; | ||
| for (const f of files) { | ||
| if (shouldExclude(f.filename)) { | ||
| excludedFiles += 1; | ||
| continue; | ||
| } | ||
| total += (f.additions || 0) + (f.deletions || 0); | ||
| } | ||
|
|
||
| bucket = pickBucket(total); | ||
| core.info(`PR #${pull_number}: ${total} changed lines (excluded ${excludedFiles}/${files.length} files) -> ${bucket.label}`); | ||
| await core.summary | ||
| .addHeading('PR size') | ||
| .addRaw(`**${bucket.label}** — ${total} changed lines (excluded ${excludedFiles}/${files.length} files)${truncated ? ' _(file list was truncated)_' : ''}`) | ||
| .write(); | ||
|
|
||
| currentLabels = (context.payload.pull_request.labels || []).map(l => l.name); | ||
| const stale = currentLabels.filter(n => SIZE_LABELS.includes(n) && n !== bucket.label); | ||
| const alreadyApplied = currentLabels.includes(bucket.label); | ||
|
|
||
| if (!alreadyApplied) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think the most useful part is when this feature gates huge PRs. But I don't think we care if its a |
||
| await github.rest.issues.addLabels({ | ||
| owner, | ||
| repo, | ||
| issue_number: pull_number, | ||
| labels: [bucket.label], | ||
| }); | ||
| core.info(`Applied label ${bucket.label}`); | ||
| } | ||
| for (const name of stale) { | ||
| try { | ||
| await github.rest.issues.removeLabel({ | ||
| owner, | ||
| repo, | ||
| issue_number: pull_number, | ||
| name, | ||
| }); | ||
| core.info(`Removed stale label ${name}`); | ||
| } catch (err) { | ||
| // 404 = label was already removed by another run. Ignore. | ||
| if (err.status !== 404) throw err; | ||
| } | ||
| } | ||
| } catch (err) { | ||
| // Sizing infra should not block PRs when GitHub's API is flaky. | ||
| // The intentional XXL-without-override failure happens AFTER this | ||
| // catch and is therefore unaffected. | ||
| core.warning(`PR size labeler: non-fatal error during sizing/labeling: ${err.message}`); | ||
| await core.summary | ||
| .addRaw(`PR size labeler hit a non-fatal error: \`${err.message}\``) | ||
| .write(); | ||
| return; // Skip the gate when we can't trust `bucket` / `currentLabels`. | ||
| } | ||
|
|
||
| // ---- XXL gate (outside try/catch). ---- | ||
| if (bucket.label === 'size/XXL') { | ||
| const overrideActive = currentLabels.includes(OVERRIDE_LABEL); | ||
| const overrideParagraph = overrideActive | ||
| ? `A maintainer applied \`${OVERRIDE_LABEL}\`; this PR is allowed despite its size. Remove the label to re-arm the gate.` | ||
| : `A maintainer can apply the \`${OVERRIDE_LABEL}\` label to bypass this check.`; | ||
| const body = [ | ||
| `**PR size: \`XXL\` (${total} changed lines)**`, | ||
| ``, | ||
| `This PR exceeds the 1,000-line threshold. Smaller PRs get reviewed faster and are easier to revert.`, | ||
| ``, | ||
| overrideParagraph, | ||
| ``, | ||
| `_Excluded from the count: lockfiles, schema, snapshots, images. See \`.github/workflows/pr-size-labeler.yml\` to adjust._`, | ||
| ].join('\n'); | ||
| await upsertStickyComment(body); | ||
|
|
||
| if (!overrideActive) { | ||
| core.setFailed( | ||
| `PR has ${total} changed lines (>= 1000). Apply the \`${OVERRIDE_LABEL}\` label to bypass.`, | ||
| ); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
non-relavant comment