name: Revisión con IA on: # Incluimos "synchronize" desde que la revision pasó al modo por contexto: # antes costaba un pedido por archivo (unos veinte por revision) y revisar # cada push era inviable contra el limite diario; ahora cuesta dos, asi que # conviene que el pull request se revise de nuevo al corregirlo. Ademas el # estado del check queda al dia: sin esto un pull request arreglado seguia # figurando en rojo por la corrida vieja. pull_request: types: [opened, reopened, synchronize] workflow_dispatch: inputs: pull-request-number: description: "Número de PR a revisar" required: true type: string review-command: description: "Modo de revisión" required: false default: auto type: choice options: # Lo mismo que hace el disparo automático: contexto + resumen, # dos pedidos al modelo en total. - auto # Un pedido por archivo cambiado. Más minucioso y mucho más caro # en cuota: con 20 archivos son 20 pedidos. - run-inline - run-context - run-summary - clear-inline - clear-summary jobs: ai-review: runs-on: ai-review timeout-minutes: 20 steps: - name: Descargar el código uses: actions/checkout@v4 with: fetch-depth: 0 - name: Preparar Python uses: actions/setup-python@v5 with: python-version: "3.12" - name: Instalar la herramienta de revisión run: pip install xai-review==0.71.0 - name: Leer los secretos de Infisical env: INFISICAL_URL: ${{ vars.INFISICAL_URL }} INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_CLIENT_ID }} INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_CLIENT_SECRET }} INFISICAL_PROJECT_ID: ${{ vars.INFISICAL_PROJECT_ID }} INFISICAL_ENV: ${{ vars.INFISICAL_ENV }} INFISICAL_PATH: ${{ vars.INFISICAL_PATH }} run: | set -eu # Infisical vive en la misma red docker que este job, asi que le # hablamos por DNS interno sin salir a Internet. : "${INFISICAL_URL:=http://infisical:8080}" : "${INFISICAL_PROJECT_ID:=4db7af6a-1f7a-445d-a5f3-fddb9e64b614}" : "${INFISICAL_ENV:=prod}" : "${INFISICAL_PATH:=/gitea-ai-review}" export INFISICAL_URL INFISICAL_PROJECT_ID INFISICAL_ENV INFISICAL_PATH python3 - <<'PY' import json, os, sys, time, urllib.error, urllib.parse, urllib.request base = os.environ["INFISICAL_URL"].rstrip("/") INTENTOS = 3 ESPERA = 5 def diagnosticar(error): """Devuelve (mensaje, si_conviene_reintentar).""" if isinstance(error, urllib.error.HTTPError): motivos = { 400: "el pedido fue rechazado; revisar el identificador del proyecto", 401: "credenciales rechazadas; revisar INFISICAL_CLIENT_ID y INFISICAL_CLIENT_SECRET", 403: "la identidad de maquina no tiene permiso sobre el proyecto", 404: "no existe el proyecto, el entorno o la ruta indicada", 429: "demasiados pedidos a Infisical", } motivo = motivos.get(error.code, "respuesta inesperada del servicio") # Reintentar solo lo transitorio: un 401 no se arregla esperando. return f"HTTP {error.code} — {motivo}", error.code >= 500 or error.code == 429 if isinstance(error, urllib.error.URLError): return f"no se pudo contactar a {base} ({error.reason})", True return f"error inesperado: {error}", False def call(path, data=None, token=None): ultimo = None for intento in range(1, INTENTOS + 1): req = urllib.request.Request(base + path) if token: req.add_header("Authorization", "Bearer " + token) if data is not None: req.add_header("Content-Type", "application/json") req.data = json.dumps(data).encode() try: with urllib.request.urlopen(req, timeout=30) as r: return json.load(r) except Exception as exc: # noqa: BLE001 - se clasifica abajo mensaje, reintentable = diagnosticar(exc) ultimo = mensaje if not reintentable or intento == INTENTOS: break print(f"Infisical: {mensaje}. Reintento {intento}/{INTENTOS - 1} en {ESPERA}s.", file=sys.stderr) time.sleep(ESPERA) print(f"No se pudieron leer los secretos de Infisical: {ultimo}", file=sys.stderr) raise SystemExit(1) auth = call("/api/v1/auth/universal-auth/login", { "clientId": os.environ["INFISICAL_CLIENT_ID"], "clientSecret": os.environ["INFISICAL_CLIENT_SECRET"], }) query = urllib.parse.urlencode({ "workspaceId": os.environ["INFISICAL_PROJECT_ID"], "environment": os.environ["INFISICAL_ENV"], "secretPath": os.environ["INFISICAL_PATH"], }) secrets = call("/api/v3/secrets/raw?" + query, token=auth["accessToken"]) wanted = {"OPENROUTER_API_KEY": "LLM__HTTP_CLIENT__API_TOKEN", "AI_REVIEW_GITEA_TOKEN": "VCS__HTTP_CLIENT__API_TOKEN"} found = {s["secretKey"]: s["secretValue"] for s in secrets["secrets"]} faltan = [k for k in wanted if not found.get(k)] if faltan: print("Faltan secretos en Infisical: " + ", ".join(faltan), file=sys.stderr) raise SystemExit(1) with open(os.environ["GITHUB_ENV"], "a") as env_file: for clave, variable in wanted.items(): valor = found[clave] # Que el runner lo tache si alguna vez se imprime. print(f"::add-mask::{valor}") env_file.write(f"{variable}={valor}\n") print(f"Secretos leidos de Infisical: {', '.join(wanted)}") PY - name: Revisar el pull request env: VCS__PROVIDER: GITEA VCS__PIPELINE__OWNER: ${{ gitea.repository_owner }} VCS__PIPELINE__REPO: ${{ gitea.event.repository.name }} # El job corre en la red "proxy" del runner, la misma del contenedor # gitea: hablamos con la API por DNS interno en vez de salir a # Internet para volver por el proxy. Se puede sobrescribir con la # variable de repositorio GITEA_API_URL. GITEA_API_URL: ${{ vars.GITEA_API_URL }} PR_NUMBER: ${{ gitea.event.pull_request.number }} PR_NUMBER_INPUT: ${{ inputs.pull-request-number }} REVIEW_COMMAND: ${{ inputs.review-command }} run: | set -eu : "${GITEA_API_URL:=http://gitea:3000/api/v1}" : "${PR_NUMBER:=${PR_NUMBER_INPUT}}" : "${REVIEW_COMMAND:=auto}" if [ -z "${PR_NUMBER}" ]; then echo "No hay número de pull request para revisar" exit 1 fi export VCS__HTTP_CLIENT__API_URL="${GITEA_API_URL}" export VCS__PIPELINE__PULL_NUMBER="${PR_NUMBER}" # xai-review termina con codigo 0 aunque falle la publicacion de los # comentarios: imprime el traceback y saluda con # "AI review completed successfully!". Sin esto el job quedaria en # verde sin haber revisado nada, que es peor que fallar. # # Cuidado con QUE se busca: el log tambien trae el texto de los # comentarios que escribe el modelo, y ese texto puede decir # cualquier cosa. El patron 'Error: ' que habia antes se disparo con # el comentario de la propia revision (inventario#3) y marco como # fallida una revision que habia salido bien. Por eso ahora se miran # las marcas de nivel que imprime la herramienta, que el modelo no # escribe, y el traceback al principio de linea. ERRORES='\| (ERROR|CRITICAL) \||^Traceback \(most recent call last\)' # El plan gratuito de OpenRouter devuelve 502 cuando los proveedores # del modelo estan saturados; pasó el 03/08/2026 y el pull request se # mergeo sin revisar. Eso se reintenta, pero un 401 o un modelo que ya # no existe no se arreglan esperando: por eso solo reintentamos con # las marcas que deja una falla al pedirle la respuesta al modelo. # Todas ocurren antes de publicar nada, asi que el reintento no puede # dejar comentarios repetidos. PASAJEROS='OpenRouterChatResponseSchema|LLM returned empty|Upstream|Too Many Requests|timed out' INTENTOS=3 PRIMERA_ESPERA=30 revisar() { modo="$1" registro="/tmp/ai-review-${modo}.log" intento=1 espera="${PRIMERA_ESPERA}" while : ; do echo "== ai-review ${modo} (intento ${intento} de ${INTENTOS})" # Sin tuberia a proposito: con "| tee" el codigo que se evalua es # el de tee, que siempre da 0, y la falla pasaba desapercibida. salida=0 ai-review "${modo}" > "${registro}" 2>&1 || salida=$? cat "${registro}" if [ "${salida}" -eq 0 ] && ! grep -qE "${ERRORES}" "${registro}"; then return 0 fi if [ "${intento}" -ge "${INTENTOS}" ] || ! grep -qE "${PASAJEROS}" "${registro}"; then echo "La revisión reportó errores en '${modo}' pese a terminar sin código de error." >&2 return 1 fi echo "Falla pasajera del proveedor; reintento en ${espera}s." >&2 sleep "${espera}" intento=$((intento + 1)) espera=$((espera * 2)) done } # Para depurar la configuración existe "ai-review show-config", pero # imprime los tokens resueltos: no correrlo dentro del pipeline. if [ "${REVIEW_COMMAND}" = "auto" ]; then # La revisión por contexto manda todos los archivos en un único # pedido y aun así deja los comentarios pegados a cada línea, así # que el pull request completo cuesta dos pedidos al modelo. revisar run-context revisar run-summary else revisar "${REVIEW_COMMAND}" fi