diff --git a/pyproject.toml b/pyproject.toml index 35189fa..7ef8e0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,8 @@ version = "0.1.0" description = "Мост Jira для АРМ КА: зеркало задач, очередь команд, HTTP API, MCP" requires-python = ">=3.13" dependencies = [ - "markdownify>=1.2.3", + "beautifulsoup4>=4.12", + "markdownify>=1.2.3,<2", "mcp>=1.2,<2", ] @@ -31,3 +32,7 @@ filterwarnings = ["error::ResourceWarning", "error::pytest.PytestUnraisableExcep # Python от uv (python-build-standalone): SQLite в нём своя. В SQLite 3.51.0–3.51.1 взаимная блокировка в unix-VFS # вешает хаб под параллельными запросами (O-14, #29); у miniconda — 3.51.1 python-preference = "only-managed" + +[tool.ruff.lint.per-file-ignores] +# фикстура api импортируется из test_wiki_sync (идиома pytest), а не определяется заново +"tests/test_wiki_api.py" = ["F811"] diff --git a/src/jira_hub/cli.py b/src/jira_hub/cli.py index 67c10b4..271dd5f 100644 --- a/src/jira_hub/cli.py +++ b/src/jira_hub/cli.py @@ -1 +1 @@ -from .core.cli import main +from .core.cli import main # noqa: F401 точка входа jira-hub diff --git a/src/jira_hub/core/api.py b/src/jira_hub/core/api.py index a4238db..f20f74e 100644 --- a/src/jira_hub/core/api.py +++ b/src/jira_hub/core/api.py @@ -403,7 +403,7 @@ def _module_fields(body, skip, *, editable_only: bool) -> dict[str, tuple[object if editable_only and not fld.editable: raise ApiError(422, "bad_project", f"{key} после создания не меняется") try: - value = fld.validate(value) + value = (fld.validate_edit if editable_only and fld.validate_edit else fld.validate)(value) except ValueError as e: raise ApiError(422, "bad_project", f"{key}: {e}") out.setdefault(mod.name, (mod, {}))[1][key] = value diff --git a/src/jira_hub/core/db.py b/src/jira_hub/core/db.py index 34eb078..6b064d0 100644 --- a/src/jira_hub/core/db.py +++ b/src/jira_hub/core/db.py @@ -188,6 +188,11 @@ def migrate(conn: sqlite3.Connection, path=None) -> None: conn.execute("INSERT OR IGNORE INTO agent_status(agent) VALUES (?)", (m.name,)) +def has_table(conn: sqlite3.Connection, name: str) -> bool: + """Есть ли в базе таблица (в том числе виртуальная) с таким именем.""" + return conn.execute("SELECT 1 FROM sqlite_master WHERE name = ?", (name,)).fetchone() is not None + + _savepoints = itertools.count(1) diff --git a/src/jira_hub/core/modules.py b/src/jira_hub/core/modules.py index cdf88a4..3f447ac 100644 --- a/src/jira_hub/core/modules.py +++ b/src/jira_hub/core/modules.py @@ -42,6 +42,7 @@ class ProjectField: name: str # ключ в теле POST/PATCH и в GET /projects validate: Callable[[Any], Any] # ValueError(текст) → 422 с полем; возвращает нормализованное значение editable: bool = False # можно ли менять после создания (PATCH) + validate_edit: Callable[[Any], Any] | None = None # проверка при PATCH вместо validate (строже, чем при создании) @dataclass(frozen=True) diff --git a/src/jira_hub/core/settings.py b/src/jira_hub/core/settings.py index fe921ee..54bebc6 100644 --- a/src/jira_hub/core/settings.py +++ b/src/jira_hub/core/settings.py @@ -120,7 +120,3 @@ def for_agent(conn, module: str) -> dict: def tokens(conn) -> dict: """{"client": токен, "agents": {модуль: токен}}.""" return get(conn, "tokens") - - -def agent_token(conn, module: str) -> str: - return tokens(conn)["agents"][module] diff --git a/src/jira_hub/modules/jira/api.py b/src/jira_hub/modules/jira/api.py index 6ff2736..0ac3983 100644 --- a/src/jira_hub/modules/jira/api.py +++ b/src/jira_hub/modules/jira/api.py @@ -8,7 +8,7 @@ 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 ...core.http import ApiError, Raw, int_param, mapped, not_found, project_param from . import attachments, fetch, history, mirror, refresh from . import projects as jira_projects diff --git a/src/jira_hub/modules/wiki/api.py b/src/jira_hub/modules/wiki/api.py index 269ce7c..aa7e9a7 100644 --- a/src/jira_hub/modules/wiki/api.py +++ b/src/jira_hub/modules/wiki/api.py @@ -72,8 +72,9 @@ def h_page(hub, conn, m, q, body): if text.startswith("---\n"): # front matter копии клиенту не нужен end = text.find("\n---\n", 4) text = text[end + 5:].lstrip("\n") if end != -1 else text - atts = [{"id": a["id"], "name": a["name"], "state": a["state"], "size": a["size"]} for a in conn.execute( - "SELECT id, name, state, size FROM wiki_attachment WHERE label = ? AND page_code = ? ORDER BY id", (label, code))] + atts = [{"id": a["id"], "name": a["name"], "state": a["state"], "size": a["size"], + "error": a["error"]} for a in conn.execute( + "SELECT id, name, state, size, error FROM wiki_attachment WHERE label = ? AND page_code = ? ORDER BY id", (label, code))] return 200, {"code": code, "title": r["title"], "path": r["path"], "url": r["url"], "synced_at": r["synced_at"], "markdown": text, "attachments": atts} diff --git a/src/jira_hub/modules/wiki/attachments.py b/src/jira_hub/modules/wiki/attachments.py index 1b1f618..dd9e2e5 100644 --- a/src/jira_hub/modules/wiki/attachments.py +++ b/src/jira_hub/modules/wiki/attachments.py @@ -1,6 +1,5 @@ """Вложения вики по запросу: состояние, постановка wiki_fetch и хуки его результата (spec wiki «Вложения вики по запросу»). Адрес вложения хаб берёт только из своей таблицы wiki_attachment — из запросов клиента он не приходит.""" -import json from urllib.parse import quote from ...core import db, files, queue diff --git a/src/jira_hub/modules/wiki/mcp.py b/src/jira_hub/modules/wiki/mcp.py index 53f15eb..a99edda 100644 --- a/src/jira_hub/modules/wiki/mcp.py +++ b/src/jira_hub/modules/wiki/mcp.py @@ -9,7 +9,7 @@ from mcp.server.fastmcp import FastMCP from ...core import mcp as core from ...core.client import HubError -from . import projects as wiki_projects +from . import roots as wiki_roots POLL_SECONDS = 2 ATTACHMENT_WAIT = 60 @@ -228,12 +228,12 @@ def root_set(root: str) -> str: """Задать корень вики проекта: код DOC-… или ссылка на документ Eva. Хаб ставит синхронизацию нового корня; прежняя копия остаётся до её успеха.""" try: - code = wiki_projects.parse_root(root) + code = wiki_roots.parse_root(root) except ValueError as e: return f"корень не задан: {e}; пример: root_set DOC-123" if code is None: return "корень не задан: укажите код DOC-… или ссылку на документ Eva" - out = core.client().patch(f"/projects/{core.PROJECT}", {"wiki_root": code, "author": core.AUTHOR}) + out = core.client().patch(f"/projects/{quote(core.PROJECT, safe='')}", {"wiki_root": code, "author": core.AUTHOR}) ids = out.get("batch_ids") or [] return (f"корень вики проекта {core.PROJECT}: {code}; синхронизация поставлена: пакет #{ids[0]}" if ids else f"корень вики проекта {core.PROJECT}: {code}; пакета синхронизации не поставлено (корень не изменился)") @@ -244,7 +244,7 @@ def project_create(label: str, name: str, wiki_root: str = "") -> str: """Завести проект (метка, название, необязательно корень вики: код DOC-… или ссылка Eva). С корнем хаб ставит синхронизацию; в ответе — фрагмент .mcp.json для репозитория проекта с сервером jira-hub-wiki.""" try: - code = wiki_projects.parse_root(wiki_root) + code = wiki_roots.parse_root(wiki_root) except ValueError as e: return f"проект не заведён: {e}" return core.create_project(label, name, {} if code is None else {"wiki_root": code}, "wiki") diff --git a/src/jira_hub/modules/wiki/paths.py b/src/jira_hub/modules/wiki/paths.py index fc4b8de..66b0861 100644 --- a/src/jira_hub/modules/wiki/paths.py +++ b/src/jira_hub/modules/wiki/paths.py @@ -11,10 +11,10 @@ def root(conn) -> Path: def dir_name(label: str) -> str: - """Имя каталога проекта: метка, у которой «/», «\\» и «%» заменены, а «.» и «..» закрыты, чтобы метка не выводила + """Имя каталога проекта: метка, у которой «/», «\\» и «%» заменены, а ведущая «.» заменена на %2E, чтобы метка не выводила за каталог вики.""" name = label.replace("%", "%25").replace("/", "%2F").replace("\\", "%5C") - return {".": "%2E", "..": "%2E%2E"}.get(name, name) + return "%2E" + name[1:] if name.startswith(".") else name def project_dir(conn, label: str) -> Path: diff --git a/src/jira_hub/modules/wiki/projects.py b/src/jira_hub/modules/wiki/projects.py index 3c71fd6..7379643 100644 --- a/src/jira_hub/modules/wiki/projects.py +++ b/src/jira_hub/modules/wiki/projects.py @@ -1,29 +1,16 @@ """Корень вики проекта: поле wiki_root, значения для GET /projects, хуки создания и правки проекта.""" import glob import logging -import re import shutil from ...core import db, queue from ...core.modules import ProjectField from . import commands, paths +from .roots import parse_root, parse_root_edit # noqa: F401 parse_root — прежнее имя для импортёров log = logging.getLogger(__name__) -_CODE = re.compile(r"(? str | None: - """Код корня: None/"" — нет корня; строка или ссылка с DOC-<цифры> — код в верхнем регистре; иначе ValueError.""" - if value is None or value == "": - return None - m = _CODE.search(value) if isinstance(value, str) else None - if m is None: - raise ValueError("ожидается код DOC-… или ссылка на документ Eva") - return f"DOC-{m.group(1)}" - - -FIELDS = (ProjectField("wiki_root", parse_root, editable=True),) +FIELDS = (ProjectField("wiki_root", parse_root, editable=True, validate_edit=parse_root_edit),) def view(conn, labels: list[str]) -> dict[str, dict]: @@ -60,13 +47,9 @@ def _cancel_queued(conn, label: str) -> None: queue.cancel_batch(conn, batch_id) -def _has_table(conn, name: str) -> bool: - return conn.execute("SELECT 1 FROM sqlite_master WHERE name = ?", (name,)).fetchone() is not None - - def _drop_copy(conn, label: str) -> None: """Сброс корня: строки проекта, индекс поиска (если он уже создан) и каталог копии.""" - if _has_table(conn, "wiki_page_fts"): + if db.has_table(conn, "wiki_page_fts"): conn.execute("DELETE FROM wiki_page_fts WHERE label = ?", (label,)) conn.execute("DELETE FROM wiki_page WHERE label = ?", (label,)) conn.execute("DELETE FROM wiki_attachment WHERE label = ?", (label,)) diff --git a/src/jira_hub/modules/wiki/roots.py b/src/jira_hub/modules/wiki/roots.py new file mode 100644 index 0000000..3300ee7 --- /dev/null +++ b/src/jira_hub/modules/wiki/roots.py @@ -0,0 +1,21 @@ +"""Разбор корня вики: чистые функции без обращения к базе и очереди (их безопасно импортирует и MCP).""" +import re + +_CODE = re.compile(r"(? str | None: + """Код корня: None/"" — нет корня; строка или ссылка с DOC-<цифры> — код в верхнем регистре; иначе ValueError.""" + if value is None or value == "": + return None + m = _CODE.search(value) if isinstance(value, str) else None + if m is None: + raise ValueError("ожидается код DOC-… или ссылка на документ Eva") + return f"DOC-{m.group(1)}" + + +def parse_root_edit(value) -> str | None: + """Правка корня (PATCH): сбрасывает только null; пустая строка — ошибка, чтобы случайное "" не удаляло копию (M-6).""" + if value == "": + raise ValueError("пустая строка не сбрасывает корень: передайте null") + return parse_root(value) diff --git a/src/jira_hub/modules/wiki/search.py b/src/jira_hub/modules/wiki/search.py index aa66b47..b99582e 100644 --- a/src/jira_hub/modules/wiki/search.py +++ b/src/jira_hub/modules/wiki/search.py @@ -3,6 +3,7 @@ import logging import re import sqlite3 +from ...core import db from . import paths log = logging.getLogger(__name__) @@ -11,14 +12,10 @@ _WORD = re.compile(r"\w+") _warned = False -def _has_table(conn) -> bool: - return conn.execute("SELECT 1 FROM sqlite_master WHERE name = 'wiki_page_fts'").fetchone() is not None - - def ensure(conn) -> bool: """Создать индекс, если его нет. False — FTS5 недоступен (одно предупреждение в лог на процесс).""" global _warned - if _has_table(conn): + if db.has_table(conn, "wiki_page_fts"): return True try: conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS wiki_page_fts USING fts5(label UNINDEXED, code UNINDEXED, " @@ -52,7 +49,7 @@ def query(conn, label: str, q: str, limit: int) -> list[dict]: if match is None: return [] pages = {r["code"]: r for r in conn.execute("SELECT code, title, path FROM wiki_page WHERE label = ?", (label,))} - if _has_table(conn): + if db.has_table(conn, "wiki_page_fts"): try: rows = conn.execute("SELECT code, title, snippet(wiki_page_fts, 3, '', '', '…', 16) AS snippet " "FROM wiki_page_fts WHERE wiki_page_fts MATCH ? AND label = ? ORDER BY rank LIMIT ?", diff --git a/src/jira_hub/modules/wiki/stage.py b/src/jira_hub/modules/wiki/stage.py index a9334b1..b62a10e 100644 --- a/src/jira_hub/modules/wiki/stage.py +++ b/src/jira_hub/modules/wiki/stage.py @@ -23,10 +23,23 @@ def _text(page, key, code, required=True, nullable=False): return v if v.strip() else None # пустое необязательное поле (parent_code "") — как отсутствующее +def _clean(v): + """Строки с одиночным суррогатом (JSON \\ud83d без пары) не кодируются в UTF-8 и роняли запись стадии: заменяем + их на U+FFFD по всей странице (M-8 финального ревью).""" + if isinstance(v, str): + return v.encode("utf-8", "replace").decode("utf-8") + if isinstance(v, list): + return [_clean(x) for x in v] + if isinstance(v, dict): + return {k: _clean(x) for k, x in v.items()} + return v + + def _check(i, page) -> dict: """Форма одной страницы → поля для стадии (без html); ApiError 422 `<код>: <поле>`.""" if not isinstance(page, dict): raise ApiError(422, "bad_pages", f"страница {i}: не объект") + page = _clean(page) code = page.get("code") if not isinstance(code, str) or not code.strip() or len(code) > LIMITS["code"]: raise ApiError(422, "bad_pages", f"страница {i}: code") diff --git a/tests/test_api.py b/tests/test_api.py index fd368d7..a4123b5 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -11,7 +11,7 @@ from datetime import timedelta import pytest -from jira_hub.core import api, db, files, projects, queue, release, settings +from jira_hub.core import api, db, files, 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 diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 4dd6546..67b000c 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -145,7 +145,7 @@ def test_batch_wait_timeout(monkeypatch): def test_batch_wait_clamps_timeout(monkeypatch, asked, minutes): fake = FakeClient(["running"]) monkeypatch.setattr(core_mcp, "client", lambda: fake) - clock = _fake_time(monkeypatch, step=60.0) # каждый вызов часов = +1 минута + _fake_time(monkeypatch, step=60.0) # каждый вызов часов = +1 минута asyncio.run(jmcp.batch_wait(7, timeout_minutes=asked)) # первый вызов часов (1 мин) задаёт старт; дедлайн = старт + minutes; выход, когда now >= дедлайна assert fake.calls == minutes diff --git a/tests/test_queue.py b/tests/test_queue.py index e96dcbf..30211e2 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -2,7 +2,7 @@ import hashlib import pytest -from jira_hub.core import api, db, files, projects, queue, settings +from jira_hub.core import api, db, files, queue, settings from jira_hub.modules.jira import attachments, commands, mirror, refresh from jira_hub.modules.jira import fetch as jira_fetch from jira_hub.modules.jira import projects as jira_projects diff --git a/tests/test_wiki_api.py b/tests/test_wiki_api.py index 032814c..a1b2151 100644 --- a/tests/test_wiki_api.py +++ b/tests/test_wiki_api.py @@ -62,7 +62,7 @@ def test_status_tree_page(api): p = api.client.get("/wiki/pages/DOC-3", project="AWPCA") assert p["title"] == "Статусы" and p["url"].endswith("DOC-3") and p["synced_at"] assert f"attachment:{aid}" in p["markdown"] and not p["markdown"].startswith("---") - assert p["attachments"] == [{"id": aid, "name": "model.xlsx", "state": "none", "size": None}] + assert p["attachments"] == [{"id": aid, "name": "model.xlsx", "state": "none", "size": None, "error": None}] assert err(api.client.get, "/wiki/pages/DOC-99", project="AWPCA").status == 404 @@ -216,3 +216,15 @@ def test_search_results(api): first_sync(api) r = api.client.get("/wiki/search", project="AWPCA", q="согласов")["results"] assert [x["code"] for x in r] == ["DOC-3"] and set(r[0]) == {"code", "title", "path", "snippet"} + + +def test_page_attachment_failed_error_visible(api): + """M-15: ответ страницы несёт error у вложения, текст ошибки виден без клика.""" + aid = synced(api) + c = api.hub.conn() + try: + c.execute("UPDATE wiki_attachment SET state = 'failed', error = 'Eva: 404' WHERE id = ?", (aid,)) + finally: + c.close() + att = api.client.get("/wiki/pages/DOC-3", project="AWPCA")["attachments"][0] + assert att["state"] == "failed" and att["error"] == "Eva: 404" diff --git a/tests/test_wiki_mcp.py b/tests/test_wiki_mcp.py index 29fb284..ada9cb4 100644 --- a/tests/test_wiki_mcp.py +++ b/tests/test_wiki_mcp.py @@ -226,7 +226,7 @@ def test_sync_nowait_and_in_progress(hub): def test_sync_wait(hub): done = {"id": 5, "status": "done", "commands": [{"id": 1, "type": "wiki_sync", "status": "done", "error": None}]} - fake = hub({("POST", "/wiki/sync"): {"batch_id": 5}, + hub({("POST", "/wiki/sync"): {"batch_id": 5}, "/batches/5": [{"id": 5, "status": "running", "commands": []}, done], "/wiki/status": {"root": "DOC-1", "root_title": "К", "synced_at": "s", "pages": 3, "error": None, "batch": None}}) out = asyncio.run(wmcp.sync(wait=True)) @@ -304,3 +304,12 @@ def test_projects_table_and_create(hub): created = wmcp.project_create("n", "Н", "DOC-5") assert "jira-hub-wiki" in created assert fake.calls[-1][2]["wiki_root"] == "DOC-5" + + +def test_root_set_quotes_label(hub): + """M-4: метка со спецсимволами кодируется в адресе PATCH.""" + label = "A#B?c%d/e" + fake = hub({("PATCH", "/projects/A%23B%3Fc%25d%2Fe"): {"project": {}, "batch_ids": [5]}}, project=label) + fake.routes["/projects"] = {"projects": [{"label": label, "name": "x", "wiki_root": None}]} + assert "#5" in wmcp.root_set("DOC-2") + assert fake.calls[-1][1] == "/projects/A%23B%3Fc%25d%2Fe" diff --git a/tests/test_wiki_projects.py b/tests/test_wiki_projects.py index bc93552..48d3f6c 100644 --- a/tests/test_wiki_projects.py +++ b/tests/test_wiki_projects.py @@ -119,3 +119,33 @@ def test_claimed_sync_is_not_cancelled_by_new_root(hub_server): conn.close() c.patch("/projects/AWPCA", {"wiki_root": "DOC-21"}) assert [(s, p["root"]) for _, s, p in _syncs(hub_server["hub"])] == [("claimed", "DOC-12"), ("queued", "DOC-21")] + + +def test_patch_root_empty_string_rejected(hub_server): + """M-6: PATCH {wiki_root: ""} — 422, копия цела; сбрасывает только null; POST с "" — проект без корня.""" + c = hub_server["client"] + c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-1"}) + with pytest.raises(HubError) as e: + c.patch("/projects/AWPCA", {"wiki_root": ""}) + assert e.value.status == 422 and "wiki_root" in e.value.detail + assert c.get("/projects")["projects"][0]["wiki_root"] == "DOC-1" + out = c.post("/projects", {"label": "S", "name": "S", "wiki_root": ""}) + assert out["project"]["wiki_root"] is None + assert c.patch("/projects/AWPCA", {"wiki_root": None})["project"]["wiki_root"] is None + + +def test_wiki_mcp_imports_no_db_or_queue(): + """M-5: mcp.py и roots.py вики не импортируют core.db и core.queue ни прямо, ни через wiki.projects.""" + import ast + from pathlib import Path + base = Path(__file__).parent.parent / "src" / "jira_hub" / "modules" / "wiki" + for name in ("mcp.py", "roots.py"): + tree = ast.parse((base / name).read_text(encoding="utf-8")) + mods = [] + for n in ast.walk(tree): + if isinstance(n, ast.ImportFrom): + mods += [f"{'.' * n.level}{n.module or ''}.{a.name}" for a in n.names] + elif isinstance(n, ast.Import): + mods += [a.name for a in n.names] + bad = [m for m in mods if m.endswith((".db", ".queue", ".projects")) or ".core.db" in m or ".core.queue" in m] + assert not bad, (name, bad) diff --git a/tests/test_wiki_sync.py b/tests/test_wiki_sync.py index 3283b8e..6469759 100644 --- a/tests/test_wiki_sync.py +++ b/tests/test_wiki_sync.py @@ -606,3 +606,25 @@ def test_housekeeping_ensures_fts(open_db, tmp_path): assert not conn.execute("SELECT 1 FROM sqlite_master WHERE name = 'wiki_page_fts'").fetchone() tick.housekeeping_tick(None, conn) assert conn.execute("SELECT 1 FROM sqlite_master WHERE name = 'wiki_page_fts'").fetchone() + + +def test_lone_surrogate_in_page_does_not_break_sync(api): + """M-8: одиночный суррогат (JSON-эскейп \\ud83d без пары) в html/title не роняет запись стадии (раньше 500).""" + import urllib.request + bad = [page("DOC-1", "Корень \ud83d", html="

текст \ud83d конец

")] + cmd = api.claim() + body = json.dumps({"command_id": cmd["id"], "pages": bad}).encode("ascii") # ensure_ascii: эскейп остаётся + req = urllib.request.Request(api.base + "/agent/wiki/pages", data=body, method="POST", + headers={"Authorization": "Bearer " + api.wiki.token, "Content-Type": "application/json"}) + with urllib.request.urlopen(req) as r: + assert r.status == 200 + api.report(cmd, "done", {"root_title": "К", "codes": ["DOC-1"], "eva_calls": 1}) + assert api.rows("SELECT title FROM wiki_page WHERE code = 'DOC-1'")[0]["title"] == "Корень ?" # кодек заменяет суррогат на «?» + + + +def test_dir_name_escapes_leading_dot(): + """M-14: метка с ведущей точкой не даёт каталог вида .X.new.5 / .X.old, который уборка при старте сочла бы своим.""" + from jira_hub.modules.wiki import paths + assert paths.dir_name(".X.new.5") == "%2EX.new.5" and paths.dir_name(".") == "%2E" and paths.dir_name("..") == "%2E." + assert not paths.dir_name(".X.old").startswith(".") and paths.dir_name("A.B") == "A.B" diff --git a/uv.lock b/uv.lock index bb8d08b..8119aaa 100644 --- a/uv.lock +++ b/uv.lock @@ -282,6 +282,7 @@ name = "jira-hub" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "beautifulsoup4" }, { name = "markdownify" }, { name = "mcp" }, ] @@ -293,7 +294,8 @@ dev = [ [package.metadata] requires-dist = [ - { name = "markdownify", specifier = ">=1.2.3" }, + { name = "beautifulsoup4", specifier = ">=4.12" }, + { name = "markdownify", specifier = ">=1.2.3,<2" }, { name = "mcp", specifier = ">=1.2,<2" }, ]