[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:
GitHub Copilot 2026-10-04 14:14:22 +02:00
parent fad510f69e
commit d9d9e8be2d
Signed by: snt
GPG key ID: A9FD34930EADBE71
13 changed files with 2154 additions and 0 deletions

138
tools/pr_bot/i18n.py Normal file
View file

@ -0,0 +1,138 @@
"""Traducciones de los addons que toca un PR.
Qué términos son traducibles y a qué registro pertenece cada uno (las líneas
«#:» del .po, sin las que Odoo no carga la traducción) solo lo sabe Odoo, así
que el .pot no se escribe a mano: lo exporta el job de tests del CI
(tools/ci/run_tests.py) y aquí se recoge. Con él se preparan los .po de cada
idioma; rellenar los msgstr vacíos es trabajo de Claude, y check() repasa lo
que deja.
"""
import re
import polib
# Odoo pone la fecha de exportación en la cabecera: cambiaría en cada PR.
VOLATILE_METADATA = ("POT-Creation-Date", "PO-Revision-Date")
PLURAL_FORMS = "nplurals=2; plural=(n != 1);"
PLACEHOLDER = re.compile(r"%\(\w+\)[sdfr]|%[sdfr]|\{\w*\}")
def changed_addons(repo_dir, files):
"""Addons con algún fichero modificado fuera de su carpeta i18n."""
addons = set()
for name in files:
addon, _, rest = name.partition("/")
if rest and not rest.startswith("i18n/"):
if (repo_dir / addon / "__manifest__.py").is_file():
addons.add(addon)
return sorted(addons)
def terms(po):
return [(e.msgid, e.msgstr, e.comment, e.occurrences, e.flags) for e in po]
def pending(po):
return sum(1 for entry in po if not entry.msgstr)
def placeholders(text):
return sorted(PLACEHOLDER.findall(text))
def write_if_changed(po, path):
"""No reescribe un fichero que ya dice lo mismo: evita diffs de formato."""
if path.is_file() and terms(polib.pofile(str(path))) == terms(po):
return
path.parent.mkdir(exist_ok=True)
po.save(str(path))
def merge(po, pot):
"""Deja en el .po los términos del .pot, y solo esos, con sus referencias."""
po.merge(pot)
for entry in po.obsolete_entries():
po.remove(entry)
po.sort(key=lambda entry: entry.msgid)
def prepare(addon_dir, pot_file, langs):
"""Pone al día el .pot del addon y un .po por idioma.
Devuelve el estado que necesita check(), o None si el addon no tiene
términos traducibles.
"""
pot = polib.pofile(str(pot_file))
if not pot:
return None
for key in VOLATILE_METADATA:
pot.metadata.pop(key, None)
i18n_dir = addon_dir / "i18n"
write_if_changed(pot, i18n_dir / f"{addon_dir.name}.pot")
files = {}
for lang in langs:
path = i18n_dir / f"{lang}.po"
new = not path.is_file()
if new:
po = polib.POFile()
po.header = pot.header
po.metadata = dict(pot.metadata, Language=lang)
po.metadata["Plural-Forms"] = PLURAL_FORMS
else:
po = polib.pofile(str(path))
merge(po, pot)
write_if_changed(po, path)
files[lang] = {
"path": path,
"new": new,
"pending": pending(po),
"backup": path.read_text(),
}
return {"pot": pot, "files": files}
def check(state):
"""Repasa los .po después de que Claude los haya rellenado.
Anota en cada fichero cuántas traducciones se han añadido (`done`) y cuántas
faltan (`pending`), y devuelve los avisos que deben salir en la revisión.
"""
notes = []
for info in state["files"].values():
path = info["path"]
name = f"{path.parent.parent.name}/i18n/{path.name}"
try:
po = polib.pofile(str(path)) if path.is_file() else None
except (OSError, UnicodeDecodeError):
po = None
if po is None:
notes.append(
f"`{name}` quedó ilegible y se han descartado sus traducciones."
)
path.write_text(info["backup"])
po = polib.pofile(str(path))
merge(po, state["pot"])
before = {e.msgid: e.msgstr for e in polib.pofile(info["backup"])}
broken = 0
for entry in po:
if not entry.msgstr or entry.msgstr == before.get(entry.msgid):
continue
# Una traducción con otros marcadores falla al formatear el texto.
if placeholders(entry.msgstr) != placeholders(entry.msgid):
entry.msgstr = ""
broken += 1
if broken:
notes.append(
f"`{name}`: {broken} traducciones descartadas porque no "
"conservaban los marcadores (`%s`, `%(nombre)s`) del original."
)
info["done"] = info["pending"] - pending(po)
info["pending"] = pending(po)
if info["new"] and info["pending"] == len(po):
# Un .po recién creado y sin ninguna traducción no aporta nada.
path.unlink()
info["removed"] = True
elif path.read_text() != info["backup"]:
po.save(str(path))
return notes