Отметка журнала: history в refresh, sync_run.history_at (#70)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 10:12:16 +04:00
parent f0590ac9ea
commit e3649a1a38
4 changed files with 144 additions and 10 deletions

View File

@ -1,6 +1,10 @@
"""Журнал изменений задач (issue_change): приём от агента, логическое имя поля.
Пишет таблицу только приём журнала (POST /agent/history); API и MCP её только читают (spec data-model)."""
from . import db, settings
"""Журнал изменений задач (issue_change): приём от агента, логическое имя поля, отметка «с какого момента» для
refresh. Пишет таблицу только приём журнала (POST /agent/history); API и MCP её только читают (spec data-model)."""
from datetime import timedelta
from . import db, projects, settings
SINCE_MARGIN = timedelta(minutes=15) # расхождение часов Mac mini и Jira и длительность прогона (design #5)
BUILTIN_FIELDS = ("status", "description", "summary", "assignee", "priority", "duedate", "labels", "resolution")
_REQUIRED = ("history_id", "key", "created", "field_name")
@ -46,7 +50,7 @@ def logical_field(item: dict, field_ids: dict | None, epic_link: dict | None) ->
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
now, new = db.now(), 0
with db.transaction(conn):
for c in changes:
cur = conn.execute(
@ -55,5 +59,32 @@ def record_changes(conn, changes: list[dict]) -> dict:
(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}
new += cur.rowcount
return {"accepted": new, "duplicates": len(changes) - new}
def backfill_jql(base_jql: str) -> str:
"""JQL стартовой загрузки: незакрытые задачи открытых спринтов (design #6)."""
return f"({base_jql}) AND sprint in openSprints() AND statusCategory != Done"
def delivery_param(conn, params: dict) -> dict | None:
"""Параметр history выдаваемого refresh (spec commands «Журнал изменений в refresh»); None — не дописывать."""
base = projects.base_jql(conn)
if base is None:
return None
mark = conn.execute("SELECT started_at, query FROM sync_run WHERE history_at IS NOT NULL "
"ORDER BY started_at DESC, id DESC LIMIT 1").fetchone()
out = {}
if mark is not None:
since = db.parse(mark["started_at"]) - SINCE_MARGIN
out["since"] = since.strftime("%Y-%m-%dT%H:%M:%SZ")
if params.get("jql") == base and (mark is None or mark["query"] != base):
out["jql"] = backfill_jql(base)
return out or None
def accepted(result) -> bool:
"""Итог журнала в результате refresh принят: history — словарь без непустого error (backfill_error и incomplete
отметку не держат)."""
return isinstance(result, dict) and isinstance(result.get("history"), dict) and not result["history"].get("error")

View File

@ -7,7 +7,7 @@ from datetime import timedelta
from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim)
from . import commands as C
from . import db, files, mirror, projects, settings
from . import db, files, history, mirror, projects, settings
log = logging.getLogger(__name__)
@ -264,10 +264,14 @@ def _redeliver(conn, now) -> dict | None:
def deliver_params(conn, cmd, files_dir) -> dict:
"""Параметры команды для агента: ensure_attachment получает size — текущий размер файла в хранилище или None,
если файла уже нет (решение 5); refresh — boards, доски проектов (design #7). В базе params остаются без них:
повтор команды проходит проверку как новая."""
если файла уже нет (решение 5); refresh — boards, доски проектов (design #7), и history — отметка журнала
(change-history). В базе params остаются без них: повтор команды проходит проверку как новая."""
if cmd["type"] == "refresh":
return {**cmd["params"], "boards": projects.boards(conn)}
out = {**cmd["params"], "boards": projects.boards(conn)}
hist = history.delivery_param(conn, cmd["params"]) # отметка журнала (change-history, design #5)
if hist is not None:
out["history"] = hist
return out
if cmd["type"] != "ensure_attachment":
return cmd["params"]
size = files.size(files_dir, cmd["params"].get("file")) if files_dir is not None else None
@ -370,6 +374,8 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls=
(now, int(jira_calls or 0), int(rate_limited or 0), command_id))
if isinstance(result, dict) and "boards" in result:
projects.record_board_types(conn, result["boards"]) # в любом статусе; без ключа (агент v11) типы не трогаем
if status == "done" and is_full_refresh(conn, command_id) and history.accepted(result):
conn.execute("UPDATE sync_run SET history_at = ? WHERE command_id = ?", (now, command_id))
if status == "done" and is_full_refresh(conn, command_id):
# полный прогон не обновил задачу — в базовую JQL она больше не входит: сосед. Пустой прогон (0 задач) —
# почти всегда сбой выборки, а не «все задачи ушли»: соседей не трогаем (M-12)

View File

@ -963,3 +963,13 @@ def test_agent_history_route(hub):
assert conn.execute("SELECT COUNT(*) AS n FROM issue_change").fetchone()["n"] == 3
finally:
conn.close()
def test_sync_runs_history_at(hub):
conn = hub["hub"].conn()
try:
conn.execute("INSERT INTO sync_run(started_at, kind, history_at) VALUES ('2026-10-22T09:00:00+00:00', 'jql', "
"'2026-10-22T09:02:00+00:00')")
finally:
conn.close()
assert hub["client"].get("/sync-runs")["runs"][0]["history_at"] == "2026-10-22T09:02:00+00:00"

View File

@ -941,3 +941,90 @@ def test_refresh_failed_result_records_board_types(conn, add_project): # R-3
c = queue.claim(conn)
queue.report_result(conn, c["id"], "failed", result={"boards": {"5120": "kanban"}}, error="e")
assert projects.get(conn, "STOCKS_DRRK")["board_type"] == "kanban"
# --- журнал изменений: history в выдаче refresh, sync_run.history_at (#70, Task 3) ----------------------------------
def _full_run(conn, result, started_at=None, status="done"):
"""Полный refresh: поставить, выдать, отчитаться; started_at — подменить время начала прогона."""
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}])
c = queue.claim(conn)
if started_at:
conn.execute("UPDATE sync_run SET started_at = ? WHERE command_id = ?", (started_at, c["id"]))
queue.report_result(conn, c["id"], status, result=result)
return c
def test_first_full_refresh_gets_backfill(conn, add_project): # сценарий «Первый refresh после выкатки»
add_project(conn)
b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}])
got = queue.claim(conn)
assert got["params"]["history"] == {
"jql": '(project = AURORA AND labels in ("AWPCA")) AND sprint in openSprints() AND statusCategory != Done'}
assert "history" not in queue.batch_view(conn, b["id"])["commands"][0]["params"]
def test_regular_refresh_gets_since(conn, add_project): # «Регулярный refresh» и «Журнал не дочитан»
add_project(conn)
_full_run(conn, {"history": {"issues": 1, "changes": 2, "incomplete": []}}, "2026-10-22T09:00:00+00:00")
_full_run(conn, {"history": {"error": "HTTP 500"}}, "2026-10-22T10:00:00+00:00")
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}])
assert queue.claim(conn)["params"]["history"] == {"since": "2026-10-22T08:45:00Z"}
def test_new_project_gets_since_and_backfill(conn, add_project): # «Новый проект»
add_project(conn)
_full_run(conn, {"history": {"issues": 0, "changes": 0, "incomplete": []}}, "2026-10-22T09:00:00+00:00")
add_project(conn, "STOCKS_DRRK", board_id=5120)
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}])
h = queue.claim(conn)["params"]["history"]
assert h["since"] == "2026-10-22T08:45:00Z"
assert h["jql"] == "(" + projects.base_jql(conn) + ") AND sprint in openSprints() AND statusCategory != Done"
assert "STOCKS_DRRK" in h["jql"]
def test_tail_refresh_since_and_no_mark(conn, add_project): # «Хвост пакета»
add_project(conn)
_full_run(conn, {"history": {"issues": 0, "changes": 0, "incomplete": []}}, "2026-10-22T09:00:00+00:00")
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
c = queue.claim(conn)
assert c["params"]["history"] == {"since": "2026-10-22T08:45:00Z"}
queue.report_result(conn, c["id"], "done", result={"history": {"issues": 0, "changes": 0, "incomplete": []}})
assert conn.execute("SELECT history_at FROM sync_run WHERE command_id = ?", (c["id"],)).fetchone()["history_at"] is None
def test_tail_refresh_without_mark_gets_nothing(conn, add_project): # «Хвост пакета до первой отметки»
add_project(conn)
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
assert "history" not in queue.claim(conn)["params"]
def test_no_projects_no_history(conn):
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
assert "history" not in queue.claim(conn)["params"]
def test_redelivery_recomputes_history(conn, add_project): # Review Focus 3
add_project(conn)
b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}])
first = queue.claim(conn)
again = queue.claim(conn) # отчёта нет — довыдача
assert again["id"] == first["id"] and again["params"]["history"] == first["params"]["history"]
assert "history" not in queue.batch_view(conn, b["id"])["commands"][0]["params"]
@pytest.mark.parametrize("result, status, marked", [
({"history": {"issues": 3, "changes": 7, "incomplete": []}}, "done", True),
({"history": {"issues": 1, "changes": 2, "incomplete": ["X"], "backfill_error": "HTTP 400"}}, "done", True),
({"history": {"issues": 0, "changes": 0, "error": ""}}, "done", True),
({"history": {"error": "HTTP 500"}}, "done", False),
({"issues": 3}, "done", False), # агент v12
({"history": "мусор"}, "done", False),
(None, "done", False),
({"history": {"issues": 3, "changes": 7, "incomplete": []}}, "failed", False),
])
def test_report_sets_history_at(conn, add_project, result, status, marked):
add_project(conn)
c = _full_run(conn, result, status=status)
run = conn.execute("SELECT history_at FROM sync_run WHERE command_id = ?", (c["id"],)).fetchone()
assert (run["history_at"] is not None) == marked