Лента изменений GET /changes и история задачи (#70)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 10:15:23 +04:00
parent e3649a1a38
commit 337e36f1a0
5 changed files with 253 additions and 1 deletions

View File

@ -9,6 +9,7 @@ import re
import threading
import time
import traceback
from datetime import timedelta
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import NamedTuple
@ -250,6 +251,35 @@ def h_issue(hub, conn, m, q, body):
return 200, _not_found(mirror.get_issue(conn, m.group(1)), "issue")
def _time_param(q, name, default):
raw = q.get(name)
if raw in (None, ""):
return default
value = db.jira_utc(raw)
if value is None:
raise ApiError(422, "bad_request", f"{name}: ожидается время ISO-8601 со смещением или Z")
return value
def h_changes(hub, conn, m, q, body):
project = _project_param(conn, q)
until = _time_param(q, "until", db.now())
since = _time_param(q, "since", (db.parse(until) - timedelta(hours=24)).isoformat())
if since > until:
raise ApiError(422, "bad_request", "since: позже until")
try:
kinds = history.parse_kinds(q.get("kinds"))
except ValueError as e:
raise ApiError(422, "bad_request", str(e))
try:
limit = int(q.get("limit", 500))
except (TypeError, ValueError):
limit = 0
if not 1 <= limit <= 2000:
raise ApiError(422, "bad_request", "limit: целое от 1 до 2000")
return 200, history.feed(conn, since, until, kinds, project=project, key=q.get("key") or None, limit=limit)
def h_board(hub, conn, m, q, body):
return 200, mirror.board(conn, projects.base_jql(conn), _project_param(conn, q))
@ -465,6 +495,7 @@ ROUTES = [
("GET", r"^/issues$", h_issues, "client"),
("GET", r"^/issues/([^/]+)$", h_issue, "client"),
("GET", r"^/board$", h_board, "client"),
("GET", r"^/changes$", h_changes, "client"),
("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"),
("POST", r"^/batches$", h_batches_post, "client"),
("GET", r"^/batches$", h_batches_get, "client"),

View File

@ -88,3 +88,82 @@ 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")
# --- лента и история задачи (spec hub-api «Лента изменений», «История задачи в карточке») -------------------------
KINDS = ("status", "description", "dates", "comment", "other")
DEFAULT_KINDS = ("status", "description", "dates", "comment")
DATE_FIELDS = ("duedate", "planned_start", "planned_end")
def parse_kinds(value: str | None) -> tuple[str, ...]:
if not value:
return DEFAULT_KINDS
if value.strip() == "all":
return KINDS
kinds = tuple(k.strip() for k in value.split(",") if k.strip())
unknown = [k for k in kinds if k not in KINDS]
if unknown or not kinds:
raise ValueError(f"kinds: неизвестный вид {', '.join(unknown) or value!r}; допустимо {', '.join(KINDS)} или all")
return kinds
def kind_of(field: str) -> str:
if field in ("status", "description"):
return field
return "dates" if field in DATE_FIELDS else "other"
def _event(row) -> dict:
"""Элемент ленты из строки issue_change: from/to — строка журнала, без неё — значение."""
fs, ts = row["from_string"], row["to_string"]
return {"at": row["at"], "key": row["key"], "kind": kind_of(row["field"]), "field": row["field"],
"author": row["author"], "from": fs if fs is not None else row["from_value"],
"to": ts if ts is not None else row["to_value"], "from_value": row["from_value"], "to_value": row["to_value"]}
_OWN = "EXISTS (SELECT 1 FROM json_each(i.labels) WHERE value = ?)"
def feed(conn, since: str, until: str, kinds: tuple, project: str | None = None, key: str | None = None,
limit: int = 500) -> dict:
"""События за [since, until] (UTC в формате db.now()): журнал и новые комментарии. project — только свои задачи
проекта; key — одна задача без ограничения проектом. Больше limit — первые limit по порядку и truncated."""
where, args = [], []
if key:
where.append("x.key = ?")
args.append(key)
elif project:
where.append(_OWN)
args.append(project)
extra = "".join(" AND " + w for w in where)
events = [] # (сортировочный ключ, элемент)
if any(k != "comment" for k in kinds):
for r in conn.execute("SELECT x.*, i.summary AS i_summary, i.status AS i_status FROM issue_change x "
"LEFT JOIN issue i ON i.key = x.key WHERE x.at >= ? AND x.at <= ?" + extra,
[since, until] + args):
e = _event(r)
if e["kind"] in kinds:
events.append(((r["at"], r["key"], r["history_id"], r["seq"]), {**e, "summary": r["i_summary"],
"status": r["i_status"]}))
if "comment" in kinds:
for r in conn.execute("SELECT x.*, i.summary AS i_summary, i.status AS i_status FROM issue_comment x "
"LEFT JOIN issue i ON i.key = x.key WHERE x.created_at >= ? AND x.created_at <= ?" + extra,
[since, until] + args):
events.append(((r["created_at"], r["key"], r["comment_id"], 0), {
"at": r["created_at"], "key": r["key"], "summary": r["i_summary"], "status": r["i_status"],
"kind": "comment", "field": "comment", "author": r["author"], "from": None, "to": r["body"],
"from_value": None, "to_value": None}))
# at — по убыванию, при равном времени — key, history_id, seq по возрастанию
events.sort(key=lambda p: p[0][1:])
events.sort(key=lambda p: p[0][0], reverse=True)
order = ("at", "key", "summary", "status", "kind", "field", "author", "from", "to", "from_value", "to_value")
return {"since": since, "until": until, "truncated": len(events) > limit,
"changes": [{k: e[k] for k in order} for _, e in events[:limit]]}
def issue_changes(conn, key: str) -> list[dict]:
"""Весь журнал задачи, новые сверху; строки без времени (at = NULL) — в конце."""
rows = conn.execute("SELECT * FROM issue_change WHERE key = ? ORDER BY at IS NULL, at DESC, history_id, seq", (key,))
return [_event(r) for r in rows]

View File

@ -2,7 +2,7 @@
Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает."""
import json
from . import db
from . import db, history
ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter",
"duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"]
@ -154,6 +154,7 @@ def get_issue(conn, key) -> dict | None:
d["comments"] = [dict(r) for r in conn.execute(
"SELECT comment_id, author, created, updated, body FROM issue_comment WHERE key = ? "
"ORDER BY created DESC, CAST(comment_id AS INTEGER) DESC, comment_id DESC", (key,))]
d["changes"] = history.issue_changes(conn, key)
d["note"] = get_note(conn, key)
d["commands"] = [dict(r) for r in conn.execute(
"SELECT id, batch_id, type, status, finished_at, error FROM command WHERE params LIKE ? ORDER BY id DESC LIMIT 10",

View File

@ -973,3 +973,34 @@ def test_sync_runs_history_at(hub):
finally:
conn.close()
assert hub["client"].get("/sync-runs")["runs"][0]["history_at"] == "2026-10-22T09:02:00+00:00"
def test_changes_route(hub, add_project):
conn = hub["hub"].conn()
try:
add_project(conn)
finally:
conn.close()
hub["agent"].post("/agent/history", {"changes": [{**HISTORY_ITEM, "created": "2026-10-22T11:00:00Z"}]})
got = hub["client"].get("/changes", since="2026-10-22T10:00:00Z", until="2026-10-22T13:00:00+03:00", kinds="status")
assert got["since"] == "2026-10-22T10:00:00+00:00" and got["until"] == "2026-10-22T10:00:00+00:00"
got = hub["client"].get("/changes", since="2026-10-22T10:00:00Z", until="2026-10-22T12:00:00Z")
assert [e["key"] for e in got["changes"]] == ["X"] and got["truncated"] is False
assert hub["client"].get("/changes", since="2026-10-22T10:00:00Z", until="2026-10-22T12:00:00Z",
project="AWPCA")["changes"] == [] # X без метки проекта
default = hub["client"].get("/changes")
assert db.parse(default["until"]) - db.parse(default["since"]) == timedelta(hours=24)
@pytest.mark.parametrize("params, status, name", [
({"since": "вчера"}, 422, "since"), ({"until": "x"}, 422, "until"),
({"since": "2026-10-22T12:00:00Z", "until": "2026-10-22T10:00:00Z"}, 422, "since"),
({"kinds": "foo"}, 422, "kinds"), ({"limit": "0"}, 422, "limit"), ({"limit": "2001"}, 422, "limit"),
({"limit": "x"}, 422, "limit"), ({"project": "NOPE"}, 404, None),
])
def test_changes_route_bad_params(hub, params, status, name):
with pytest.raises(HubError) as e:
hub["client"].get("/changes", **params)
assert e.value.status == status
if name:
assert e.value.error == "bad_request" and name in e.value.detail

View File

@ -63,3 +63,113 @@ def test_check_changes_shape():
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}])
# --- лента и история задачи (Task 4) ----------------------------------------------------------------------------
def _issue(conn, key, labels, summary="Тема", status="В работе"):
import json
conn.execute("INSERT INTO issue(key, summary, status, labels, fetched_at) VALUES (?, ?, ?, ?, '2026-10-22T00:00:00+00:00')",
(key, summary, status, json.dumps(labels)))
def _change(conn, key, hid, created, field_name, frm=None, to=None, seq=0, field_id=None, from_value=None, to_value=None):
history.record_changes(conn, [{"history_id": hid, "seq": seq, "key": key, "created": created, "author": "Петров",
"field_id": field_id, "field_name": field_name, "from": from_value,
"from_string": frm, "to": to_value, "to_string": to}])
def test_feed_closed_in_two_hours(conn): # сценарий «Что закрылось за два часа»
_issue(conn, "X", ["AWPCA"], status="Готово")
_issue(conn, "Y", ["STOCKS_DRRK"])
_change(conn, "X", "1", "2026-10-22T14:15:00.000+0300", "status", "В работе", "Готово")
_change(conn, "Y", "2", "2026-10-22T14:20:00.000+0300", "status", "Открыта", "В работе")
got = history.feed(conn, "2026-10-22T10:00:00+00:00", "2026-10-22T12:00:00+00:00", ("status",), project="AWPCA")
assert got["truncated"] is False
assert got["changes"] == [{"at": "2026-10-22T11:15:00+00:00", "key": "X", "summary": "Тема", "status": "Готово",
"kind": "status", "field": "status", "author": "Петров", "from": "В работе", "to": "Готово",
"from_value": None, "to_value": None}]
def test_feed_comment(conn): # «Комментарий в ленте»
from jira_hub import mirror
mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"], "comments": [
{"id": "7", "author": "Иванов", "body": "Готово к проверке", "created": "2026-10-22T13:30:00.000+0300"}]}], [])
got = history.feed(conn, "2026-10-22T10:00:00+00:00", "2026-10-22T12:00:00+00:00", history.DEFAULT_KINDS)
assert got["changes"] == [{"at": "2026-10-22T10:30:00+00:00", "key": "X", "summary": None, "status": None,
"kind": "comment", "field": "comment", "author": "Иванов", "from": None,
"to": "Готово к проверке", "from_value": None, "to_value": None}]
def test_feed_other_only_on_request(conn): # «Прочие поля по запросу»
_change(conn, "X", "1", "2026-10-22T11:00:00Z", "assignee", "a", "b", field_id="assignee")
window = ("2026-10-22T10:00:00+00:00", "2026-10-22T12:00:00+00:00")
assert history.feed(conn, *window, history.DEFAULT_KINDS)["changes"] == []
got = history.feed(conn, *window, history.parse_kinds("all"))["changes"]
assert [(e["kind"], e["field"]) for e in got] == [("other", "assignee")]
def test_feed_neighbor_not_in_project(conn): # «Сосед проекта не в ленте»
_issue(conn, "A", ["AWPCA"])
_issue(conn, "Z", ["OTHER"])
conn.execute("INSERT INTO issue_link(source_key, target_key, link_type) VALUES ('Z', 'A', 'блок')")
_change(conn, "Z", "1", "2026-10-22T11:00:00Z", "status", "a", "b")
window = ("2026-10-22T10:00:00+00:00", "2026-10-22T12:00:00+00:00")
assert history.feed(conn, *window, ("status",), project="AWPCA")["changes"] == []
assert len(history.feed(conn, *window, ("status",), key="Z")["changes"]) == 1
def test_feed_issue_not_in_mirror(conn): # Review Focus 4
_change(conn, "Q", "1", "2026-10-22T11:00:00Z", "status", "a", "b")
got = history.feed(conn, "2026-10-22T10:00:00+00:00", "2026-10-22T12:00:00+00:00", ("status",), key="Q")["changes"]
assert got[0]["key"] == "Q" and got[0]["summary"] is None and got[0]["status"] is None
assert history.feed(conn, "2026-10-22T10:00:00+00:00", "2026-10-22T12:00:00+00:00", ("status",))["changes"]
def test_feed_truncated_and_order(conn):
for n, t in enumerate(("10:10", "10:30", "10:20")):
_change(conn, "X", str(n), f"2026-10-22T{t}:00Z", "status", "a", "b")
got = history.feed(conn, "2026-10-22T10:00:00+00:00", "2026-10-22T12:00:00+00:00", ("status",), limit=2)
assert got["truncated"] is True
assert [e["at"][11:16] for e in got["changes"]] == ["10:30", "10:20"]
def test_feed_window_bounds(conn):
_change(conn, "X", "1", "2026-10-22T10:00:00Z", "status", "a", "b")
_change(conn, "X", "2", "2026-10-22T12:00:01Z", "status", "b", "c")
got = history.feed(conn, "2026-10-22T10:00:00+00:00", "2026-10-22T12:00:00+00:00", ("status",))["changes"]
assert [e["to"] for e in got] == ["b"]
def test_feed_dates_kind_value(conn):
_change(conn, "X", "1", "2026-10-22T11:00:00Z", "duedate", "25/окт/26", "30/окт/26", field_id="duedate",
from_value="2026-10-25", to_value="2026-10-30")
_change(conn, "X", "2", "2026-10-22T11:05:00Z", "description", None, None, field_id="description",
from_value="старое", to_value="новое")
got = history.feed(conn, "2026-10-22T10:00:00+00:00", "2026-10-22T12:00:00+00:00", ("dates", "description"))["changes"]
assert [(e["kind"], e["from"], e["from_value"]) for e in got] == [
("description", "старое", "старое"), ("dates", "25/окт/26", "2026-10-25")]
@pytest.mark.parametrize("value, want", [(None, history.DEFAULT_KINDS), ("", history.DEFAULT_KINDS),
("all", history.KINDS), ("status, comment", ("status", "comment"))])
def test_parse_kinds(value, want):
assert history.parse_kinds(value) == want
def test_parse_kinds_unknown():
with pytest.raises(ValueError, match="kinds"):
history.parse_kinds("status,foo")
def test_issue_changes(conn): # «Задача с историей»
from jira_hub import mirror
_issue(conn, "X", ["AWPCA"])
_change(conn, "X", "1", "2026-10-21T11:00:00Z", "status", "a", "b")
_change(conn, "X", "2", "2026-10-22T11:00:00Z", "description", "c", "d", field_id="description")
_change(conn, "X", "3", "мусор", "assignee", "e", "f", field_id="assignee")
got = mirror.get_issue(conn, "X")["changes"]
assert [(e["kind"], e["at"]) for e in got] == [("description", "2026-10-22T11:00:00+00:00"),
("status", "2026-10-21T11:00:00+00:00"), ("other", None)]
assert "summary" not in got[0] and "status" not in got[0]
assert mirror.get_issue(conn, "X")["changes"] and history.issue_changes(conn, "NOPE") == []