Task 3: author в PATCH, ответ POST по спеке, проверка editable-полей (#91)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 01:33:03 +04:00
parent 04e67d5139
commit 9534e1385a
5 changed files with 66 additions and 10 deletions

View File

@ -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):

View File

@ -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)
}

View File

@ -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))

View File

@ -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}")

View File

@ -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: