Pull Request Automation & Bots Jump to heading

The time a change spends as an open pull request is mostly waiting, and most of that waiting is administrative: waiting to be routed to the right person, waiting for someone to notice it exists, waiting for a result that has been posted somewhere nobody reads. None of it is review. Automating the routing and reporting layer is the cheapest throughput improvement available to most teams, and β€” unlike asking people to review faster β€” it works. This part of Git Automation & CI/CD Hook Engineering covers the bots worth running and the ones that make things worse.

Prerequisites Jump to heading

What Automation Should and Should Not Decide Jump to heading

The useful boundary is between mechanical facts and judgements. Which paths a change touches is mechanical; whether a change is a good idea is not. Bots that stay on the mechanical side are welcomed and forgotten about; bots that make judgements β€” closing work as abandoned, demanding changes to formatting a human already accepted β€” generate resentment and eventually get disabled along with the useful ones.

Two kinds of decision, only one of which should be automatedMechanical facts such as which paths changed, which team owns them and how large the diff is can be derived reliably. Judgements about whether work is abandoned or whether an exception is warranted need a person, because being wrong about them costs trust.Mechanical β€” automateJudgement β€” ask a personwhich paths changedderived from the diffβ€”who owns the codefrom CODEOWNERSβ€”is this abandoned?β€”ask the authoris the size justified?β€”reviewer decidesa bot that is wrong about a fact is fixed; a bot that is wrong about a judgement is muted

Step 1 β€” Derive Labels From the Diff Jump to heading

Labels are the routing table for everything else: notifications, review assignment, release notes, dashboards. Deriving them from the changed paths means they are always accurate and never someone’s chore.

# .github/labeler.yml β€” paths in, labels out
area/api:
  - changed-files:
      - any-glob-to-any-file: ['services/api/**', 'packages/api-client/**']
area/web:
  - changed-files:
      - any-glob-to-any-file: ['apps/web/**']
type/dependencies:
  - changed-files:
      - any-glob-to-any-file: ['**/package-lock.json', '**/go.sum']
risk/migration:
  - changed-files:
      - any-glob-to-any-file: ['**/migrations/**']
# Verification: what would the rules label your current branch?
git diff --name-only origin/main...HEAD | head -20

The risk/ prefix earns its keep. A label that marks a database migration is read by humans deciding what to review first and by pipelines deciding whether to require an extra approval.

Step 2 β€” Route to Reviewers by Ownership, Not by Rota Jump to heading

A round-robin across the whole team distributes load evenly and expertise badly. Ownership-based routing sends the change to someone who can actually judge it, and load balancing happens within the owning group.

# Who owns the paths this change touches?
git diff --name-only origin/main...HEAD | while read -r f; do
  printf '%s -> ' "$f"
  git check-attr -a "$f" >/dev/null 2>&1
  grep -E "^${f%%/*}" .github/CODEOWNERS | tail -1 || echo "(unowned)"
done | sort -u | head
# .github/workflows/assign.yml
name: assign
on:
  pull_request:
    types: [opened, ready_for_review]
permissions:
  pull-requests: write
jobs:
  reviewers:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: gh pr edit "$PR" --add-reviewer "$(scripts/owners-for-diff.sh)"
        env:
          GH_TOKEN: ${{ github.token }}
          PR: ${{ github.event.number }}

Unowned paths are the signal worth acting on. A file nobody owns is a file whose review is nobody’s responsibility β€” the problem path-based CODEOWNERS in a monorepo exists to solve.

From an opened pull request to a reviewer who can judge itThe diff yields a set of paths, ownership rules map those paths to a team, and the assignment step picks an available member of that team. Each step is mechanical, so the routing is right without anyone maintaining a rota.Diffchanged pathsOwnership rulesCODEOWNERSlongest match winsOwning teamone or moreAssigned revieweravailable memberan unowned path falls out of this chain β€” which is exactly the thing to fix

Step 3 β€” Report Results Without Flooding the Thread Jump to heading

A bot that appends a comment per push turns a five-comment review into a fifty-comment thread, and the human comments become unfindable. Edit one comment in place instead.

# Update the existing bot comment, or create it the first time
body=$(cat ci-summary.md)
existing=$(gh pr view "$PR" --json comments \
  --jq '.comments[] | select(.author.login=="github-actions") | .id' | head -1)
if [ -n "$existing" ]; then
  gh pr comment "$PR" --edit-last --body "$body"
else
  gh pr comment "$PR" --body "$body"
fi
# Verification: push twice and confirm the comment count does not grow
gh pr view "$PR" --json comments --jq '[.comments[] | select(.author.login=="github-actions")] | length'

Step 4 β€” Clean Up Branches, Carefully Jump to heading

Merged branches are safe to delete: the commits are reachable from the default branch. Unmerged branches are not, and a bot that deletes them destroys work.

# Branches whose tip is already contained in the default branch
git fetch --prune origin
git branch -r --merged origin/main | grep -v 'origin/main$' | sed 's|origin/||' | head -20

SAFETY WARNING β€” never automate deletion of unmerged branches. A branch that looks abandoned may be a fork point someone depends on, a release line, or six weeks of work its author stepped away from. Automatic deletion is defensible only for branches whose commits are reachable from the default branch; everything else needs a human and a conversation.

Step 5 β€” Measure Waiting, Not Activity Jump to heading

Bot activity is easy to see and tells you nothing. The number that matters is how long a pull request spends waiting for a human.

# Hours from opened to first review, over the last fifty pull requests
gh pr list --state merged --limit 50 --json number,createdAt,reviews \
  --jq '.[] | select(.reviews | length > 0)
        | {pr: .number,
           hours: (((.reviews[0].submittedAt | fromdate) - (.createdAt | fromdate)) / 3600 | floor)}'
Where the hours go in a typical pull requestMost of a pull request's life is spent before a reviewer ever opens it: waiting to be routed, waiting to be noticed. The review itself and the subsequent fixes are a small fraction, which is why routing automation moves the number and asking for faster reviews does not.median hours per stagewaiting to be picked up19 hunder review3 hauthor addressing feedback6 hwaiting to merge2 hthe first bar is the one automation can actually shrink

Configuration Reference Jump to heading

MechanismTypical defaultEffectWhen to change
Path labeleroffLabels derived from the diffTurn on first β€” everything else routes off labels
CODEOWNERSabsentMaps paths to owning teamsKeep it accurate or routing degrades silently
Auto-assignoffRequests review from ownersEnable once ownership is trustworthy
Comment strategyappendHow results reach the threadAlways edit in place
Branch deletionmanualRemoves merged branchesAutomate for merged only
Stale handlingnoneWhat happens to idle pull requestsNotify, never close automatically

Troubleshooting Jump to heading

SymptomLikely causeFix
Labels are wrong on renamed filesGlobs match old pathsRegenerate rules from the current tree
Reviewers assigned who know nothing about the changeRota-based rather than ownership-basedRoute from CODEOWNERS
Thread buried under bot commentsAppending instead of editingUse edit-in-place and one summary comment
Bot cannot comment on fork pull requestsRead-only token, correctlyUse the job summary; never pull_request_target
Everyone mutes the repositoryA bot that makes judgementsRemove the judgement, keep the facts
Branches pile up after mergesNo automatic deletion for merged branchesEnable it β€” those commits are reachable

Consolidating Bots Before Adding Another Jump to heading

Every repository reaches a point where the automation is itself the problem: four bots, each reasonable alone, collectively producing more output than the humans. The symptom is that people stop reading pull request threads, which removes the value of every bot simultaneously. Before adding anything, it is worth auditing what already writes to a pull request and what each of those writes is for.

# Who posts to pull requests, and how much?
gh pr list --state merged --limit 50 --json number,comments   --jq '[.[].comments[] | .author.login] | group_by(.)[] | {who: .[0], comments: length}'   | head
# And how much of it is machine output
gh pr list --state merged --limit 50 --json comments   --jq '[.[].comments[] | select(.author.login | test("bot|actions"))] | length'

The rule that holds up in practice is one machine comment per pull request, composed from however many jobs need to contribute. Everything else becomes either a check-run annotation, which attaches to the line it concerns, or a job summary, which attaches to the run. Both are read by the people who need them and ignored by the people who do not, which is exactly the property a thread comment lacks.

There is one more consolidation worth making, and it is about ownership rather than output. Automation that nobody maintains degrades into noise the moment the repository’s structure shifts: globs stop matching, owners leave, thresholds calibrated for a different codebase start firing constantly. Each bot needs a named owner and a periodic review, exactly as a test suite does, and the review should ask the only question that matters β€” when this last spoke up, did anyone act on it? Anything that fails that test is costing attention without buying anything, and removing it makes the remaining automation more credible rather than less.

Frequently Asked Questions Jump to heading

Should a bot close stale pull requests? Jump to heading

It should notice and notify; closing is a judgement. A pull request idle for a month may be waiting on a decision elsewhere, and closing it deletes context while solving nothing. Post a comment asking whether it is still wanted, and let a person act.

Do automatic reviewer assignments reduce review quality? Jump to heading

They improve it when driven by ownership and degrade it when driven by a rota. The difference is whether the assignee has context. If ownership data is poor, fix that first; assignment automation amplifies whatever the data says.

How many bots is too many? Jump to heading

The practical limit is one comment per pull request in total. Past that, the thread stops being readable and people start skimming, which costs more attention than the bots save. Consolidate into a single summary that several jobs write into.

Can these run on contributions from forks? Jump to heading

The read-only parts can. Anything that writes needs a token a fork build does not get, so post from a workflow triggered after the run completes, or use the job summary β€” the safety reasoning is in linting commit messages for forked pull requests.

Where should automation live β€” workflows in the repository, or a hosted app? Jump to heading

Prefer workflows you can read. A hosted application is quicker to enable and harder to reason about: its rules live outside the repository, its permissions are granted once and rarely reviewed, and when its behaviour changes there is no diff to look at. Automation defined as a workflow file changes through a pull request like anything else, which means the rule and the reasoning behind it stay together in history. The exception is genuinely complex behaviour β€” a merge queue, a sophisticated triage system β€” where reimplementing it badly is worse than adopting something maintained. In that case, pin the version and review its permission scope on a schedule, exactly as you would for any other dependency with write access to your repository.