From 5e027d2c8c2b905ed5724b189c751af024350bd5 Mon Sep 17 00:00:00 2001 From: jpbeldomenico Date: Mon, 3 Aug 2026 18:55:10 -0300 Subject: [PATCH] feat(ci): revisar los pull requests con IA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replica en este repositorio la revision automatica que ya corre en prode_app: al abrir, reabrir o actualizar un pull request, un modelo lee el diff completo y publica comentarios pegados a cada linea mas un resumen, firmados por el usuario ai-bot. El modelo es gratuito y cada revision cuesta dos pedidos, porque manda todos los archivos juntos en lugar de uno por archivo. Los secretos se leen de Infisical al arrancar el job, asi que en el repositorio solo quedan las credenciales de la identidad de maquina. El prompt esta escrito sobre este proyecto en particular: turnos que se pisan, husos horarios, autorizacion distinta para admin, jefe y cliente, magic links tratados como credenciales, migraciones que acompañen a schema.prisma y datos personales que no terminen en los registros. Co-Authored-By: Claude Opus 5 --- .ai-review.yaml | 60 ++++++++++ .gitea/ai-review/context.md | 68 +++++++++++ .gitea/ai-review/summary.md | 23 ++++ .gitea/workflows/ai-review.yml | 203 +++++++++++++++++++++++++++++++++ 4 files changed, 354 insertions(+) create mode 100644 .ai-review.yaml create mode 100644 .gitea/ai-review/context.md create mode 100644 .gitea/ai-review/summary.md create mode 100644 .gitea/workflows/ai-review.yml diff --git a/.ai-review.yaml b/.ai-review.yaml new file mode 100644 index 0000000..644e59a --- /dev/null +++ b/.ai-review.yaml @@ -0,0 +1,60 @@ +# Configuración de la revisión con IA (https://github.com/Nikita-Filonov/ai-review) +# +# Acá va sólo lo estático. Los tokens y el contexto del pull request llegan por +# variables de entorno desde .gitea/workflows/ai-review.yml, con la convención +# de doble guión bajo que usa la herramienta (VCS__PIPELINE__OWNER, etc.). +# Ojo: la sintaxis ${VARIABLE} dentro de este archivo NO se resuelve. + +llm: + provider: OPENROUTER + + meta: + # Modelo sin costo, 1M de contexto. + model: nvidia/nemotron-3-ultra-550b-a55b:free + max_tokens: 8000 + temperature: 0.2 + # Viaja como cabecera HTTP y se codifica en ASCII: sin acentos ni enies. + title: "Revision IA Taller" + + http_client: + timeout: 180 + api_url: https://openrouter.ai/api/v1 + + # --- Si este modelo deja de estar disponible --- + # El catalogo gratuito de OpenRouter rota. Si el job falla con 404 de modelo, + # listar los vigentes y reemplazar la clave model: + # curl -s https://openrouter.ai/api/v1/models | grep -o '"id":"[^"]*:free"' + # Alternativas verificadas el 03/08/2026: + # cohere/north-mini-code:free + # nvidia/nemotron-3-super-120b-a12b:free + +core: + # El plan gratuito de OpenRouter admite 20 pedidos por minuto. + concurrency: 3 + +review: + mode: FULL_FILE_DIFF + ignore_pure_renames: true + max_context_comments: 15 + max_inline_comments: 10 + + ignore_changes: + - "package-lock.json" + - "*.lock" + - "*.min.js" + - "*.min.css" + - "*.svg" + - "*.png" + - "*.jpg" + - "*.jpeg" + - "*.ico" + - "*.woff*" + - "public/**" + - "tsconfig.tsbuildinfo" + +prompt: + context_prompt_files: + - ./.gitea/ai-review/context.md + + summary_prompt_files: + - ./.gitea/ai-review/summary.md diff --git a/.gitea/ai-review/context.md b/.gitea/ai-review/context.md new file mode 100644 index 0000000..d0530c2 --- /dev/null +++ b/.gitea/ai-review/context.md @@ -0,0 +1,68 @@ +# Revisión de código — Taller Automotriz + +Escribí siempre en español rioplatense, directo y sin rodeos. + +Recibís **todos los archivos cambiados del pull request juntos**, no uno por +vez. Aprovechalo: además de los errores dentro de cada archivo, buscá los que +sólo se ven mirando el conjunto. + +## Contexto del proyecto + +Sistema de turnos para un taller mecánico. Next.js (App Router) con TypeScript, +Prisma sobre PostgreSQL: + +- `src/app/api` — endpoints. +- `src/app/{admin,jefe,cliente}` — las tres áreas, con permisos distintos. +- `src/lib` — la lógica que importa: `scheduling.ts` y `turnos.ts` (slots y + reservas), `whatsapp-bot.ts` (bot de reservas), `magic-link.ts` y + `auth-admin.ts` (autenticación), `prisma.ts` (cliente). +- `prisma/schema.prisma` — modelo de datos: `Cliente`, `Auto`, `Turno`, + `TipoServicio`, `JefeMecanico`, `HistorialServicio`, `Admin`, `MagicToken`, + `ConversacionWhatsapp`. + +## Qué mirar con prioridad + +1. **Turnos y disponibilidad**: es el corazón del sistema. Reservas que se + pisan, husos horarios, slots que se calculan distinto en dos lugares, + condiciones de carrera entre dos clientes reservando el mismo horario, + estados de `Turno` que cambian sin validar la transición. +2. **Autorización por área**: `admin`, `jefe` y `cliente` ven cosas distintas. + Un endpoint que no verifica el rol en el backend, o que confía en lo que + manda el cliente, es un hallazgo grave. Los magic links son credenciales: + token que no expira, que se reutiliza o que se registra en un log. +3. **Bugs reales**: promesas sin `await`, `null`/`undefined` sin contemplar, + errores tragados, casos borde sin cubrir. +4. **Prisma**: migraciones que no acompañan al cambio de `schema.prisma`, + consultas dentro de un bucle (N+1), operaciones que deberían ir en una + transacción y no lo están. +5. **Coherencia entre archivos**: un tipo que cambia en `src/lib` y deja + desactualizado a quien lo consume, lógica de negocio duplicada entre un + endpoint y el bot de WhatsApp en vez de vivir en `src/lib`. + +## Cuidados propios de este proyecto + +- Los datos de clientes (teléfono, email, patente) son personales: no deben + terminar en logs ni en respuestas de error. +- El bot de WhatsApp responde a mensajes de afuera: todo lo que llega por ahí + es entrada no confiable y hay que validarlo. +- Nunca claves ni tokens en el código, los documentos o los mensajes. + +## Antes de afirmar + +Si tu observación depende de cómo se comporta una función del lenguaje, de +Next.js o de Prisma, verificá que estás seguro. Si no lo estás, plantealo como +pregunta ("¿confirmaste que X hace Y?") en vez de afirmarlo. Un señalamiento +correcto apoyado en una premisa falsa hace perder tiempo y resta credibilidad al +resto de la revisión. + +## Qué no comentar + +- Formato, comillas, punto y coma o largo de línea: de eso se ocupa el linter. +- Preferencias de estilo sin impacto en el comportamiento. +- Renombres de variables porque te suenan mejor. +- Reescrituras completas de un archivo que ya funciona. +- Elogios o comentarios que sólo describen lo que hace el código. + +Como ves muchos archivos de una sola vez, resistí la tentación de comentar en +todos para parecer completo. Si de veinte archivos sólo tres tienen algo que +decir, comentá esos tres. Un comentario ruidoso cuesta más que uno faltante. diff --git a/.gitea/ai-review/summary.md b/.gitea/ai-review/summary.md new file mode 100644 index 0000000..aa565bf --- /dev/null +++ b/.gitea/ai-review/summary.md @@ -0,0 +1,23 @@ +# Resumen del pull request — J2 ProDE + +Escribí en español rioplatense. Sé breve: quien lee quiere decidir si aprobar o +no, no leer una redacción. + +Estructura: + +1. **Qué cambia** — dos o tres oraciones sobre la intención del cambio, no un + listado de archivos tocados. +2. **Riesgos** — sólo si los hay de verdad. Priorizá: violaciones de las + invariantes del proyecto, cambios de contrato en `packages/contracts`, + migraciones de PostgreSQL, autorización, y todo lo que pueda romper un + despliegue en curso. +3. **Antes de aprobar** — verificaciones concretas que convenga correr a mano si + el gate (`pnpm check`) no las cubre. + +Reglas: + +- Si no hay riesgos, decilo en una línea y terminá. +- No repitas el diff ni parafrasees cada archivo. +- No felicites al autor. +- Ante la duda de si algo es un problema, decilo como duda y explicá qué + verificar, sin afirmar que está roto. diff --git a/.gitea/workflows/ai-review.yml b/.gitea/workflows/ai-review.yml new file mode 100644 index 0000000..4a57463 --- /dev/null +++ b/.gitea/workflows/ai-review.yml @@ -0,0 +1,203 @@ +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. + revisar() { + echo "== ai-review $1" + if ! ai-review "$1" 2>&1 | tee "/tmp/ai-review-$1.log"; then + return 1 + fi + if grep -qiE 'Traceback \(most recent call last\)|HTTPClientError|Error: ' "/tmp/ai-review-$1.log"; then + echo "La revisión reportó errores en '$1' pese a terminar sin código de error." >&2 + return 1 + fi + } + + # 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 -- 2.52.0