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 uso | Tipos a incluir |
|---|---|
| Análisis básico (default) | opened, synchronize |
| Solo cuando se pide review | review_requested |
| Activable manualmente con label | opened, synchronize, labeled + filtro |
| Ignorar drafts hasta que estén listos | opened, 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:
| Variable | Significado |
|---|---|
github.event.pull_request.number | Número del PR |
github.event.pull_request.title | Título del PR |
github.event.pull_request.body | Descripción/body |
github.event.pull_request.user.login | Username del autor |
github.event.pull_request.base.ref | Rama destino (típicamente main) |
github.event.pull_request.head.ref | Rama del PR |
github.event.pull_request.head.sha | SHA del último commit |
github.event.pull_request.changed_files | Cantidad de archivos modificados |
github.event.pull_request.additions | Líneas agregadas |
github.event.pull_request.deletions | Líneas eliminadas |
github.event.pull_request.draft | true/false |
github.event.pull_request.labels | Array 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
openedysynchronize - 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:
- Obtiene la lista de archivos vía API
- Filtra por extensiones de código (.py, .ts, .tsx, .js)
- Excluye archivos con "test" o "fixture" en el nombre
- 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:
- PRs sin archivos relevantes (skip silencioso)
- Archivos sin
patch(binarios, muy grandes) - 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 diffdirecto) - Filtrar y priorizar archivos antes de pasar al modelo: extensiones + tamaño + tipo
fetch-depth: 0es 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
- GitHub Pull Request events — Lista completa de tipos
- GitHub Pulls API: List files — Endpoint de archivos modificados
- actions/checkout — Documentación oficial
- Git diff syntax — Sintaxis completa de
git diff - .gitattributes y linguist-generated — Marcar archivos auto-generados
- GitHub Actions context — Variables disponibles en workflows