diff --git a/pyproject.toml b/pyproject.toml index 0128e00..35189fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,10 @@ name = "jira-hub" version = "0.1.0" description = "Мост Jira для АРМ КА: зеркало задач, очередь команд, HTTP API, MCP" requires-python = ">=3.13" -dependencies = ["mcp>=1.2,<2"] +dependencies = [ + "markdownify>=1.2.3", + "mcp>=1.2,<2", +] [project.scripts] jira-hub = "jira_hub.cli:main" diff --git a/src/jira_hub/core/registry.py b/src/jira_hub/core/registry.py index 3f8ccf4..a49bfea 100644 --- a/src/jira_hub/core/registry.py +++ b/src/jira_hub/core/registry.py @@ -7,9 +7,9 @@ _cache: list[Module] | None = None def modules() -> list[Module]: global _cache if _cache is None: - from ..modules import jira # внутри функции: ядро не зависит от модулей при импорте + from ..modules import jira, wiki # внутри функции: ядро не зависит от модулей при импорте from . import settings - mods = [jira.MODULE] + mods = [jira.MODULE, wiki.MODULE] # маршруты ядра проверяет core/api.py при сборке ROUTES: здесь их ещё нет check(mods, [], settings.CORE_DEFAULTS) _cache = mods diff --git a/src/jira_hub/modules/wiki/__init__.py b/src/jira_hub/modules/wiki/__init__.py new file mode 100644 index 0000000..4e3657b --- /dev/null +++ b/src/jira_hub/modules/wiki/__init__.py @@ -0,0 +1,18 @@ +"""Модуль wiki: Markdown-копия вики Eva по проектам, поиск, вложения по запросу.""" +from ...core.modules import Module +from . import commands, projects, schema, settings, tick + +MODULE = Module( + name="wiki", + schema=schema.SQL, + settings_defaults=settings.DEFAULTS, + agent_keys=tuple(settings.AGENT_KEYS), + agent_min_version_key="wiki_min_agent_version", + validate_setting=settings.validate_setting, + commands=commands.HOOKS, + tick=(tick.auto_sync_tick,), + project_fields=projects.FIELDS, + project_view=projects.view, + on_project_created=projects.on_created, + on_project_updated=projects.on_updated, +) diff --git a/src/jira_hub/modules/wiki/commands.py b/src/jira_hub/modules/wiki/commands.py new file mode 100644 index 0000000..d54051b --- /dev/null +++ b/src/jira_hub/modules/wiki/commands.py @@ -0,0 +1,81 @@ +"""Команды модуля wiki: wiki_sync (синхронизация дерева проекта) и wiki_fetch (файл вложения). Обе читают Eva.""" +import re + +from ...core import queue +from ...core.errors import CommandError +from ...core.modules import CommandHooks + +TYPES = {"wiki_sync", "wiki_fetch"} +SPEC = {"wiki_sync": {"required": ["label", "root"], "optional": []}, + "wiki_fetch": {"required": ["attachment_id", "url"], "optional": []}} +_ROOT = re.compile(r"^DOC-\d+$") + + +def validate(cmds) -> list[dict]: + if not isinstance(cmds, list) or not cmds: + raise CommandError("пакет без команд") + out = [] + for i, cmd in enumerate(cmds, 1): + if not isinstance(cmd, dict) or "type" not in cmd: + raise CommandError(f"команда {i}: нет type") + ctype, params = cmd["type"], cmd.get("params") or {} + if ctype not in SPEC: + raise CommandError(f"команда {i}: неизвестный тип «{ctype}»") + if not isinstance(params, dict): + raise CommandError(f"команда {i}: params не объект") + spec = SPEC[ctype] + missing = [f for f in spec["required"] if params.get(f) in (None, "")] + if missing: + raise CommandError(f"команда {i} ({ctype}): нет {', '.join(missing)}; ожидаются {', '.join(spec['required'])}") + unknown = set(params) - set(spec["required"]) + if unknown: + raise CommandError(f"команда {i} ({ctype}): лишние параметры {', '.join(sorted(unknown))}; " + f"ожидаются {', '.join(spec['required'])}") + if ctype == "wiki_sync": + if not isinstance(params["label"], str): + raise CommandError(f"команда {i}: label — строка") + if not isinstance(params["root"], str) or not _ROOT.match(params["root"]): + raise CommandError(f"команда {i}: root — код DOC-<цифры>") + else: + a = params["attachment_id"] + if not isinstance(a, int) or isinstance(a, bool): + raise CommandError(f"команда {i}: attachment_id — целое") + if not isinstance(params["url"], str): + raise CommandError(f"команда {i}: url — строка") + out.append({"type": ctype, "params": params}) + return out + + +def check_batch(conn, cmds, project, files_dir) -> list[dict]: + """wiki_sync живёт в пакете своего проекта: project пакета равен label команды.""" + for i, c in enumerate(cmds, 1): + if c["type"] == "wiki_sync" and c["params"]["label"] != project: + raise CommandError(f"команда {i}: проект пакета ({project}) не равен label ({c['params']['label']})") + return cmds + + +def enqueue_sync(conn, label: str, root: str, author: str) -> dict: + """Пакет «Вики <метка>» из одной команды wiki_sync.""" + return queue.create_batch(conn, f"Вики {label}", author, [{"type": "wiki_sync", "params": {"label": label, "root": root}}], + project=label) + + +def active_sync(conn, label: str) -> dict | None: + """Пакет wiki_sync проекта не в терминальном статусе (есть команда queued или claimed) или None.""" + row = conn.execute( + "SELECT b.id FROM batch b WHERE b.project = ? AND EXISTS (SELECT 1 FROM command c WHERE c.batch_id = b.id " + "AND c.type = 'wiki_sync' AND c.status IN ('queued', 'claimed')) ORDER BY b.id DESC LIMIT 1", (label,)).fetchone() + return queue.batch_view(conn, row["id"], with_commands=False) if row else None + + +_noop = lambda *a, **k: None # noqa: E731 + +# Хуки результата пустые: их заполняют приём пачек и отчёт синхронизации +HOOKS = CommandHooks( + types=frozenset(TYPES), read_types=frozenset(TYPES), validate=validate, check_batch=check_batch, + tail=lambda cmds: None, pause=lambda conn, ctype: 0, deliver_params=lambda conn, cmd, files_dir: cmd["params"], + temp_definer=None, temp_keys_used=lambda cmd: [], substitute=lambda ctype, params, mapping: params, + drop_unresolved=lambda cmd, unresolved: unresolved, retry_params=lambda cmd: cmd["params"], + check_confirmed=lambda conn, cmd: (False, None), on_claim=_noop, on_failed=_noop, + check_result=lambda conn, cmd, status, result, files_dir: (status, None), on_result=_noop, after_result=_noop, + on_late_result=lambda *a: False) diff --git a/src/jira_hub/modules/wiki/paths.py b/src/jira_hub/modules/wiki/paths.py new file mode 100644 index 0000000..fc4b8de --- /dev/null +++ b/src/jira_hub/modules/wiki/paths.py @@ -0,0 +1,21 @@ +"""Где лежит Markdown-копия вики: <каталог базы>/wiki/<метка>/.""" +from pathlib import Path + + +def root(conn) -> Path: + """Каталог копий всех проектов — рядом с файлом базы. База в памяти файла не имеет: RuntimeError.""" + row = conn.execute("PRAGMA database_list").fetchone() + if row is None or not row[2]: + raise RuntimeError("у базы нет файла: каталог вики не определён") + return Path(row[2]).parent / "wiki" + + +def dir_name(label: str) -> str: + """Имя каталога проекта: метка, у которой «/», «\\» и «%» заменены, а «.» и «..» закрыты, чтобы метка не выводила + за каталог вики.""" + name = label.replace("%", "%25").replace("/", "%2F").replace("\\", "%5C") + return {".": "%2E", "..": "%2E%2E"}.get(name, name) + + +def project_dir(conn, label: str) -> Path: + return root(conn) / dir_name(label) diff --git a/src/jira_hub/modules/wiki/projects.py b/src/jira_hub/modules/wiki/projects.py new file mode 100644 index 0000000..8022bbd --- /dev/null +++ b/src/jira_hub/modules/wiki/projects.py @@ -0,0 +1,93 @@ +"""Корень вики проекта: поле wiki_root, значения для GET /projects, хуки создания и правки проекта.""" +import re +import shutil + +from ...core import db, queue +from ...core.modules import ProjectField +from . import commands, paths + +_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),) + + +def view(conn, labels: list[str]) -> dict[str, dict]: + """Поля проекта для списка меток; без корня все значения None.""" + if not labels: + return {} + marks = ", ".join("?" * len(labels)) + rows = {r["label"]: r for r in conn.execute(f"SELECT * FROM wiki_project WHERE label IN ({marks})", labels)} + out = {} + for label in labels: + r = rows.get(label) + out[label] = {"wiki_root": r["root_code"] if r else None, "wiki_root_title": r["root_title"] if r else None, + "wiki_pages": r["pages_count"] if r else None, "wiki_synced_at": r["synced_at"] if r else None, + "wiki_sync_error": r["sync_error"] if r else None} + return out + + +def on_created(conn, project, fields, author) -> dict | None: + """Проект создан (в его транзакции): с корнем — строка wiki_project и пакет синхронизации.""" + root = fields.get("wiki_root") + if root is None: + return None + conn.execute("INSERT INTO wiki_project(label, root_code) VALUES (?, ?)", (project["label"], root)) + return commands.enqueue_sync(conn, project["label"], root, author) + + +def _cancel_queued(conn, label: str) -> None: + """Отменить пакеты wiki_sync проекта, ни одна команда которых ещё не выдана.""" + ids = [r["id"] for r in conn.execute( + "SELECT b.id FROM batch b WHERE b.project = ? AND b.cancelled_at IS NULL " + "AND EXISTS (SELECT 1 FROM command c WHERE c.batch_id = b.id AND c.type = 'wiki_sync' AND c.status = 'queued') " + "AND NOT EXISTS (SELECT 1 FROM command c WHERE c.batch_id = b.id AND c.status NOT IN ('queued'))", (label,))] + for batch_id in ids: + 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"): + conn.execute("DELETE FROM wiki_page_fts WHERE rowid IN (SELECT rowid FROM wiki_page WHERE label = ?)", (label,)) + conn.execute("DELETE FROM wiki_page WHERE label = ?", (label,)) + conn.execute("DELETE FROM wiki_attachment WHERE label = ?", (label,)) + conn.execute("DELETE FROM wiki_project WHERE label = ?", (label,)) + try: + shutil.rmtree(paths.project_dir(conn, label), ignore_errors=True) + except RuntimeError: # база без файла (тесты в памяти): копии на диске нет + pass + + +def on_updated(conn, project, fields, author) -> list[dict]: + """Корень изменён (в транзакции PATCH): невыданные пакеты проекта отменяются; новый корень — пакет синхронизации, + прежняя копия остаётся до успеха; None — копия, страницы, индекс и вложения проекта удаляются.""" + if "wiki_root" not in fields: + return [] + label, root = project["label"], fields["wiki_root"] + row = conn.execute("SELECT root_code FROM wiki_project WHERE label = ?", (label,)).fetchone() + if (row["root_code"] if row else None) == root: + return [] + _cancel_queued(conn, label) + if root is None: + _drop_copy(conn, label) + return [] + if row is None: + conn.execute("INSERT INTO wiki_project(label, root_code) VALUES (?, ?)", (label, root)) + else: + conn.execute("UPDATE wiki_project SET root_code = ? WHERE label = ?", (root, label)) + return [commands.enqueue_sync(conn, label, root, author)] diff --git a/src/jira_hub/modules/wiki/schema.py b/src/jira_hub/modules/wiki/schema.py new file mode 100644 index 0000000..0deea3c --- /dev/null +++ b/src/jira_hub/modules/wiki/schema.py @@ -0,0 +1,46 @@ +"""Схема базы модуля wiki: корень проекта, страницы копии, вложения, стадия синхронизации.""" + +SQL = """ +CREATE TABLE IF NOT EXISTS wiki_project ( + label TEXT PRIMARY KEY REFERENCES project(label), + root_code TEXT NOT NULL, + root_title TEXT, + synced_at TEXT, + failed_at TEXT, + pages_count INTEGER NOT NULL DEFAULT 0, + sync_error TEXT +); +CREATE TABLE IF NOT EXISTS wiki_page ( + label TEXT NOT NULL, + code TEXT NOT NULL, + eva_id TEXT, + title TEXT, + parent_code TEXT, + orderno INTEGER, + depth INTEGER, + path TEXT, + url TEXT, + size INTEGER, + synced_at TEXT, + PRIMARY KEY (label, code) +); +CREATE TABLE IF NOT EXISTS wiki_attachment ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT NOT NULL, + page_code TEXT NOT NULL, + url TEXT NOT NULL, + name TEXT, + state TEXT NOT NULL DEFAULT 'none', + file TEXT, + size INTEGER, + error TEXT, + fetched_at TEXT, + UNIQUE (label, page_code, url) +); +CREATE TABLE IF NOT EXISTS wiki_sync_stage ( + command_id INTEGER NOT NULL, + code TEXT NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY (command_id, code) +); +""" diff --git a/src/jira_hub/modules/wiki/settings.py b/src/jira_hub/modules/wiki/settings.py new file mode 100644 index 0000000..a972966 --- /dev/null +++ b/src/jira_hub/modules/wiki/settings.py @@ -0,0 +1,26 @@ +"""Настройки модуля wiki: умолчания, ключи для агента, проверка значений.""" + +DEFAULTS = { + "wiki_pacing": 0.2, # пауза агента между запросами к Eva, с + "wiki_auto_sync_hours": 24, # фоновая синхронизация, ч; 0 — выключена + "wiki_min_agent_version": 1, +} + +AGENT_KEYS = ["wiki_pacing"] + + +def _is_int(v) -> bool: + return isinstance(v, int) and not isinstance(v, bool) + + +def validate_setting(key, value) -> bool: + """True — ключ принадлежит модулю и проверен; ValueError — значение плохое (422 в API).""" + if key == "wiki_pacing": + if isinstance(value, bool) or not isinstance(value, (int, float)) or not 0 <= value <= 10: + raise ValueError("wiki_pacing — число 0..10") + return True + if key in ("wiki_auto_sync_hours", "wiki_min_agent_version"): + if not _is_int(value) or value < 0: + raise ValueError(f"{key} — целое >= 0") + return True + return False diff --git a/src/jira_hub/modules/wiki/tick.py b/src/jira_hub/modules/wiki/tick.py new file mode 100644 index 0000000..0dafb21 --- /dev/null +++ b/src/jira_hub/modules/wiki/tick.py @@ -0,0 +1,22 @@ +"""Фоновая работа модуля wiki: ежедневная синхронизация проектов с корнем.""" +from datetime import timedelta + +from ...core import db, settings +from . import commands + + +def auto_sync_tick(hub, conn) -> None: + """Проект с корнем, у которого с последней синхронизации (удачной или нет) прошло больше wiki_auto_sync_hours и нет + активного пакета wiki_sync, получает новый.""" + hours = settings.get(conn, "wiki_auto_sync_hours") or 0 + if hours <= 0: + return + for row in conn.execute("SELECT label, root_code, synced_at, failed_at FROM wiki_project ORDER BY label").fetchall(): + last = max((db.parse(t) for t in (row["synced_at"], row["failed_at"]) if t), default=None) + if last is not None and db.utcnow() - last <= timedelta(hours=hours): + continue + if commands.active_sync(conn, row["label"]) is None: + commands.enqueue_sync(conn, row["label"], row["root_code"], "hub") + + +auto_sync_tick.label = "синхронизация вики" diff --git a/tests/fixtures/baseline/schema.expected.json b/tests/fixtures/baseline/schema.expected.json index ee34721..545b629 100644 --- a/tests/fixtures/baseline/schema.expected.json +++ b/tests/fixtures/baseline/schema.expected.json @@ -94,6 +94,26 @@ "table", "sync_run", "CREATE TABLE sync_run (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n started_at TEXT NOT NULL,\n finished_at TEXT,\n kind TEXT NOT NULL,\n query TEXT,\n issues_count INTEGER NOT NULL DEFAULT 0,\n links_count INTEGER NOT NULL DEFAULT 0,\n jira_calls INTEGER NOT NULL DEFAULT 0,\n rate_limited INTEGER NOT NULL DEFAULT 0,\n command_id INTEGER,\n history_at TEXT\n)" + ], + [ + "table", + "wiki_attachment", + "CREATE TABLE wiki_attachment (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n label TEXT NOT NULL,\n page_code TEXT NOT NULL,\n url TEXT NOT NULL,\n name TEXT,\n state TEXT NOT NULL DEFAULT 'none',\n file TEXT,\n size INTEGER,\n error TEXT,\n fetched_at TEXT,\n UNIQUE (label, page_code, url)\n)" + ], + [ + "table", + "wiki_page", + "CREATE TABLE wiki_page (\n label TEXT NOT NULL,\n code TEXT NOT NULL,\n eva_id TEXT,\n title TEXT,\n parent_code TEXT,\n orderno INTEGER,\n depth INTEGER,\n path TEXT,\n url TEXT,\n size INTEGER,\n synced_at TEXT,\n PRIMARY KEY (label, code)\n)" + ], + [ + "table", + "wiki_project", + "CREATE TABLE wiki_project (\n label TEXT PRIMARY KEY REFERENCES project(label),\n root_code TEXT NOT NULL,\n root_title TEXT,\n synced_at TEXT,\n failed_at TEXT,\n pages_count INTEGER NOT NULL DEFAULT 0,\n sync_error TEXT\n)" + ], + [ + "table", + "wiki_sync_stage", + "CREATE TABLE wiki_sync_stage (\n command_id INTEGER NOT NULL,\n code TEXT NOT NULL,\n payload TEXT NOT NULL,\n PRIMARY KEY (command_id, code)\n)" ] ], "schema_version": 7 diff --git a/tests/fixtures/baseline/settings.expected.json b/tests/fixtures/baseline/settings.expected.json index 5a1f5fa..79ac8d9 100644 --- a/tests/fixtures/baseline/settings.expected.json +++ b/tests/fixtures/baseline/settings.expected.json @@ -52,7 +52,10 @@ "relay_dir": null, "settings_version": 1, "status_labels": {}, - "transitions_map": {} + "transitions_map": {}, + "wiki_auto_sync_hours": 24, + "wiki_min_agent_version": 1, + "wiki_pacing": 0.2 }, "protected": [ "schema_version", diff --git a/tests/test_agents.py b/tests/test_agents.py index c8b3804..e83c6aa 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -27,7 +27,7 @@ def test_v6_upgrade_keeps_jira_agent(tmp_path): assert tok["client"] == "T-client" and tok["agents"]["jira"] == "T-agent" and "agent" not in tok row = conn.execute("SELECT * FROM agent_status WHERE agent = 'jira'").fetchone() assert row["agent_version"] == "14" - assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 + assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 2 # jira и wiki assert list((tmp_path / "backups").glob("*schema-v6*")) # копия перед миграцией finally: conn.close() @@ -95,7 +95,7 @@ def test_tokens_cli_lists_agents(tmp_path, monkeypatch, capsys): assert cli.main(["tokens"]) == 0 lines = capsys.readouterr().out.splitlines() assert lines[0].startswith("клиентский токен: ") and lines[1].startswith("агент jira: ") - assert lines[2].startswith("база: ") + assert lines[2].startswith("агент wiki: ") and lines[3].startswith("база: ") def test_agent_view_min_version_from_module_key(conn_two_modules): @@ -246,3 +246,17 @@ def test_agents_endpoint(hub_server_two_modules): with pytest.raises(HubError) as e: c.get("/agent", module="nope") assert e.value.status == 404 and e.value.error == "not_found" + + +def test_wiki_agent_token_after_upgrade(tmp_path): + """Миграция v6: токен jira сохранён, токен wiki создан; строка agent_status есть у обоих.""" + make_v6_db(tmp_path / "hub.sqlite") + conn = db.connect(tmp_path / "hub.sqlite") + try: + settings.ensure_defaults(conn) + agents = settings.tokens(conn)["agents"] + assert agents["jira"] == "T-agent" and agents["wiki"] and agents["wiki"] != agents["jira"] + names = {r["agent"] for r in conn.execute("SELECT agent FROM agent_status")} + assert names == {"jira", "wiki"} + finally: + conn.close() diff --git a/tests/test_db.py b/tests/test_db.py index edeb1b1..52872d7 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -20,7 +20,7 @@ def test_connect_creates_schema(open_db): assert "history_at" in {r["name"] for r in conn.execute("PRAGMA table_info(sync_run)")} assert "project" in {r["name"] for r in conn.execute("PRAGMA table_info(batch)")} assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7" - assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 + assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 2 cols = {r["name"] for r in conn.execute("PRAGMA table_info(command)")} assert "deliveries" in cols @@ -83,7 +83,7 @@ def test_connect_is_idempotent(tmp_path): path = tmp_path / "hub.sqlite" db.connect(path).close() conn = db.connect(path) - assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 + assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 2 conn.close() @@ -280,7 +280,8 @@ def test_migrates_v3_creates_awpca(tmp_path): _v3_database(path) conn = db.connect(path) new = 'project = AURORA AND labels in ("AWPCA")' - assert api.project_rows(conn)[0] | {"created_at": None, "issues_count": None} == { + assert {k: v for k, v in api.project_rows(conn)[0].items() if not k.startswith("wiki_")} | { + "created_at": None, "issues_count": None} == { "label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": None, "created_at": None, "issues_count": None} assert settings.get(conn, "base_jql") == "project = AURORA AND labels = AWPCA" # ключи на месте assert settings.get(conn, "board_id") == 4216 diff --git a/tests/test_project_fields.py b/tests/test_project_fields.py index 4d19e79..a83b9c4 100644 --- a/tests/test_project_fields.py +++ b/tests/test_project_fields.py @@ -33,7 +33,8 @@ def test_projects_get_shape(hub_server, add_project): conn.close() got = hub_server["client"].get("/projects")["projects"] assert got == [{"label": "AWPCA", "name": "АРМ КА", "created_at": got[0]["created_at"], - "board_id": 4216, "board_type": "scrum", "issues_count": 1}] + "board_id": 4216, "board_type": "scrum", "issues_count": 1, "wiki_root": None, + "wiki_root_title": None, "wiki_pages": None, "wiki_synced_at": None, "wiki_sync_error": None}] def test_post_unknown_field(hub_server): diff --git a/tests/test_queue.py b/tests/test_queue.py index 7304fe2..e96dcbf 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -216,7 +216,7 @@ def test_make_backup_rotates(tmp_path, open_db): backup.make_backup(conn, tmp_path / "backups", f"b{i}", keep=2) names = sorted(p.name for p in (tmp_path / "backups").glob("*.sqlite")) assert len(names) == 2 and names[-1].endswith("-b3.sqlite") - assert open_db(tmp_path / "backups" / names[-1]).execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 + assert open_db(tmp_path / "backups" / names[-1]).execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 2 def test_rotation_keeps_schema_migration_copy(tmp_path, open_db): diff --git a/tests/test_registry.py b/tests/test_registry.py index 6520df6..1467510 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -24,7 +24,7 @@ def _hooks(types): def test_jira_registered(): - assert [m.name for m in registry.modules()] == ["jira"] + assert [m.name for m in registry.modules()] == ["jira", "wiki"] assert registry.module("jira").name == "jira" assert registry.module("nope") is None diff --git a/tests/test_wiki_commands.py b/tests/test_wiki_commands.py new file mode 100644 index 0000000..3804f95 --- /dev/null +++ b/tests/test_wiki_commands.py @@ -0,0 +1,130 @@ +"""Команды wiki и фоновая синхронизация (wiki-module, spec wiki «Синхронизация вики»).""" +from datetime import timedelta + +import pytest + +from jira_hub.core import db, queue, settings +from jira_hub.core.errors import CommandError +from jira_hub.modules.jira import refresh +from jira_hub.modules.wiki import commands, settings as wiki_settings, tick + + +@pytest.fixture +def conn(open_db, add_project): + c = open_db() + settings.ensure_defaults(c) + add_project(c, "AWPCA") + return c + + +def _root(conn, synced_hours=None, failed_hours=None): + ago = lambda h: None if h is None else (db.utcnow() - timedelta(hours=h)).isoformat() # noqa: E731 + conn.execute("INSERT OR REPLACE INTO wiki_project(label, root_code, synced_at, failed_at) VALUES ('AWPCA', 'DOC-1', ?, ?)", + (ago(synced_hours), ago(failed_hours))) + + +def _count(conn): + return conn.execute("SELECT COUNT(*) AS n FROM batch WHERE project = 'AWPCA'").fetchone()["n"] + + +def test_auto_sync_tick(conn): + _root(conn, synced_hours=25) + tick.auto_sync_tick(None, conn) + assert _count(conn) == 1 + batch = queue.list_batches(conn, project="AWPCA")[0] + assert batch["title"] == "Вики AWPCA" and batch["author"] == "hub" + tick.auto_sync_tick(None, conn) # пакет в очереди: второй не ставится + assert _count(conn) == 1 + + +def test_auto_sync_tick_recent(conn): + _root(conn, synced_hours=23) + tick.auto_sync_tick(None, conn) + assert _count(conn) == 0 + + +def test_auto_sync_tick_failed_resets_clock(conn): + _root(conn, synced_hours=30, failed_hours=1) # свежая неудача откладывает следующую попытку + tick.auto_sync_tick(None, conn) + assert _count(conn) == 0 + _root(conn, synced_hours=30, failed_hours=26) + tick.auto_sync_tick(None, conn) + assert _count(conn) == 1 + + +def test_auto_sync_tick_never_synced_without_batch(conn): + _root(conn) # корень задан, пакета нет + tick.auto_sync_tick(None, conn) + assert _count(conn) == 1 + + +def test_auto_sync_tick_disabled(conn): + _root(conn, synced_hours=25) + settings.set_(conn, "wiki_auto_sync_hours", 0) + tick.auto_sync_tick(None, conn) + assert _count(conn) == 0 + + +def test_tick_label(): + assert tick.auto_sync_tick.label == "синхронизация вики" + + +def test_active_sync(conn): + _root(conn, synced_hours=1) + assert commands.active_sync(conn, "AWPCA") is None + batch = commands.enqueue_sync(conn, "AWPCA", "DOC-1", "u") + assert commands.active_sync(conn, "AWPCA")["id"] == batch["id"] + queue.cancel_batch(conn, batch["id"]) + assert commands.active_sync(conn, "AWPCA") is None + + +def test_auto_refresh_ignores_wiki_batches(conn): + _root(conn, synced_hours=1) + conn.execute("UPDATE agent_status SET last_seen_at = ? WHERE agent = 'jira'", (db.now(),)) + commands.enqueue_sync(conn, "AWPCA", "DOC-1", "u") + batch = refresh.maybe_auto_refresh(conn) + assert batch is not None + + +def test_check_batch_requires_own_project(conn): + cmd = [{"type": "wiki_sync", "params": {"label": "AWPCA", "root": "DOC-1"}}] + with pytest.raises(CommandError): + queue.create_batch(conn, "t", "u", cmd, project="OTHER") + with pytest.raises(CommandError): + queue.create_batch(conn, "t", "u", cmd) + assert queue.create_batch(conn, "t", "u", cmd, project="AWPCA")["count"] == 1 + + +@pytest.mark.parametrize("params", [{"label": "AWPCA"}, {"label": "AWPCA", "root": "страница"}, + {"label": "AWPCA", "root": "DOC-1", "x": 1}]) +def test_validate_wiki_sync(params): + with pytest.raises(CommandError): + commands.validate([{"type": "wiki_sync", "params": params}]) + + +def test_validate_wiki_fetch(): + assert commands.validate([{"type": "wiki_fetch", "params": {"attachment_id": 7, "url": "https://e/f"}}]) + with pytest.raises(CommandError): + commands.validate([{"type": "wiki_fetch", "params": {"attachment_id": "7", "url": "https://e/f"}}]) + + +def test_read_types_have_no_tail_or_pause(conn): + assert queue.create_batch(conn, "t", "u", [{"type": "wiki_fetch", "params": {"attachment_id": 1, "url": "u"}}])["count"] == 1 + assert queue.estimate(conn, ["wiki_sync", "wiki_fetch"]) == 0 + + +@pytest.mark.parametrize("key,bad,good", [("wiki_pacing", [-1, 11, "x", True], [0, 0.2, 10]), + ("wiki_auto_sync_hours", [-1, 1.5, True, "1"], [0, 24]), + ("wiki_min_agent_version", [-1, 1.5, "1"], [0, 3])]) +def test_validate_settings(key, bad, good): + for v in bad: + with pytest.raises(ValueError): + settings.validate(key, v) + for v in good: + settings.validate(key, v) + assert wiki_settings.DEFAULTS[key] is not None + + +def test_wiki_agent_gets_own_keys(conn): + out = settings.for_agent(conn, "wiki") + assert out == {"wiki_pacing": 0.2, "settings_version": 1} diff --git a/tests/test_wiki_projects.py b/tests/test_wiki_projects.py new file mode 100644 index 0000000..bc93552 --- /dev/null +++ b/tests/test_wiki_projects.py @@ -0,0 +1,121 @@ +"""Корень вики проекта: wiki_root в POST/PATCH /projects, сброс копии (wiki-module, spec wiki «Корень вики проекта»).""" +import json + +import pytest + +from jira_hub.core.client import HubError +from jira_hub.modules.wiki.projects import parse_root + + +def test_parse_root(): + assert parse_root("https://eva.example.ru/project/Document/doc-001234") == "DOC-001234" + assert parse_root(" DOC-7 ") == "DOC-7" and parse_root(None) is None and parse_root("") is None + with pytest.raises(ValueError, match="DOC-"): + parse_root("страница про статусы") + with pytest.raises(ValueError): + parse_root(5) + + +def _syncs(hub, label=None): + """Пакеты wiki_sync: [(id, статус команды, params)] по порядку.""" + conn = hub.conn() + try: + return [(r["batch_id"], r["status"], json.loads(r["params"])) for r in conn.execute( + "SELECT batch_id, status, params FROM command WHERE type = 'wiki_sync' ORDER BY id")] + finally: + conn.close() + + +def test_post_project_with_root(hub_server): + out = hub_server["client"].post("/projects", {"label": "S", "name": "S", "wiki_root": "DOC-001234"}) + assert set(out) == {"project", "batch_id"} and out["project"]["wiki_root"] == "DOC-001234" + hub = hub_server["hub"] + conn = hub.conn() + try: + first = conn.execute("SELECT type FROM command WHERE batch_id = ?", (out["batch_id"],)).fetchone() + finally: + conn.close() + assert first["type"] == "refresh" # batch_id — пакет первого модуля (jira) + assert [(s, p) for _, s, p in _syncs(hub)] == [("queued", {"label": "S", "root": "DOC-001234"})] + + +def test_post_project_without_root_has_no_wiki_batch(hub_server): + out = hub_server["client"].post("/projects", {"label": "S", "name": "S"}) + assert out["project"]["wiki_root"] is None and out["project"]["wiki_pages"] is None + assert _syncs(hub_server["hub"]) == [] + + +def test_patch_root_existing(hub_server): + c = hub_server["client"] + c.post("/projects", {"label": "AWPCA", "name": "АРМ КА"}) + out = c.patch("/projects/AWPCA", {"wiki_root": "DOC-000777"}) + assert len(out["batch_ids"]) == 1 and out["project"]["wiki_root"] == "DOC-000777" + assert [p for _, _, p in _syncs(hub_server["hub"])] == [{"label": "AWPCA", "root": "DOC-000777"}] + got = {p["label"]: p for p in c.get("/projects")["projects"]} + assert got["AWPCA"]["wiki_root"] == "DOC-000777" + batch = c.get(f"/batches/{out['batch_ids'][0]}") + assert batch["title"] == "Вики AWPCA" and batch["project"] == "AWPCA" + + +def test_patch_root_bad(hub_server): + 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" + assert len(_syncs(hub_server["hub"])) == 1 + + +def test_patch_root_cancels_queued(hub_server): + c = hub_server["client"] + c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-12"}) + c.patch("/projects/AWPCA", {"wiki_root": "DOC-21"}) + assert [(s, p["root"]) for _, s, p in _syncs(hub_server["hub"])] == [("cancelled", "DOC-12"), ("queued", "DOC-21")] + + +def test_patch_same_root_does_nothing(hub_server): + c = hub_server["client"] + c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-12"}) + assert c.patch("/projects/AWPCA", {"wiki_root": "doc-12"})["batch_ids"] == [] + assert len(_syncs(hub_server["hub"])) == 1 + + +def test_reset_root_removes_copy(hub_server, tmp_path): + c = hub_server["client"] + c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-1"}) + c.post("/projects", {"label": "OTHER", "name": "O", "wiki_root": "DOC-2"}) + copy = tmp_path / "wiki" / "AWPCA" / "АРМ КА" + copy.mkdir(parents=True) + (copy / "index.md").write_text("# т", encoding="utf-8") + other = tmp_path / "wiki" / "OTHER" + other.mkdir() + conn = hub_server["hub"].conn() + try: + for label in ("AWPCA", "OTHER"): + conn.execute("INSERT INTO wiki_page(label, code, title) VALUES (?, 'DOC-1', 'т')", (label,)) + conn.execute("INSERT INTO wiki_attachment(label, page_code, url) VALUES (?, 'DOC-1', 'https://e/f')", (label,)) + finally: + conn.close() + out = c.patch("/projects/AWPCA", {"wiki_root": None}) + assert out["batch_ids"] == [] and out["project"]["wiki_root"] is None + assert not (tmp_path / "wiki" / "AWPCA").exists() and other.exists() + conn = hub_server["hub"].conn() + try: + for table in ("wiki_project", "wiki_page", "wiki_attachment"): + assert [r["label"] for r in conn.execute(f"SELECT label FROM {table}")] == ["OTHER"], table + finally: + conn.close() + assert [(s, p["root"]) for _, s, p in _syncs(hub_server["hub"])] == [("cancelled", "DOC-1"), ("queued", "DOC-2")] + + +def test_claimed_sync_is_not_cancelled_by_new_root(hub_server): + c = hub_server["client"] + c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-12"}) + conn = hub_server["hub"].conn() + try: + conn.execute("UPDATE command SET status = 'claimed' WHERE type = 'wiki_sync'") + finally: + 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")] diff --git a/uv.lock b/uv.lock index 561895d..bb8d08b 100644 --- a/uv.lock +++ b/uv.lock @@ -37,6 +37,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -269,6 +282,7 @@ name = "jira-hub" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "markdownify" }, { name = "mcp" }, ] @@ -278,7 +292,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "mcp", specifier = ">=1.2,<2" }] +requires-dist = [ + { name = "markdownify", specifier = ">=1.2.3" }, + { name = "mcp", specifier = ">=1.2,<2" }, +] [package.metadata.requires-dev] dev = [{ name = "pytest", specifier = ">=8" }] @@ -310,6 +327,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "markdownify" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/ab/d1297139c0e2ceb151ae564c8c4f57ac0155d8f1f8b4cbd5d6523c82ea36/markdownify-1.2.3.tar.gz", hash = "sha256:1a176f05522c8a2cb1dd3ab9d307dcdadbed5c26ae717855bfc42b3b6d38d937", size = 18852, upload-time = "2026-06-30T20:27:39.06Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/10/fa543d484e8b1199243fe20eedd02cc5af050edebce98a7293a5773df592/markdownify-1.2.3-py3-none-any.whl", hash = "sha256:a189a0bedfd14009030fde5f85bb6f77c56897cb839b5c25315dd7d4e3e290ba", size = 15732, upload-time = "2026-06-30T20:27:38.094Z" }, +] + [[package]] name = "mcp" version = "1.30.0" @@ -645,6 +675,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/e2/91f70d804c61f8eac39a417e82aa1024f655ab9e8bdd7393b196242bc41b/rpds_py-2026.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:fe5ad0664ec772b02c45859041aa17655709cced7a31005817fbbbd988c25567", size = 223037, upload-time = "2026-10-04T16:31:40.468Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "soupsieve" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/63/0b6bae54546aed98d72f12d09df57b19a287f01c84d22047fa2e23772ab9/soupsieve-3.0.4.tar.gz", hash = "sha256:924fab84ae21604c19e175e1f39815eb6fa83ba23836e09e9de180925e5bc4c0", size = 119017, upload-time = "2026-10-22T00:45:26.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/66/473f7ca0be0b06e38ee8c85ee2db184e3981bd769fac2e7c19c8a8865211/soupsieve-3.0.4-py3-none-any.whl", hash = "sha256:8412826614f9c632227067d0985aeb9f584bd15efc852955430e097a9fe7afbf", size = 42458, upload-time = "2026-10-22T00:45:24.843Z" }, +] + [[package]] name = "sse-starlette" version = "3.5.0"