refactor: HTTP — сервер ядра, маршруты модуля jira (#87)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-23 22:32:51 +04:00
parent fe71adf189
commit 0bafff9bc4
8 changed files with 335 additions and 261 deletions

View File

@ -9,16 +9,13 @@ import re
import threading import threading
import time import time
import traceback import traceback
from datetime import timedelta
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from typing import NamedTuple from urllib.parse import parse_qs, unquote, urlparse
from urllib.parse import parse_qs, quote, unquote, urlparse
from . import backup, db, files, projects, queue, relay, release, settings from . import backup, db, files, projects, queue, registry, relay, release, settings
from ..modules.jira import attachments, fetch, history, mirror, refresh # временно: R-1, до Task 5 from .errors import CommandError, HttpMapped
from ..modules.jira import projects as jira_projects from .http import ApiError, Raw, int_group, int_param, mapped, not_found
from .errors import CommandError
from .paths import backups_dir as default_backups_dir from .paths import backups_dir as default_backups_dir
from .paths import secure_dir from .paths import secure_dir
from .queue import ClaimedError from .queue import ClaimedError
@ -33,23 +30,9 @@ ASSET_CACHE = {"Cache-Control": "public, max-age=31536000, immutable"} # им
NOT_BUILT = ("<!doctype html><meta charset=\"utf-8\"><title>Хаб Jira</title><p>Веб-приложение не собрано: выполните " NOT_BUILT = ("<!doctype html><meta charset=\"utf-8\"><title>Хаб Jira</title><p>Веб-приложение не собрано: выполните "
"<code>make web</code> в каталоге jira-hub и перезапустите хаб.</p>").encode("utf-8") "<code>make web</code> в каталоге jira-hub и перезапустите хаб.</p>").encode("utf-8")
SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит
MIME_RE = re.compile(r"[\w!#$&^.+-]+/[\w!#$&^.+-]+", re.ASCII) # тип/подтип без параметров, пробелов и не-ASCII: заголовок не внушить
SWEEP_EVERY = 24 * 3600 # уборка files/ не чаще раза в сутки (hub-api «Хранилище файлов вложений») SWEEP_EVERY = 24 * 3600 # уборка files/ не чаще раза в сутки (hub-api «Хранилище файлов вложений»)
class Raw(NamedTuple):
"""Не-JSON ответ обработчика (веб-приложение, перенаправление). headers=None — Cache-Control: no-cache."""
content_type: str
data: bytes
headers: dict | None = None
class ApiError(Exception):
def __init__(self, status, error, detail=None):
super().__init__(error)
self.status, self.error, self.detail = status, error, detail
class Hub: class Hub:
def __init__(self, db_path, backups_dir=None, release_dir=None, web_dir=None, files_dir=None): def __init__(self, db_path, backups_dir=None, release_dir=None, web_dir=None, files_dir=None):
self.db_path = Path(db_path) self.db_path = Path(db_path)
@ -57,8 +40,8 @@ class Hub:
self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir() self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir()
self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release" self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release"
self.files_dir = Path(files_dir) if files_dir else self.db_path.parent / "files" self.files_dir = Path(files_dir) if files_dir else self.db_path.parent / "files"
self.downloads_dir = self.db_path.parent / "downloads" # копии вложений для MCP; каталог создаёт MCP
self.last_sweep = None # время прошлой уборки files/, в памяти (решение 15) self.last_sweep = None # время прошлой уборки files/, в памяти (решение 15)
self.module_state: dict[str, dict] = {} # память модулей между тиками (имя модуля → словарь)
# последнее из claim; в памяти (R-3). reported — был ли claim с запуска хаба: до него launcher_version # последнее из claim; в памяти (R-3). reported — был ли claim с запуска хаба: до него launcher_version
# неизвестен, а не null (hub-hygiene-d, решение 7) # неизвестен, а не null (hub-hygiene-d, решение 7)
self.agent_update = {"update_state": None, "launcher_version": None, "reported": False} self.agent_update = {"update_state": None, "launcher_version": None, "reported": False}
@ -73,19 +56,22 @@ class Hub:
return db.connect(self.db_path) return db.connect(self.db_path)
def tick(self): def tick(self):
"""Один шаг фоновых работ: автообновление и уборка файлов вложений (не чаще раза в сутки). Сбой каждой """Один шаг фоновых работ: уборка файлов ядра (не чаще раза в сутки), затем tick модулей. Сбой каждой
логируется и не мешает ни другой, ни следующему тику.""" работы логируется и не мешает ни другой, ни следующему тику; соединение открывается при первой работе."""
works = [("уборка файлов: сбой", self.sweep_files)]
for m in registry.modules():
for fn in m.tick:
label = getattr(fn, "label", getattr(fn, "__name__", m.name))
works.append((f"{label}: сбой тика", lambda conn, fn=fn: fn(self, conn)))
conn = None conn = None
try: try:
conn = self.conn() for message, work in works:
refresh.maybe_auto_refresh(conn) try:
except Exception: # noqa: BLE001 if conn is None:
log.exception("автообновление: сбой тика") conn = self.conn()
try: work(conn)
if conn is not None: except Exception: # noqa: BLE001
self.sweep_files(conn) log.exception(message)
except Exception: # noqa: BLE001
log.exception("уборка файлов: сбой")
finally: finally:
if conn is not None: if conn is not None:
conn.close() conn.close()
@ -100,12 +86,6 @@ class Hub:
removed = files.sweep(conn, self.files_dir, now) removed = files.sweep(conn, self.files_dir, now)
if removed: if removed:
log.info("уборка файлов: удалено %d", len(removed)) log.info("уборка файлов: удалено %d", len(removed))
try:
gone = files.sweep_downloads(self.downloads_dir, now)
if gone:
log.info("уборка копий вложений: удалено %d", len(gone))
except Exception: # noqa: BLE001 — копии MCP не должны мешать основной уборке
log.exception("уборка копий вложений: сбой")
return removed return removed
def note_agent_update(self, body): def note_agent_update(self, body):
@ -133,23 +113,6 @@ class Hub:
# --- обработчики: fn(hub, conn, match, query, body) -> (status, payload) ---------- # --- обработчики: fn(hub, conn, match, query, body) -> (status, payload) ----------
def _int(m, i=1):
return int(m.group(i))
def _int_param(q, name, default):
try:
return int(q.get(name, default))
except (TypeError, ValueError):
raise ApiError(400, "bad_param", f"{name}: ожидается целое число")
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): def h_claim(hub, conn, m, q, body):
hub.note_agent_update(body) hub.note_agent_update(body)
deadline = time.monotonic() + hub.claim_wait deadline = time.monotonic() + hub.claim_wait
@ -190,13 +153,13 @@ def h_agent_settings(hub, conn, m, q, body):
def h_agent_command(hub, conn, m, q, body): def h_agent_command(hub, conn, m, q, body):
"""Команда по id для восстановления из журнала агента: параметры — как при выдаче (с size у ensure_attachment).""" """Команда по id для восстановления из журнала агента: параметры — как при выдаче (с size у ensure_attachment)."""
cmd = _not_found(queue.get_command(conn, _int(m)), "command") cmd = not_found(queue.get_command(conn, int_group(m)), "command")
return 200, {**cmd, "params": queue.deliver_params(conn, cmd, hub.files_dir)} return 200, {**cmd, "params": queue.deliver_params(conn, cmd, hub.files_dir)}
def h_agent_result(hub, conn, m, q, body): def h_agent_result(hub, conn, m, q, body):
try: try:
cmd = queue.report_result(conn, _int(m), body.get("status"), body.get("result"), body.get("error"), cmd = queue.report_result(conn, int_group(m), body.get("status"), body.get("result"), body.get("error"),
body.get("jira_calls", 0), body.get("rate_limited", 0), files_dir=hub.files_dir) body.get("jira_calls", 0), body.get("rate_limited", 0), files_dir=hub.files_dir)
except KeyError: except KeyError:
raise ApiError(404, "not_found", "command") raise ApiError(404, "not_found", "command")
@ -207,97 +170,6 @@ def h_agent_result(hub, conn, m, q, body):
return 200, cmd return 200, cmd
def h_agent_state(hub, conn, m, q, body):
issues = body.get("issues") or []
try:
mirror.check_comments(issues) # до записи: неверная пачка не применяется целиком
mirror.check_attachments(issues)
except ValueError as e:
raise ApiError(422, "bad_state", str(e))
with db.transaction(conn):
out = mirror.apply_state(conn, issues, body.get("links") or [], body.get("neighbor_keys") or [],
authoritative=refresh.is_full_refresh(conn, body.get("command_id")))
if body.get("command_id"):
refresh.record_state(conn, body["command_id"], out["issues"], out["links"])
return 200, out
def h_agent_history(hub, conn, m, q, body):
changes = body.get("changes")
try:
history.check_changes(changes) # до записи: неверная пачка не применяется целиком
except ValueError as e:
raise ApiError(422, "bad_history", str(e))
return 200, history.record_changes(conn, changes)
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", "hide_closed", "project")
def _project_param(conn, q) -> str | None:
"""q["project"] — метка существующего проекта; без параметра — None, неизвестная метка — 404."""
label = q.get("project")
if label in (None, ""):
return None
if projects.get(conn, label) is None:
raise ApiError(404, "not_found", f"project {label}")
return label
def h_issues(hub, conn, m, q, body):
project = _project_param(conn, q)
filters = {k: q.get(k) for k in ISSUE_FILTERS}
filters["project"] = project
closed = settings.get(conn, "closed_statuses")
closed = [s for s in closed if isinstance(s, str)] if isinstance(closed, list) else []
return 200, {"issues": mirror.list_issues(conn, filters, _int_param(q, "limit", 50), _int_param(q, "offset", 0),
closed_statuses=closed)}
def h_issue(hub, conn, m, q, body):
return 200, _not_found(mirror.get_issue(conn, m.group(1), hub.files_dir), "issue")
_OFFSET = re.compile(r"(Z|[+-]\d{2}:?\d{2})$")
def _time_param(q, name, default):
raw = q.get(name)
if raw in (None, ""):
return default
value = history.jira_utc(raw) if _OFFSET.search(raw) else None # без смещения — не угадываем пояс (M-2)
if value is None:
raise ApiError(422, "bad_request", f"{name}: ожидается время ISO-8601 со смещением или Z")
return value
def h_changes(hub, conn, m, q, body):
project = _project_param(conn, q)
until = _time_param(q, "until", db.now())
since = _time_param(q, "since", (db.parse(until) - timedelta(hours=24)).isoformat())
if since > until:
raise ApiError(422, "bad_request", "since: позже until")
try:
kinds = history.parse_kinds(q.get("kinds"))
except ValueError as e:
raise ApiError(422, "bad_request", str(e))
try:
limit = int(q.get("limit", 500))
except (TypeError, ValueError):
limit = 0
if not 1 <= limit <= 2000:
raise ApiError(422, "bad_request", "limit: целое от 1 до 2000")
return 200, history.feed(conn, since, until, kinds, project=project, key=q.get("key") or None, limit=limit)
def h_board(hub, conn, m, q, body):
return 200, mirror.board(conn, jira_projects.base_jql(conn), _project_param(conn, q))
def h_app(hub, conn, m, q, body): def h_app(hub, conn, m, q, body):
"""Веб-приложение: файлы assets/ сборки как есть, любой другой путь /app… — index.html (маршруты на клиенте). """Веб-приложение: файлы assets/ сборки как есть, любой другой путь /app… — index.html (маршруты на клиенте).
Путь файла после разрешения обязан лежать внутри assets/, иначе 404; без сборки — 503 с подсказкой.""" Путь файла после разрешения обязан лежать внутри assets/, иначе 404; без сборки — 503 с подсказкой."""
@ -324,14 +196,6 @@ def h_root(hub, conn, m, q, body):
return 302, Raw("text/html; charset=utf-8", b"", {"Location": "/app/board", "Cache-Control": "no-cache"}) return 302, Raw("text/html; charset=utf-8", b"", {"Location": "/app/board", "Cache-Control": "no-cache"})
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): def h_batches_post(hub, conn, m, q, body):
if not body.get("title") or not isinstance(body["title"], str): if not body.get("title") or not isinstance(body["title"], str):
raise ApiError(422, "bad_batch", "title — непустая строка") raise ApiError(422, "bad_batch", "title — непустая строка")
@ -346,23 +210,23 @@ def h_batches_post(hub, conn, m, q, body):
try: try:
return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands"), return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands"),
files_dir=hub.files_dir, project=body.get("project")) files_dir=hub.files_dir, project=body.get("project"))
except fetch.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (M-2) except HttpMapped as e: # раньше CommandError: исключения модулей несут свой код (M-2)
raise ApiError(413, "too_large", str(e)) from None raise mapped(e) from None
except CommandError as e: except CommandError as e:
raise ApiError(422, "bad_batch", str(e)) raise ApiError(422, "bad_batch", str(e))
def h_batches_get(hub, conn, m, q, body): def h_batches_get(hub, conn, m, q, body):
return 200, {"batches": queue.list_batches(conn, _int_param(q, "limit", 20), project=q.get("project") or None)} return 200, {"batches": queue.list_batches(conn, int_param(q, "limit", 20), project=q.get("project") or None)}
def h_batch_get(hub, conn, m, q, body): def h_batch_get(hub, conn, m, q, body):
return 200, _not_found(queue.batch_view(conn, _int(m)), "batch") return 200, not_found(queue.batch_view(conn, int_group(m)), "batch")
def h_batch_cancel(hub, conn, m, q, body): def h_batch_cancel(hub, conn, m, q, body):
try: try:
return 200, queue.cancel_batch(conn, _int(m)) return 200, queue.cancel_batch(conn, int_group(m))
except KeyError: except KeyError:
raise ApiError(404, "not_found", "batch") raise ApiError(404, "not_found", "batch")
@ -395,48 +259,6 @@ def h_agent_files_post(hub, conn, m, q, body):
return _put_file(hub, body, allow_empty=True) return _put_file(hub, body, allow_empty=True)
def _attachment(hub, conn, m):
att = attachments.get(conn, hub.files_dir, m.group(1))
if att is None:
raise ApiError(404, "no_attachment", f"вложения #{m.group(1)} нет в зеркале — обновите задачу")
return att
def h_attachment_get(hub, conn, m, q, body):
return 200, _attachment(hub, conn, m)
def h_attachment_fetch(hub, conn, m, q, body):
author = body.get("author") or "api"
if not isinstance(author, str):
raise ApiError(422, "bad_request", "author — строка")
try:
return 200, fetch.request_fetch(conn, hub.files_dir, m.group(1), author)
except KeyError:
raise ApiError(404, "no_attachment", f"вложения #{m.group(1)} нет в зеркале — обновите задачу") from None
except fetch.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (решение 17)
raise ApiError(413, "too_large", str(e)) from None
except fetch.AgentTooOld as e:
raise ApiError(409, "agent_too_old", str(e)) from None
except CommandError as e:
raise ApiError(422, "bad_batch", str(e)) from None
def h_attachment_content(hub, conn, m, q, body):
"""Содержимое скачанного вложения; mime — из вложения, негодный → octet-stream; имя — filename* (RFC 5987)."""
att = _attachment(hub, conn, m)
# state == ready означает, что запись attachment_file есть и файл в хранилище на месте (attachments.state)
row = conn.execute("SELECT file FROM attachment_file WHERE attachment_id = ?", (m.group(1),)).fetchone() \
if att["state"] == "ready" else None
data = files.read(hub.files_dir, row["file"]) if row else None
if data is None:
raise ApiError(404, "not_fetched", "вложение не скачано — запросите скачивание")
files.touch(hub.files_dir, row["file"])
mime = att["mime_type"] if isinstance(att["mime_type"], str) and MIME_RE.fullmatch(att["mime_type"]) else "application/octet-stream"
return 200, Raw(mime, data, {"Content-Disposition": "attachment; filename*=UTF-8''" + quote(att["filename"], safe=""),
"X-Content-Type-Options": "nosniff", "Cache-Control": "no-cache"})
def h_command_retry(hub, conn, m, q, body): def h_command_retry(hub, conn, m, q, body):
if not isinstance(body.get("author") or "api", str): if not isinstance(body.get("author") or "api", str):
raise ApiError(422, "bad_retry", "author — строка") raise ApiError(422, "bad_retry", "author — строка")
@ -445,47 +267,25 @@ def h_command_retry(hub, conn, m, q, body):
if body.get("project") == "": if body.get("project") == "":
raise ApiError(422, "bad_retry", "project: пустая строка") raise ApiError(422, "bad_retry", "project: пустая строка")
try: try:
return 201, queue.retry_command(conn, _int(m), body.get("author") or "api", files_dir=hub.files_dir, return 201, queue.retry_command(conn, int_group(m), body.get("author") or "api", files_dir=hub.files_dir,
project=body.get("project")) project=body.get("project"))
except KeyError: except KeyError:
raise ApiError(404, "not_found", "command") raise ApiError(404, "not_found", "command")
except fetch.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (M-2) except HttpMapped as e: # раньше CommandError: исключения модулей несут свой код (M-2)
raise ApiError(413, "too_large", str(e)) from None raise mapped(e) from None
except CommandError as e: except CommandError as e:
raise ApiError(422, "bad_batch", str(e)) raise ApiError(422, "bad_batch", str(e))
def h_command_cancel(hub, conn, m, q, body): def h_command_cancel(hub, conn, m, q, body):
try: try:
return 200, queue.cancel_command(conn, _int(m), bool(body.get("force"))) return 200, queue.cancel_command(conn, int_group(m), bool(body.get("force")))
except KeyError: except KeyError:
raise ApiError(404, "not_found", "command") raise ApiError(404, "not_found", "command")
except ClaimedError as e: except ClaimedError as e:
raise ApiError(409, "claimed", e.claimed_at) raise ApiError(409, "claimed", e.claimed_at)
def h_refresh(hub, conn, m, q, body):
if not isinstance(body.get("author") or "api", str):
raise ApiError(422, "bad_refresh", "author — строка")
keys = body.get("keys")
if keys is not None and (not isinstance(keys, list) or not all(isinstance(k, str) and k for k in keys)):
raise ApiError(422, "bad_refresh", "keys — список строк")
if body.get("jql") is not None and not isinstance(body["jql"], str):
raise ApiError(422, "bad_refresh", "jql — строка")
if body.get("full"):
jql = jira_projects.base_jql(conn)
if jql is None:
raise ApiError(422, "bad_refresh", "нет проектов")
params = {"jql": jql}
elif body.get("jql"):
params = {"jql": body["jql"]}
elif body.get("keys"):
params = {"keys": 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): def h_agent_get(hub, conn, m, q, body):
view = queue.agent_view(conn) view = queue.agent_view(conn)
rel = release.current(hub.release_dir) rel = release.current(hub.release_dir)
@ -496,13 +296,17 @@ def h_agent_get(hub, conn, m, q, body):
return 200, view return 200, view
def h_sync_runs(hub, conn, m, q, body): def _settings_view(conn) -> dict:
rows = conn.execute("SELECT * FROM sync_run ORDER BY id DESC LIMIT ?", (_int_param(q, "limit", 20),)).fetchall() """Настройки ядра и модулей плюс вычисляемые поля модулей (settings_view)."""
return 200, {"runs": [dict(r) for r in rows]} out = settings.get_all(conn)
for mod in registry.modules():
if mod.settings_view:
out.update(mod.settings_view(conn))
return out
def h_settings_get(hub, conn, m, q, body): def h_settings_get(hub, conn, m, q, body):
return 200, {**settings.get_all(conn), "base_jql": jira_projects.base_jql(conn)} return 200, _settings_view(conn)
def h_settings_put(hub, conn, m, q, body): def h_settings_put(hub, conn, m, q, body):
@ -520,7 +324,7 @@ def h_settings_put(hub, conn, m, q, body):
with db.transaction(conn): with db.transaction(conn):
for key, value in body.items(): for key, value in body.items():
settings.set_(conn, key, value) settings.set_(conn, key, value)
return 200, {**settings.get_all(conn), "base_jql": jira_projects.base_jql(conn)} return 200, _settings_view(conn)
def h_projects_get(hub, conn, m, q, body): def h_projects_get(hub, conn, m, q, body):
@ -528,63 +332,56 @@ def h_projects_get(hub, conn, m, q, body):
def h_projects_post(hub, conn, m, q, body): def h_projects_post(hub, conn, m, q, body):
"""Новый проект: проект и пакет «Новый проект <метка>» (один refresh по базовой JQL с новой меткой) — в одной """Новый проект: проект и пакеты, которые ставят модули (on_project_created; у jira — «Новый проект <метка>») —
транзакции; ошибка любого шага не оставляет ни проекта, ни пакета (hub-api, «Маршруты проектов»).""" в одной транзакции; ошибка любого шага не оставляет ни проекта, ни пакета (hub-api, «Маршруты проектов»)."""
author = body.get("author") author = body.get("author")
if author is None or author == "": if author is None or author == "":
author = "api" author = "api"
elif not isinstance(author, str): elif not isinstance(author, str):
raise ApiError(422, "bad_project", "author — строка") raise ApiError(422, "bad_project", "author — строка")
batch_id = None
try: try:
with db.transaction(conn): with db.transaction(conn):
project = projects.create(conn, body.get("label"), body.get("name"), body.get("board_id")) project = projects.create(conn, body.get("label"), body.get("name"), body.get("board_id"))
batch = queue.create_batch(conn, f"Новый проект {project['label']}", author, for mod in registry.modules():
[{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}]) batch = mod.on_project_created(conn, project, author) if mod.on_project_created else None
if batch is not None and batch_id is None:
batch_id = batch["id"]
except projects.ProjectExists as e: except projects.ProjectExists as e:
raise ApiError(409, "project_exists", str(e)) raise ApiError(409, "project_exists", str(e))
except projects.ProjectError as e: except projects.ProjectError as e:
raise ApiError(422, "bad_project", str(e)) raise ApiError(422, "bad_project", str(e))
except CommandError as e: except CommandError as e:
raise ApiError(422, "bad_batch", str(e)) raise ApiError(422, "bad_batch", str(e))
return 201, {"project": project, "batch_id": batch["id"]} return 201, {"project": project, "batch_id": batch_id}
ROUTES = [ CORE_ROUTES = [
("GET", r"^/(links)?$", h_root, None), ("GET", r"^/(links)?$", h_root, None),
("GET", r"^/app(?:/(.*))?$", h_app, None), ("GET", r"^/app(?:/(.*))?$", h_app, None),
("POST", r"^/agent/claim$", h_claim, "agent"), ("POST", r"^/agent/claim$", h_claim, "agent"),
("GET", r"^/agent/settings$", h_agent_settings, "agent"), ("GET", r"^/agent/settings$", h_agent_settings, "agent"),
("GET", r"^/agent/commands/(\d+)$", h_agent_command, "agent"), ("GET", r"^/agent/commands/(\d+)$", h_agent_command, "agent"),
("POST", r"^/agent/commands/(\d+)/result$", h_agent_result, "agent"), ("POST", r"^/agent/commands/(\d+)/result$", h_agent_result, "agent"),
("POST", r"^/agent/state$", h_agent_state, "agent"),
("POST", r"^/agent/history$", h_agent_history, "agent"),
("GET", r"^/agent/known-keys$", h_known_keys, "agent"),
("GET", r"^/agent/release$", h_agent_release, "agent"), ("GET", r"^/agent/release$", h_agent_release, "agent"),
("GET", r"^/agent/files/([^/]+)$", h_agent_file, "agent"), ("GET", r"^/agent/files/([^/]+)$", h_agent_file, "agent"),
("POST", r"^/agent/files$", h_agent_files_post, "agent"), ("POST", r"^/agent/files$", h_agent_files_post, "agent"),
("GET", r"^/issues$", h_issues, "client"),
("GET", r"^/issues/([^/]+)$", h_issue, "client"),
("GET", r"^/board$", h_board, "client"),
("GET", r"^/changes$", h_changes, "client"),
("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"),
("POST", r"^/batches$", h_batches_post, "client"), ("POST", r"^/batches$", h_batches_post, "client"),
("GET", r"^/batches$", h_batches_get, "client"), ("GET", r"^/batches$", h_batches_get, "client"),
("GET", r"^/batches/(\d+)$", h_batch_get, "client"), ("GET", r"^/batches/(\d+)$", h_batch_get, "client"),
("POST", r"^/batches/(\d+)/cancel$", h_batch_cancel, "client"), ("POST", r"^/batches/(\d+)/cancel$", h_batch_cancel, "client"),
("POST", r"^/files$", h_files_post, "client"), ("POST", r"^/files$", h_files_post, "client"),
("GET", r"^/attachments/(\d+)$", h_attachment_get, "client"),
("POST", r"^/attachments/(\d+)/fetch$", h_attachment_fetch, "client"),
("GET", r"^/attachments/(\d+)/content$", h_attachment_content, "client"),
("POST", r"^/commands/(\d+)/retry$", h_command_retry, "client"), ("POST", r"^/commands/(\d+)/retry$", h_command_retry, "client"),
("POST", r"^/commands/(\d+)/cancel$", h_command_cancel, "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"^/agent$", h_agent_get, "client"),
("GET", r"^/sync-runs$", h_sync_runs, "client"),
("GET", r"^/settings$", h_settings_get, "client"), ("GET", r"^/settings$", h_settings_get, "client"),
("PUT", r"^/settings$", h_settings_put, "client"), ("PUT", r"^/settings$", h_settings_put, "client"),
("GET", r"^/projects$", h_projects_get, "client"), ("GET", r"^/projects$", h_projects_get, "client"),
("POST", r"^/projects$", h_projects_post, "client"), ("POST", r"^/projects$", h_projects_post, "client"),
] ]
# маршрут ядра и модуля не делят метод + шаблон: пересечение — ошибка старта; ROUTES собирается после проверки
registry.check(registry.modules(), CORE_ROUTES, settings.CORE_DEFAULTS)
ROUTES = CORE_ROUTES + [r for mod in registry.modules() for r in mod.routes]
_COMPILED = [(m, re.compile(p), fn, scope) for m, p, fn, scope in ROUTES] _COMPILED = [(m, re.compile(p), fn, scope) for m, p, fn, scope in ROUTES]

50
src/jira_hub/core/http.py Normal file
View File

@ -0,0 +1,50 @@
"""Общее для обработчиков HTTP ядра и модулей: ошибки API, не-JSON ответ, разбор параметров."""
from typing import NamedTuple
from . import projects
from .errors import HttpMapped
class Raw(NamedTuple):
"""Не-JSON ответ обработчика (веб-приложение, перенаправление, файл). headers=None — Cache-Control: no-cache."""
content_type: str
data: bytes
headers: dict | None = None
class ApiError(Exception):
def __init__(self, status, error, detail=None):
super().__init__(error)
self.status, self.error, self.detail = status, error, detail
def int_group(m, i=1) -> int:
return int(m.group(i))
def int_param(q, name, default) -> int:
try:
return int(q.get(name, default))
except (TypeError, ValueError):
raise ApiError(400, "bad_param", f"{name}: ожидается целое число")
def not_found(obj, what):
if obj is None:
raise ApiError(404, "not_found", what)
return obj
def project_param(conn, q) -> str | None:
"""q["project"] — метка существующего проекта; без параметра — None, неизвестная метка — 404."""
label = q.get("project")
if label in (None, ""):
return None
if projects.get(conn, label) is None:
raise ApiError(404, "not_found", f"project {label}")
return label
def mapped(e: HttpMapped) -> ApiError:
"""Исключение модуля с кодом HTTP → ответ API; ядро не знает его типа."""
return ApiError(e.status, e.error, str(e))

View File

@ -10,7 +10,7 @@ def modules() -> list[Module]:
from ..modules import jira # внутри функции: ядро не зависит от модулей при импорте from ..modules import jira # внутри функции: ядро не зависит от модулей при импорте
from . import settings from . import settings
mods = [jira.MODULE] mods = [jira.MODULE]
# маршруты ядра сюда попадут в Task 5, когда api перестанет быть владельцем всех маршрутов # маршруты ядра проверяет core/api.py при сборке ROUTES: здесь их ещё нет
check(mods, [], settings.CORE_DEFAULTS) check(mods, [], settings.CORE_DEFAULTS)
_cache = mods _cache = mods
return _cache return _cache

View File

@ -1,6 +1,6 @@
"""Модуль jira: зеркало задач Jira, очередь команд агенту, вложения, журнал изменений.""" """Модуль jira: зеркало задач Jira, очередь команд агенту, вложения, журнал изменений."""
from ...core.modules import Module from ...core.modules import Module
from . import hooks, schema, settings from . import api, hooks, refresh, schema, settings
MODULE = Module( MODULE = Module(
name="jira", name="jira",
@ -12,4 +12,7 @@ MODULE = Module(
on_init=settings.on_init, on_init=settings.on_init,
commands=hooks.HOOKS, commands=hooks.HOOKS,
file_refs=hooks.file_refs, file_refs=hooks.file_refs,
routes=api.ROUTES,
tick=(refresh.auto_refresh_tick, api.downloads_tick),
on_project_created=api.on_project_created,
) )

View File

@ -0,0 +1,211 @@
"""HTTP модуля jira: зеркало задач, доска, лента изменений, заметки, вложения, обновление из Jira, журнал синхронизаций.
Маршруты подключает MODULE.routes; обработчики — fn(hub, conn, match, query, body) -> (status, payload)."""
import logging
import re
import time
from datetime import timedelta
from urllib.parse import quote
from ...core import db, files, queue, settings
from ...core.errors import CommandError, HttpMapped
from ...core.http import ApiError, Raw, int_group, int_param, mapped, not_found, project_param
from . import attachments, fetch, history, mirror, refresh
from . import projects as jira_projects
log = logging.getLogger(__name__)
MIME_RE = re.compile(r"[\w!#$&^.+-]+/[\w!#$&^.+-]+", re.ASCII) # тип/подтип без параметров, пробелов и не-ASCII: заголовок не внушить
DOWNLOADS_EVERY = 24 * 3600 # уборка downloads/ не чаще раза в сутки
ISSUE_FILTERS = ("sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q", "hide_closed", "project")
_OFFSET = re.compile(r"(Z|[+-]\d{2}:?\d{2})$")
def h_agent_state(hub, conn, m, q, body):
issues = body.get("issues") or []
try:
mirror.check_comments(issues) # до записи: неверная пачка не применяется целиком
mirror.check_attachments(issues)
except ValueError as e:
raise ApiError(422, "bad_state", str(e))
with db.transaction(conn):
out = mirror.apply_state(conn, issues, body.get("links") or [], body.get("neighbor_keys") or [],
authoritative=refresh.is_full_refresh(conn, body.get("command_id")))
if body.get("command_id"):
refresh.record_state(conn, body["command_id"], out["issues"], out["links"])
return 200, out
def h_agent_history(hub, conn, m, q, body):
changes = body.get("changes")
try:
history.check_changes(changes) # до записи: неверная пачка не применяется целиком
except ValueError as e:
raise ApiError(422, "bad_history", str(e))
return 200, history.record_changes(conn, changes)
def h_known_keys(hub, conn, m, q, body):
return 200, {"keys": mirror.known_keys(conn)}
def h_issues(hub, conn, m, q, body):
project = project_param(conn, q)
filters = {k: q.get(k) for k in ISSUE_FILTERS}
filters["project"] = project
closed = settings.get(conn, "closed_statuses")
closed = [s for s in closed if isinstance(s, str)] if isinstance(closed, list) else []
return 200, {"issues": mirror.list_issues(conn, filters, int_param(q, "limit", 50), int_param(q, "offset", 0),
closed_statuses=closed)}
def h_issue(hub, conn, m, q, body):
return 200, not_found(mirror.get_issue(conn, m.group(1), hub.files_dir), "issue")
def _time_param(q, name, default):
raw = q.get(name)
if raw in (None, ""):
return default
value = history.jira_utc(raw) if _OFFSET.search(raw) else None # без смещения — не угадываем пояс (M-2)
if value is None:
raise ApiError(422, "bad_request", f"{name}: ожидается время ISO-8601 со смещением или Z")
return value
def h_changes(hub, conn, m, q, body):
project = project_param(conn, q)
until = _time_param(q, "until", db.now())
since = _time_param(q, "since", (db.parse(until) - timedelta(hours=24)).isoformat())
if since > until:
raise ApiError(422, "bad_request", "since: позже until")
try:
kinds = history.parse_kinds(q.get("kinds"))
except ValueError as e:
raise ApiError(422, "bad_request", str(e))
try:
limit = int(q.get("limit", 500))
except (TypeError, ValueError):
limit = 0
if not 1 <= limit <= 2000:
raise ApiError(422, "bad_request", "limit: целое от 1 до 2000")
return 200, history.feed(conn, since, until, kinds, project=project, key=q.get("key") or None, limit=limit)
def h_board(hub, conn, m, q, body):
return 200, mirror.board(conn, jira_projects.base_jql(conn), project_param(conn, q))
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 _attachment(hub, conn, m):
att = attachments.get(conn, hub.files_dir, m.group(1))
if att is None:
raise ApiError(404, "no_attachment", f"вложения #{m.group(1)} нет в зеркале — обновите задачу")
return att
def h_attachment_get(hub, conn, m, q, body):
return 200, _attachment(hub, conn, m)
def h_attachment_fetch(hub, conn, m, q, body):
author = body.get("author") or "api"
if not isinstance(author, str):
raise ApiError(422, "bad_request", "author — строка")
try:
return 200, fetch.request_fetch(conn, hub.files_dir, m.group(1), author)
except KeyError:
raise ApiError(404, "no_attachment", f"вложения #{m.group(1)} нет в зеркале — обновите задачу") from None
except HttpMapped as e: # раньше CommandError: TooLarge — его подкласс (решение 17)
raise mapped(e) from None
except CommandError as e:
raise ApiError(422, "bad_batch", str(e)) from None
def h_attachment_content(hub, conn, m, q, body):
"""Содержимое скачанного вложения; mime — из вложения, негодный → octet-stream; имя — filename* (RFC 5987)."""
att = _attachment(hub, conn, m)
# state == ready означает, что запись attachment_file есть и файл в хранилище на месте (attachments.state)
row = conn.execute("SELECT file FROM attachment_file WHERE attachment_id = ?", (m.group(1),)).fetchone() \
if att["state"] == "ready" else None
data = files.read(hub.files_dir, row["file"]) if row else None
if data is None:
raise ApiError(404, "not_fetched", "вложение не скачано — запросите скачивание")
files.touch(hub.files_dir, row["file"])
mime = att["mime_type"] if isinstance(att["mime_type"], str) and MIME_RE.fullmatch(att["mime_type"]) else "application/octet-stream"
return 200, Raw(mime, data, {"Content-Disposition": "attachment; filename*=UTF-8''" + quote(att["filename"], safe=""),
"X-Content-Type-Options": "nosniff", "Cache-Control": "no-cache"})
def h_refresh(hub, conn, m, q, body):
if not isinstance(body.get("author") or "api", str):
raise ApiError(422, "bad_refresh", "author — строка")
keys = body.get("keys")
if keys is not None and (not isinstance(keys, list) or not all(isinstance(k, str) and k for k in keys)):
raise ApiError(422, "bad_refresh", "keys — список строк")
if body.get("jql") is not None and not isinstance(body["jql"], str):
raise ApiError(422, "bad_refresh", "jql — строка")
if body.get("full"):
jql = jira_projects.base_jql(conn)
if jql is None:
raise ApiError(422, "bad_refresh", "нет проектов")
params = {"jql": jql}
elif body.get("jql"):
params = {"jql": body["jql"]}
elif body.get("keys"):
params = {"keys": 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_sync_runs(hub, conn, m, q, body):
rows = conn.execute("SELECT * FROM sync_run ORDER BY id DESC LIMIT ?", (int_param(q, "limit", 20),)).fetchall()
return 200, {"runs": [dict(r) for r in rows]}
ROUTES = (
("POST", r"^/agent/state$", h_agent_state, "agent"),
("POST", r"^/agent/history$", h_agent_history, "agent"),
("GET", r"^/agent/known-keys$", h_known_keys, "agent"),
("GET", r"^/issues$", h_issues, "client"),
("GET", r"^/issues/([^/]+)$", h_issue, "client"),
("GET", r"^/board$", h_board, "client"),
("GET", r"^/changes$", h_changes, "client"),
("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"),
("GET", r"^/attachments/(\d+)$", h_attachment_get, "client"),
("POST", r"^/attachments/(\d+)/fetch$", h_attachment_fetch, "client"),
("GET", r"^/attachments/(\d+)/content$", h_attachment_content, "client"),
("POST", r"^/refresh$", h_refresh, "client"),
("GET", r"^/sync-runs$", h_sync_runs, "client"),
)
# --- фоновые работы и события ядра ------------------------------------------------
def downloads_tick(hub, conn, now=None) -> None:
"""Уборка копий вложений для MCP (<каталог базы>/downloads) не чаще раза в сутки; время прошлой — в памяти хаба."""
state = hub.module_state.setdefault("jira", {})
now = time.time() if now is None else now
last = state.get("downloads_at")
if last is not None and now - last < DOWNLOADS_EVERY:
return
state["downloads_at"] = now
gone = files.sweep_downloads(hub.db_path.parent / "downloads", now)
if gone:
log.info("уборка копий вложений: удалено %d", len(gone))
downloads_tick.label = "уборка копий вложений"
def on_project_created(conn, project, author) -> dict | None:
"""Проект создан (в той же транзакции): пакет «Новый проект <метка>» — один refresh по базовой JQL."""
return queue.create_batch(conn, f"Новый проект {project['label']}", author,
[{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}])

View File

@ -35,5 +35,8 @@ def maybe_auto_refresh(conn) -> dict | None:
def auto_refresh_tick(hub, conn) -> None: def auto_refresh_tick(hub, conn) -> None:
"""Фоновая работа модуля: автообновление (подключается к tick хаба в Task 5).""" """Фоновая работа модуля: автообновление (Module.tick)."""
maybe_auto_refresh(conn) maybe_auto_refresh(conn)
auto_refresh_tick.label = "автообновление"

View File

@ -13,6 +13,7 @@ import pytest
from jira_hub.core import api, db, files, projects, queue, release, settings from jira_hub.core import api, db, files, projects, queue, release, settings
from jira_hub.core.client import HubClient, HubError from jira_hub.core.client import HubClient, HubError
from jira_hub.modules.jira import api as jira_api
from jira_hub.modules.jira.commands import CommandError from jira_hub.modules.jira.commands import CommandError
@ -1184,7 +1185,7 @@ def test_attachment_fetch_agent_too_old_409(hub):
def test_sweep_files_downloads(hub): def test_sweep_files_downloads(hub):
h = hub["hub"] h = hub["hub"]
d = h.downloads_dir / "31337" d = h.db_path.parent / "downloads" / "31337"
d.mkdir(parents=True) d.mkdir(parents=True)
(d / "a.png").write_bytes(b"x") (d / "a.png").write_bytes(b"x")
old = time.time() - 31 * 86400 old = time.time() - 31 * 86400
@ -1192,7 +1193,7 @@ def test_sweep_files_downloads(hub):
os.utime(d / "a.png", (old, old)) os.utime(d / "a.png", (old, old))
conn = h.conn() conn = h.conn()
try: try:
h.sweep_files(conn) jira_api.downloads_tick(h, conn)
finally: finally:
conn.close() conn.close()
assert not d.exists() assert not d.exists()

View File

@ -9,8 +9,8 @@ from jira_hub.core.modules import CommandHooks, Module
CORE = Path(__file__).resolve().parent.parent / "src" / "jira_hub" / "core" CORE = Path(__file__).resolve().parent.parent / "src" / "jira_hub" / "core"
# временно: R-1, сокращается в Task 4–6 # временно: R-1, сокращается в Task 6
PENDING = {"api.py", "cli.py", "mcp_server.py"} PENDING = {"cli.py", "mcp_server.py"}
def _noop(*a, **k): def _noop(*a, **k):
@ -48,6 +48,15 @@ def test_route_conflict():
registry.check([m], core, {}) registry.check([m], core, {})
def test_routes_assembled():
from jira_hub.core import api
from jira_hub.modules import jira
pairs = lambda routes: {(m, p) for m, p, _, _ in routes} # noqa: E731
assert pairs(api.ROUTES) == pairs(api.CORE_ROUTES) | pairs(jira.MODULE.routes)
assert not pairs(api.CORE_ROUTES) & pairs(jira.MODULE.routes)
assert len(api.ROUTES) == len(api.CORE_ROUTES) + len(jira.MODULE.routes)
def test_module_of(monkeypatch): def test_module_of(monkeypatch):
a = Module(name="a", commands=_hooks({"t1"})) a = Module(name="a", commands=_hooks({"t1"}))
monkeypatch.setattr(registry, "_cache", [a]) monkeypatch.setattr(registry, "_cache", [a])