diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 9c892e9..1d5efce 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -163,8 +163,13 @@ def h_agent_result(hub, conn, m, q, body): def h_agent_state(hub, conn, m, q, body): + issues = body.get("issues") or [] + try: + mirror.check_comments(issues) # до записи: неверная пачка не применяется целиком + except ValueError as e: + raise ApiError(422, "bad_state", str(e)) with db.transaction(conn): - out = mirror.apply_state(conn, body.get("issues") or [], body.get("links") or [], body.get("neighbor_keys") or [], + out = mirror.apply_state(conn, issues, body.get("links") or [], body.get("neighbor_keys") or [], authoritative=queue.is_full_refresh(conn, body.get("command_id"))) if body.get("command_id"): queue.record_state(conn, body["command_id"], out["issues"], out["links"]) diff --git a/src/jira_hub/mcp_server.py b/src/jira_hub/mcp_server.py index e538b1a..2fdcbd8 100644 --- a/src/jira_hub/mcp_server.py +++ b/src/jira_hub/mcp_server.py @@ -80,8 +80,14 @@ def fmt_issue(d) -> str: note = d.get("note") or {} notes = [f" {k}: {note[k]}" for k in ("fact_status", "work_kind", "done_text", "rest_text", "owner_note") if note.get(k)] cmds = [f" #{c['id']} {c['type']} {c['status']} {c.get('finished_at') or ''} {c.get('error') or ''}".rstrip() for c in d.get("commands", [])] - return "\n".join(head + ["связи:"] + (links or [" —"]) + ["заметки:"] + (notes or [" —"]) + ["команды:"] + (cmds or [" —"]) - + ["описание:", d.get("description") or "—"]) + comments = d.get("comments") or [] + if comments: + shown = [f" {_v(c.get('author'))} {_v(c.get('created'))}: {_cut(c.get('body'), 300)}" for c in comments[:5]] + cmts = [f"комментарии ({len(comments)}):"] + shown + ([f" ещё {len(comments) - 5}"] if len(comments) > 5 else []) + else: + cmts = ["комментариев нет"] + return "\n".join(head + ["связи:"] + (links or [" —"]) + ["заметки:"] + (notes or [" —"]) + cmts + + ["команды:"] + (cmds or [" —"]) + ["описание:", d.get("description") or "—"]) def fmt_batch(b) -> str: diff --git a/tests/test_api.py b/tests/test_api.py index e42bacd..e11d7c0 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -72,7 +72,7 @@ def test_agent_flow(hub): a.post(f"/agent/commands/{tail['id']}/result", {"status": "done", "jira_calls": 1}) assert c.get("/batches/%d" % b["id"])["status"] == "done" issue = c.get("/issues/A-1") - assert issue["links_out"] == [{"target_key": "B-1", "link_type": "ASUPR_Связанные"}] + assert issue["links_out"] == [{"target_key": "B-1", "link_type": "ASUPR_Связанные", "summary": "Сосед", "status": None}] assert c.get("/issues/B-1")["neighbor"] is True runs = c.get("/sync-runs", limit=5)["runs"] assert runs[0]["issues_count"] == 2 and runs[0]["finished_at"] @@ -572,3 +572,29 @@ def test_tick_creates_auto_refresh_once(hub): assert [b["title"] for b in batches] == ["Автообновление"] and batches[0]["author"] == "hub" hub["hub"].tick() assert len(hub["client"].get("/batches", limit=5)["batches"]) == 1 + + +def test_agent_state_comments_validated_whole_batch(hub): + a, c = hub["agent"], hub["client"] + a.post("/agent/state", {"issues": [{"key": "X-1", "summary": "Икс", "labels": []}], "links": []}) + with pytest.raises(HubError) as e: + a.post("/agent/state", {"issues": [{"key": "X-1", "summary": "Икс новый", "labels": []}, + {"key": "Y-1", "summary": "Игрек", "labels": [], + "comments": [{"id": "7", "body": 42}]}], "links": []}) + assert e.value.status == 422 and e.value.error == "bad_state" + assert "Y-1" in e.value.detail and "body" in e.value.detail + assert c.get("/issues/X-1")["summary"] == "Икс" # пачка не применена целиком + with pytest.raises(HubError) as e: + c.get("/issues/Y-1") + assert e.value.status == 404 + + +def test_issue_detail_with_comments(hub): + hub["agent"].post("/agent/state", {"issues": [ + {"key": "A-1", "summary": "Схема", "labels": [], "epic_key": "E-1", "comments": [ + {"id": "1", "author": "Петров Пётр", "created": "2026-10-14T12:00:00.000+0300", "updated": None, "body": "старый"}, + {"id": "2", "author": None, "created": "2026-10-15T12:00:00.000+0300", "updated": None, "body": "новый"}]}, + {"key": "E-1", "summary": "Эпик", "labels": []}], "links": []}) + got = hub["client"].get("/issues/A-1") + assert [x["body"] for x in got["comments"]] == ["новый", "старый"] + assert got["epic_summary"] == "Эпик" diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 029be47..429929e 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -236,3 +236,25 @@ def test_agent_status_right_after_hub_start(live): live["agent"].post("/agent/claim", {"agent_version": 9, "launcher_version": 2}) text = mcp_server.agent_status() assert "запускатель: v2" in text and "неизвестно" not in text + + +def _detail(**over): + d = {"key": "A-1", "summary": "Т", "labels": [], "links_out": [], "links_in": [], "note": None, "commands": [], + "comments": [], "description": None} + d.update(over) + return d + + +def test_fmt_issue_comments(): + assert "комментариев нет" in mcp_server.fmt_issue(_detail()) + comments = [{"comment_id": str(n), "author": f"Автор {n}", "created": f"2026-10-1{n}T12:00:00.000+0300", + "updated": None, "body": ("д" * 500 if n == 7 else f"текст {n}\nстрока")} for n in range(7, 0, -1)] + text = mcp_server.fmt_issue(_detail(comments=comments)) + assert "комментарии (7):" in text + assert "д" * 299 + "…" in text and "д" * 300 not in text # самый новый — первым, обрезан до 300 + assert "Автор 3" in text and "Автор 2" not in text # 5 последних: 7…3 + assert "ещё 2" in text + assert text.index("заметки:") < text.index("комментарии (7):") < text.index("команды:") + no_author = mcp_server.fmt_issue(_detail(comments=[{"comment_id": "1", "author": None, "created": None, + "updated": None, "body": ""}])) + assert "комментарии (1):" in no_author and "ещё" not in no_author