1844 words, 10 min read

At work, we do a lot of code review. Like most teams, we've learned the hard way that a 1,500-line PR is not the same as a 500-line PR—even if the diff viewer disagrees.

We've been running a GitHub Actions workflow that automatically labels every pull request with a size badge: small, medium, or large. Recently, we overhauled it, and the improvements are worth sharing.

Why bother labelling PR size?

A size label on a PR does one simple thing: it sets expectations before anyone opens the diff.

A reviewer who sees small knows they can knock it out between two meetings. Someone who sees large knows to carve out an hour and bring coffee. And if the whole team can see the size distribution in the pull request list at a glance, they can prioritise review queues more effectively and spot when a feature branch is growing out of hand before it becomes a problem.

It also creates a gentle forcing function for authors. When you know the workflow is going to slap a large on your PR, you think twice about whether those refactors really need to live in the same commit.

The naive approach and its problems

The first version of our workflow was simple: count additions, count deletions, and compare the total to a threshold.

score = additions + deletions

That works, but it has some obvious blind spots:

  • Deleting code is easy to review. A file removal or a big git rm shouldn't count the same as a 200-line feature addition. The reviewer isn't absorbing new logic; they're confirming that something is gone.
  • Test files are verbose by design. A well-tested change might add 300 lines of test code alongside 80 lines of feature code. Penalising it the same as 380 lines of business logic discourages good testing habits.
  • Some PRs are simply not reviewable. Our old workflow topped out at large regardless of score. A 5,000-line PR got the same label as a 700-line one, which gave reviewers no signal that they were looking at something that genuinely needed to be split.

The weighted scoring model

Our new approach scores each file individually, with different weights depending on the type of change.

Three file categories

Deleted files (status === 'removed'): the entire file is going away. There's almost no cognitive load for a reviewer—they need to confirm the deletion is intentional and check for stray references. We weight the lines at 20% and add a flat 10 to the score per file.

score += (f.additions + f.deletions) * 0.2 + 10;

Test files: identified by a set of patterns covering the conventions we actually use (*Test.php, *Tests.php, *.spec.ts, *.test.ts, and paths under tests/ or test/). These are important, but they're also repetitive—a reviewer can skim a block of assertions much faster than an equivalent block of business logic. Additions are weighted at 50%, deletions at 15%, and each test file adds 10 to the flat score.

score += f.additions * 0.5 + f.deletions * 0.15 + 10;

Everything else: regular source files count additions at full weight, deletions at 30%, and each file adds 20 to the flat score. The per-file component matters because touching ten files in different parts of the system—even with few lines changed—still takes real review time to understand the blast radius.

score += f.additions + f.deletions * 0.3 + 20;

Thresholds

Once we have the weighted score, we bucket it:

Label Score
small ≀ 250
medium ≀ 900
large ≀ 1800
not-reviewable > 1800

We deliberately raised the thresholds compared to our first version. The old numbers were too aggressive—a well-structured change with a handful of files and some tests would land in medium even when it was genuinely easy to review. The new thresholds better reflect the actual reviewer experience.

The not-reviewable label

This is new, and it's probably the most useful addition. When a PR scores above 1,800, it gets labelled not-reviewable. That's a signal to the author—not a judgment, just a fact—that the PR is too large to give a meaningful review.

It's also a signal to reviewers that they're not expected to wade through it; the author needs to split it first.

The label swaps in and out cleanly alongside the others: if a PR starts as not-reviewable, gets split, and the score drops, the next push removes the label and applies the correct one.

Ignoring noise

The workflow reads a .github/pr-size-ignore.txt file where you can list glob patterns for files that should be excluded entirely from scoring. Ours includes things like lock files, generated assets, and vendored code. There's no point penalising a PR because Composer updated a JSON file.

# .github/pr-size-ignore.txt
composer.lock
package-lock.json
*.min.js
public/build/**

The full workflow

The workflow triggers on pull_request_target (opened, synchronised, or reopened), skips Dependabot PRs, and needs pull-requests: write and issues: write permissions to manage labels.

It paginates through the file list (GitHub caps the API at 3,000 files), filters out ignored paths, scores each file with the logic above, determines the label, removes any stale size label, and applies the new one. The whole thing runs in a single actions/github-script step with no external action dependencies beyond actions/checkout.

The label swap is idempotent: if the correct label is already on the PR, no API call is made.

Full workflow source

name: PR Size Label

on:
  pull_request_target:
    types: [opened, synchronize, reopened]

permissions:
  contents: read
  pull-requests: write
  issues: write

jobs:
  label:
    runs-on: ubuntu-latest
    if: github.actor != 'dependabot[bot]'

    steps:
      - uses: actions/checkout@v4

      - name: Apply size label
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const path = require('path');

            // Read ignore patterns from .github/pr-size-ignore.txt
            const ignoreFile = '.github/pr-size-ignore.txt';
            const ignorePatterns = fs.existsSync(ignoreFile)
              ? fs.readFileSync(ignoreFile, 'utf8')
                  .split('\n')
                  .map(l => l.trim())
                  .filter(l => l && !l.startsWith('#'))
              : [];

            function globToRegex(pattern) {
              let result = '';
              for (let i = 0; i < pattern.length; i++) {
                const c = pattern[i];
                if (c === '*' && pattern[i + 1] === '*') {
                  result += '.*';
                  i++;
                  if (pattern[i + 1] === '/') i++;
                } else if (c === '*') {
                  result += '[^/]*';
                } else if (c === '?') {
                  result += '[^/]';
                } else if (['.', '+', '^', '$', '{', '}', '(', ')', '|', '[', ']', '\\'].includes(c)) {
                  result += '\\' + c;
                } else {
                  result += c;
                }
              }
              return new RegExp('^' + result + '$');
            }

            function isIgnored(filePath, patterns) {
              return patterns.some(pattern => {
                const regex = globToRegex(pattern);
                // Patterns without a slash match against the basename as well
                if (!pattern.includes('/') && !pattern.startsWith('**/')) {
                  return regex.test(filePath) || regex.test(path.basename(filePath));
                }
                return regex.test(filePath);
              });
            }

            // Fetch all changed files (paginated, GitHub caps at 3 000)
            const allFiles = [];
            for (let page = 1; ; page++) {
              const { data: batch } = await github.rest.pulls.listFiles({
                owner: context.repo.owner,
                repo: context.repo.repo,
                pull_number: context.issue.number,
                per_page: 100,
                page,
              });
              allFiles.push(...batch);
              if (batch.length < 100) break;
            }

            const relevant = allFiles.filter(f => !isIgnored(f.filename, ignorePatterns));

            function isTestFile(filePath) {
              const base = path.basename(filePath);
              return (
                /Test(s)?\.php$/.test(base) ||
                /\.(spec|test)\.(ts|js|tsx|jsx)$/.test(base) ||
                /^tests\//.test(filePath) ||
                /\/tests\//.test(filePath) ||
                /\/test\//.test(filePath)
              );
            }

            // Score each file with weighted contributions:
            // - Deleted files: lines at 20 % weight, file count at 10
            // - Test files:    additions at 50 %, deletions at 15 %, file count at 10
            // - Regular files: additions at 100 %, deletions at 30 %, file count at 20
            let score = 0;
            let additions = 0, deletions = 0;
            for (const f of relevant) {
              additions += f.additions;
              deletions += f.deletions;
              if (f.status === 'removed') {
                score += (f.additions + f.deletions) * 0.2 + 10;
              } else if (isTestFile(f.filename)) {
                score += f.additions * 0.5 + f.deletions * 0.15 + 10;
              } else {
                score += f.additions + f.deletions * 0.3 + 20;
              }
            }

            const filesChanged = relevant.length;

            core.info(`Relevant files: ${filesChanged} | +${additions} -${deletions} | score: ${score.toFixed(1)}`);

            const sizeLabel = score <= 250 ? 'small'
                            : score <= 900 ? 'medium'
                            : score <= 1800 ? 'large'
                            : 'not-reviewable';

            // Swap out any existing size label
            const SIZE_LABELS = ['small', 'medium', 'large', 'not-reviewable'];
            const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });
            for (const label of currentLabels) {
              if (SIZE_LABELS.includes(label.name) && label.name !== sizeLabel) {
                await github.rest.issues.removeLabel({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: context.issue.number,
                  name: label.name,
                });
              }
            }
            if (!currentLabels.some(l => l.name === sizeLabel)) {
              await github.rest.issues.addLabels({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                labels: [sizeLabel],
              });
            }

            core.info(`Applied label: ${sizeLabel}`);

Things to keep in mind

Create the not-reviewable label first. GitHub's label API throws if you try to apply a label that doesn't exist in the repository. Add it once through the UI or with the API before the workflow runs.

Test file detection is heuristic. The patterns we use match our conventions. If your project puts tests in a __tests__ directory or uses a .test.rb suffix, you'll need to adjust the isTestFile function.

The thresholds are starting points. What counts as small depends entirely on your team's pace and context. We tuned ours over a few weeks by looking at which PRs felt easy and which felt painful, and adjusting until the labels matched our intuition. Start with something reasonable and iterate.

PR size labels are a low-effort, high-signal addition to any review workflow. The key insight behind the weighted model is that not all lines are equal. A deleted file, a test assertion, and a new service class represent very different amounts of reviewer work. Treating them the same produces labels that mislead; treating them differently produces labels that actually help.

The workflow is about 100 lines of JavaScript inside a YAML file. It's easy to read, easy to adapt, and it has meaningfully changed how we queue and prioritise reviews at Contractify.