Remote Work Tools

How to Automate Pull Request Labeling

Labels tell your team what a pull request is before they open it. Without automation, labels get applied inconsistently or not at all. With a few workflow files, every PR gets labeled the moment it’s opened by changed files, size, and title convention.


Approach 1: GitHub’s Official Labeler Action

.github/labeler.yml

frontend:
  - changed-files:
    - any-glob-to-any-file:
      - "src/frontend/**"
      - "app/assets/**"
      - "*.css"

backend:
  - changed-files:
    - any-glob-to-any-file:
      - "src/api/**"
      - "app/controllers/**"
      - "app/models/**"

infrastructure:
  - changed-files:
    - any-glob-to-any-file:
      - "terraform/**"
      - "kubernetes/**"
      - "docker-compose*.yml"
      - "Dockerfile*"

documentation:
  - changed-files:
    - any-glob-to-any-file:
      - "docs/**"
      - "**/*.md"

tests:
  - changed-files:
    - any-glob-to-any-file:
      - "**/*.test.*"
      - "**/*.spec.*"
      - "tests/**"

dependencies:
  - changed-files:
    - any-glob-to-any-file:
      - "package.json"
      - "package-lock.json"
      - "yarn.lock"
      - "go.mod"
      - "requirements*.txt"

.github/workflows/labeler.yml

name: Label Pull Request
on:
  pull_request:
    types: [opened, synchronize, reopened]

permissions:
  contents: read
  pull-requests: write

jobs:
  label:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/labeler@v5
        with:
          repo-token: ${{ secrets.GITHUB_TOKEN }}
          configuration-path: .github/labeler.yml
          sync-labels: true

Approach 2: Label by PR Size

name: PR Size Label
on:
  pull_request:
    types: [opened, synchronize, reopened]

permissions:
  pull-requests: write

jobs:
  size:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const pr = context.payload.pull_request;
            const total = pr.additions + pr.deletions;

            const sizeLabels = {
              "size/XS": total <= 10,
              "size/S":  total > 10  && total <= 100,
              "size/M":  total > 100 && total <= 500,
              "size/L":  total > 500 && total <= 1000,
              "size/XL": total > 1000,
            };

            const allSizeLabels = Object.keys(sizeLabels);
            const newLabel = Object.entries(sizeLabels)
              .find(([, matches]) => matches)?.[0];

            const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: pr.number,
            });

            for (const label of currentLabels.map(l => l.name).filter(l => allSizeLabels.includes(l))) {
              await github.rest.issues.removeLabel({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: pr.number,
                name: label,
              });
            }

            try {
              await github.rest.issues.createLabel({
                owner: context.repo.owner,
                repo: context.repo.repo,
                name: newLabel,
                color: total <= 100 ? "0e8a16" : total <= 500 ? "e4e669" : "b60205",
              });
            } catch (e) { /* Label already exists */ }

            await github.rest.issues.addLabels({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: pr.number,
              labels: [newLabel],
            });

Approach 3: Label by Conventional Commit Title

name: Conventional PR Labels
on:
  pull_request:
    types: [opened, edited, synchronize]

permissions:
  pull-requests: write

jobs:
  label:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const pr = context.payload.pull_request;
            const title = pr.title.toLowerCase();

            const typeMap = {
              "feat":     { label: "feature",      color: "0075ca" },
              "fix":      { label: "bug",           color: "d73a4a" },
              "chore":    { label: "chore",         color: "e4e669" },
              "docs":     { label: "documentation", color: "0075ca" },
              "refactor": { label: "refactor",      color: "7057ff" },
              "perf":     { label: "performance",   color: "e4e669" },
              "test":     { label: "tests",         color: "0e8a16" },
              "ci":       { label: "ci/cd",         color: "0052cc" },
            };

            const labelsToAdd = [];
            for (const [prefix, { label, color }] of Object.entries(typeMap)) {
              if (title.startsWith(prefix + ":") || title.startsWith(prefix + "(")) {
                labelsToAdd.push({ name: label, color });
                break;
              }
            }

            if (title.includes("!:") || title.includes("breaking")) {
              labelsToAdd.push({ name: "breaking-change", color: "b60205" });
            }

            for (const { name, color } of labelsToAdd) {
              try {
                await github.rest.issues.createLabel({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  name, color,
                });
              } catch (e) { /* exists */ }

              await github.rest.issues.addLabels({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: pr.number,
                labels: [name],
              });
            }

Bootstrap Labels in a New Repo

#!/bin/bash
REPO="${GITHUB_REPOSITORY:-yourorg/yourrepo}"

create_label() {
  gh label create "$1" --color "$2" --description "$3" --repo "$REPO" --force 2>/dev/null || true
}

create_label "feature"        "0075ca" "New feature"
create_label "bug"            "d73a4a" "Bug fix"
create_label "chore"          "e4e669" "Maintenance task"
create_label "documentation"  "0075ca" "Documentation update"
create_label "refactor"       "7057ff" "Code refactoring"
create_label "tests"          "0e8a16" "Test additions or fixes"
create_label "ci/cd"          "0052cc" "CI/CD pipeline changes"
create_label "breaking-change" "b60205" "Breaking API or behavior change"
create_label "size/XS"        "0e8a16" "< 10 lines"
create_label "size/S"         "0e8a16" "10-100 lines"
create_label "size/M"         "e4e669" "100-500 lines"
create_label "size/L"         "d93f0b" "500-1000 lines"
create_label "size/XL"        "b60205" "> 1000 lines"
create_label "dependencies"   "0075ca" "Dependency updates"

echo "Labels created in $REPO"

Using Labels in Changelog Generation

# .github/release-please.yml
changelog-sections:
  - type: feature
    section: "Features"
  - type: bug
    section: "Bug Fixes"
  - type: breaking-change
    section: "Breaking Changes"
  - type: performance
    section: "Performance"
  - type: dependencies
    section: "Dependencies"


Built by theluckystrike — More at zovo.one