Task 3: author в PATCH, ответ POST по спеке, проверка editable-полей (#91)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
04e67d5139
commit
9534e1385a
@ -382,6 +382,8 @@ def _module_fields(body, skip, *, editable_only: bool) -> dict[str, tuple[object
|
|||||||
known = registry.project_fields()
|
known = registry.project_fields()
|
||||||
out: dict[str, tuple[object, dict]] = {}
|
out: dict[str, tuple[object, dict]] = {}
|
||||||
for key, value in body.items():
|
for key, value in body.items():
|
||||||
|
if key == "author": # автор пакетов, не поле проекта: читает _author
|
||||||
|
continue
|
||||||
if key in skip:
|
if key in skip:
|
||||||
if editable_only:
|
if editable_only:
|
||||||
raise ApiError(422, "bad_project", f"{key} после создания не меняется")
|
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):
|
def h_projects_post(hub, conn, m, q, body):
|
||||||
"""Новый проект: проект, поля и пакеты модулей (on_project_created; у jira — «Новый проект <метка>») — в одной
|
"""Новый проект: проект, поля и пакеты модулей (on_project_created; у jira — «Новый проект <метка>») — в одной
|
||||||
транзакции; ошибка любого шага не оставляет ни проекта, ни пакета (hub-api, «Маршруты проектов»).
|
транзакции; ошибка любого шага не оставляет ни проекта, ни пакета (hub-api, «Маршруты проектов»).
|
||||||
Ответ: project, batch_ids (все пакеты) и batch_id — первый из них (у jira это её пакет, R-2)."""
|
Ответ: project и batch_id — пакет первого модуля, вернувшего пакет."""
|
||||||
author = _author(body)
|
author = _author(body)
|
||||||
try:
|
try:
|
||||||
label, name = projects.validate(body.get("label"), body.get("name"))
|
label, name = projects.validate(body.get("label"), body.get("name"))
|
||||||
except projects.ProjectError as e:
|
except projects.ProjectError as e:
|
||||||
raise ApiError(422, "bad_project", str(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 = []
|
batch_ids = []
|
||||||
try:
|
try:
|
||||||
with db.transaction(conn):
|
with db.transaction(conn):
|
||||||
@ -425,7 +427,7 @@ def h_projects_post(hub, conn, m, q, body):
|
|||||||
raise ApiError(422, "bad_project", str(e))
|
raise ApiError(422, "bad_project", str(e))
|
||||||
except CommandError as e:
|
except CommandError as e:
|
||||||
raise ApiError(422, "bad_batch", str(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):
|
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))
|
label = unquote(m.group(1))
|
||||||
project = not_found(projects.get(conn, label), f"project {label}")
|
project = not_found(projects.get(conn, label), f"project {label}")
|
||||||
author = _author(body)
|
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 = []
|
batch_ids = []
|
||||||
try:
|
try:
|
||||||
with db.transaction(conn):
|
with db.transaction(conn):
|
||||||
|
|||||||
@ -62,7 +62,7 @@ def _migrate_projects(conn) -> None:
|
|||||||
projects.migrate_v3(conn)
|
projects.migrate_v3(conn)
|
||||||
|
|
||||||
|
|
||||||
def _migrate_jira_project(conn) -> None:
|
def _project_fields_v7(conn) -> None:
|
||||||
from ..modules.jira import projects
|
from ..modules.jira import projects
|
||||||
projects.migrate_v6(conn)
|
projects.migrate_v6(conn)
|
||||||
|
|
||||||
@ -129,7 +129,7 @@ MIGRATIONS = {
|
|||||||
_fill_comment_utc],
|
_fill_comment_utc],
|
||||||
5: [], # v5 → v6: issue_attachment и attachment_file создаются схемой (CREATE TABLE IF NOT EXISTS), данных переносить не нужно
|
5: [], # v5 → v6: issue_attachment и attachment_file создаются схемой (CREATE TABLE IF NOT EXISTS), данных переносить не нужно
|
||||||
6: [_tokens_v7, _agent_status_v7,
|
6: [_tokens_v7, _agent_status_v7,
|
||||||
_migrate_jira_project], # v6 → v7: токены и статус по агентам модулей (wiki-module, решение 15)
|
_project_fields_v7], # v6 → v7: токены и статус по агентам модулей (wiki-module, решение 15)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -116,7 +116,8 @@ def create_project(label: str, name: str, fields: dict, module: str) -> str:
|
|||||||
исключения клиента: вызывающий инструмент обёрнут в guard."""
|
исключения клиента: вызывающий инструмент обёрнут в guard."""
|
||||||
out = client().post("/projects", {"label": label, "name": name, "author": AUTHOR, **fields})
|
out = client().post("/projects", {"label": label, "name": name, "author": AUTHOR, **fields})
|
||||||
p = out["project"]
|
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.json (слейте с имеющимся) и переподключите MCP: "
|
||||||
"/mcp → reconnect, одобрите сервер проекта.\n"
|
"/mcp → reconnect, одобрите сервер проекта.\n"
|
||||||
+ json.dumps(mcp_json(p["label"], module), ensure_ascii=False, indent=2))
|
+ json.dumps(mcp_json(p["label"], module), ensure_ascii=False, indent=2))
|
||||||
|
|||||||
@ -60,6 +60,8 @@ def check(mods: list[Module], core_routes, core_settings: dict) -> None:
|
|||||||
if f.name in fields:
|
if f.name in fields:
|
||||||
raise RuntimeError(f"поле проекта {f.name}: {fields[f.name]} и {m.name}")
|
raise RuntimeError(f"поле проекта {f.name}: {fields[f.name]} и {m.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:
|
for r in m.routes:
|
||||||
if (r[0], r[1]) in routes:
|
if (r[0], r[1]) in routes:
|
||||||
raise RuntimeError(f"маршрут {r[0]} {r[1]}: {routes[(r[0], r[1])]} и модуль {m.name}")
|
raise RuntimeError(f"маршрут {r[0]} {r[1]}: {routes[(r[0], r[1])]} и модуль {m.name}")
|
||||||
|
|||||||
@ -44,9 +44,60 @@ def test_post_unknown_field(hub_server):
|
|||||||
assert c.get("/projects")["projects"] == []
|
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})
|
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):
|
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
|
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"})
|
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 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"})
|
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
|
assert code == 200 and out["project"]["root"] == "DOC-2" and len(out["batch_ids"]) == 1
|
||||||
with pytest.raises(ApiError) as e:
|
with pytest.raises(ApiError) as e:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user