#!/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 = "" FOOTER = ( "\n\n---\n🤖 Respuesta automática (IA). Si no resuelve la incidencia, " "responde aquí y la retomo.\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" 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()