feat: fold bot-bottle-orchestrator into bot_bottle/orchestrator subpackage
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
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
"""Unit: webhook HTTP surface (signature + routing over a real server)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import threading
|
||||
import unittest
|
||||
import urllib.request
|
||||
from urllib.error import HTTPError
|
||||
|
||||
from bot_bottle.orchestrator.model import RunRecord
|
||||
from bot_bottle.orchestrator.store import InMemoryStateStore
|
||||
from bot_bottle.orchestrator.webhook import WebhookServer, verify_signature
|
||||
|
||||
_ISSUE_ASSIGNED = {
|
||||
"action": "assigned",
|
||||
"repository": {"name": "bot-bottle", "owner": {"login": "didericis"}},
|
||||
"issue": {
|
||||
"number": 17, "title": "t", "body": "b",
|
||||
"assignees": [{"login": "agent-bot"}],
|
||||
"labels": [{"name": "bot-bottle:impl"}],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class _RecordingOrch:
|
||||
def __init__(self) -> None:
|
||||
self.events: list[object] = []
|
||||
|
||||
def handle(self, event: object) -> None:
|
||||
self.events.append(event)
|
||||
|
||||
|
||||
class SignatureTest(unittest.TestCase):
|
||||
def test_verify(self):
|
||||
secret = b"s3cret"
|
||||
body = b'{"x":1}'
|
||||
sig = hmac.new(secret, body, hashlib.sha256).hexdigest()
|
||||
self.assertTrue(verify_signature(secret, body, sig))
|
||||
self.assertFalse(verify_signature(secret, body, "deadbeef"))
|
||||
|
||||
|
||||
class WebhookServerTest(unittest.TestCase):
|
||||
# _serve is the per-test setup; attributes are assigned there.
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
def _serve(self, **kwargs: object) -> None:
|
||||
self.orch = _RecordingOrch()
|
||||
kwargs.setdefault("store", InMemoryStateStore())
|
||||
self.server = WebhookServer(
|
||||
("127.0.0.1", 0), orchestrator=self.orch, **kwargs, # type: ignore[arg-type]
|
||||
)
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
self.addCleanup(self._shutdown)
|
||||
|
||||
def _shutdown(self) -> None:
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
|
||||
def _post(
|
||||
self, path: str, body: bytes, headers: dict[str, str] | None = None
|
||||
) -> tuple[int, dict[str, object]]:
|
||||
req = urllib.request.Request(
|
||||
f"http://127.0.0.1:{self.port}{path}", data=body, method="POST",
|
||||
headers=headers or {},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
return resp.status, json.loads(resp.read())
|
||||
|
||||
def _get(self, path: str) -> tuple[int, dict[str, object]]:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{self.port}{path}", timeout=5) as r:
|
||||
return r.status, json.loads(r.read())
|
||||
|
||||
def test_webhook_dispatches(self):
|
||||
self._serve()
|
||||
body = json.dumps(_ISSUE_ASSIGNED).encode()
|
||||
status, payload = self._post("/webhook", body, {"X-Gitea-Event": "issues"})
|
||||
self.assertEqual(200, status)
|
||||
self.assertTrue(payload["handled"])
|
||||
self.assertEqual(1, len(self.orch.events))
|
||||
|
||||
def test_unhandled_event_ok_but_not_handled(self):
|
||||
self._serve()
|
||||
body = json.dumps({"action": "push"}).encode()
|
||||
_status, payload = self._post("/webhook", body, {"X-Gitea-Event": "push"})
|
||||
self.assertFalse(payload["handled"])
|
||||
self.assertEqual([], self.orch.events)
|
||||
|
||||
def test_invalid_json_400(self):
|
||||
self._serve()
|
||||
with self.assertRaises(HTTPError) as ctx:
|
||||
self._post("/webhook", b"{not json", {"X-Gitea-Event": "issues"})
|
||||
self.assertEqual(400, ctx.exception.code)
|
||||
|
||||
def test_bad_signature_rejected(self):
|
||||
self._serve(secret=b"sekret")
|
||||
body = json.dumps(_ISSUE_ASSIGNED).encode()
|
||||
with self.assertRaises(HTTPError) as ctx:
|
||||
self._post("/webhook", body,
|
||||
{"X-Gitea-Event": "issues", "X-Gitea-Signature": "deadbeef"})
|
||||
self.assertEqual(401, ctx.exception.code)
|
||||
self.assertEqual([], self.orch.events)
|
||||
|
||||
def test_good_signature_accepted(self):
|
||||
self._serve(secret=b"sekret")
|
||||
body = json.dumps(_ISSUE_ASSIGNED).encode()
|
||||
sig = hmac.new(b"sekret", body, hashlib.sha256).hexdigest()
|
||||
status, _payload = self._post(
|
||||
"/webhook", body, {"X-Gitea-Event": "issues", "X-Gitea-Signature": sig})
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual(1, len(self.orch.events))
|
||||
|
||||
def test_healthz(self):
|
||||
self._serve()
|
||||
self.assertEqual(200, self._get("/healthz")[0])
|
||||
|
||||
def test_unknown_path_404(self):
|
||||
self._serve()
|
||||
with self.assertRaises(HTTPError) as ctx:
|
||||
self._post("/nope", b"{}", {"X-Gitea-Event": "issues"})
|
||||
self.assertEqual(404, ctx.exception.code)
|
||||
|
||||
def test_provenance_returns_record_and_ops(self):
|
||||
store = InMemoryStateStore()
|
||||
store.upsert(RunRecord(owner="didericis", repo="bot-bottle", issue_number=17,
|
||||
slug="impl-17", agent_name="impl", bottle_names=["claude"]))
|
||||
|
||||
def reader(rec: object) -> list[dict[str, object]]: # pylint: disable=unused-argument
|
||||
return [{"at": "T", "op": "post_comment", "target": 17, "detail": "ok"}]
|
||||
|
||||
self._serve(store=store, op_log_reader=reader)
|
||||
status, payload = self._get("/provenance?owner=didericis&repo=bot-bottle&issue=17")
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual("impl-17", payload["slug"])
|
||||
self.assertEqual(1, len(payload["ops"])) # type: ignore[arg-type]
|
||||
|
||||
def test_provenance_missing_params_400(self):
|
||||
self._serve()
|
||||
with self.assertRaises(HTTPError) as ctx:
|
||||
self._get("/provenance?owner=didericis")
|
||||
self.assertEqual(400, ctx.exception.code)
|
||||
|
||||
def test_provenance_unknown_run_404(self):
|
||||
self._serve()
|
||||
with self.assertRaises(HTTPError) as ctx:
|
||||
self._get("/provenance?owner=x&repo=y&issue=1")
|
||||
self.assertEqual(404, ctx.exception.code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user