Module 2: Automated Code Review on PRs

PR Triggers and Diff Extraction

PR Triggers and Diff Extraction

Overview

The first technical step to building a code review bot is to trigger the workflow at the right moment and give the model the right context. It sounds simple, but the details matter: poorly configured triggers generate unnecessary runs (capsule 05 of the previous module covered costs), poorly extracted diffs produce analysis with incomplete information, and unhandled events cause runs that fail silently.

This capsule covers both steps: configuring specific PR triggers based on the use case, and extracting the diff efficiently with the information Claude Code needs (no more, no less). By the end, you'll have a workflow that triggers only when it adds value and passes the model exactly the context it needs.


Pull Request Events: The Complete List

GitHub fires different events during a PR's lifecycle. Knowing them lets you choose the right one:

on:
  pull_request:
    types:
      - opened          # PR just opened
      - synchronize     # New push to the PR's branch
      - reopened        # PR closed and reopened
      - ready_for_review # Changed from draft to ready
      - labeled         # A label was added
      - unlabeled       # A label was removed
      - edited          # Title or description was edited
      - assigned        # Assigned to someone
      - review_requested # Review was explicitly requested
      - closed          # Closed (merged or not)

Default triggers (if you don't specify types): opened, synchronize, reopened. Enough for most cases.

When to add other types

Use caseTypes to include
Basic analysis (default)opened, synchronize
Only when review is requestedreview_requested
Manually activatable with a labelopened, synchronize, labeled + filter
Ignore drafts until they're readyopened, synchronize, ready_for_review with filter

Example: Manually activatable with the needs-ai-review label

on:
  pull_request:
    types: [opened, synchronize, labeled]

jobs:
  review:
    if: |
      github.event.action != 'labeled' ||
      github.event.label.name == 'needs-ai-review'
    runs-on: ubuntu-latest

This lets developers invoke the review explicitly by adding a label, in addition to the automatic runs. Useful when the bot isn't yet default-on for the whole team.


Trigger on State Change: Drafts

A common decision: don't analyze draft PRs. Reason: they're a work in progress, the analysis will get stale fast, it wastes tokens.

jobs:
  review:
    if: github.event.pull_request.draft == false
    runs-on: ubuntu-latest

The workflow triggers when the PR leaves draft (the ready_for_review event) if you add it to the types. Typical combination:

on:
  pull_request:
    types: [opened, synchronize, ready_for_review]

jobs:
  review:
    if: github.event.pull_request.draft == false
    runs-on: ubuntu-latest

Result: automatic analysis when the PR is ready, not before.


Extracting the Diff: The Basics

The diff is the bot's main input. But there are several ways to extract it, with different trade-offs.

Option 1: Direct git diff (the simplest)

- name: Get diff
  run: git diff origin/${{ github.base_ref }}...HEAD > diff.txt

When to use it: general analysis of the complete PR. It returns all the changes (all files, all modified lines) in unified format.

Limitations:

  • If the PR has 50 files, the diff can be huge (tens of thousands of tokens)
  • It doesn't distinguish between critical files and trivial files
  • It includes formatting changes, whitespace, etc.

Option 2: GitHub API (more structured)

import requests
import os

token = os.environ["GITHUB_TOKEN"]
repo = os.environ["GITHUB_REPOSITORY"]
pr_number = os.environ["PR_NUMBER"]

# Get the list of modified files with metadata
url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}/files"
headers = {
    "Authorization": f"Bearer {token}",
    "Accept": "application/vnd.github+json",
}

files = requests.get(url, headers=headers).json()

# Each element has: filename, additions, deletions, changes, patch, status
for f in files:
    print(f"{f['filename']}: +{f['additions']} -{f['deletions']} ({f['status']})")
    # f["patch"] contains the diff of that specific file

When to use it:

  • You want per-file metadata (lines added, removed, status)
  • You want to prioritize/filter files before passing them to the model
  • You want to generate inline comments later (capsule 03) using the exact SHA

Key advantage: it returns structured JSON instead of text. Each file comes with its own patch, which makes chunking easier (capsule 06).

Option 3: Diff filtered by file type

- name: Get filtered diff
  run: |
    git diff origin/${{ github.base_ref }}...HEAD \
      -- '*.py' '*.ts' '*.js' \
      ':!*test*' ':!*.lock' \
      > diff.txt

Only includes .py, .ts, .js files and excludes files with "test" in the name and .lock files. Useful for reducing the diff before sending it to the model.


The Pattern: File List + Selective Diff

In practice, combining the options produces the best result:

"""Diff extraction with prioritization."""
import os
import requests

token = os.environ["GITHUB_TOKEN"]
repo = os.environ["GITHUB_REPOSITORY"]
pr_number = os.environ["PR_NUMBER"]

# 1. List of the PR's files (via API)
url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}/files?per_page=100"
headers = {
    "Authorization": f"Bearer {token}",
    "Accept": "application/vnd.github+json",
}

files = requests.get(url, headers=headers).json()

# 2. Filter and prioritize
RELEVANT_EXTENSIONS = {".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rb"}
MAX_FILES_TO_REVIEW = 15

def is_relevant(f):
    """Filter: source code files only."""
    name = f["filename"]
    if not any(name.endswith(ext) for ext in RELEVANT_EXTENSIONS):
        return False
    if "test" in name or "fixture" in name:
        return False
    if f["status"] == "removed":
        return False  # removed files aren't reviewed
    return True

relevant = [f for f in files if is_relevant(f)]

# Prioritize by amount of changes (more changes = more relevant)
relevant.sort(key=lambda f: f["changes"], reverse=True)

# Take the top N
to_review = relevant[:MAX_FILES_TO_REVIEW]

# 3. Build the combined diff
diff_parts = []
for f in to_review:
    if f.get("patch"):  # some files have no patch (binaries, very large)
        diff_parts.append(f"--- {f['filename']} ---\n{f['patch']}\n")

combined_diff = "\n".join(diff_parts)

# Save for the next step
with open("filtered_diff.txt", "w") as out:
    out.write(combined_diff)

print(f"Total files in PR: {len(files)}")
print(f"Relevant files: {len(relevant)}")
print(f"Files sent to Claude: {len(to_review)}")

Result: a filtered and prioritized diff that fits comfortably in the model's context window and focuses on what's worth reviewing.


Handling PRs with an Empty Diff

Edge case: the PR has no visible changes (all the modified files are binary, generated files, or the PR is just a merge).

if not combined_diff.strip():
    print("PR with no analyzable changes. Skipping.")
    # Optional: post a comment to the PR explaining
    sys.exit(0)

Don't mark the workflow as failed in this case. It's a valid case (the bot has nothing to say).


PR Events: Context Variables

GitHub Actions exposes PR information via github.event.pull_request. Useful for making decisions:

- name: Conditional logic
  if: |
    github.event.pull_request.changed_files <= 30 &&
    github.event.pull_request.additions <= 1000
  run: # ...

Typical available variables:

VariableMeaning
github.event.pull_request.numberPR number
github.event.pull_request.titlePR title
github.event.pull_request.bodyDescription/body
github.event.pull_request.user.loginAuthor's username
github.event.pull_request.base.refTarget branch (typically main)
github.event.pull_request.head.refPR's branch
github.event.pull_request.head.shaSHA of the last commit
github.event.pull_request.changed_filesNumber of modified files
github.event.pull_request.additionsLines added
github.event.pull_request.deletionsLines removed
github.event.pull_request.drafttrue/false
github.event.pull_request.labelsArray of labels

Common Pitfalls

Error 1: Forgetting fetch-depth: 0

Symptom: git diff origin/main...HEAD fails with "unknown revision".

Why it happens: By default, actions/checkout does a shallow clone (last commit only). Without the full history, git can't resolve origin/main.

How to fix it: Always fetch-depth: 0 when you compare against another branch:

- uses: actions/checkout@v4
  with:
    fetch-depth: 0

Error 2: Assuming github.base_ref is main

Symptom: It works on some PRs and fails on others (e.g. PRs against develop).

Why it happens: base_ref is the PR's target branch — it can be main, develop, release/v2, etc. Hardcoding origin/main breaks in repos with several active branches.

How to fix it: Always use origin/${{ github.base_ref }}:

run: git diff origin/${{ github.base_ref }}...HEAD > diff.txt

Error 3: Diff too large

Symptom: The Claude Code step fails with a context window error or produces generic analysis.

Why it happens: A PR with 50 files generates a diff of 50K+ lines. Passing everything to the model is counterproductive (it degrades quality and costs more).

How to fix it: Filter and prioritize as in the pattern shown above. Capsule 06 develops more sophisticated chunking.

Error 4: Not filtering generated files

Symptom: The bot analyzes package-lock.json, yarn.lock, .min.js files, auto-generated schemas — and its comments are noise.

Why it happens: The extension filter includes auto-generated files that shouldn't be reviewed.

How to fix it: An explicit list of files to ignore. In .gitattributes you can mark files as linguist-generated=true and filter by that.

Error 5: Not handling the missing f["patch"] case

Symptom: The script fails with KeyError: 'patch' for some files.

Why it happens: GitHub doesn't return patch for binary files, files that are too large (>3000 lines of diff), or files renamed with no changes.

How to fix it: Always validate with f.get("patch") and skip if it doesn't exist:

if not f.get("patch"):
    continue  # binary file, too large, or renamed

Diagnosis

Question 1: Does your workflow have `fetch-depth: 0` in the checkout?

If not, the git diff against origin/main will fail for real PRs. Capsule 02 of the previous module covered it.

Question 2: Does your script ignore draft PRs?

If not, you waste tokens analyzing work in progress. Enable the filter if: github.event.pull_request.draft == false.

Question 3: How do you decide which files to pass to the model when the PR is large?

If you said "all of them": likely quality degradation from a full context. If you said "the first N alphabetically": it's not optimal. The right thing is to prioritize by relevance (extension + amount of changes).

Question 4: Do you filter auto-generated files (lock files, minified, schemas)?

If not, the bot will comment on files it shouldn't touch. An explicit list or use .gitattributes.

Question 5: Does your script handle the case of a PR with no analyzable changes?

If not, the script may fail or produce empty analysis. Validate at the start and exit 0 if there's nothing to analyze.


Exercises

Exercise 1: Conditional trigger with a label (Easy)

Configure the workflow to trigger:

  • Automatically on opened and synchronize
  • When the needs-ai-review label is added
  • NOT on draft PRs
See solution
on:
  pull_request:
    types: [opened, synchronize, labeled]

jobs:
  review:
    if: |
      github.event.pull_request.draft == false &&
      (
        github.event.action != 'labeled' ||
        github.event.label.name == 'needs-ai-review'
      )
    runs-on: ubuntu-latest

Exercise 2: Filtering and prioritization (Medium)

Implement the extraction script that:

  1. Gets the list of files via the API
  2. Filters by code extensions (.py, .ts, .tsx, .js)
  3. Excludes files with "test" or "fixture" in the name
  4. Takes the top 10 by amount of changes
See solution

See the complete pattern in the "The Pattern: File List + Selective Diff" section above. Adapt it to your specific extensions.

Exercise 3: Handle edge cases (Hard)

Modify the script to correctly handle:

  1. PRs with no relevant files (silent skip)
  2. Files with no patch (binaries, very large)
  3. PRs with more than 50 files (message to the PR + skip the exhaustive analysis)
See solution
if not files:
    print("PR with no modified files.")
    sys.exit(0)

if len(files) > 50:
    # Post a comment to the PR
    body = f"PR too large ({len(files)} files). Consider splitting it."
    requests.post(comments_url, headers=headers, json={"body": body})
    sys.exit(0)

relevant = [f for f in files if is_relevant(f) and f.get("patch")]

if not relevant:
    print("PR with no relevant files to review.")
    sys.exit(0)

# ... continue with analysis

Summary

  • Default triggers (opened, synchronize, reopened) cover most cases
  • Filtering drafts saves tokens on work in progress
  • The GitHub API returns structured per-file metadata (better than direct git diff)
  • Filter and prioritize files before passing them to the model: extensions + size + type
  • fetch-depth: 0 is a prerequisite for diffs against another branch
  • Handling edge cases (no patch, empty PR, giant PR) avoids failed workflows

Next capsule: 03 — Inline comments via the GitHub API. You have the diff. Now you learn to publish the model's output not as a general comment, but as inline comments on specific lines — the most visible and actionable format for developers.


Additional Resources

  1. GitHub Pull Request events — Complete list of types
  2. GitHub Pulls API: List files — Modified files endpoint
  3. actions/checkout — Official documentation
  4. Git diff syntax — Complete git diff syntax
  5. .gitattributes and linguist-generated — Mark auto-generated files
  6. GitHub Actions context — Variables available in workflows