feat(ci): revisar los pull requests con IA
Revisión con IA / ai-review (pull_request) Successful in 1m58s
Revisión con IA / ai-review (pull_request) Successful in 1m58s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user