"""Wire the concrete bot-bottle implementations into the core. This is the ONLY module that imports from `bot_bottle.contrib`. It adapts `SqliteForgeStateStore` to our `StateStore`, builds `GiteaForge`s (and scope-wrapped forges for sidecars), constructs the `Orchestrator`, and runs the webhook server + watchdog + done-signal relay. Imports are direct (no lazy loading) because the orchestrator is now part of the same package installation. """ from __future__ import annotations import os import threading from pathlib import Path from typing import Any from ..contrib.forge.base import ScopedForge from ..contrib.gitea.client import GiteaClient, GiteaForge from ..contrib.gitea.forge_state import ForgeState, SqliteForgeStateStore from .config import Config from .lifecycle import Orchestrator from .model import RunRecord from .runner import ProgrammaticBottleRunner from .sidecar import ForgeSidecar, OpLog, drain_done_events from .watchdog import Watchdog from .webhook import WebhookServer _RELAY_TICK_SECS = 2.0 def _token() -> str: tok = os.environ.get("GITEA_TOKEN") or os.environ.get("FORGE_GITEA_TOKEN") if not tok: raise RuntimeError("set GITEA_TOKEN (or FORGE_GITEA_TOKEN)") return tok class BotBottleStateStore: """Adapts `SqliteForgeStateStore` to our `StateStore`, translating `RunRecord` <-> `ForgeState` field-for-field.""" def __init__(self, db_path: Path | None) -> None: self._inner = SqliteForgeStateStore(db_path) def upsert(self, record: RunRecord) -> None: self._inner.upsert(_to_forge_state(record)) def get(self, owner: str, repo: str, issue_number: int) -> RunRecord | None: state = self._inner.get(owner, repo, issue_number) return _to_record(state) if state is not None else None def delete(self, owner: str, repo: str, issue_number: int) -> None: self._inner.delete(owner, repo, issue_number) def all(self) -> list[RunRecord]: return [_to_record(s) for s in self._inner.all()] def _to_forge_state(r: RunRecord) -> ForgeState: return ForgeState( owner=r.owner, repo=r.repo, issue_number=r.issue_number, slug=r.slug, agent_name=r.agent_name, bottle_names=list(r.bottle_names), backend_name=r.backend_name, agent_git_user=r.agent_git_user, pr_number=r.pr_number, status=r.status, last_checkin_at=r.last_checkin_at, ) def _to_record(s: ForgeState) -> RunRecord: return RunRecord( owner=s.owner, repo=s.repo, issue_number=s.issue_number, slug=s.slug, agent_name=s.agent_name, bottle_names=list(s.bottle_names), backend_name=s.backend_name, agent_git_user=s.agent_git_user, pr_number=s.pr_number, status=s.status, last_checkin_at=s.last_checkin_at, ) def make_forge(config: Config, owner: str, repo: str) -> Any: """A `GiteaForge` bound to one repo.""" client = GiteaClient( api_url=config.gitea_api, owner=owner, repo=repo, token=_token() ) return GiteaForge(client) def make_sidecar( config: Config, owner: str, repo: str, issue_number: int, assigned_prs: list[int] ) -> ForgeSidecar: """A scope-enforced sidecar for one run (read-anywhere / write-scoped).""" scoped = ScopedForge( make_forge(config, owner, repo), assigned_issue=issue_number, assigned_prs=assigned_prs, ) op_log = OpLog(config.queue_dir / f"{owner}-{repo}-{issue_number}.oplog.jsonl") return ForgeSidecar( forge=scoped, op_log=op_log, queue_dir=config.queue_dir, run_key=(owner, repo, issue_number), ) def build(config: Config) -> tuple[WebhookServer, Watchdog, Orchestrator]: store = BotBottleStateStore(config.db_path) runner = ProgrammaticBottleRunner() membership_forge = make_forge(config, "_", "_") orchestrator = Orchestrator( forge=membership_forge, store=store, runner=runner, org=config.forge_org, gitea_api=config.gitea_api, forge_env_base={ "GITEA_TOKEN": _token(), "FORGE_QUEUE_DIR": str(config.queue_dir), "FORGE_SIDECAR_SOCKET": str(config.sidecar_socket), }, ) watchdog = Watchdog( store=store, runner=runner, timeout_secs=config.watchdog_timeout_secs ) server = WebhookServer( (config.webhook_host, config.webhook_port), orchestrator=orchestrator, store=store, ) return server, watchdog, orchestrator def _relay_loop(config: Config, orchestrator: Orchestrator, stop: threading.Event) -> None: while not stop.wait(_RELAY_TICK_SECS): for ev in drain_done_events(config.queue_dir): orchestrator.on_done_signal( ev["owner"], ev["repo"], int(ev["issue_number"]), str(ev.get("status", "")), str(ev.get("summary", "")), ) def run(config: Config) -> None: """Blocking run: webhook server + watchdog + done-signal relay.""" server, watchdog, orchestrator = build(config) watchdog.start() stop = threading.Event() relay = threading.Thread( target=_relay_loop, args=(config, orchestrator, stop), daemon=True ) relay.start() try: server.serve_forever() finally: stop.set() watchdog.stop() server.server_close()