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 time
import traceback
from datetime import timedelta
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import NamedTuple
from urllib.parse import parse_qs, quote, unquote, urlparse
from urllib.parse import parse_qs, unquote, urlparse
from . import backup, db, files, projects, queue, relay, release, settings
from ..modules.jira import attachments, fetch, history, mirror, refresh # временно: R-1, до Task 5
from ..modules.jira import projects as jira_projects
from .errors import CommandError
from . import backup, db, files, projects, queue, registry, relay, release, settings
from .errors import CommandError, HttpMapped
from .http import ApiError, Raw, int_group, int_param, mapped, not_found
from .paths import backups_dir as default_backups_dir
from .paths import secure_dir
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>Веб-приложение не собрано: выполните "
"<code>make web</code> в каталоге jira-hub и перезапустите хаб.</p>").encode("utf-8")
SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит
MIME_RE = re.compile(r"[\w!#$&^.+-]+/[\w!#$&^.+-]+", re.ASCII) # тип/подтип без параметров, пробелов и не-ASCII: заголовок не внушить
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:
def __init__(self, db_path, backups_dir=None, release_dir=None, web_dir=None, files_dir=None):
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.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.downloads_dir = self.db_path.parent / "downloads" # копии вложений для MCP; каталог создаёт MCP
self.last_sweep = None # время прошлой уборки files/, в памяти (решение 15)
self.module_state: dict[str, dict] = {} # память модулей между тиками (имя модуля → словарь)
# последнее из claim; в памяти (R-3). reported — был ли claim с запуска хаба: до него launcher_version
# неизвестен, а не null (hub-hygiene-d, решение 7)
self.agent_update = {"update_state": None, "launcher_version": None, "reported": False}
@ -73,19 +56,22 @@ class Hub:
return db.connect(self.db_path)
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
try:
conn = self.conn()
refresh.maybe_auto_refresh(conn)
except Exception: # noqa: BLE001
log.exception("автообновление: сбой тика")
try:
if conn is not None:
self.sweep_files(conn)
except Exception: # noqa: BLE001
log.exception("уборка файлов: сбой")
for message, work in works:
try:
if conn is None:
conn = self.conn()
work(conn)
except Exception: # noqa: BLE001
log.exception(message)
finally:
if conn is not None:
conn.close()
@ -100,12 +86,6 @@ class Hub:
removed = files.sweep(conn, self.files_dir, now)
if 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
def note_agent_update(self, body):
@ -133,23 +113,6 @@ class Hub:
# --- обработчики: 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):
hub.note_agent_update(body)
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):
"""Команда по 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)}
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"),
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)
except KeyError:
raise ApiError(404, "not_found", "command")
@ -207,97 +170,6 @@ def h_agent_result(hub, conn, m, q, body):
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):
"""Веб-приложение: файлы assets/ сборки как есть, любой другой путь /app… — index.html (маршруты на клиенте).
Путь файла после разрешения обязан лежать внутри 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"})
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") or not isinstance(body["title"], str):
raise ApiError(422, "bad_batch", "title — непустая строка")
@ -346,23 +210,23 @@ def h_batches_post(hub, conn, m, q, body):
try:
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"))
except fetch.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (M-2)
raise ApiError(413, "too_large", str(e)) from None
except HttpMapped as e: # раньше CommandError: исключения модулей несут свой код (M-2)
raise mapped(e) from None
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_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):
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):
try:
return 200, queue.cancel_batch(conn, _int(m))
return 200, queue.cancel_batch(conn, int_group(m))
except KeyError:
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)
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):
if not isinstance(body.get("author") or "api", str):
raise ApiError(422, "bad_retry", "author — строка")
@ -445,47 +267,25 @@ def h_command_retry(hub, conn, m, q, body):
if body.get("project") == "":
raise ApiError(422, "bad_retry", "project: пустая строка")
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"))
except KeyError:
raise ApiError(404, "not_found", "command")
except fetch.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (M-2)
raise ApiError(413, "too_large", str(e)) from None
except HttpMapped as e: # раньше CommandError: исключения модулей несут свой код (M-2)
raise mapped(e) from None
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")))
return 200, queue.cancel_command(conn, int_group(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 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):
view = queue.agent_view(conn)
rel = release.current(hub.release_dir)
@ -496,13 +296,17 @@ def h_agent_get(hub, conn, m, q, body):
return 200, view
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]}
def _settings_view(conn) -> dict:
"""Настройки ядра и модулей плюс вычисляемые поля модулей (settings_view)."""
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):
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):
@ -520,7 +324,7 @@ def h_settings_put(hub, conn, m, q, body):
with db.transaction(conn):
for key, value in body.items():
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):
@ -528,63 +332,56 @@ def h_projects_get(hub, conn, m, q, body):
def h_projects_post(hub, conn, m, q, body):
"""Новый проект: проект и пакет «Новый проект <метка>» (один refresh по базовой JQL с новой меткой) — в одной
транзакции; ошибка любого шага не оставляет ни проекта, ни пакета (hub-api, «Маршруты проектов»)."""
"""Новый проект: проект и пакеты, которые ставят модули (on_project_created; у jira — «Новый проект <метка>») —
в одной транзакции; ошибка любого шага не оставляет ни проекта, ни пакета (hub-api, «Маршруты проектов»)."""
author = body.get("author")
if author is None or author == "":
author = "api"
elif not isinstance(author, str):
raise ApiError(422, "bad_project", "author — строка")
batch_id = None
try:
with db.transaction(conn):
project = projects.create(conn, body.get("label"), body.get("name"), body.get("board_id"))
batch = queue.create_batch(conn, f"Новый проект {project['label']}", author,
[{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}])
for mod in registry.modules():
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:
raise ApiError(409, "project_exists", str(e))
except projects.ProjectError as e:
raise ApiError(422, "bad_project", str(e))
except CommandError as 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"^/app(?:/(.*))?$", h_app, None),
("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"),
("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/files/([^/]+)$", h_agent_file, "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"),
("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"^/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+)/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"),
("GET", r"^/projects$", h_projects_get, "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]

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 . import settings
mods = [jira.MODULE]
# маршруты ядра сюда попадут в Task 5, когда api перестанет быть владельцем всех маршрутов
# маршруты ядра проверяет core/api.py при сборке ROUTES: здесь их ещё нет
check(mods, [], settings.CORE_DEFAULTS)
_cache = mods
return _cache

View File

@ -1,6 +1,6 @@
"""Модуль jira: зеркало задач Jira, очередь команд агенту, вложения, журнал изменений."""
from ...core.modules import Module
from . import hooks, schema, settings
from . import api, hooks, refresh, schema, settings
MODULE = Module(
name="jira",
@ -12,4 +12,7 @@ MODULE = Module(
on_init=settings.on_init,
commands=hooks.HOOKS,
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:
"""Фоновая работа модуля: автообновление (подключается к tick хаба в Task 5)."""
"""Фоновая работа модуля: автообновление (Module.tick)."""
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.client import HubClient, HubError
from jira_hub.modules.jira import api as jira_api
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):
h = hub["hub"]
d = h.downloads_dir / "31337"
d = h.db_path.parent / "downloads" / "31337"
d.mkdir(parents=True)
(d / "a.png").write_bytes(b"x")
old = time.time() - 31 * 86400
@ -1192,7 +1193,7 @@ def test_sweep_files_downloads(hub):
os.utime(d / "a.png", (old, old))
conn = h.conn()
try:
h.sweep_files(conn)
jira_api.downloads_tick(h, conn)
finally:
conn.close()
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"
# временно: R-1, сокращается в Task 4–6
PENDING = {"api.py", "cli.py", "mcp_server.py"}
# временно: R-1, сокращается в Task 6
PENDING = {"cli.py", "mcp_server.py"}
def _noop(*a, **k):
@ -48,6 +48,15 @@ def test_route_conflict():
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):
a = Module(name="a", commands=_hooks({"t1"}))
monkeypatch.setattr(registry, "_cache", [a])