Лента изменений 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 threading
import time import time
import traceback import traceback
from datetime import timedelta
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from typing import NamedTuple 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") 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): def h_board(hub, conn, m, q, body):
return 200, mirror.board(conn, projects.base_jql(conn), _project_param(conn, q)) 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_issues, "client"),
("GET", r"^/issues/([^/]+)$", h_issue, "client"), ("GET", r"^/issues/([^/]+)$", h_issue, "client"),
("GET", r"^/board$", h_board, "client"), ("GET", r"^/board$", h_board, "client"),
("GET", r"^/changes$", h_changes, "client"),
("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"), ("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"),
("POST", r"^/batches$", h_batches_post, "client"), ("POST", r"^/batches$", h_batches_post, "client"),
("GET", r"^/batches$", h_batches_get, "client"), ("GET", r"^/batches$", h_batches_get, "client"),

View File

@ -88,3 +88,82 @@ def accepted(result) -> bool:
"""Итог журнала в результате refresh принят: history — словарь без непустого error (backfill_error и incomplete """Итог журнала в результате refresh принят: history — словарь без непустого error (backfill_error и incomplete
отметку не держат).""" отметку не держат)."""
return isinstance(result, dict) and isinstance(result.get("history"), dict) and not result["history"].get("error") 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 import json
from . import db from . import db, history
ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter", ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter",
"duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"] "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( d["comments"] = [dict(r) for r in conn.execute(
"SELECT comment_id, author, created, updated, body FROM issue_comment WHERE key = ? " "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,))] "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["note"] = get_note(conn, key)
d["commands"] = [dict(r) for r in conn.execute( 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", "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: finally:
conn.close() conn.close()
assert hub["client"].get("/sync-runs")["runs"][0]["history_at"] == "2026-10-22T09:02:00+00:00" 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\]"): with pytest.raises(ValueError, match=r"changes\[0\]"):
history.check_changes(["x"]) history.check_changes(["x"])
history.check_changes([ITEM, {**ITEM, "author": None, "field_id": None, "from": None, "to_string": None}]) 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") == []