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:

  1. El bot funcionando en GitHub Actions (carry-over del Módulo 2)
  2. El mismo bot funcionando en GitLab CI/CD (lo que construyes acá)
  3. Una capa de abstracción común que ambas plataformas consumen
  4. 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:

  1. Configurar el bot en GitHub:

    • Push de los archivos a un repo de GitHub
    • Configurar ANTHROPIC_API_KEY en Settings → Secrets
    • Abrir un PR
    • Verificar que aparece el comment del bot
  2. Configurar el bot en GitLab:

    • Push de los mismos archivos a un repo de GitLab
    • Configurar ANTHROPIC_API_KEY y GITLAB_API_TOKEN en Settings → CI/CD → Variables
    • Abrir un MR
    • Verificar que aparece el comment del bot
  3. 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()
  1. Crear bitbucket-pipelines.yml que 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