From 2558248dc99d3f5c55af8f0aa7a85e5df90c3f95 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 22:15:59 +0400 Subject: [PATCH] =?UTF-8?q?feat(hub):=20HTTP=20API=20=E2=80=94=20=D0=BC?= =?UTF-8?q?=D0=B0=D1=80=D1=88=D1=80=D1=83=D1=82=D1=8B=20=D0=B0=D0=B3=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=20=D0=B8=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80?= =?UTF-8?q?=D1=84=D0=B5=D0=B9=D1=81=D0=BE=D0=B2,=20=D1=82=D0=BE=D0=BA?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B,=20=D0=B4=D0=BE=D0=BB=D0=B3=D0=B8=D0=B9=20cl?= =?UTF-8?q?aim,=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Соединение SQLite на запрос, сравнение токенов в постоянное время, 400 на битый JSON и нечисловые параметры. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/jira_hub/api.py | 318 +++++++++++++++++++++++++++++++++++++++++ src/jira_hub/client.py | 63 ++++++++ tests/test_api.py | 153 ++++++++++++++++++++ 3 files changed, 534 insertions(+) create mode 100644 src/jira_hub/api.py create mode 100644 src/jira_hub/client.py create mode 100644 tests/test_api.py diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py new file mode 100644 index 0000000..01771fe --- /dev/null +++ b/src/jira_hub/api.py @@ -0,0 +1,318 @@ +"""HTTP API хаба (stdlib http.server, поток на запрос, соединение SQLite на запрос). +Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md).""" +import hmac +import json +import re +import threading +import time +import traceback +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +from . import backup, db, mirror, queue, settings +from .commands import CommandError +from .paths import backups_dir as default_backups_dir +from .queue import ClaimedError + + +class ApiError(Exception): + def __init__(self, status, error, detail=None): + super().__init__(error) + self.status, self.error, self.detail = status, error, detail + + +class Hub: + def __init__(self, db_path, backups_dir=None): + self.db_path = Path(db_path) + self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir() + self.claim_wait = 30 + conn = self.conn() + settings.ensure_defaults(conn) + conn.close() + + def conn(self): + return db.connect(self.db_path) + + def make_backup(self, label): + conn = self.conn() + try: + backup.make_backup(conn, self.backups_dir, label, int(settings.get(conn, "backup_keep") or 30)) + finally: + conn.close() + + +# --- обработчики: fn(hub, conn, match, query, body) -> (status, payload) ---------- + +def _int(m, i=1): + return int(m.group(i)) + + +def _not_found(obj, what): + if obj is None: + raise ApiError(404, "not_found", what) + return obj + + +def h_claim(hub, conn, m, q, body): + deadline = time.monotonic() + hub.claim_wait + while True: + cmd = queue.claim(conn, body.get("agent_version"), body.get("settings_version"), body.get("last_jira_call_at"), + backup=hub.make_backup) + if cmd or time.monotonic() >= deadline: + break + time.sleep(1) + return 200, {"command": cmd, "settings_version": settings.get(conn, "settings_version")} + + +def h_agent_settings(hub, conn, m, q, body): + return 200, settings.for_agent(conn) + + +def h_agent_command(hub, conn, m, q, body): + return 200, _not_found(queue.get_command(conn, _int(m)), "command") + + +def h_agent_result(hub, conn, m, q, body): + try: + cmd = queue.report_result(conn, _int(m), body.get("status"), body.get("result"), body.get("error"), + body.get("jira_calls", 0), body.get("rate_limited", 0)) + except KeyError: + raise ApiError(404, "not_found", "command") + except ValueError as e: + raise ApiError(422, "bad_status", str(e)) + return 200, cmd + + +def h_agent_state(hub, conn, m, q, body): + with db.transaction(conn): + out = mirror.apply_state(conn, body.get("issues") or [], body.get("links") or [], body.get("neighbor_keys") or []) + if body.get("command_id"): + queue.record_state(conn, body["command_id"], out["issues"], out["links"]) + return 200, out + + +def h_known_keys(hub, conn, m, q, body): + return 200, {"keys": mirror.known_keys(conn)} + + +ISSUE_FILTERS = ("sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q") + + +def h_issues(hub, conn, m, q, body): + filters = {k: q.get(k) for k in ISSUE_FILTERS} + return 200, {"issues": mirror.list_issues(conn, filters, int(q.get("limit", 50)), int(q.get("offset", 0)))} + + +def h_issue(hub, conn, m, q, body): + return 200, _not_found(mirror.get_issue(conn, m.group(1)), "issue") + + +def h_note_put(hub, conn, m, q, body): + fields = {k: v for k, v in body.items() if k != "updated_by"} + try: + return 200, mirror.set_note(conn, m.group(1), fields, body.get("updated_by") or "api") + except ValueError as e: + raise ApiError(422, "bad_note", str(e)) + + +def h_batches_post(hub, conn, m, q, body): + if not body.get("title"): + raise ApiError(422, "bad_batch", "нет title") + try: + return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands")) + except CommandError as e: + raise ApiError(422, "bad_batch", str(e)) + + +def h_batches_get(hub, conn, m, q, body): + return 200, {"batches": queue.list_batches(conn, int(q.get("limit", 20)))} + + +def h_batch_get(hub, conn, m, q, body): + return 200, _not_found(queue.batch_view(conn, _int(m)), "batch") + + +def h_batch_cancel(hub, conn, m, q, body): + try: + return 200, queue.cancel_batch(conn, _int(m)) + except KeyError: + raise ApiError(404, "not_found", "batch") + + +def h_command_retry(hub, conn, m, q, body): + try: + return 201, queue.retry_command(conn, _int(m), body.get("author") or "api") + except KeyError: + raise ApiError(404, "not_found", "command") + except CommandError as e: + raise ApiError(422, "bad_batch", str(e)) + + +def h_command_cancel(hub, conn, m, q, body): + try: + return 200, queue.cancel_command(conn, _int(m), bool(body.get("force"))) + except KeyError: + raise ApiError(404, "not_found", "command") + except ClaimedError as e: + raise ApiError(409, "claimed", e.claimed_at) + + +def h_refresh(hub, conn, m, q, body): + if body.get("full"): + params = {"jql": settings.get(conn, "base_jql")} + elif body.get("jql"): + params = {"jql": body["jql"]} + elif body.get("keys"): + params = {"keys": list(body["keys"])} + else: + raise ApiError(422, "bad_refresh", "нужно full, jql или keys") + return 201, queue.create_batch(conn, "Обновление", body.get("author") or "api", [{"type": "refresh", "params": params}]) + + +def h_agent_get(hub, conn, m, q, body): + return 200, queue.agent_view(conn) + + +def h_sync_runs(hub, conn, m, q, body): + rows = conn.execute("SELECT * FROM sync_run ORDER BY id DESC LIMIT ?", (int(q.get("limit", 20)),)).fetchall() + return 200, {"runs": [dict(r) for r in rows]} + + +def h_settings_get(hub, conn, m, q, body): + return 200, settings.get_all(conn) + + +def h_settings_put(hub, conn, m, q, body): + bad = [k for k in body if k in settings.PROTECTED] + if bad: + raise ApiError(422, "protected", ", ".join(bad)) + version = settings.get(conn, "settings_version") + with db.transaction(conn): + for key, value in body.items(): + version = settings.set_(conn, key, value) + return 200, {"settings_version": version} + + +ROUTES = [ + ("POST", r"^/agent/claim$", h_claim, "agent"), + ("GET", r"^/agent/settings$", h_agent_settings, "agent"), + ("GET", r"^/agent/commands/(\d+)$", h_agent_command, "agent"), + ("POST", r"^/agent/commands/(\d+)/result$", h_agent_result, "agent"), + ("POST", r"^/agent/state$", h_agent_state, "agent"), + ("GET", r"^/agent/known-keys$", h_known_keys, "agent"), + ("GET", r"^/issues$", h_issues, "client"), + ("GET", r"^/issues/([^/]+)$", h_issue, "client"), + ("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"), + ("POST", r"^/batches$", h_batches_post, "client"), + ("GET", r"^/batches$", h_batches_get, "client"), + ("GET", r"^/batches/(\d+)$", h_batch_get, "client"), + ("POST", r"^/batches/(\d+)/cancel$", h_batch_cancel, "client"), + ("POST", r"^/commands/(\d+)/retry$", h_command_retry, "client"), + ("POST", r"^/commands/(\d+)/cancel$", h_command_cancel, "client"), + ("POST", r"^/refresh$", h_refresh, "client"), + ("GET", r"^/agent$", h_agent_get, "client"), + ("GET", r"^/sync-runs$", h_sync_runs, "client"), + ("GET", r"^/settings$", h_settings_get, "client"), + ("PUT", r"^/settings$", h_settings_put, "client"), +] +_COMPILED = [(m, re.compile(p), fn, scope) for m, p, fn, scope in ROUTES] + + +class Handler(BaseHTTPRequestHandler): + hub: Hub = None # подставляется в make_server + + def do_GET(self): + self._dispatch("GET") + + def do_POST(self): + self._dispatch("POST") + + def do_PUT(self): + self._dispatch("PUT") + + def log_message(self, fmt, *args): # тише стандартного: без адреса клиента + print(f"[{self.log_date_time_string()}] {fmt % args}") + + def _auth(self, scope): + token = (self.headers.get("Authorization") or "").removeprefix("Bearer ").strip() + conn = self.hub.conn() + try: + tokens = settings.tokens(conn) + finally: + conn.close() + expected = tokens["agent" if scope == "agent" else "client"] + if not token or not hmac.compare_digest(token.encode("utf-8"), expected.encode("utf-8")): + raise ApiError(401, "unauthorized", "неверный токен для этого маршрута") + + def _dispatch(self, method): + url = urlparse(self.path) + try: + query = {k: v[0] for k, v in parse_qs(url.query).items()} + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length else b"" + for route_method, rx, fn, scope in _COMPILED: + if route_method != method: + continue + match = rx.match(url.path) + if not match: + continue + self._auth(scope) + try: + body = json.loads(raw.decode("utf-8")) if raw else {} + except ValueError as e: # битый JSON или не UTF-8 + raise ApiError(400, "bad_json", str(e)) + if not isinstance(body, dict): + raise ApiError(400, "bad_json", "ожидается объект") + conn = self.hub.conn() + try: + status, payload = fn(self.hub, conn, match, query, body) + except (ValueError, TypeError) as e: # нечисловой limit, неверные типы полей + raise ApiError(400, "bad_request", str(e)) + finally: + conn.close() + return self._send(status, payload) + raise ApiError(404, "not_found", url.path) + except ApiError as e: + self._send(e.status, {"error": e.error, "detail": e.detail}) + except Exception as e: # noqa: BLE001 — любой сбой обработчика виден клиенту и в логе + traceback.print_exc() + self._send(500, {"error": "internal", "detail": str(e)}) + + def _send(self, status, payload): + data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + +def make_server(hub: Hub, host="0.0.0.0", port=0) -> ThreadingHTTPServer: + handler = type("HubHandler", (Handler,), {"hub": hub}) + return ThreadingHTTPServer((host, port), handler) + + +def serve(hub: Hub, host="0.0.0.0", port=8790) -> None: + srv = make_server(hub, host, port) + stop = threading.Event() + + def ticker(): + while not stop.wait(60): + conn = hub.conn() + try: + queue.maybe_auto_refresh(conn) + except Exception: # noqa: BLE001 + traceback.print_exc() + finally: + conn.close() + + threading.Thread(target=ticker, daemon=True).start() + print(f"jira-hub: http://{host}:{srv.server_port}, база {hub.db_path}") + try: + srv.serve_forever() + except KeyboardInterrupt: + pass + finally: + stop.set() + srv.server_close() diff --git a/src/jira_hub/client.py b/src/jira_hub/client.py new file mode 100644 index 0000000..55f356f --- /dev/null +++ b/src/jira_hub/client.py @@ -0,0 +1,63 @@ +"""HTTP-клиент хаба: для MCP, тестов и скриптов. Ошибки API → HubError, недоступный хаб → HubUnavailable.""" +import json +import urllib.error +import urllib.parse +import urllib.request + +from . import db, paths, settings + + +class HubError(Exception): + def __init__(self, status, error, detail=None): + super().__init__(f"HTTP {status} {error}: {detail or ''}".strip()) + self.status, self.error, self.detail = status, error, detail + + +class HubUnavailable(Exception): + """Хаб не отвечает — обычно не запущен (jira-hub serve).""" + + +class HubClient: + def __init__(self, base_url: str, token: str, timeout: float = 60): + self.base, self.token, self.timeout = base_url.rstrip("/"), token, timeout + + @classmethod + def from_home(cls, timeout: float = 60) -> "HubClient": + conn = db.connect(paths.db_path()) + try: + settings.ensure_defaults(conn) + return cls(f"http://127.0.0.1:{settings.get(conn, 'port')}", settings.tokens(conn)["client"], timeout) + finally: + conn.close() + + def call(self, method: str, path: str, body=None, params=None): + url = self.base + path + if params: + url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v not in (None, "")}) + data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Authorization", "Bearer " + self.token) + req.add_header("Accept", "application/json") + if data is not None: + req.add_header("Content-Type", "application/json; charset=utf-8") + try: + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + raw = resp.read() + return json.loads(raw.decode("utf-8")) if raw else {} + except urllib.error.HTTPError as e: + try: + payload = json.loads(e.read().decode("utf-8")) + except Exception: + payload = {"error": "http", "detail": str(e)} + raise HubError(e.code, payload.get("error", "http"), payload.get("detail")) from None + except (urllib.error.URLError, ConnectionError, TimeoutError) as e: + raise HubUnavailable(f"хаб не отвечает ({self.base}): {e}") from None + + def get(self, path, **params): + return self.call("GET", path, params=params or None) + + def post(self, path, body=None): + return self.call("POST", path, body if body is not None else {}) + + def put(self, path, body): + return self.call("PUT", path, body) diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..0469c86 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,153 @@ +import threading + +import pytest + +from jira_hub import api, db, settings +from jira_hub.client import HubClient, HubError + + +@pytest.fixture +def hub(tmp_path): + h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") + h.claim_wait = 0 + conn = h.conn() + settings.set_(conn, "link_types", {"связанные": "ASUPR_Связанные"}) + tok = settings.tokens(conn) + conn.close() + srv = api.make_server(h, "127.0.0.1", 0) + th = threading.Thread(target=srv.serve_forever, daemon=True) + th.start() + base = f"http://127.0.0.1:{srv.server_port}" + yield {"hub": h, "agent": HubClient(base, tok["agent"]), "client": HubClient(base, tok["client"]), "base": base} + srv.shutdown() + srv.server_close() + th.join(5) + + +def test_auth_scopes(hub): + with pytest.raises(HubError) as e: + hub["agent"].get("/issues") + assert e.value.status == 401 + with pytest.raises(HubError) as e: + hub["client"].post("/agent/claim", {}) + assert e.value.status == 401 + with pytest.raises(HubError) as e: + HubClient(hub["base"], "wrong").get("/issues") + assert e.value.status == 401 + with pytest.raises(HubError) as e: + hub["client"].get("/nope") + assert e.value.status == 404 + + +def test_agent_flow(hub): + c = hub["client"] + b = c.post("/batches", {"title": "срок", "author": "claude", + "commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]}) + assert b["count"] == 2 + a = hub["agent"] + claim = a.post("/agent/claim", {"agent_version": 1, "settings_version": 1}) + cmd = claim["command"] + assert cmd["type"] == "ensure_fields" and cmd["pause_seconds"] == 120 and claim["settings_version"] >= 1 + assert a.get(f"/agent/commands/{cmd['id']}")["id"] == cmd["id"] + assert a.get("/agent/settings")["link_types"]["связанные"] == "ASUPR_Связанные" + assert a.post("/agent/claim", {})["command"] is None # одна в полёте, долгий запрос выключен + r = a.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": {"changed": ["duedate"]}, "jira_calls": 2}) + assert r["status"] == "done" + assert a.post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "x"})["status"] == "done" + tail = a.post("/agent/claim", {})["command"] + assert tail["type"] == "refresh" + out = a.post("/agent/state", {"command_id": tail["id"], "page": 1, "last_page": True, + "issues": [{"key": "A-1", "summary": "Т", "duedate": "2026-10-23", "labels": ["AWPCA"], + "created": "2026-10-10T10:00:00.000+0300"}, + {"key": "B-1", "summary": "Сосед", "labels": []}], + "links": [{"source_key": "A-1", "target_key": "B-1", "link_type": "ASUPR_Связанные"}], + "neighbor_keys": ["B-1"]}) + assert out == {"issues": 2, "links": 1} + assert a.get("/agent/known-keys")["keys"] == ["A-1", "B-1"] + a.post(f"/agent/commands/{tail['id']}/result", {"status": "done", "jira_calls": 1}) + assert c.get("/batches/%d" % b["id"])["status"] == "done" + issue = c.get("/issues/A-1") + assert issue["links_out"] == [{"target_key": "B-1", "link_type": "ASUPR_Связанные"}] + assert c.get("/issues/B-1")["neighbor"] is True + runs = c.get("/sync-runs", limit=5)["runs"] + assert runs[0]["issues_count"] == 2 and runs[0]["finished_at"] + assert c.get("/agent")["online"] is True + + +def test_interface_routes(hub): + c = hub["client"] + with pytest.raises(HubError) as e: + c.get("/issues/A-404") + assert e.value.status == 404 + hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "sprint": "Спринт 2", "labels": []}, + {"key": "A-2", "summary": "Д", "sprint": "Спринт 1", "labels": []}], "links": []}) + assert [i["key"] for i in c.get("/issues", sprint="Спринт 2")["issues"]] == ["A-1"] + note = c.put("/issues/A-1/note", {"fact_status": "В работе", "updated_by": "owner"}) + assert note["fact_status"] == "В работе" and note["updated_by"] == "owner" + with pytest.raises(HubError) as e: + c.put("/issues/A-1/note", {"summary": "x"}) + assert e.value.status == 422 + with pytest.raises(HubError) as e: + c.post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", "params": {"key": "A-1"}}]}) + assert e.value.status == 422 and "без полей" in e.value.detail + b = c.post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}, + {"type": "ensure_fields", "params": {"key": "A-2", "duedate": "2026-10-23"}}]}) + assert c.get("/batches", limit=5)["batches"][0]["id"] == b["id"] + cmd = hub["agent"].post("/agent/claim", {})["command"] + with pytest.raises(HubError) as e: + c.post(f"/commands/{cmd['id']}/cancel", {}) + assert e.value.status == 409 and e.value.detail + assert c.post(f"/commands/{cmd['id']}/cancel", {"force": True})["status"] == "cancelled" + assert c.post(f"/batches/{b['id']}/cancel", {})["status"] == "cancelled" + retry = c.post(f"/commands/{cmd['id']}/retry", {"author": "claude"}) + assert c.get(f"/batches/{retry['id']}")["title"] == f"Повтор команды {cmd['id']}" + full = c.post("/refresh", {"full": True}) + assert c.get(f"/batches/{full['id']}")["commands"][0]["params"]["jql"] == "project = AURORA AND labels = AWPCA" + keys = c.post("/refresh", {"keys": ["A-1"]}) + assert c.get(f"/batches/{keys['id']}")["commands"][0]["params"] == {"keys": ["A-1"]} + + +def test_settings_put_rejects_protected(hub): + c = hub["client"] + v0 = c.get("/settings")["settings_version"] + for key in ("tokens", "settings_version"): + with pytest.raises(HubError) as e: + c.put("/settings", {key: "x", "pacing": {"refresh": 1}}) + assert e.value.status == 422 + assert c.get("/settings")["settings_version"] == v0 + assert "tokens" not in c.get("/settings") + v1 = c.put("/settings", {"pacing": {"refresh": 1}})["settings_version"] + assert v1 == v0 + 1 and c.get("/settings")["pacing"]["refresh"] == 1 + + +def test_backup_created_on_first_write(hub, tmp_path): + c = hub["client"] + c.post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]}) + hub["agent"].post("/agent/claim", {}) + assert len(list((tmp_path / "backups").glob("*-batch-*.sqlite"))) == 1 + + +def test_client_unavailable(): + from jira_hub.client import HubUnavailable + with pytest.raises(HubUnavailable): + HubClient("http://127.0.0.1:9", "t", timeout=1).get("/issues") + + +def test_no_backup_for_refresh_batch(hub, tmp_path): + hub["client"].post("/refresh", {"keys": ["A-1"]}) + assert hub["agent"].post("/agent/claim", {})["command"]["type"] == "refresh" + assert not list((tmp_path / "backups").glob("*.sqlite")) + + +def test_bad_requests(hub): + c = hub["client"] + with pytest.raises(HubError) as e: + c.get("/issues", limit="abc") + assert e.value.status == 400 + import urllib.request, urllib.error + req = urllib.request.Request(hub["base"] + "/batches", data=b"{not json", method="POST", + headers={"Authorization": "Bearer " + c.token}) + with pytest.raises(urllib.error.HTTPError) as e2: + urllib.request.urlopen(req) + assert e2.value.code == 400 + e2.value.close()