refactor: HTTP — сервер ядра, маршруты модуля jira (#87)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fe71adf189
commit
0bafff9bc4
@ -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
50
src/jira_hub/core/http.py
Normal 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))
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
211
src/jira_hub/modules/jira/api.py
Normal file
211
src/jira_hub/modules/jira/api.py
Normal 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)}}])
|
||||
@ -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 = "автообновление"
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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])
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user