addons-cm/tools/ci/run_tests.py
GitHub Copilot d9d9e8be2d
[ADD] ci: Forgejo Actions CI and Claude bots ported from ecocentral
Same system as ecocentral-18 (see its docs/CI-CD.md):

- ci.yml: lint (pre-commit), test (tools/ci/run_tests.py, OCA-style
  two-phase install) and review (PR bot, after lint and test).
- issue-bot.yml: the issue bot answers issues or opens a PR on bot/issue-N.
- pr-bot.yml: review when WIP is removed or the i18n label is added.
- .gitea/issue-bot.md: context for the bots; this repo is shared by
  several clients.

The bot scripts are copies of ecocentral's. Only the defaults differ (repo,
token and state files, i18n languages es,eu,ca,gl) along with the [I18N]
commit message and the references to the runner's sources.txt.

The jobs run on a second forgejo-runner registration (CriptoMart org) on
ecocentral's runner machine, sharing its image, mounts and cache. The
runner files (Dockerfile, update.sh, sources.txt) live in ecocentral.
Specific to this repo:

- ODOO_SRC_SKIP: the runner's /odoo-src/18.0 holds a clone of addons-cm,
  so test skips it and checks the PR checkout instead.
- BOT_BASE_BRANCH "18.0": the repo's default branch is 16.0.
- test PostgreSQL on port 54330: ecocentral's runs on 54329 and a test job
  from each repo can run at the same time.

The lint job fails until the repo is cleaned up with
pre-commit run --all-files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:34:58 +02:00

216 lines
7.5 KiB
Python
Executable file

#!/usr/bin/env python3
"""Tests de los addons del repo, con el mismo esquema que el CI de la OCA.
Primero crea una base de datos con las dependencias de los addons y después
instala los addons con --test-enable: así solo corren los tests del repo, no
los del core ni los de la OCA.
tools/ci/run_tests.py [addon ...]
Sin argumentos prueba todos los addons instalables del repo menos los de
EXCLUDE. Odoo y los repos de los que dependen los addons se toman de ODOO_SRC
(un checkout por carpeta; en el runner es un montaje, ver
tools/issue_bot/README.md), menos las carpetas de ODOO_SRC_SKIP, y la conexión
a PostgreSQL, de las variables PG*.
ODOO_SRC_SKIP existe porque el runner es compartido: su ODOO_SRC trae un clon
de este mismo repo para los repos que dependen de él, y aquí se prueba el
checkout del PR, no ese clon de la semana pasada.
Si los tests pasan y está definida BOT_POT_DIR, deja ahí el .pot de cada addon
para el bot de revisión de PR (tools/pr_bot), que así no tiene que instalar
los addons otra vez.
"""
import ast
import logging
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
ODOO_SRC = Path(os.environ.get("ODOO_SRC", "/odoo-src/18.0"))
SKIP = set(os.environ.get("ODOO_SRC_SKIP", "").replace(",", " ").split())
DB = os.environ.get("PGDATABASE", "test")
POT_DIR = os.environ.get("BOT_POT_DIR")
POT_KEEP_DAYS = 14
# Se le pasa a `odoo shell` por la entrada estándar: un solo arranque de Odoo
# para todos los addons, en lugar de un --i18n-export por addon.
EXPORT_POTS = """
from odoo.tools.translate import trans_export
for addon in {addons!r}:
with open({out!r} + "/" + addon + ".pot", "wb") as pot:
trans_export(None, [addon], pot, "po", env.cr)
"""
_logger = logging.getLogger("ci")
def find_sources():
"""Devuelve odoo-bin y el addons_path: el repo y lo que haya en ODOO_SRC."""
odoo_bin, paths = None, [REPO]
for src in sorted(ODOO_SRC.iterdir()):
if src.name in SKIP:
_logger.info("Se omite %s (ODOO_SRC_SKIP)", src)
elif (src / "odoo-bin").is_file():
odoo_bin = src / "odoo-bin"
paths += [src / "addons", src / "odoo" / "addons"]
elif any(src.glob("*/__manifest__.py")):
paths.append(src)
if not odoo_bin:
sys.exit(f"No hay un checkout de Odoo en {ODOO_SRC}")
return odoo_bin, paths
def read_manifests(paths):
manifests = {}
for path in paths:
for manifest in sorted(path.glob("*/__manifest__.py")):
# Como en Odoo, gana el primer addons_path que trae el addon.
manifests.setdefault(
manifest.parent.name, ast.literal_eval(manifest.read_text())
)
return manifests
def missing_dependencies(addon, manifests):
missing, seen, todo = set(), set(), [addon]
while todo:
name = todo.pop()
if name in seen:
continue
seen.add(name)
manifest = manifests.get(name)
if not manifest or not manifest.get("installable", True):
missing.add(name)
else:
todo += manifest.get("depends", [])
return missing
def wait_for_postgres(timeout=60):
deadline = time.monotonic() + timeout
while subprocess.run(["pg_isready", "--quiet"], check=False).returncode:
if time.monotonic() > deadline:
sys.exit("PostgreSQL no responde")
time.sleep(1)
def odoo(odoo_bin, paths, *args):
cmd = [
sys.executable,
str(odoo_bin),
f"--addons-path={','.join(map(str, paths))}",
f"--database={DB}",
# Con --test-enable Odoo levanta su servidor HTTP para los tours, y
# el runner lanza el job en la red del host: sin esto escucharía en
# todas las interfaces de la máquina. Los tests lo buscan en
# 127.0.0.1.
"--http-interface=127.0.0.1",
"--stop-after-init",
*args,
]
_logger.info("+ %s", " ".join(cmd))
return subprocess.run(cmd, check=False).returncode
def export_pots(odoo_bin, paths, addons):
"""Deja en BOT_POT_DIR/<commit> el .pot de cada addon ya instalado."""
pot_dir = Path(POT_DIR)
head = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=REPO,
capture_output=True,
text=True,
check=False,
).stdout.strip()
if not head:
_logger.warning("No se exportan los .pot: no se sabe el commit")
return
out, tmp = pot_dir / head, pot_dir / f"{head}.tmp"
for stale in (out, tmp):
shutil.rmtree(stale, ignore_errors=True)
tmp.mkdir(parents=True)
cmd = [
sys.executable,
str(odoo_bin),
"shell",
f"--addons-path={','.join(map(str, paths))}",
f"--database={DB}",
"--no-http",
"--log-level=warn",
]
_logger.info("Exportando los .pot a %s", out)
script = EXPORT_POTS.format(addons=addons, out=str(tmp))
if subprocess.run(cmd, input=script, text=True, check=False).returncode:
_logger.warning("No se han podido exportar los .pot")
shutil.rmtree(tmp, ignore_errors=True)
return
tmp.rename(out)
limit = time.time() - POT_KEEP_DAYS * 86400
for old in pot_dir.iterdir():
if old.stat().st_mtime < limit:
shutil.rmtree(old, ignore_errors=True)
def main():
logging.basicConfig(level=logging.INFO, format="%(message)s")
odoo_bin, paths = find_sources()
manifests = read_manifests(paths)
repo_addons = sorted(m.parent.name for m in REPO.glob("*/__manifest__.py"))
exclude = set(os.environ.get("EXCLUDE", "").replace(",", " ").split())
addons = sys.argv[1:] or [
addon
for addon in repo_addons
if manifests[addon].get("installable", True) and addon not in exclude
]
unknown = sorted(set(addons) - set(repo_addons))
if unknown:
sys.exit(f"No son addons del repo: {', '.join(unknown)}")
# Odoo no falla si a un addon le falta una dependencia: lo deja sin
# instalar y sus tests no corren. Se comprueba aquí para que el CI no
# salga en verde sin haber probado nada.
unmet = {
addon: missing
for addon in addons
if (missing := missing_dependencies(addon, manifests))
}
if unmet:
for addon, missing in unmet.items():
_logger.error("%s: falta %s", addon, ", ".join(sorted(missing)))
sys.exit(
"Dependencias sin resolver: añade su repo al sources.txt del runner "
"(tools/issue_bot/runner/ en el repo de ecocentral) o el addon a "
"EXCLUDE."
)
dependencies = sorted(
{dep for addon in addons for dep in manifests[addon].get("depends", [])}
- set(addons)
)
wait_for_postgres()
subprocess.run(["dropdb", "--if-exists", DB], check=True)
_logger.info("Instalando las dependencias: %s", ", ".join(dependencies))
init = ["--init", ",".join(dependencies), "--log-level=warn"]
if odoo(odoo_bin, paths, *init):
sys.exit("No se ha podido crear la base de datos con las dependencias")
_logger.info("Probando: %s", ", ".join(addons))
result = odoo(odoo_bin, paths, "--init", ",".join(addons), "--test-enable")
if not result and POT_DIR:
# Que falte la caché no es un fallo de los tests: el bot de revisión
# se queda sin las traducciones y lo dice.
try:
export_pots(odoo_bin, paths, addons)
except OSError as e:
_logger.warning("No se han podido exportar los .pot: %s", e)
sys.exit(result)
if __name__ == "__main__":
main()