[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>
This commit is contained in:
parent
fad510f69e
commit
d9d9e8be2d
13 changed files with 2154 additions and 0 deletions
216
tools/ci/run_tests.py
Executable file
216
tools/ci/run_tests.py
Executable file
|
|
@ -0,0 +1,216 @@
|
|||
#!/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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue