314dc03b0d
Moves the orchestrator into bot_bottle/orchestrator/ so one install gets everything. Entry point is now `python -m bot_bottle.orchestrator run`. - Add bot_bottle/orchestrator/ with all 14 modules (verbatim move; internal imports were already relative, so no changes inside orchestrator modules) - Rewrite bootstrap.py: remove the lazy bot_bottle import guard, use direct relative imports from ..contrib.* - Add bot_bottle/contrib/forge/base.py: ScopedForge (read-anywhere / write-scoped) - Add bot_bottle/contrib/gitea/client.py: GiteaClient + GiteaForge (urllib.request only) - Add bot_bottle/contrib/gitea/forge_state.py: ForgeState + SqliteForgeStateStore - Add tests/unit/orchestrator/ (82 tests: 63 migrated + 19 new for contrib modules) Closes #321
156 lines
5.3 KiB
Python
156 lines
5.3 KiB
Python
"""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 SubprocessBottleRunner
|
|
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 = SubprocessBottleRunner(cli=config.bot_bottle_cli, base_env=dict(os.environ))
|
|
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()
|