Módulo 2: Code Review Automático en PRs

Triggers de PR y Extracción del Diff

Triggers de PR y Extracción del Diff

Descripción

El primer paso técnico para construir un bot de code review es disparar el workflow en el momento correcto y darle al modelo el contexto correcto. Suena simple, pero los detalles importan: triggers mal configurados generan ejecuciones innecesarias (cápsula 05 del módulo anterior cubrió costos), diffs mal extraídos producen análisis con información incompleta, y eventos no manejados causan ejecuciones que fallan silenciosamente.

Esta cápsula cubre los dos pasos: configurar triggers de PR específicos según el caso de uso, y extraer el diff de forma eficiente con la información que Claude Code necesita (no más, no menos). Al terminar, vas a tener un workflow que se dispara solo cuando aporta valor y le pasa al modelo exactamente el contexto que necesita.


Eventos de Pull Request: La Lista Completa

GitHub dispara distintos eventos durante el ciclo de vida de un PR. Conocerlos te permite elegir el correcto:

on:
  pull_request:
    types:
      - opened          # PR recién abierto
      - synchronize     # Push nuevo a la rama del PR
      - reopened        # PR cerrado y reabierto
      - ready_for_review # Cambió de draft a ready
      - labeled         # Se agregó una label
      - unlabeled       # Se removió una label
      - edited          # Se editó título o descripción
      - assigned        # Se asignó a alguien
      - review_requested # Se pidió review explícitamente
      - closed          # Se cerró (merged o no)

Triggers default (si no especificas types): opened, synchronize, reopened. Suficiente para la mayoría de casos.

Cuándo agregar otros tipos

Caso de usoTipos a incluir
Análisis básico (default)opened, synchronize
Solo cuando se pide reviewreview_requested
Activable manualmente con labelopened, synchronize, labeled + filtro
Ignorar drafts hasta que estén listosopened, synchronize, ready_for_review con filtro

Ejemplo: Activable manualmente con label needs-ai-review

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

Esto permite a developers invocar el review explícitamente agregando una label, además de los runs automáticos. Útil cuando el bot todavía no es default-on para todo el equipo.


Trigger por Cambio de Estado: Drafts

Una decisión común: no analizar PRs en draft. Razón: están en construcción, el análisis se hará obsoleto rápido, gasta tokens.

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

El workflow se dispara cuando el PR sale de draft (evento ready_for_review) si lo agregas a los types. Combinación típica:

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

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

Resultado: análisis automático cuando el PR está listo, no antes.


Extraer el Diff: Lo Básico

El diff es el input principal del bot. Pero hay varias formas de extraerlo, con trade-offs distintos.

Opción 1: git diff directo (la más simple)

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

Cuándo usarla: análisis general del PR completo. Devuelve todos los cambios (todos los archivos, todas las líneas modificadas) en formato unificado.

Limitaciones:

  • Si el PR tiene 50 archivos, el diff puede ser gigante (decenas de miles de tokens)
  • No distingue entre archivos críticos y archivos triviales
  • Incluye cambios de formato, espacios en blanco, etc.

Opción 2: GitHub API (más estructurada)

import requests
import os

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

# Obtener lista de archivos modificados con 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()

# Cada elemento tiene: filename, additions, deletions, changes, patch, status
for f in files:
    print(f"{f['filename']}: +{f['additions']} -{f['deletions']} ({f['status']})")
    # f["patch"] contiene el diff de ese archivo específico

Cuándo usarla:

  • Quieres metadata por archivo (líneas agregadas, eliminadas, status)
  • Quieres priorizar/filtrar archivos antes de pasar al modelo
  • Quieres generar inline comments después (cápsula 03) usando el SHA exacto

Ventaja clave: retorna JSON estructurado en lugar de texto. Cada archivo viene con su propio patch, lo cual facilita chunking (cápsula 06).

Opción 3: Diff filtrado por tipo de archivo

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

Solo incluye archivos .py, .ts, .js y excluye archivos con "test" en el nombre y archivos .lock. Útil para reducir el diff antes de enviarlo al modelo.


El Patrón: Lista de Archivos + Diff Selectivo

En la práctica, combinar las opciones produce el mejor resultado:

"""Extracción de diff con priorización."""
import os
import requests

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

# 1. Lista de archivos del PR (vía 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. Filtrar y priorizar
RELEVANT_EXTENSIONS = {".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rb"}
MAX_FILES_TO_REVIEW = 15

def is_relevant(f):
    """Filtrar: solo archivos de código fuente."""
    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  # archivos eliminados no se revisan
    return True

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

# Priorizar por cantidad de cambios (más cambios = más relevante)
relevant.sort(key=lambda f: f["changes"], reverse=True)

# Tomar los top N
to_review = relevant[:MAX_FILES_TO_REVIEW]

# 3. Construir diff combinado
diff_parts = []
for f in to_review:
    if f.get("patch"):  # algunos archivos no tienen patch (binarios, muy grandes)
        diff_parts.append(f"--- {f['filename']} ---\n{f['patch']}\n")

combined_diff = "\n".join(diff_parts)

# Guardar para el siguiente step
with open("filtered_diff.txt", "w") as out:
    out.write(combined_diff)

print(f"Total archivos en PR: {len(files)}")
print(f"Archivos relevantes: {len(relevant)}")
print(f"Archivos enviados a Claude: {len(to_review)}")

Resultado: un diff filtrado y priorizado que cabe cómodamente en el context window del modelo y se enfoca en lo que vale la pena revisar.


Manejar PRs con Diff Vacío

Caso límite: el PR no tiene cambios visibles (todos los archivos modificados son binarios, generated files, o el PR es solo una merge).

if not combined_diff.strip():
    print("PR sin cambios analizables. Skipping.")
    # Opcional: postear comment al PR explicando
    sys.exit(0)

No marques el workflow como failed en este caso. Es un caso válido (el bot no tiene nada que decir).


Eventos del PR: Variables del Contexto

GitHub Actions expone información del PR vía github.event.pull_request. Útil para tomar decisiones:

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

Variables disponibles típicas:

VariableSignificado
github.event.pull_request.numberNúmero del PR
github.event.pull_request.titleTítulo del PR
github.event.pull_request.bodyDescripción/body
github.event.pull_request.user.loginUsername del autor
github.event.pull_request.base.refRama destino (típicamente main)
github.event.pull_request.head.refRama del PR
github.event.pull_request.head.shaSHA del último commit
github.event.pull_request.changed_filesCantidad de archivos modificados
github.event.pull_request.additionsLíneas agregadas
github.event.pull_request.deletionsLíneas eliminadas
github.event.pull_request.drafttrue/false
github.event.pull_request.labelsArray de labels

Trampas Comunes

Error 1: Olvidar fetch-depth: 0

Síntoma: git diff origin/main...HEAD falla con "unknown revisión".

Por qué pasa: Por defecto, actions/checkout hace shallow clone (solo último commit). Sin historial completo, git no puede resolver origin/main.

Cómo corregir: Siempre fetch-depth: 0 cuando comparas contra otra rama:

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

Error 2: Asumir que github.base_ref es main

Síntoma: Funciona en algunos PRs y falla en otros (ej. PRs contra develop).

Por qué pasa: base_ref es la rama destino del PR — puede ser main, develop, release/v2, etc. Hardcodear origin/main rompe en repos con varias ramas activas.

Cómo corregir: Siempre usar origin/${{ github.base_ref }}:

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

Error 3: Diff demasiado grande

Síntoma: El step de Claude Code falla con error de context window o produce análisis genérico.

Por qué pasa: PR con 50 archivos genera diff de 50K+ líneas. Pasar todo al modelo es contraproducente (degrada calidad y cuesta más).

Cómo corregir: Filtrar y priorizar como en el patrón mostrado arriba. La cápsula 06 desarrolla chunking más sofisticado.

Error 4: No filtrar archivos generados

Síntoma: El bot analiza package-lock.json, yarn.lock, archivos .min.js, schemas auto-generados — y sus comentarios son ruido.

Por qué pasa: El filtro por extensión incluye archivos auto-generados que no deben revisarse.

Cómo corregir: Lista explícita de archivos a ignorar. En .gitattributes puedes marcar archivos como linguist-generated=true y filtrar por eso.

Error 5: No manejar el caso f["patch"] ausente

Síntoma: El script falla con KeyError: 'patch' para algunos archivos.

Por qué pasa: GitHub no devuelve patch para archivos binarios, archivos demasiado grandes (>3000 líneas de diff), o archivos renombrados sin cambios.

Cómo corregir: Validar siempre con f.get("patch") y skipear si no existe:

if not f.get("patch"):
    continue  # archivo binario, demasiado grande, o renombrado

Diagnóstico

Pregunta 1: ¿Tu workflow tiene `fetch-depth: 0` en el checkout?

Si no, el git diff contra origin/main va a fallar para PRs reales. La cápsula 02 del módulo anterior lo cubrió.

Pregunta 2: ¿Tu script ignora PRs en draft?

Si no, gastas tokens analizando trabajo en progreso. Activar el filtro if: github.event.pull_request.draft == false.

Pregunta 3: ¿Cómo decides qué archivos pasar al modelo cuando el PR es grande?

Si dijiste "todos": probable degradación de calidad por context lleno. Si dijiste "los primeros N alfabéticamente": no es óptimo. Lo correcto es priorizar por relevancia (extensión + cantidad de cambios).

Pregunta 4: ¿Filtras archivos auto-generados (lock files, minified, schemas)?

Si no, el bot va a comentar sobre archivos que no debería tocar. Lista explícita o usar .gitattributes.

Pregunta 5: ¿Tu script maneja el caso de un PR sin cambios analizables?

Si no, el script puede fallar o producir análisis vacío. Validar al inicio y exit 0 si no hay nada que analizar.


Ejercicios

Ejercicio 1: Trigger condicional con label (Fácil)

Configura el workflow para que se dispare:

  • Automáticamente en opened y synchronize
  • Cuando se agrega la label needs-ai-review
  • NO en PRs draft
Ver solución
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

Ejercicio 2: Filtrado y priorización (Medio)

Implementa el script de extracción que:

  1. Obtiene la lista de archivos vía API
  2. Filtra por extensiones de código (.py, .ts, .tsx, .js)
  3. Excluye archivos con "test" o "fixture" en el nombre
  4. Toma los top 10 por cantidad de cambios
Ver solución

Ver el patrón completo en la sección "El Patrón: Lista de Archivos + Diff Selectivo" arriba. Adaptarlo a tus extensiones específicas.

Ejercicio 3: Manejar caso límite (Difícil)

Modifica el script para manejar correctamente:

  1. PRs sin archivos relevantes (skip silencioso)
  2. Archivos sin patch (binarios, muy grandes)
  3. PRs con más de 50 archivos (mensaje al PR + skipear análisis exhaustivo)
Ver solución
if not files:
    print("PR sin archivos modificados.")
    sys.exit(0)

if len(files) > 50:
    # Postear comment al PR
    body = f"PR demasiado grande ({len(files)} archivos). Considera dividirlo."
    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 sin archivos relevantes para revisar.")
    sys.exit(0)

# ... continuar con análisis

Resumen

  • Triggers default (opened, synchronize, reopened) cubren la mayoría de casos
  • Filtrar drafts ahorra tokens en trabajo en progreso
  • GitHub API retorna metadata estructurada por archivo (mejor que git diff directo)
  • Filtrar y priorizar archivos antes de pasar al modelo: extensiones + tamaño + tipo
  • fetch-depth: 0 es prerequisito para diffs contra otra rama
  • Manejar casos límite (sin patch, PR vacío, PR gigante) evita workflows fallidos

Próxima cápsula: 03 — Inline comments via GitHub API. Tienes el diff. Ahora aprendes a publicar el output del modelo no como un comentario general, sino como comentarios inline en líneas específicas — el formato más visible y accionable para los developers.


Recursos Adicionales

  1. GitHub Pull Request events — Lista completa de tipos
  2. GitHub Pulls API: List files — Endpoint de archivos modificados
  3. actions/checkout — Documentación oficial
  4. Git diff syntax — Sintaxis completa de git diff
  5. .gitattributes y linguist-generated — Marcar archivos auto-generados
  6. GitHub Actions context — Variables disponibles en workflows