From 337e36f1a0b46d8416369528f9144a9aa448ad94 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 10:15:23 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9B=D0=B5=D0=BD=D1=82=D0=B0=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B9=20GET=20/changes=20?= =?UTF-8?q?=D0=B8=20=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8=D1=8F=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=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 | 31 +++++++++++ src/jira_hub/history.py | 79 +++++++++++++++++++++++++++++ src/jira_hub/mirror.py | 3 +- tests/test_api.py | 31 +++++++++++ tests/test_history.py | 110 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 253 insertions(+), 1 deletion(-) diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index fad7c4d..722cc02 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -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"), diff --git a/src/jira_hub/history.py b/src/jira_hub/history.py index 8dab465..0637b57 100644 --- a/src/jira_hub/history.py +++ b/src/jira_hub/history.py @@ -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] diff --git a/src/jira_hub/mirror.py b/src/jira_hub/mirror.py index eea22c8..0fb3184 100644 --- a/src/jira_hub/mirror.py +++ b/src/jira_hub/mirror.py @@ -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", diff --git a/tests/test_api.py b/tests/test_api.py index 66d0676..dd7564f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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 diff --git a/tests/test_history.py b/tests/test_history.py index 955c6b3..770dd32 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -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") == []