feat(hub): HTTP API — маршруты агента и интерфейсов, токены, долгий claim, клиент
Соединение SQLite на запрос, сравнение токенов в постоянное время, 400 на битый JSON и нечисловые параметры. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o
This commit is contained in:
parent
68b73439a6
commit
2558248dc9
318
src/jira_hub/api.py
Normal file
318
src/jira_hub/api.py
Normal file
@ -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()
|
||||
63
src/jira_hub/client.py
Normal file
63
src/jira_hub/client.py
Normal file
@ -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)
|
||||
153
tests/test_api.py
Normal file
153
tests/test_api.py
Normal file
@ -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()
|
||||
Loading…
x
Reference in New Issue
Block a user