Módulo 2: Code Review Automático en PRs
Inline Comments via GitHub API
Inline Comments via GitHub API
Descripción
Esta es la cápsula que transforma a Claude Code de "una herramienta que comenta en general" a un reviewer real: un agente que deja comentarios en la línea exacta donde detecta un problema. Los inline comments son la diferencia entre feedback que se acciona inmediatamente y feedback que se ignora.
Vas a aprender la diferencia técnica entre comments generales y inline comments, cómo crear los segundos vía la GitHub API, qué SHA de commit usar, cómo manejar errores comunes (líneas fuera de rango, archivos no en el diff), y cómo combinar inline comments con un summary general en un único review.
Al terminar, vas a tener un bot que produce comentarios inline calidad-review-humano: específicos, en la línea correcta, con sugerencias accionables.
La Diferencia: Comment General vs Inline Comment
COMMENT GENERAL (al final del PR):
┌─────────────────────────────────────────────┐
│ 🤖 Análisis de Claude Code │
│ │
│ Encontré 3 problemas: │
│ - Falta validación en payment.py:42 │
│ - SQL injection en auth.py:87 │
│ - Naming inconsistente en orders.py:120 │
└─────────────────────────────────────────────┘
PROBLEMA: el developer tiene que SALTAR entre
el comment y cada archivo. Visibilidad baja.
VERSUS
INLINE COMMENTS (en cada línea):
payment.py
┌──────────────────────────────────────┐
│ Línea 42: │
│ if amount < 0: │
│ process() │
│ │
│ 💬 Claude Code: │
│ Falta validar que amount sea numérico │
│ antes de comparar. Si recibe string,│
│ esto compara strings (resultado no │
│ determinístico). │
└──────────────────────────────────────┘
Visibilidad y accionabilidad son superiores con inline comments. El developer ve el problema en contexto del código, no a tres clicks de distancia.
La API de Review Comments
GitHub tiene dos APIs distintas para "comentarios" en PRs:
| API | Endpoint | Uso |
|---|---|---|
| Issue comments | /repos/{repo}/issues/{number}/comments | Comments generales (al final del PR) |
| PR review comments | /repos/{repo}/pulls/{number}/comments | Inline comments (línea-archivo específico) |
Suenan similares pero son distintas. La cápsula 04 del módulo 1 usó la primera (issue comments para summary general). Esta cápsula usa la segunda.
Crear un inline comment: estructura mínima
import requests
import os
token = os.environ["GITHUB_TOKEN"]
repo = os.environ["GITHUB_REPOSITORY"]
pr_number = os.environ["PR_NUMBER"]
commit_sha = os.environ["PR_HEAD_SHA"]
url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}/comments"
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
payload = {
"body": "Falta validar que `amount` sea numérico.",
"commit_id": commit_sha,
"path": "payment.py",
"line": 42,
"side": "RIGHT",
}
response = requests.post(url, headers=headers, json=payload)
if response.status_code == 201:
print(f"Inline comment creado: {response.json()['html_url']}")
else:
print(f"Error: {response.status_code} {response.text}")
Campos clave:
| Campo | Significado | Notas |
|---|---|---|
body | Texto del comentario (markdown) | Hasta 65,536 caracteres |
commit_id | SHA del commit a comentar | Típicamente el HEAD del PR |
path | Archivo, relativo al root del repo | Debe estar en el diff del PR |
line | Línea (1-indexed) en el archivo | Debe estar en el diff |
side | RIGHT (versión nueva) o LEFT (versión vieja) | Casi siempre RIGHT |
Pasar el commit SHA al workflow
En el YAML, expone el SHA correcto:
- name: Run review
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: python .github/scripts/review_pr.py
pull_request.head.sha es el SHA del último commit del PR — el que quieres comentar.
El Patrón Recomendado: Crear un Review Completo
En lugar de crear comments uno por uno, puedes crear un review con múltiples comentarios en una sola llamada. Es más eficiente y atómico.
Endpoint
POST /repos/{repo}/pulls/{number}/reviews
Estructura del payload
review_payload = {
"commit_id": commit_sha,
"body": "## Resumen del review\n\nEncontré 3 issues. Detalles inline.",
"event": "COMMENT", # o "REQUEST_CHANGES" o "APPROVE"
"comments": [
{
"path": "payment.py",
"line": 42,
"side": "RIGHT",
"body": "Falta validar que `amount` sea numérico.",
},
{
"path": "auth.py",
"line": 87,
"side": "RIGHT",
"body": "SQL injection: usar parameterized query.",
},
{
"path": "orders.py",
"line": 120,
"side": "RIGHT",
"body": "El naming `createOrder` no sigue snake_case.",
},
],
}
url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}/reviews"
response = requests.post(url, headers=headers, json=review_payload)
El campo event
| Valor | Comportamiento |
|---|---|
COMMENT | Solo comenta, no aprueba ni rechaza |
APPROVE | Aprueba el PR (equivale a un review aprobatorio) |
REQUEST_CHANGES | Solicita cambios (bloquea merge si está configurado) |
Para suggest-only mode (cápsula 04), siempre usas COMMENT. Aprobar o rechazar automáticamente requiere mucha más confianza en el modelo y la cápsula 04 desarrolla cuándo es apropiado.
Comments Multi-Línea
A veces el problema involucra varias líneas (no solo una). GitHub soporta comments multi-línea:
{
"path": "payment.py",
"start_line": 40, # primera línea del rango
"line": 45, # última línea del rango
"start_side": "RIGHT",
"side": "RIGHT",
"body": "Esta función completa tiene problemas: ...",
}
Casos de uso:
- Una función entera con problemas estructurales
- Un bloque de código duplicado
- Una secuencia de líneas con lógica defectuosa
Generar los Inline Comments desde Claude
Pedirle a Claude Code que devuelva los comments en formato estructurado JSON simplifica la integración:
"""Review con inline comments estructurados."""
import json
import re
import os
from anthropic import Anthropic
client = Anthropic()
# Leer el diff filtrado (cápsula 02)
with open("filtered_diff.txt") as f:
diff_text = f.read()
prompt = f"""Haz code review de este diff de pull request.
Devuelve un JSON con esta estructura exacta:
{{
"summary": "Resumen del review (2-3 párrafos en markdown)",
"comments": [
{{
"path": "ruta/al/archivo",
"line": 42,
"severity": "critical|warning|suggestion",
"body": "Texto del comentario en markdown"
}}
]
}}
Reglas:
- Solo incluye comentarios accionables (no "se ve bien")
- `path` debe ser exactamente como aparece en el diff
- `line` debe ser un número de línea presente en el diff
- Usa severity "critical" solo para bugs reales o vulnerabilidades
- Devuelve SOLO el JSON, sin texto adicional
Diff:
{diff_text}
"""
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=4000,
messages=[{"role": "user", "content": prompt}],
)
# Parsear el JSON
text = response.content[0].text.strip()
text = re.sub(r"^```json\n?", "", text)
text = re.sub(r"\n?```$", "", text)
try:
review_data = json.loads(text)
except json.JSONDecodeError as e:
print(f"ERROR: respuesta no es JSON válido: {e}")
print(f"Texto recibido:\n{text}")
sys.exit(1)
print(f"Review generado: {len(review_data['comments'])} comments")
Ahora review_data["comments"] es directamente el formato que la API espera.
Validar Antes de Publicar
Crítico: validar que los comments referencien líneas que existen en el diff. Si Claude sugiere comentar la línea 200 de un archivo cuyo diff solo toca líneas 40-60, la API responde 422 (Unprocessable Entity).
def validate_comment(comment, files_in_diff):
"""Validar que un comment es publicable."""
path = comment["path"]
line = comment["line"]
# ¿El archivo está en el diff?
file_data = next((f for f in files_in_diff if f["filename"] == path), None)
if not file_data:
return False, f"Archivo {path} no está en el diff"
# ¿La línea está en alguna sección del patch?
patch = file_data.get("patch", "")
if not patch:
return False, f"Archivo {path} no tiene patch (binario o muy grande)"
# Extraer rangos del patch (formato: @@ -X,Y +A,B @@)
line_in_patch = False
for hunk in re.finditer(r"@@\s+-\d+,\d+\s+\+(\d+),(\d+)\s+@@", patch):
start = int(hunk.group(1))
count = int(hunk.group(2))
if start <= line < start + count:
line_in_patch = True
break
if not line_in_patch:
return False, f"Línea {line} de {path} no está en el diff"
return True, "OK"
# Filtrar comments válidos
valid_comments = []
for c in review_data["comments"]:
is_valid, reason = validate_comment(c, files_in_diff)
if is_valid:
valid_comments.append({
"path": c["path"],
"line": c["line"],
"side": "RIGHT",
"body": format_severity(c["severity"]) + c["body"],
})
else:
print(f"SKIP: {reason}")
Resultado: comentarios inválidos se filtran antes de la API, evitando errores 422.
Formatear con Severity
Para que los comments comuniquen importancia visual rápido, prepende emoji o tag según severity:
def format_severity(severity: str) -> str:
"""Prefijo visual por severidad."""
return {
"critical": "🚨 **CRITICAL** — ",
"warning": "⚠️ **WARNING** — ",
"suggestion": "💡 **Suggestion** — ",
}.get(severity, "")
# Uso:
body = format_severity("critical") + "SQL injection: usar parameterized query"
# Resultado: "🚨 **CRITICAL** — SQL injection: usar parameterized query"
Trampas Comunes
Error 1: Línea fuera del diff
Síntoma: API responde 422 "line must be part of the diff".
Por qué pasa: El modelo sugiere comentar una línea que no fue modificada en el PR (aunque exista en el archivo).
Cómo corregir: Validar contra el patch antes de publicar (sección "Validar Antes de Publicar").
Error 2: Path con ./ al inicio
Síntoma: API responde 422 "path must be relative".
Por qué pasa: Algunos modelos devuelven paths como ./src/payment.py. La API espera src/payment.py.
Cómo corregir: Limpiar paths antes de enviar:
path = comment["path"].lstrip("./")
Error 3: SHA del commit incorrecto
Síntoma: API responde 422 "commit_id is not the head of the pull request".
Por qué pasa: Pasaste github.sha (SHA del workflow run) en lugar de github.event.pull_request.head.sha.
Cómo corregir: En PRs, siempre pull_request.head.sha. github.sha apunta a un commit merge artificial que GitHub crea para los workflows, no al HEAD del PR.
Error 4: Permisos insuficientes
Síntoma: API responde 403 "Resource not accessible by integration".
Por qué pasa: El YAML no declara permissions: pull-requests: write.
Cómo corregir: Agregar al workflow:
permissions:
pull-requests: write
contents: read
Error 5: Crear comments uno por uno en lugar de un review
Síntoma: Varios comments se publican gradualmente; si el script falla a mitad, queda un review parcial.
Por qué pasa: Llamar al endpoint de comments en loop en lugar del endpoint de reviews.
Cómo corregir: Usar POST /pulls/{number}/reviews con todos los comments en una sola llamada. Atomicidad: o se publica todo o nada.
Diagnóstico
Pregunta 1: ¿Sabes la diferencia entre el endpoint de issue comments y el de PR review comments?
Issue comments → comments generales del PR (al final). Review comments → inline en línea-archivo. Distintos endpoints, distinto payload.
Pregunta 2: ¿Validas que la línea del comment esté efectivamente en el diff?
Si no, vas a tener errores 422 frecuentes. La validación contra el patch evita 90% de esos errores.
Pregunta 3: ¿Estás usando `github.event.pull_request.head.sha` o `github.sha`?
Para inline comments en PRs, debe ser el primero. El segundo apunta a un merge commit artificial.
Pregunta 4: ¿Tu workflow declara `permissions: pull-requests: write`?
Sin esto, la API responde 403. Default permissions de GITHUB_TOKEN son read-only.
Pregunta 5: ¿Publicas todos los comments en una sola llamada (review) o uno por uno?
Uno por uno = no atómico, frágil. Una sola llamada (review) = atómica, eficiente.
Ejercicios
Ejercicio 1: Crear un inline comment manualmente (Fácil)
Usando curl o Postman, crea un inline comment en un PR de prueba directamente con la API. Verifica que aparece en la línea correcta.
curl -X POST \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/repos/OWNER/REPO/pulls/PR_NUMBER/comments \
-d '{
"body": "Test comment",
"commit_id": "COMMIT_SHA",
"path": "ARCHIVO_DEL_PR",
"line": NÚMERO_DE_LÍNEA,
"side": "RIGHT"
}'
Ejercicio 2: Crear un review con múltiples comments (Medio)
Modifica el script de la cápsula 02 para que genere un review con 2-3 inline comments en líneas reales del diff.
Ver solución
# (asumiendo que ya tienes review_data del modelo)
review_payload = {
"commit_id": commit_sha,
"body": review_data["summary"],
"event": "COMMENT",
"comments": valid_comments, # del paso de validación
}
url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}/reviews"
r = requests.post(url, headers=headers, json=review_payload)
print(f"Review: {r.status_code} — {r.json().get('html_url', r.text)}")
Ejercicio 3: Validación robusta (Difícil)
Implementa la función validate_comment que:
- Verifica que el archivo está en el diff
- Verifica que la línea está en algún hunk del patch
- Limpia paths con
./o trailing slashes - Maneja archivos sin patch (binarios)
Usa la implementación de la sección "Validar Antes de Publicar" como base y extiéndela.
Resumen
- Inline comments > comments generales — visibilidad y accionabilidad
- Dos APIs distintas: issue comments (general) vs review comments (inline)
- Crear un review con múltiples comments en una llamada es más atómico que loops
- Validar línea en el diff antes de publicar evita errores 422
github.event.pull_request.head.shaes el SHA correcto para PRspermissions: pull-requests: writees prerequisito- Severity con emoji prefix comunica importancia visual rápido
Próxima cápsula: 04 — Review summary y suggest-only mode. Tienes inline comments funcionales. Ahora aprendes a complementarlos con un summary del review y a decidir cuándo el bot bloquea merges vs cuándo solo sugiere.
Recursos Adicionales
- GitHub PR Reviews API — Endpoint de reviews
- GitHub PR Review Comments API — Endpoint de comments individuales
- Octokit — Cliente oficial JS/TS
- PyGithub — Cliente Python
- GitHub Actions: GITHUB_TOKEN permissions — Permisos default
- Anthropic structured output — Forzar JSON output del modelo