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
109 lines
4.0 KiB
Python
109 lines
4.0 KiB
Python
"""Unit: forge sidecar dispatch, op log, queue relay, socket server."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import socket
|
|
import tempfile
|
|
import threading
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from bot_bottle.orchestrator.sidecar import (
|
|
ForgeSidecar,
|
|
OpLog,
|
|
drain_done_events,
|
|
serve,
|
|
write_done_event,
|
|
)
|
|
|
|
from ._fakes import FakeForge
|
|
|
|
|
|
class SidecarDispatchTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = Path(self.enterContext(tempfile.TemporaryDirectory())) # pylint: disable=consider-using-with
|
|
self.forge = FakeForge()
|
|
self.log = OpLog(self.tmp / "ops.jsonl", now=lambda: "T")
|
|
self.queue = self.tmp / "queue"
|
|
self.sc = ForgeSidecar(
|
|
forge=self.forge, op_log=self.log, queue_dir=self.queue,
|
|
run_key=("o", "r", 17),
|
|
)
|
|
|
|
def test_read_pr_ok_and_logged(self):
|
|
resp = self.sc.dispatch("read_pr", {"number": 5})
|
|
self.assertTrue(resp["ok"])
|
|
self.assertEqual(5, resp["result"]["number"])
|
|
self.assertEqual([("read_pr", 5, "ok")],
|
|
[(o["op"], o["target"], o["detail"]) for o in self.log.read()])
|
|
|
|
def test_post_comment_writes_and_logs(self):
|
|
resp = self.sc.dispatch("post_comment", {"number": 17, "body": "done"})
|
|
self.assertTrue(resp["ok"])
|
|
self.assertEqual([(17, "done")], self.forge.comments)
|
|
|
|
def test_scope_denied_write_returns_error_and_audits_rejection(self):
|
|
self.forge.scope_denied.add(999)
|
|
resp = self.sc.dispatch("post_comment", {"number": 999, "body": "x"})
|
|
self.assertFalse(resp["ok"])
|
|
self.assertIn("denied", resp["error"])
|
|
# The rejection is recorded in the op log, not just the allows.
|
|
self.assertIn("error", self.log.read()[-1]["detail"])
|
|
self.assertEqual([], self.forge.comments)
|
|
|
|
def test_signal_done_queues_event(self):
|
|
resp = self.sc.dispatch("signal_done", {"status": "success", "summary": "ok"})
|
|
self.assertTrue(resp["ok"])
|
|
events = drain_done_events(self.queue)
|
|
self.assertEqual(1, len(events))
|
|
self.assertEqual(("o", "r", 17, "success"),
|
|
(events[0]["owner"], events[0]["repo"],
|
|
events[0]["issue_number"], events[0]["status"]))
|
|
|
|
def test_unknown_method(self):
|
|
resp = self.sc.dispatch("delete_repo", {})
|
|
self.assertFalse(resp["ok"])
|
|
|
|
|
|
class QueueTest(unittest.TestCase):
|
|
def test_drain_removes_events(self):
|
|
tmp = Path(self.enterContext(tempfile.TemporaryDirectory())) # pylint: disable=consider-using-with
|
|
write_done_event(tmp, {"owner": "o", "repo": "r", "issue_number": 1})
|
|
self.assertEqual(1, len(drain_done_events(tmp)))
|
|
self.assertEqual([], drain_done_events(tmp)) # drained
|
|
|
|
def test_drain_missing_dir(self):
|
|
self.assertEqual([], drain_done_events(Path("/nonexistent/queue")))
|
|
|
|
|
|
class SocketServerTest(unittest.TestCase):
|
|
def test_round_trip_over_unix_socket(self):
|
|
tmp = tempfile.mkdtemp()
|
|
sock = Path(tmp) / "s.sock"
|
|
if len(str(sock)) > 100: # AF_UNIX path limit; skip on long tmp paths
|
|
self.skipTest("temp socket path too long for AF_UNIX")
|
|
sidecar = ForgeSidecar(
|
|
forge=FakeForge(), op_log=OpLog(Path(tmp) / "ops.jsonl"),
|
|
queue_dir=Path(tmp) / "q", run_key=("o", "r", 1),
|
|
)
|
|
srv = serve(sidecar, sock)
|
|
t = threading.Thread(target=srv.handle_request, daemon=True)
|
|
t.start()
|
|
try:
|
|
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
client.connect(str(sock))
|
|
client.sendall(b'{"method": "read_issue", "params": {"number": 3}}\n')
|
|
line = client.makefile().readline()
|
|
client.close()
|
|
finally:
|
|
t.join(timeout=5)
|
|
srv.server_close()
|
|
resp = json.loads(line)
|
|
self.assertTrue(resp["ok"])
|
|
self.assertEqual(3, resp["result"]["number"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|