From 9534e1385a3d421d7ea6071d03d865943dc837a1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 01:33:03 +0400 Subject: [PATCH] =?UTF-8?q?Task=203:=20author=20=D0=B2=20PATCH,=20=D0=BE?= =?UTF-8?q?=D1=82=D0=B2=D0=B5=D1=82=20POST=20=D0=BF=D0=BE=20=D1=81=D0=BF?= =?UTF-8?q?=D0=B5=D0=BA=D0=B5,=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=20editable-=D0=BF=D0=BE=D0=BB=D0=B5=D0=B9=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/core/api.py | 10 +++--- src/jira_hub/core/db.py | 4 +-- src/jira_hub/core/mcp.py | 3 +- src/jira_hub/core/registry.py | 2 ++ tests/test_project_fields.py | 57 +++++++++++++++++++++++++++++++++-- 5 files changed, 66 insertions(+), 10 deletions(-) diff --git a/src/jira_hub/core/api.py b/src/jira_hub/core/api.py index de165b1..4170ca1 100644 --- a/src/jira_hub/core/api.py +++ b/src/jira_hub/core/api.py @@ -382,6 +382,8 @@ def _module_fields(body, skip, *, editable_only: bool) -> dict[str, tuple[object known = registry.project_fields() out: dict[str, tuple[object, dict]] = {} for key, value in body.items(): + if key == "author": # автор пакетов, не поле проекта: читает _author + continue if key in skip: if editable_only: raise ApiError(422, "bad_project", f"{key} после создания не меняется") @@ -402,13 +404,13 @@ def _module_fields(body, skip, *, editable_only: bool) -> dict[str, tuple[object def h_projects_post(hub, conn, m, q, body): """Новый проект: проект, поля и пакеты модулей (on_project_created; у jira — «Новый проект <метка>») — в одной транзакции; ошибка любого шага не оставляет ни проекта, ни пакета (hub-api, «Маршруты проектов»). - Ответ: project, batch_ids (все пакеты) и batch_id — первый из них (у jira это её пакет, R-2).""" + Ответ: project и batch_id — пакет первого модуля, вернувшего пакет.""" author = _author(body) try: label, name = projects.validate(body.get("label"), body.get("name")) except projects.ProjectError as e: raise ApiError(422, "bad_project", str(e)) - by_module = _module_fields(body, {"label", "name", "author"}, editable_only=False) + by_module = _module_fields(body, {"label", "name"}, editable_only=False) batch_ids = [] try: with db.transaction(conn): @@ -425,7 +427,7 @@ def h_projects_post(hub, conn, m, q, body): raise ApiError(422, "bad_project", str(e)) except CommandError as e: raise ApiError(422, "bad_batch", str(e)) - return 201, {"project": row, "batch_id": batch_ids[0] if batch_ids else None, "batch_ids": batch_ids} + return 201, {"project": row, "batch_id": batch_ids[0] if batch_ids else None} def h_project_patch(hub, conn, m, q, body): @@ -433,7 +435,7 @@ def h_project_patch(hub, conn, m, q, body): label = unquote(m.group(1)) project = not_found(projects.get(conn, label), f"project {label}") author = _author(body) - by_module = _module_fields(body, {"label", "name", "created_at", "author"}, editable_only=True) + by_module = _module_fields(body, {"label", "name", "created_at"}, editable_only=True) batch_ids = [] try: with db.transaction(conn): diff --git a/src/jira_hub/core/db.py b/src/jira_hub/core/db.py index 003a17e..34eb078 100644 --- a/src/jira_hub/core/db.py +++ b/src/jira_hub/core/db.py @@ -62,7 +62,7 @@ def _migrate_projects(conn) -> None: projects.migrate_v3(conn) -def _migrate_jira_project(conn) -> None: +def _project_fields_v7(conn) -> None: from ..modules.jira import projects projects.migrate_v6(conn) @@ -129,7 +129,7 @@ MIGRATIONS = { _fill_comment_utc], 5: [], # v5 → v6: issue_attachment и attachment_file создаются схемой (CREATE TABLE IF NOT EXISTS), данных переносить не нужно 6: [_tokens_v7, _agent_status_v7, - _migrate_jira_project], # v6 → v7: токены и статус по агентам модулей (wiki-module, решение 15) + _project_fields_v7], # v6 → v7: токены и статус по агентам модулей (wiki-module, решение 15) } diff --git a/src/jira_hub/core/mcp.py b/src/jira_hub/core/mcp.py index 67a90d4..c0202ff 100644 --- a/src/jira_hub/core/mcp.py +++ b/src/jira_hub/core/mcp.py @@ -116,7 +116,8 @@ def create_project(label: str, name: str, fields: dict, module: str) -> str: исключения клиента: вызывающий инструмент обёрнут в guard.""" out = client().post("/projects", {"label": label, "name": name, "author": AUTHOR, **fields}) p = out["project"] - return (f"проект {p['label']} «{p['name']}» заведён, пакет обновления #{out['batch_id']}\n" + batch = f", пакет обновления #{out['batch_id']}" if out.get("batch_id") is not None else "" + return (f"проект {p['label']} «{p['name']}» заведён{batch}\n" "Положите в корень репозитория проекта .mcp.json (слейте с имеющимся) и переподключите MCP: " "/mcp → reconnect, одобрите сервер проекта.\n" + json.dumps(mcp_json(p["label"], module), ensure_ascii=False, indent=2)) diff --git a/src/jira_hub/core/registry.py b/src/jira_hub/core/registry.py index 7c9c60a..3f8ccf4 100644 --- a/src/jira_hub/core/registry.py +++ b/src/jira_hub/core/registry.py @@ -60,6 +60,8 @@ def check(mods: list[Module], core_routes, core_settings: dict) -> None: if f.name in fields: raise RuntimeError(f"поле проекта {f.name}: {fields[f.name]} и {m.name}") fields[f.name] = m.name + if f.editable and m.on_project_updated is None: + raise RuntimeError(f"поле проекта {f.name}: модуль {m.name} объявил его изменяемым без on_project_updated") for r in m.routes: if (r[0], r[1]) in routes: raise RuntimeError(f"маршрут {r[0]} {r[1]}: {routes[(r[0], r[1])]} и модуль {m.name}") diff --git a/tests/test_project_fields.py b/tests/test_project_fields.py index c466772..4d19e79 100644 --- a/tests/test_project_fields.py +++ b/tests/test_project_fields.py @@ -44,9 +44,60 @@ def test_post_unknown_field(hub_server): assert c.get("/projects")["projects"] == [] -def test_post_returns_batch_ids(hub_server): +def test_post_response_shape(hub_server): out = hub_server["client"].post("/projects", {"label": "X", "name": "X", "board_id": 7}) - assert out["batch_ids"] == [out["batch_id"]] and out["project"]["board_id"] == 7 + assert set(out) == {"project", "batch_id"} and out["project"]["board_id"] == 7 + assert hub_server["client"].get(f"/batches/{out['batch_id']}")["title"] == "Новый проект X" + + +def test_patch_author_ok(hub_server, add_project): + conn = hub_server["hub"].conn() + try: + add_project(conn) + finally: + conn.close() + out = hub_server["client"].patch("/projects/AWPCA", {"author": "viewer"}) + assert out["batch_ids"] == [] + + +def test_patch_unknown_field(hub_server, add_project): + conn = hub_server["hub"].conn() + try: + add_project(conn) + finally: + conn.close() + with pytest.raises(HubError) as e: + hub_server["client"].patch("/projects/AWPCA", {"nope": 1}) + assert e.value.status == 422 and e.value.detail == "nope: неизвестное поле" + + +def test_editable_field_needs_updater(): + m = Module(name="a", project_fields=(ProjectField("root", _ok, editable=True),)) + with pytest.raises(RuntimeError) as e: + registry.check([m], [], {}) + assert "root" in str(e.value) and "on_project_updated" in str(e.value) + + +def test_migrate_v6_idempotent(tmp_path): + from jira_hub.modules.jira import projects as jira_projects + make_v6_db(tmp_path / "hub.sqlite") + conn = db.connect(tmp_path / "hub.sqlite") + try: + before = [tuple(r) for r in conn.execute("SELECT * FROM jira_project ORDER BY label")] + jira_projects.migrate_v6(conn) + assert [tuple(r) for r in conn.execute("SELECT * FROM jira_project ORDER BY label")] == before + assert conn.execute("PRAGMA foreign_key_check").fetchall() == [] + assert [r["name"] for r in conn.execute("SELECT name FROM project ORDER BY label")] == ["АРМ КА", "sfm_kedr"] + finally: + conn.close() + + +def test_create_project_text_without_batch(monkeypatch): + class C: + def post(self, path, body): + return {"project": {"label": "P", "name": "p"}, "batch_id": None} + monkeypatch.setattr(core_mcp, "client", lambda: C()) + assert "#None" not in core_mcp.create_project("P", "p", {}, "wiki") and "пакет" not in core_mcp.create_project("P", "p", {}, "wiki").splitlines()[0] def test_patch_board_forbidden(hub_server, add_project): @@ -152,7 +203,7 @@ def test_module_fields_post_and_patch(two_modules): api, ApiError, conn, re = two_modules code, out = api.h_projects_post(None, conn, None, {}, {"label": "P", "name": "p", "board_id": 3, "root": "doc-1"}) assert code == 201 and out["project"]["root"] == "DOC-1" and out["project"]["board_id"] == 3 - assert out["batch_ids"] == [out["batch_id"]] # пакет есть только у jira + assert set(out) == {"project", "batch_id"} and out["batch_id"] is not None # пакет только у jira code, out = api.h_project_patch(None, conn, re.match(r"(.*)", "P"), {}, {"root": "doc-2"}) assert code == 200 and out["project"]["root"] == "DOC-2" and len(out["batch_ids"]) == 1 with pytest.raises(ApiError) as e: