diff --git a/src/jira_hub/core/api.py b/src/jira_hub/core/api.py index 55c0abd..65e11bc 100644 --- a/src/jira_hub/core/api.py +++ b/src/jira_hub/core/api.py @@ -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 = ("Хаб Jira

Веб-приложение не собрано: выполните " "make web в каталоге jira-hub и перезапустите хаб.

").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] diff --git a/src/jira_hub/core/http.py b/src/jira_hub/core/http.py new file mode 100644 index 0000000..1f9db6b --- /dev/null +++ b/src/jira_hub/core/http.py @@ -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)) diff --git a/src/jira_hub/core/registry.py b/src/jira_hub/core/registry.py index 302f1a8..11a296a 100644 --- a/src/jira_hub/core/registry.py +++ b/src/jira_hub/core/registry.py @@ -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 diff --git a/src/jira_hub/modules/jira/__init__.py b/src/jira_hub/modules/jira/__init__.py index 2c8e766..d863252 100644 --- a/src/jira_hub/modules/jira/__init__.py +++ b/src/jira_hub/modules/jira/__init__.py @@ -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, ) diff --git a/src/jira_hub/modules/jira/api.py b/src/jira_hub/modules/jira/api.py new file mode 100644 index 0000000..487eb18 --- /dev/null +++ b/src/jira_hub/modules/jira/api.py @@ -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)}}]) diff --git a/src/jira_hub/modules/jira/refresh.py b/src/jira_hub/modules/jira/refresh.py index 2eec202..7989a97 100644 --- a/src/jira_hub/modules/jira/refresh.py +++ b/src/jira_hub/modules/jira/refresh.py @@ -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 = "автообновление" diff --git a/tests/test_api.py b/tests/test_api.py index 009af3a..8da70e5 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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() diff --git a/tests/test_registry.py b/tests/test_registry.py index 1a0a1cf..d302046 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -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])