From f0590ac9eab9011139f7dbc75dc96c48cd9ca269 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 10:08:29 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=80=D0=B8=D1=91=D0=BC=20=D0=B6=D1=83?= =?UTF-8?q?=D1=80=D0=BD=D0=B0=D0=BB=D0=B0=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9:=20POST=20/agent/history=20(#70)?= 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/api.py | 12 +++++++- src/jira_hub/history.py | 59 +++++++++++++++++++++++++++++++++++++ tests/test_api.py | 22 ++++++++++++++ tests/test_history.py | 65 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 src/jira_hub/history.py create mode 100644 tests/test_history.py diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 95362d0..fad7c4d 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -14,7 +14,7 @@ from pathlib import Path from typing import NamedTuple from urllib.parse import parse_qs, unquote, urlparse -from . import backup, db, files, mirror, projects, queue, relay, release, settings +from . import backup, db, files, history, mirror, projects, queue, relay, release, settings from .commands import CommandError from .paths import backups_dir as default_backups_dir from .paths import secure_dir @@ -210,6 +210,15 @@ def h_agent_state(hub, conn, m, q, body): return 200, out +def h_agent_history(hub, conn, m, q, body): + changes = body.get("changes") + try: + history.check_changes(changes) # до записи: неверная пачка не применяется целиком + except ValueError as e: + raise ApiError(422, "bad_history", str(e)) + return 200, history.record_changes(conn, changes) + + def h_known_keys(hub, conn, m, q, body): return 200, {"keys": mirror.known_keys(conn)} @@ -449,6 +458,7 @@ ROUTES = [ ("GET", r"^/agent/commands/(\d+)$", h_agent_command, "agent"), ("POST", r"^/agent/commands/(\d+)/result$", h_agent_result, "agent"), ("POST", r"^/agent/state$", h_agent_state, "agent"), + ("POST", r"^/agent/history$", h_agent_history, "agent"), ("GET", r"^/agent/known-keys$", h_known_keys, "agent"), ("GET", r"^/agent/release$", h_agent_release, "agent"), ("GET", r"^/agent/files/([^/]+)$", h_agent_file, "agent"), diff --git a/src/jira_hub/history.py b/src/jira_hub/history.py new file mode 100644 index 0000000..e66a1a2 --- /dev/null +++ b/src/jira_hub/history.py @@ -0,0 +1,59 @@ +"""Журнал изменений задач (issue_change): приём от агента, логическое имя поля. +Пишет таблицу только приём журнала (POST /agent/history); API и MCP её только читают (spec data-model).""" +from . import db, settings + +BUILTIN_FIELDS = ("status", "description", "summary", "assignee", "priority", "duedate", "labels", "resolution") +_REQUIRED = ("history_id", "key", "created", "field_name") +_OPTIONAL = ("author", "field_id", "from", "from_string", "to", "to_string") + + +def check_changes(changes) -> None: + """Форма пачки журнала до записи: нарушение — ValueError с номером элемента и полем (422 bad_history).""" + if not isinstance(changes, list): + raise ValueError("changes — список элементов журнала") + for n, c in enumerate(changes): + where = f"changes[{n}]" + if not isinstance(c, dict): + raise ValueError(f"{where} — объект элемента журнала") + for f in _REQUIRED: + if not isinstance(c.get(f), str) or not c[f]: + raise ValueError(f"{where}.{f} — непустая строка") + seq = c.get("seq") + if not isinstance(seq, int) or isinstance(seq, bool) or seq < 0: + raise ValueError(f"{where}.seq — целое >= 0") + for f in _OPTIONAL: + if c.get(f) is not None and not isinstance(c[f], str): + raise ValueError(f"{where}.{f} — строка или null") + + +def logical_field(item: dict, field_ids: dict | None, epic_link: dict | None) -> str: + """Логическое имя поля журнала: встроенные — по field_id, без него — по field_name; плановые даты и эпик — по id + из настроек; иначе — имя поля Jira как есть.""" + fid, name = item.get("field_id"), item.get("field_name") + if fid: + if fid in BUILTIN_FIELDS: + return fid + for logical in ("planned_start", "planned_end"): + if (field_ids or {}).get(logical) == fid: + return logical + if (epic_link or {}).get("field_id") == fid: + return "epic_link" + elif name in BUILTIN_FIELDS: + return name + return name + + +def record_changes(conn, changes: list[dict]) -> dict: + """Записать проверенную пачку (INSERT OR IGNORE по (history_id, seq)) в одной транзакции.""" + field_ids, epic_link = settings.get(conn, "field_ids"), settings.get(conn, "epic_link") + now, accepted = db.now(), 0 + with db.transaction(conn): + for c in changes: + cur = conn.execute( + "INSERT OR IGNORE INTO issue_change(history_id, seq, key, at, created, author, field, field_id, field_name, " + "from_value, from_string, to_value, to_string, fetched_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (c["history_id"], c["seq"], c["key"], db.jira_utc(c["created"]), c["created"], c.get("author"), + logical_field(c, field_ids, epic_link), c.get("field_id"), c["field_name"], c.get("from"), + c.get("from_string"), c.get("to"), c.get("to_string"), now)) + accepted += cur.rowcount + return {"accepted": accepted, "duplicates": len(changes) - accepted} diff --git a/tests/test_api.py b/tests/test_api.py index 07ee54b..89f8d76 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -941,3 +941,25 @@ def test_board_type_round_trip(hub): # сце with pytest.raises(HubError) as e: c.post("/batches", {"title": "s", "commands": [{"type": "ensure_sprint", "params": {"key": "A-5", "sprint": "С2"}}]}) assert e.value.status == 422 and "канбан-доска" in e.value.detail + + +HISTORY_ITEM = {"history_id": "5001", "seq": 0, "key": "X", "created": "2026-10-22T09:00:00.000+0300", "author": "Петров", + "field_id": "status", "field_name": "status", "from": "3", "from_string": "В работе", "to": "10001", + "to_string": "Готово"} + + +def test_agent_history_route(hub): + assert hub["agent"].post("/agent/history", {"command_id": None, "changes": [HISTORY_ITEM]}) == {"accepted": 1, "duplicates": 0} + items = [HISTORY_ITEM, {**HISTORY_ITEM, "seq": 1}, {**HISTORY_ITEM, "seq": 2}] + assert hub["agent"].post("/agent/history", {"changes": items}) == {"accepted": 2, "duplicates": 1} + with pytest.raises(HubError) as e: + hub["client"].post("/agent/history", {"changes": items}) + assert e.value.status == 401 + with pytest.raises(HubError) as e: + hub["agent"].post("/agent/history", {"changes": [{**HISTORY_ITEM, "seq": 7}, {**HISTORY_ITEM, "seq": "0"}]}) + assert e.value.status == 422 and e.value.error == "bad_history" and "changes[1].seq" in e.value.detail + conn = hub["hub"].conn() + try: + assert conn.execute("SELECT COUNT(*) AS n FROM issue_change").fetchone()["n"] == 3 + finally: + conn.close() diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 0000000..955c6b3 --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,65 @@ +import pytest + +from jira_hub import history + +ITEM = {"history_id": "5001", "seq": 0, "key": "X", "created": "2026-10-22T09:00:00.000+0300", "author": "Петров Пётр", + "field_id": "status", "field_name": "status", "from": "3", "from_string": "В работе", "to": "10001", + "to_string": "Готово"} + + +@pytest.fixture +def conn(open_db): + return open_db() + + +def test_record_dedup(conn): + assert history.record_changes(conn, [ITEM]) == {"accepted": 1, "duplicates": 0} + assert history.record_changes(conn, [ITEM, {**ITEM, "seq": 1, "field_id": None, "field_name": "resolution"}]) == \ + {"accepted": 1, "duplicates": 1} + row = conn.execute("SELECT * FROM issue_change WHERE seq = 0").fetchone() + assert row["at"] == "2026-10-22T06:00:00+00:00" and row["created"] == ITEM["created"] + assert row["field"] == "status" and row["from_value"] == "3" and row["to_string"] == "Готово" + assert conn.execute("SELECT field FROM issue_change WHERE seq = 1").fetchone()["field"] == "resolution" + + +def test_record_keeps_first_version(conn): + history.record_changes(conn, [ITEM]) + history.record_changes(conn, [{**ITEM, "to_string": "Снято"}]) + assert conn.execute("SELECT to_string FROM issue_change").fetchone()["to_string"] == "Готово" + + +def test_record_uses_field_settings(conn): + from jira_hub import settings + settings.set_(conn, "field_ids", {"planned_end": "customfield_10201"}) + history.record_changes(conn, [{**ITEM, "field_id": "customfield_10201", "field_name": "Плановое окончание"}]) + assert conn.execute("SELECT field FROM issue_change").fetchone()["field"] == "planned_end" + + +@pytest.mark.parametrize("item, field_ids, epic, want", [ + ({"field_id": "customfield_10201", "field_name": "Плановое окончание"}, {"planned_end": "customfield_10201"}, {}, "planned_end"), + ({"field_id": "customfield_10200", "field_name": "Плановое начало"}, {"planned_start": "customfield_10200"}, {}, "planned_start"), + ({"field_id": None, "field_name": "duedate"}, {}, {}, "duedate"), + ({"field_id": "status", "field_name": "Статус"}, {}, {}, "status"), + ({"field_id": None, "field_name": "Fix Version"}, {}, {}, "Fix Version"), + ({"field_id": "customfield_1", "field_name": "Epic Link"}, {}, {"field_id": "customfield_1"}, "epic_link"), + ({"field_id": "customfield_9", "field_name": "Sprint"}, None, None, "Sprint"), +]) +def test_logical_field(item, field_ids, epic, want): + assert history.logical_field(item, field_ids, epic) == want + + +@pytest.mark.parametrize("bad, field", [({"seq": "0"}, "seq"), ({"seq": -1}, "seq"), ({"seq": True}, "seq"), + ({"key": ""}, "key"), ({"history_id": 5001}, "history_id"), + ({"created": None}, "created"), ({"field_name": ""}, "field_name"), + ({"to_string": 5}, "to_string"), ({"author": []}, "author")]) +def test_check_changes_rejects(bad, field): + with pytest.raises(ValueError, match=rf"changes\[1\]\.{field}"): + history.check_changes([ITEM, {**ITEM, **bad}]) + + +def test_check_changes_shape(): + with pytest.raises(ValueError, match="changes — список"): + history.check_changes({"a": 1}) + with pytest.raises(ValueError, match=r"changes\[0\]"): + history.check_changes(["x"]) + history.check_changes([ITEM, {**ITEM, "author": None, "field_id": None, "from": None, "to_string": None}])