[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

584
tools/issue_bot/bot.py Normal file
View file

@ -0,0 +1,584 @@
#!/usr/bin/env python3
"""Bot de incidencias de Forgejo atendido por Claude.
Para cada incidencia abierta cuyo último mensaje sea de una persona, lanza
Claude Code en modo headless sobre un clon limpio del repo y, según lo que
decida, hace una de estas cosas:
answer comenta la respuesta (pregunta funcional o de configuración)
needs_info comenta pidiendo los datos que faltan
code commit en la rama bot/issue-N, push y PR contra la rama base
none nada (el último mensaje no pide respuesta)
Claude solo lee y edita ficheros: no recibe el token de Forgejo. Comentar,
etiquetar, hacer push y abrir el PR lo hace este script.
Uso:
bot.py run N [N ...] [--dry-run] atiende esas incidencias
bot.py poll [--dry-run] atiende las modificadas desde el último poll
Configuración por variables de entorno: ver README.md.
"""
import argparse
import base64
import datetime
import hashlib
import json
import logging
import os
import pathlib
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
HERE = pathlib.Path(__file__).resolve().parent
_logger = logging.getLogger("issue_bot")
# Marca los comentarios que publica el bot. No basta con mirar el autor: la
# cuenta del bot puede publicar otros comentarios (p.ej. respuestas de correo).
MARKER = "<!-- eco-issue-bot -->"
FOOTER = (
"\n\n---\n<sub>🤖 Respuesta automática (IA). Si no resuelve la incidencia, "
"responde aquí y la retomo.</sub>\n" + MARKER
)
NO_BOT_LABEL = "no-bot"
# Descripción del cliente y convenciones del repo; cada repo aporta la suya.
CONTEXT_FILE = ".gitea/issue-bot.md"
# Ficheros que el bot no puede tocar en un PR: el código de los bots, el CI y
# la configuración de Claude, que el bot de revisión cargaría desde el PR.
PROTECTED_PATHS = (
".claude/",
"CLAUDE.md",
".gitea/",
".forgejo/",
".github/",
"tools/ci/",
"tools/issue_bot/",
"tools/pr_bot/",
)
RESULT_SCHEMA = {
"type": "object",
"properties": {
"kind": {"type": "string", "enum": ["answer", "needs_info", "code", "none"]},
"comment": {"type": "string"},
"labels": {"type": "array", "items": {"type": "string"}},
"commit_message": {"type": "string"},
"pr_title": {"type": "string"},
"pr_body": {"type": "string"},
},
"required": ["kind", "comment"],
}
ALLOWED_TOOLS = [
"Read",
"Grep",
"Glob",
"Edit",
"Write",
"Bash(pre-commit run *)",
"Bash(git status *)",
"Bash(git diff *)",
"Bash(git log *)",
"Bash(git show *)",
]
DISALLOWED_TOOLS = ["WebFetch", "WebSearch", "Agent"]
SECRET_ENV_PREFIXES = ("BOT_", "FORGEJO_", "GITHUB_", "GITEA_", "ACTIONS_")
class BotError(Exception):
pass
def log(msg):
_logger.info(msg)
def env(name, default=None):
return os.environ.get(name) or default
class Config:
def __init__(self):
self.url = env("BOT_FORGEJO_URL", "https://git.criptomart.net").rstrip("/")
self.repo = env("BOT_FORGEJO_REPO", "CriptoMart/addons-cm")
self.token = (env("BOT_FORGEJO_TOKEN") or "").strip()
if not self.token:
token_file = pathlib.Path(
env("BOT_FORGEJO_TOKEN_FILE", "~/.config/gitea-addons-cm.token")
).expanduser()
if not token_file.is_file():
sys.exit("Falta BOT_FORGEJO_TOKEN o BOT_FORGEJO_TOKEN_FILE")
self.token = token_file.read_text().strip()
name = self.repo.split("/")[-1]
self.repo_dir = pathlib.Path(
env("BOT_REPO_DIR", f"~/.cache/eco-issue-bot/{name}")
).expanduser()
self.state_file = pathlib.Path(
env("BOT_STATE_FILE", f"~/.local/state/eco-issue-bot/{name}/since")
).expanduser()
self.base_branch = env("BOT_BASE_BRANCH") # por defecto, la del repo
# La primera carpeta son las dependencias del proyecto; las demás, el
# resto de OCA, que solo sirve de consulta.
self.odoo_src = [
d
for d in env("BOT_ODOO_SRC", "/media/snt/data/odoo").split(os.pathsep)
if os.path.isdir(d)
]
self.model = env("BOT_MODEL", "claude-opus-5-5")
self.max_budget = env("BOT_MAX_BUDGET_USD", "5")
self.timeout = int(env("BOT_TIMEOUT", "2400"))
self.max_replies = int(env("BOT_MAX_REPLIES", "8"))
class Forgejo:
def __init__(self, cfg):
self.cfg = cfg
self.api = f"{cfg.url}/api/v1"
self.login = self.call("GET", "/user")["login"]
self.email = f"{self.login}@noreply.{urllib.parse.urlparse(cfg.url).hostname}"
self.info = self.call("GET", f"/repos/{cfg.repo}")
self.base = cfg.base_branch or self.info["default_branch"]
self.can_push = self.info["permissions"]["push"]
def call(self, method, path, data=None, **params):
url = self.api + path
if params:
url += "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(
url,
method=method,
data=json.dumps(data).encode() if data is not None else None,
headers={
"Authorization": f"token {self.cfg.token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
body = resp.read()
except urllib.error.HTTPError as e:
detail = e.read().decode(errors="replace")[:300]
raise BotError(f"{method} {path} -> {e.code}: {detail}") from e
return json.loads(body) if body else None
def pages(self, path, **params):
page = 1
while True:
items = self.call("GET", path, page=page, limit=50, **params)
yield from items
if len(items) < 50:
return
page += 1
def repo_path(self, suffix=""):
return f"/repos/{self.cfg.repo}{suffix}"
def label_ids(self):
owner = self.cfg.repo.split("/")[0]
labels = list(self.pages(self.repo_path("/labels")))
try:
labels += list(self.pages(f"/orgs/{owner}/labels"))
except BotError:
log(f" {owner} no es una organización: solo etiquetas del repo")
return {lb["name"]: lb["id"] for lb in labels}
def open_pr(self, branch):
for pr in self.pages(self.repo_path("/pulls"), state="open"):
if pr["head"]["ref"] == branch:
return pr
return None
def git_env(self):
"""Credenciales por entorno: no quedan en .git/config ni en `ps`."""
basic = base64.b64encode(f"{self.login}:{self.cfg.token}".encode()).decode()
return {
"GIT_TERMINAL_PROMPT": "0",
"GIT_CONFIG_COUNT": "1",
"GIT_CONFIG_KEY_0": "http.extraHeader",
"GIT_CONFIG_VALUE_0": f"Authorization: Basic {basic}",
}
class Repo:
"""Clon de trabajo del bot. Nunca debe ser un checkout con trabajo a medias."""
def __init__(self, cfg, forge):
self.cfg, self.forge, self.dir = cfg, forge, cfg.repo_dir
self.remote = f"{cfg.url}/{cfg.repo}.git"
def git(self, *args, auth=False, check=True):
git_env = dict(os.environ, **(self.forge.git_env() if auth else {}))
# Claude puede escribir en el árbol: que unos hooks o un fsmonitor que
# deje en .git no se ejecuten con el token en el entorno.
res = subprocess.run(
["git", "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=", *args],
cwd=self.dir,
env=git_env,
capture_output=True,
text=True,
)
if check and res.returncode:
raise BotError(f"git {' '.join(args)}: {res.stderr.strip()}")
return res.stdout.rstrip()
def prepare(self):
if not (self.dir / ".git").exists():
log(f"Clonando {self.remote} en {self.dir}")
self.dir.mkdir(parents=True, exist_ok=True)
self.git("clone", self.remote, ".", auth=True)
if self.git("status", "--porcelain"):
raise BotError(
f"{self.dir} tiene cambios sin commitear: el bot necesita un clon propio"
)
self.git(
"fetch",
"--prune",
self.remote,
"+refs/heads/*:refs/remotes/bot/*",
auth=True,
)
def context(self):
"""Contexto del repo, leído de la rama base y no de la rama del PR."""
ref = f"refs/remotes/bot/{self.forge.base}:{CONTEXT_FILE}"
return self.git("show", ref, check=False)
def has_remote_branch(self, branch):
res = self.git(
"rev-parse", "--verify", "-q", f"refs/remotes/bot/{branch}", check=False
)
return bool(res)
def checkout(self, branch, start):
self.git("checkout", "-q", "-B", branch, f"refs/remotes/bot/{start}")
def changed_files(self):
out = self.git("status", "--porcelain", "--untracked-files=all")
return [line[3:].split(" -> ")[-1].strip('"') for line in out.splitlines()]
def reset(self):
self.git("reset", "-q", "--hard")
self.git("clean", "-q", "-fd")
self.git("checkout", "-q", "--detach", f"refs/remotes/bot/{self.forge.base}")
def commit_and_push(self, branch, message, paths=()):
"""Sin `paths` entra en el commit todo el árbol de trabajo."""
self.git("add", *(["--", *paths] if paths else ["-A"]))
identity = [
"-c",
f"user.name={self.forge.login}",
"-c",
f"user.email={self.forge.email}",
]
self.git(*identity, "commit", "-q", "-m", message)
self.git("push", self.remote, f"HEAD:refs/heads/{branch}", auth=True)
def conversation(forge, issue):
"""Comentarios de la incidencia, sin el ruido que publica la cuenta del bot."""
comments = forge.pages(forge.repo_path(f"/issues/{issue['number']}/comments"))
return [
c for c in comments if MARKER in c["body"] or c["user"]["login"] != forge.login
]
def skip_reason(forge, cfg, issue, comments):
if issue.get("pull_request"):
return "es un PR"
if issue["state"] != "open":
return "está cerrada"
if NO_BOT_LABEL in [lb["name"] for lb in issue["labels"]]:
return f"lleva la etiqueta {NO_BOT_LABEL}"
bot_replies = [c for c in comments if MARKER in c["body"]]
if comments and comments[-1] in bot_replies:
return "el último mensaje es del bot"
if not comments and issue["user"]["login"] == forge.login:
return "la abrió el bot"
if len(bot_replies) >= cfg.max_replies:
return f"el bot ya ha respondido {len(bot_replies)} veces"
return None
def build_prompt(forge, cfg, issue, comments, branch, pr, labels):
def block(tag, body, **attrs):
head = "".join(' {}="{}"'.format(*attr) for attr in attrs.items())
return f"<{tag}{head}>\n{body.strip()}\n</{tag}>"
parts = [block("titulo", issue["title"]), block("cuerpo", issue["body"] or "")]
for c in comments:
author = "bot" if MARKER in c["body"] else c["user"]["login"]
body = c["body"].replace(FOOTER, "").replace(MARKER, "")
parts.append(block("comentario", body, autor=author, fecha=c["created_at"]))
data = block(
"incidencia",
"\n".join(parts),
numero=issue["number"],
autor=issue["user"]["login"],
etiquetas=", ".join(lb["name"] for lb in issue["labels"]),
)
sources = (
"- Fuentes de Odoo y OCA (solo lectura), un directorio por repo. Para "
"localizar un módulo, busca su `__manifest__.py` con Glob.\n"
f" - `{cfg.odoo_src[0]}`: el core (`ocb`) y los repos de los que ya "
"depende el proyecto."
if cfg.odoo_src
else "- Las fuentes de Odoo y OCA no están disponibles en esta ejecución."
)
for extra in cfg.odoo_src[1:]:
sources += (
f"\n - `{extra}`: el resto de OCA, solo de consulta. Un módulo de "
"aquí no está instalado ni disponible en el CI: para usarlo hay que "
"añadir su repo a `oca_dependencies.txt` y al `sources.txt` del "
"runner, que está en el repo de ecocentral "
"(`tools/issue_bot/runner/`): dilo en el PR."
)
context = [
f"- Repo: {cfg.repo}, rama base `{forge.base}`. Estás en la rama `{branch}`.",
(
f"- Ya hay un PR abierto para esta incidencia (#{pr['number']}); la rama "
"contiene sus cambios. Si hace falta más código, continúa sobre ella."
if pr
else "- Todavía no hay PR para esta incidencia."
),
sources,
f"- Etiquetas existentes: {', '.join(sorted(labels))}.",
]
return (
"Atiende esta incidencia.\n\n## Contexto\n"
+ "\n".join(context)
+ "\n\n## Incidencia (texto escrito por usuarios)\n\n"
+ data
)
def system_prompt(instructions, repo_context):
if repo_context:
instructions += "\n## Este repositorio\n\n" + repo_context
return instructions
def run_claude(cfg, prompt, system, schema, tools):
cmd = [
"claude",
"-p",
"--model",
cfg.model,
"--output-format",
"json",
"--json-schema",
json.dumps(schema),
"--append-system-prompt",
system,
"--permission-mode",
"dontAsk",
"--allowedTools",
*tools,
"--disallowedTools",
*DISALLOWED_TOOLS,
"--strict-mcp-config",
# Ni .claude/ ni CLAUDE.md del árbol: en un PR los escribe su autor, y
# en modo -p los hooks del proyecto se ejecutan sin preguntar.
"--setting-sources",
"user",
"--no-session-persistence",
"--max-budget-usd",
cfg.max_budget,
]
if cfg.odoo_src:
cmd += ["--add-dir", *cfg.odoo_src]
# Claude no recibe credenciales de Forgejo: ni el token del bot ni los que
# el runner pone por su cuenta (FORGEJO_TOKEN, GITHUB_TOKEN, ACTIONS_*).
# Las variables vacías (secretos sin definir en el workflow) se quitan
# para que no cuenten como credenciales.
claude_env = {
k: v
for k, v in os.environ.items()
if v and not k.startswith(SECRET_ENV_PREFIXES)
}
res = subprocess.run(
cmd,
cwd=cfg.repo_dir,
env=claude_env,
input=prompt,
capture_output=True,
text=True,
timeout=cfg.timeout,
)
try:
out = json.loads(res.stdout)
except json.JSONDecodeError as e:
raise BotError(f"claude no devolvió JSON: {res.stderr[-500:]}") from e
if out.get("is_error") or not out.get("structured_output"):
raise BotError(f"claude falló ({out.get('subtype')}): {out.get('result')}")
log(
f" claude: {out.get('num_turns')} turnos, {out.get('total_cost_usd', 0):.2f} USD"
)
return out["structured_output"]
def pick_branch(forge, repo, number):
"""Rama de trabajo, PR abierto si lo hay, y punto de partida."""
branch = f"bot/issue-{number}"
pr = forge.open_pr(branch)
if pr:
return branch, pr, branch
# Una rama de un PR ya cerrado o fusionado no se reutiliza.
suffix = 1
while repo.has_remote_branch(branch):
suffix += 1
branch = f"bot/issue-{number}-{suffix}"
return branch, None, forge.base
def handle(forge, cfg, repo, number, dry_run):
issue = forge.call("GET", forge.repo_path(f"/issues/{number}"))
comments = conversation(forge, issue)
reason = skip_reason(forge, cfg, issue, comments)
if reason:
log(f"#{number}: se omite, {reason}")
return
log(f"#{number}: {issue['title']}")
labels = forge.label_ids()
repo.prepare()
branch, pr, start = pick_branch(forge, repo, number)
repo.checkout(branch, start)
try:
prompt = build_prompt(forge, cfg, issue, comments, branch, pr, labels)
system = system_prompt((HERE / "prompt.md").read_text(), repo.context())
result = run_claude(cfg, prompt, system, RESULT_SCHEMA, ALLOWED_TOOLS)
publish(forge, cfg, repo, issue, result, branch, pr, labels, dry_run)
finally:
repo.reset()
def publish(forge, cfg, repo, issue, result, branch, pr, labels, dry_run):
number, kind = issue["number"], result["kind"]
comment = result.get("comment", "").strip()
files = repo.changed_files()
log(f" decisión: {kind}" + (f", {len(files)} ficheros" if files else ""))
if any(cfg.token in str(v) for v in result.values()):
raise BotError("la respuesta contiene el token de Forgejo: no se publica")
if kind == "none":
return
if not comment:
raise BotError(f"decisión {kind} sin comentario")
if kind == "code":
if not files:
raise BotError("decisión code sin cambios en el árbol de trabajo")
forbidden = [f for f in files if f.startswith(PROTECTED_PATHS)]
if forbidden:
raise BotError(f"el cambio toca ficheros protegidos: {forbidden}")
title = result.get("pr_title") or issue["title"]
message = result.get("commit_message") or title
body = result.get("pr_body") or comment
if f"#{number}" not in body:
body += f"\n\nCloses #{number}"
if dry_run:
log(f"--- [dry-run] commit en {branch}: {message}")
repo.git("add", "-A")
log(repo.git("diff", "--cached", "--stat"))
log(f"--- [dry-run] PR: {title}\n{body}")
else:
if not forge.can_push:
raise BotError(
f"{forge.login} no tiene permiso de escritura en el repo"
)
repo.commit_and_push(branch, message)
if not pr:
pr = forge.call(
"POST",
forge.repo_path("/pulls"),
{"head": branch, "base": forge.base, "title": title, "body": body},
)
log(f" PR #{pr['number']}: {pr['html_url']}")
comment += f"\n\nPropuesta de cambio: #{pr['number']}"
wanted = [labels[name] for name in result.get("labels", []) if name in labels]
if dry_run:
log(f"--- [dry-run] comentario en #{number}:\n{comment}")
log(f"--- [dry-run] etiquetas: {result.get('labels', [])}")
return
forge.call(
"POST",
forge.repo_path(f"/issues/{number}/comments"),
{"body": comment + FOOTER},
)
if wanted:
try:
forge.call(
"POST", forge.repo_path(f"/issues/{number}/labels"), {"labels": wanted}
)
except BotError as e:
log(f" no se pudieron poner las etiquetas: {e}")
def poll(forge, cfg, repo, dry_run):
now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
if not cfg.state_file.exists():
# Primer arranque: no se atiende el histórico, solo lo que llegue después.
cfg.state_file.parent.mkdir(parents=True, exist_ok=True)
cfg.state_file.write_text(now)
log(f"Primer poll: se atenderán las incidencias modificadas desde {now}")
return True
since = cfg.state_file.read_text().strip()
issues = forge.pages(
forge.repo_path("/issues"), state="open", type="issues", since=since
)
ok = run_many(forge, cfg, repo, [i["number"] for i in issues], dry_run)
# Si algo falla no se avanza, para reintentarlo en el siguiente poll.
if ok and not dry_run:
cfg.state_file.write_text(now)
return ok
def run_many(forge, cfg, repo, numbers, dry_run):
ok = True
for number in numbers:
try:
handle(forge, cfg, repo, number, dry_run)
except (BotError, subprocess.TimeoutExpired) as e:
ok = False
log(f"#{number}: ERROR {e}")
return ok
def main():
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
sub = ap.add_subparsers(dest="cmd", required=True)
p_run = sub.add_parser("run", help="atiende las incidencias indicadas")
p_run.add_argument("numbers", type=int, nargs="+", metavar="N")
p_poll = sub.add_parser("poll", help="atiende las modificadas desde el último poll")
for p in (p_run, p_poll):
p.add_argument("--dry-run", action="store_true", help="no publica nada")
args = ap.parse_args()
logging.basicConfig(level=logging.INFO, format="%(message)s")
cfg = Config()
try:
forge = Forgejo(cfg)
except BotError as e:
# La huella permite comparar el token recibido con el bueno sin mostrarlo.
digest = hashlib.sha256(cfg.token.encode()).hexdigest()[:8]
sys.exit(
f"No se pudo entrar en {cfg.url} con BOT_FORGEJO_TOKEN "
f"({len(cfg.token)} caracteres, sha256 {digest}): {e}"
)
repo = Repo(cfg, forge)
if not forge.can_push:
log(f"AVISO: {forge.login} no tiene permiso de escritura; no podrá abrir PR")
if args.cmd == "run":
ok = run_many(forge, cfg, repo, args.numbers, args.dry_run)
else:
ok = poll(forge, cfg, repo, args.dry_run)
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()