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