Módulo 3: GitLab CI/CD y SDK Headless
Proyecto: Portar el Bot del Módulo 2 a GitLab
Proyecto: Portar el Bot del Módulo 2 a GitLab
Descripción del proyecto
Este proyecto es donde se internaliza la lección central del Módulo 3: portabilidad cross-platform. Tomas el bot de code review que armaste en el Módulo 2 (que vive como YAML + scripts en GitHub Actions) y lo refactorizas para que la lógica viva en el SDK y la orquestación sea delgada en cada plataforma. El resultado: el mismo script Python corre en GitHub Actions y en GitLab CI/CD.
No es un ejercicio teórico. Al terminar, vas a tener:
- El bot funcionando en GitHub Actions (carry-over del Módulo 2)
- El mismo bot funcionando en GitLab CI/CD (lo que construyes acá)
- Una capa de abstracción común que ambas plataformas consumen
- Documentación que demuestra que cambiar de plataforma costaría 30 minutos, no días
Objetivo del Proyecto
Refactorizar el bot del Módulo 2 para que la lógica de code review sea agnóstica de plataforma, y demostrar que el mismo script corre en GitHub Actions y GitLab CI/CD con orquestación distinta pero scripts idénticos.
Al completar:
- ✅ Tu lógica de code review está en
scripts/(Python o TypeScript) - ✅ Hay una capa de adaptación que abstrae las diferencias entre plataformas
- ✅ El YAML de cada plataforma es delgado: solo orquesta, no contiene lógica
- ✅ El bot funciona en ambas plataformas con el mismo comportamiento
- ✅ Documentaste el proceso para que otro developer lo replique
Especificaciones Técnicas
Estructura del proyecto
mi-bot-portable/
├── .github/
│ └── workflows/
│ └── code-review.yml # orquestación GitHub
├── .gitlab-ci.yml # orquestación GitLab
├── scripts/
│ ├── platform_adapter.py # abstracción de plataforma ★ clave
│ ├── code_review.py # lógica común
│ ├── publish_review.py # publicación con adapter
│ └── extract_diff.py # extracción con adapter
├── CLAUDE.md
├── requirements.txt
└── README.md
Setup inicial
mkdir mi-bot-portable && cd mi-bot-portable
git init
python -m venv venv && source venv/bin/activate
pip install anthropic python-gitlab requests
requirements.txt
anthropic>=0.39.0,<1.0.0
python-gitlab>=4.0.0
requests>=2.31.0
La Capa de Adaptación: platform_adapter.py
Este es el archivo clave del proyecto. Define una interfaz común que abstrae las diferencias entre GitHub y GitLab.
"""scripts/platform_adapter.py
Abstrae las diferencias entre GitHub Actions y GitLab CI/CD.
Detecta automáticamente la plataforma según las variables del environment.
"""
from __future__ import annotations
import os
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class PRContext:
"""Contexto del PR/MR — agnóstico de plataforma."""
pr_number: int
target_branch: str
head_sha: str
repo_path: str # "owner/repo" o "group/project"
class PlatformAdapter(ABC):
"""Interfaz común para operaciones específicas de plataforma."""
@abstractmethod
def get_pr_context(self) -> PRContext:
"""Extraer contexto del PR/MR del environment."""
...
@abstractmethod
def get_pr_files(self) -> list[dict]:
"""Lista de archivos modificados con metadata."""
...
@abstractmethod
def post_summary(self, body: str) -> None:
"""Publicar comment general (summary)."""
...
@abstractmethod
def post_inline_comment(
self, path: str, line: int, body: str
) -> bool:
"""Publicar inline comment. Retorna True si fue exitoso."""
...
class GitHubAdapter(PlatformAdapter):
"""Implementación para GitHub Actions."""
def __init__(self):
import requests
self.requests = requests
self.token = os.environ["GITHUB_TOKEN"]
self.repo = os.environ["GITHUB_REPOSITORY"]
self.pr_number = int(os.environ["PR_NUMBER"])
self.head_sha = os.environ["PR_HEAD_SHA"]
self.base_branch = os.environ["PR_BASE_BRANCH"]
self.headers = {
"Authorization": f"Bearer {self.token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
def get_pr_context(self) -> PRContext:
return PRContext(
pr_number=self.pr_number,
target_branch=self.base_branch,
head_sha=self.head_sha,
repo_path=self.repo,
)
def get_pr_files(self) -> list[dict]:
url = f"https://api.github.com/repos/{self.repo}/pulls/{self.pr_number}/files?per_page=100"
return self.requests.get(url, headers=self.headers).json()
def post_summary(self, body: str) -> None:
url = f"https://api.github.com/repos/{self.repo}/issues/{self.pr_number}/comments"
r = self.requests.post(url, headers=self.headers, json={"body": body})
r.raise_for_status()
def post_inline_comment(self, path: str, line: int, body: str) -> bool:
url = f"https://api.github.com/repos/{self.repo}/pulls/{self.pr_number}/comments"
payload = {
"body": body,
"commit_id": self.head_sha,
"path": path,
"line": line,
"side": "RIGHT",
}
r = self.requests.post(url, headers=self.headers, json=payload)
return r.status_code == 201
class GitLabAdapter(PlatformAdapter):
"""Implementación para GitLab CI/CD."""
def __init__(self):
import gitlab
self.gl = gitlab.Gitlab(
os.environ["CI_SERVER_URL"],
private_token=os.environ["GITLAB_API_TOKEN"],
)
self.project = self.gl.projects.get(os.environ["CI_PROJECT_ID"])
self.mr = self.project.mergerequests.get(
int(os.environ["CI_MERGE_REQUEST_IID"])
)
def get_pr_context(self) -> PRContext:
return PRContext(
pr_number=self.mr.iid,
target_branch=self.mr.target_branch,
head_sha=self.mr.diff_refs["head_sha"],
repo_path=self.project.path_with_namespace,
)
def get_pr_files(self) -> list[dict]:
changes = self.mr.changes()
# Normalizar al formato común (similar al de GitHub)
return [
{
"filename": c.get("new_path", c.get("old_path", "")),
"patch": c.get("diff", ""),
"status": "removed" if c.get("deleted_file") else "modified",
"additions": 0, # GitLab no provee este dato directamente
"deletions": 0,
}
for c in changes.get("changes", [])
]
def post_summary(self, body: str) -> None:
self.mr.notes.create({"body": body})
def post_inline_comment(self, path: str, line: int, body: str) -> bool:
try:
self.mr.discussions.create({
"body": body,
"position": {
"base_sha": self.mr.diff_refs["base_sha"],
"start_sha": self.mr.diff_refs["start_sha"],
"head_sha": self.mr.diff_refs["head_sha"],
"position_type": "text",
"new_path": path,
"new_line": line,
},
})
return True
except Exception:
return False
def detect_platform() -> PlatformAdapter:
"""Detecta la plataforma según las variables del environment."""
if os.environ.get("GITHUB_ACTIONS") == "true":
return GitHubAdapter()
elif os.environ.get("GITLAB_CI") == "true":
return GitLabAdapter()
else:
raise RuntimeError(
"Plataforma no detectada. ¿Estás corriendo en GitHub Actions o GitLab CI?"
)
Lo importante: quien usa el adapter no necesita saber en qué plataforma corre. detect_platform() retorna la implementación correcta y todo el código downstream es agnóstico.
El Script Común: code_review.py
Este script es idéntico entre plataformas — usa el adapter:
"""scripts/code_review.py — review agnóstico de plataforma."""
import json
import os
import sys
from pathlib import Path
from anthropic import Anthropic, APIError
from platform_adapter import detect_platform
def main() -> int:
try:
adapter = detect_platform()
ctx = adapter.get_pr_context()
print(f"Reviewing PR/MR #{ctx.pr_number} en {ctx.repo_path}")
# Obtener archivos
all_files = adapter.get_pr_files()
relevant = filter_relevant(all_files)
if not relevant:
print("No hay archivos relevantes.")
return 0
# Construir diff para review
diff_text = build_combined_diff(relevant)
conventions = load_conventions()
# Llamar a Claude
client = Anthropic()
response = client.messages.create(
model=os.environ.get("CLAUDE_MODEL", "claude-haiku-4-5"),
max_tokens=int(os.environ.get("CLAUDE_MAX_TOKENS", "4000")),
messages=[{"role": "user", "content": build_prompt(diff_text, conventions)}],
)
# Parsear y guardar
review_data = parse_review_response(response.content[0].text)
Path("review_result.json").write_text(json.dumps({
"context": {
"pr_number": ctx.pr_number,
"repo_path": ctx.repo_path,
},
"review": review_data,
"tokens_used": {
"input": response.usage.input_tokens,
"output": response.usage.output_tokens,
},
}, indent=2))
print(f"Review generado: {len(review_data['comments'])} comments")
return 0
except APIError as e:
print(f"ERROR API: {e}", file=sys.stderr)
return 1
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
return 1
def filter_relevant(files: list[dict]) -> list[dict]:
"""Filtrar archivos relevantes (común a ambas plataformas)."""
RELEVANT = {".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rb"}
def is_relevant(f):
name = f.get("filename", "")
if not any(name.endswith(ext) for ext in RELEVANT):
return False
if "test" in name or "fixture" in name:
return False
if f.get("status") == "removed":
return False
return bool(f.get("patch"))
return [f for f in files if is_relevant(f)]
def build_combined_diff(files: list[dict]) -> str:
parts = []
for f in files:
parts.append(f"--- {f['filename']} ---\n{f['patch']}\n")
return "\n".join(parts)
def load_conventions() -> str:
p = Path("CLAUDE.md")
return p.read_text() if p.exists() else "Sin convenciones documentadas."
def build_prompt(diff: str, conventions: str) -> str:
return f"""[prompt completo aquí, ver módulos previos]"""
def parse_review_response(text: str) -> dict:
import re
text = text.strip()
text = re.sub(r"^```(?:json)?\n?", "", text)
text = re.sub(r"\n?```$", "", text)
return json.loads(text)
if __name__ == "__main__":
sys.exit(main())
Observación clave: este script no tiene un solo if platform == "github". Toda la diferenciación está encapsulada en el adapter.
El Script de Publicación: publish_review.py
"""scripts/publish_review.py — publicación agnóstica de plataforma."""
import json
import os
import sys
from pathlib import Path
from platform_adapter import detect_platform
MARKER = "<!-- claude-code-bot -->"
def build_summary_markdown(review_data: dict) -> str:
summary = review_data["summary"]
comments = review_data["comments"]
by_severity = {"critical": 0, "warning": 0, "suggestion": 0}
for c in comments:
by_severity[c.get("severity", "suggestion")] += 1
return f"""{MARKER}
# 🤖 Code Review (Claude Code)
## Resumen
{summary["overview"]}
### Severidad
- 🚨 Critical: {by_severity['critical']}
- ⚠️ Warning: {by_severity['warning']}
- 💡 Suggestion: {by_severity['suggestion']}
### Veredicto
**{summary["verdict"].replace("_", " ").title()}**
Detalles inline en cada archivo.
"""
def format_severity(severity: str) -> str:
return {
"critical": "🚨 **CRITICAL** — ",
"warning": "⚠️ **WARNING** — ",
"suggestion": "💡 **Suggestion** — ",
}.get(severity, "")
def main() -> int:
review_file = Path("review_result.json")
if not review_file.exists():
print("ERROR: review_result.json no encontrado.", file=sys.stderr)
return 1
data = json.loads(review_file.read_text())
review = data["review"]
if not review.get("comments"):
print("Sin comments para publicar.")
return 0
try:
adapter = detect_platform()
# 1. Summary
summary_md = build_summary_markdown(review)
adapter.post_summary(summary_md)
print("Summary publicado.")
# 2. Inline comments
published = 0
skipped = 0
for c in review["comments"]:
body = format_severity(c["severity"]) + c["body"]
ok = adapter.post_inline_comment(c["path"], c["line"], body)
if ok:
published += 1
else:
skipped += 1
print(f"Inline comments: {published} publicados, {skipped} skipped")
return 0
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
Orquestación: GitHub Actions
# .github/workflows/code-review.yml
name: Code Review (Claude Code)
on:
pull_request:
types: [opened, synchronize]
permissions:
pull-requests: write
contents: read
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- run: pip install -r requirements.txt
- name: Run code 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 }}
PR_BASE_BRANCH: ${{ github.event.pull_request.base.ref }}
run: |
python scripts/code_review.py
python scripts/publish_review.py
Observación: el YAML solo orquesta. Toda la lógica está en scripts/.
Orquestación: GitLab CI/CD
# .gitlab-ci.yml
stages:
- review
variables:
CLAUDE_MODEL: "claude-haiku-4-5"
CLAUDE_MAX_TOKENS: "4000"
code-review:
stage: review
image: python:3.11-slim
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
cache:
key: pip-cache-${CI_COMMIT_REF_SLUG}
paths:
- .pip-cache/
before_script:
- apt-get update && apt-get install -y --no-install-recommends git
- pip install --cache-dir=.pip-cache -r requirements.txt
script:
- python scripts/code_review.py
- python scripts/publish_review.py
Observación: mismo patrón — el .gitlab-ci.yml es delgado, los scripts son idénticos a la versión de GitHub.
Verificación: La Prueba de Portabilidad
Para verificar que la portabilidad realmente funciona:
-
Configurar el bot en GitHub:
- Push de los archivos a un repo de GitHub
- Configurar
ANTHROPIC_API_KEYen Settings → Secrets - Abrir un PR
- Verificar que aparece el comment del bot
-
Configurar el bot en GitLab:
- Push de los mismos archivos a un repo de GitLab
- Configurar
ANTHROPIC_API_KEYyGITLAB_API_TOKENen Settings → CI/CD → Variables - Abrir un MR
- Verificar que aparece el comment del bot
-
Comparar:
- El summary del bot debería ser similar (mismo modelo, mismo prompt)
- Los inline comments deberían aparecer en líneas equivalentes
- El tono y formato deberían ser idénticos
Si los dos bots producen output similar con scripts idénticos, probaste la portabilidad.
Documentación: README.md del proyecto
Parte del entregable es documentar para que otro developer reproduzca:
# Code Review Bot (Cross-Platform)
Bot de code review con Claude Code que opera en GitHub Actions y GitLab CI/CD.
## Arquitectura
- `scripts/platform_adapter.py` — abstrae diferencias entre plataformas
- `scripts/code_review.py` — lógica común de review
- `scripts/publish_review.py` — publicación común
- `.github/workflows/` y `.gitlab-ci.yml` — orquestación delgada
## Setup
### En GitHub
1. Configurar secret `ANTHROPIC_API_KEY` en Settings → Secrets
2. El workflow corre automáticamente en cada PR
### En GitLab
1. Configurar variables `ANTHROPIC_API_KEY` y `GITLAB_API_TOKEN` (con scope `api`) en Settings → CI/CD → Variables
2. Marcar ambas como Protected y Masked
3. El pipeline corre automáticamente en cada MR
## Cómo agregar una nueva plataforma (ej. Bitbucket)
1. Crear `BitbucketAdapter` en `platform_adapter.py` implementando `PlatformAdapter`
2. Agregar detección en `detect_platform()`:
```python
elif os.environ.get("BITBUCKET_BUILD_NUMBER"):
return BitbucketAdapter()
- Crear
bitbucket-pipelines.ymlque orqueste la ejecución
Los scripts code_review.py y publish_review.py no cambian.
---
## Entregables del Proyecto
1. **Repo público o privado** con la estructura completa
2. **Bot funcionando en GitHub Actions** (verificable abriendo un PR)
3. **Bot funcionando en GitLab CI/CD** (verificable abriendo un MR)
4. **`platform_adapter.py`** que abstrae las diferencias
5. **README.md** documentando arquitectura y setup
6. **Demostración** (screenshots, link al PR/MR funcionando) que muestra ambos bots produciendo output equivalente
---
## Rúbrica de Evaluación (100 puntos)
### Arquitectura (40 pts)
- ✅ (15 pts) `platform_adapter.py` con interfaz abstracta clara
- ✅ (10 pts) Implementaciones concretas para GitHub y GitLab
- ✅ (10 pts) `detect_platform()` automática según environment
- ✅ (5 pts) Scripts comunes (`code_review.py`, `publish_review.py`) no contienen lógica específica de plataforma
### Funcionalidad en GitHub (20 pts)
- ✅ (10 pts) Bot dispara en cada PR
- ✅ (5 pts) Summary publicado correctamente
- ✅ (5 pts) Inline comments en líneas correctas
### Funcionalidad en GitLab (20 pts)
- ✅ (10 pts) Bot dispara en cada MR
- ✅ (5 pts) Summary publicado correctamente
- ✅ (5 pts) Inline comments en líneas correctas
### Documentación (15 pts)
- ✅ (8 pts) README explica arquitectura y setup
- ✅ (5 pts) Cómo agregar una nueva plataforma documentado
- ✅ (2 pts) Comentarios en código donde no es obvio
### Calidad (5 pts)
- ✅ (3 pts) Manejo de errores con exit codes
- ✅ (2 pts) Estructura limpia y testeable
### Extra Credit (+15 pts)
- ✅ (+5 pts) Implementar adapter para una tercera plataforma (Bitbucket, Jenkins)
- ✅ (+5 pts) Tests unitarios del adapter (mock de las APIs)
- ✅ (+5 pts) Documentar tiempo medido para portar entre plataformas
---
## Errores Comunes en la Portabilidad
### 1. Lógica específica de plataforma se filtra al script común
**Síntoma:** `code_review.py` tiene `if os.environ.get("GITHUB_ACTIONS")` directo.
**Por qué pasa:** Empiezas cómodo, después agregas un `if` rápido en lugar de extender el adapter.
**Cómo corregir:** Cualquier diferencia entre plataformas vive solo en el adapter. Si tienes que poner `if` en el script común, falta abstracción en el adapter.
### 2. Adapter sin interfaz abstracta
**Síntoma:** `GitHubAdapter` tiene métodos que `GitLabAdapter` no, y viceversa.
**Por qué pasa:** No definiste la clase abstracta primero.
**Cómo corregir:** Empezar siempre con la `PlatformAdapter(ABC)` con métodos abstractos. Las implementaciones concretas obligadas a respetarla.
### 3. Variables de environment hardcodeadas
**Síntoma:** El script común lee `GITHUB_REPOSITORY`. En GitLab no existe, falla.
**Por qué pasa:** El adapter debería leer las variables específicas, no el script común.
**Cómo corregir:** Mover toda lectura de `os.environ["..."]` al adapter. El script común recibe los datos vía `adapter.get_pr_context()`.
### 4. Output diferente entre plataformas
**Síntoma:** En GitHub el comment se ve A, en GitLab se ve B.
**Por qué pasa:** Lógica de formato en el adapter en lugar de en el script común.
**Cómo corregir:** Formato de mensajes en `publish_review.py`. El adapter solo se encarga de **enviar** el mensaje a la plataforma, no de **construirlo**.
### 5. No verificar la portabilidad
**Síntoma:** "Funciona en GitHub" pero nunca probaste en GitLab.
**Por qué pasa:** Configurar GitLab toma trabajo, dejas "para después".
**Cómo corregir:** Probar en ambas plataformas es **parte del entregable**, no opcional. Sin esa prueba, no sabes si la portabilidad es real.
---
## Reflexión: Lo Que Demuestra Este Proyecto
Cuando termines el proyecto, vas a haber demostrado:
1. **Comprensión arquitectural:** sabes separar lógica de orquestación
2. **Patrones de abstracción:** sabes diseñar interfaces que aíslan diferencias
3. **Portabilidad real:** no es teoría — hiciste correr el bot en dos plataformas
4. **Testing implícito:** la prueba de portabilidad es una forma de testing arquitectural
5. **Habilidad transferible:** el patrón aplica más allá de CI/CD — cualquier vez que necesites correr lógica en plataformas distintas
**Este proyecto es portfolio-worthy.** Mostrarlo en una entrevista demuestra senior-level thinking sobre arquitectura.
---
## Resumen
- **Lógica común** en scripts Python (o TS) usando el SDK
- **Capa de adaptación** (`PlatformAdapter`) abstrae diferencias entre plataformas
- **Detección automática** de plataforma según variables de environment
- **YAMLs delgados** que solo orquestan, sin lógica
- **Portabilidad probada** corriendo en GitHub y GitLab simultáneamente
- **Patrón extensible** — agregar una tercera plataforma es agregar un nuevo adapter
**Próximo módulo:** **Módulo 4 — Deployment Automation**. Ya tienes Claude Code analizando PRs, con portabilidad cross-platform. El siguiente nivel: que también ejecute tareas post-merge — generar changelogs, validar readiness, asistir deployments.
---
## Recursos Adicionales
1. [Anthropic Python SDK](https://github.com/anthropics/anthropic-sdk-python) — Base técnica
2. [Python `abc` module](https://docs.python.org/3/library/abc.html) — Abstract base classes
3. [python-gitlab](https://python-gitlab.readthedocs.io/) — Cliente GitLab
4. [PyGithub](https://pygithub.readthedocs.io/) — Cliente GitHub (alternativa a `requests` directo)
5. [Adapter pattern](https://refactoring.guru/design-patterns/adapter) — El patrón estructural que aplicaste
6. [Strategy pattern](https://refactoring.guru/design-patterns/strategy) — Patrón relacionado, útil para variantes futuras