Приём журнала изменений: POST /agent/history (#70)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
dc9419fbb9
commit
f0590ac9ea
@ -14,7 +14,7 @@ from pathlib import Path
|
|||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
from urllib.parse import parse_qs, unquote, urlparse
|
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 .commands import CommandError
|
||||||
from .paths import backups_dir as default_backups_dir
|
from .paths import backups_dir as default_backups_dir
|
||||||
from .paths import secure_dir
|
from .paths import secure_dir
|
||||||
@ -210,6 +210,15 @@ def h_agent_state(hub, conn, m, q, body):
|
|||||||
return 200, out
|
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):
|
def h_known_keys(hub, conn, m, q, body):
|
||||||
return 200, {"keys": mirror.known_keys(conn)}
|
return 200, {"keys": mirror.known_keys(conn)}
|
||||||
|
|
||||||
@ -449,6 +458,7 @@ ROUTES = [
|
|||||||
("GET", r"^/agent/commands/(\d+)$", h_agent_command, "agent"),
|
("GET", r"^/agent/commands/(\d+)$", h_agent_command, "agent"),
|
||||||
("POST", r"^/agent/commands/(\d+)/result$", h_agent_result, "agent"),
|
("POST", r"^/agent/commands/(\d+)/result$", h_agent_result, "agent"),
|
||||||
("POST", r"^/agent/state$", h_agent_state, "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/known-keys$", h_known_keys, "agent"),
|
||||||
("GET", r"^/agent/release$", h_agent_release, "agent"),
|
("GET", r"^/agent/release$", h_agent_release, "agent"),
|
||||||
("GET", r"^/agent/files/([^/]+)$", h_agent_file, "agent"),
|
("GET", r"^/agent/files/([^/]+)$", h_agent_file, "agent"),
|
||||||
|
|||||||
59
src/jira_hub/history.py
Normal file
59
src/jira_hub/history.py
Normal file
@ -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}
|
||||||
@ -941,3 +941,25 @@ def test_board_type_round_trip(hub): # сце
|
|||||||
with pytest.raises(HubError) as e:
|
with pytest.raises(HubError) as e:
|
||||||
c.post("/batches", {"title": "s", "commands": [{"type": "ensure_sprint", "params": {"key": "A-5", "sprint": "С2"}}]})
|
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
|
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()
|
||||||
|
|||||||
65
tests/test_history.py
Normal file
65
tests/test_history.py
Normal file
@ -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}])
|
||||||
Loading…
x
Reference in New Issue
Block a user