From 63972572fd7fa6406ddbe32367578fdda6d6af4d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 20:39:10 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A5=D0=B0=D0=B1:=20=D1=80=D0=B0=D0=B7=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=BF=D0=BE=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D1=83=20=E2=80=94=20=D1=81=D0=B2=D0=BE=D0=B8,=20=D1=81?= =?UTF-8?q?=D0=BE=D1=81=D0=B5=D0=B4=D0=B8=20=D0=BF=D0=BE=20=D1=81=D0=B2?= =?UTF-8?q?=D1=8F=D0=B7=D1=8F=D0=BC=20=D0=B8=20=D1=8D=D0=BF=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=81=D0=B2=D0=BE=D0=B8=D1=85=20=D0=B2=20/board=20?= =?UTF-8?q?=D0=B8=20/issues=20(#61)?= 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 | 16 ++++++++-- src/jira_hub/mirror.py | 67 ++++++++++++++++++++++++++++++++++-------- tests/test_api.py | 21 +++++++++++++ tests/test_mirror.py | 56 +++++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 14 deletions(-) diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index deea3f7..16b4d9d 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -214,11 +214,23 @@ def h_known_keys(hub, conn, m, q, body): return 200, {"keys": mirror.known_keys(conn)} -ISSUE_FILTERS = ("sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q", "hide_closed") +ISSUE_FILTERS = ("sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q", "hide_closed", "project") + + +def _project_param(conn, q) -> str | None: + """q["project"] — метка существующего проекта; без параметра — None, неизвестная метка — 404.""" + label = q.get("project") + if label in (None, ""): + return None + if projects.get(conn, label) is None: + raise ApiError(404, "not_found", f"project {label}") + return label def h_issues(hub, conn, m, q, body): + project = _project_param(conn, q) filters = {k: q.get(k) for k in ISSUE_FILTERS} + filters["project"] = project closed = settings.get(conn, "closed_statuses") closed = [s for s in closed if isinstance(s, str)] if isinstance(closed, list) else [] return 200, {"issues": mirror.list_issues(conn, filters, _int_param(q, "limit", 50), _int_param(q, "offset", 0), @@ -230,7 +242,7 @@ def h_issue(hub, conn, m, q, body): def h_board(hub, conn, m, q, body): - return 200, mirror.board(conn, projects.base_jql(conn)) + return 200, mirror.board(conn, projects.base_jql(conn), _project_param(conn, q)) def h_app(hub, conn, m, q, body): diff --git a/src/jira_hub/mirror.py b/src/jira_hub/mirror.py index d616962..ae7503f 100644 --- a/src/jira_hub/mirror.py +++ b/src/jira_hub/mirror.py @@ -80,9 +80,31 @@ def _issue_dict(row) -> dict: return d +# Разрез по проекту: свои задачи (метка проекта — точное совпадение), их соседи по связям в обе стороны и эпики своих. +# Единственный параметр — метка, первым в списке аргументов запроса. +SCOPE_CTE = """WITH own(key) AS ( + SELECT i.key FROM issue i WHERE EXISTS (SELECT 1 FROM json_each(i.labels) WHERE value = ?)), +near(key) AS ( + SELECT target_key FROM issue_link WHERE source_key IN (SELECT key FROM own) + UNION SELECT source_key FROM issue_link WHERE target_key IN (SELECT key FROM own) + UNION SELECT epic_key FROM issue WHERE key IN (SELECT key FROM own) AND epic_key IS NOT NULL), +scope(key, nb) AS ( + SELECT key, 0 FROM own + UNION ALL SELECT n.key, 1 FROM near n JOIN issue i ON i.key = n.key WHERE n.key NOT IN (SELECT key FROM own)) +""" + + +def labels_of(conn, key: str) -> list[str] | None: + """Метки задачи зеркала; None — задачи в зеркале нет.""" + row = conn.execute("SELECT labels FROM issue WHERE key = ?", (key,)).fetchone() + return None if row is None else json.loads(row["labels"] or "[]") + + def list_issues(conn, filters=None, limit=50, offset=0, closed_statuses=()) -> list[dict]: - """hide_closed — без задач, чей status из closed_statuses (задача без статуса не закрыта).""" + """hide_closed — без задач, чей status из closed_statuses (задача без статуса не закрыта). + filters["project"] — разрез по проекту (SCOPE_CTE): neighbor — относительно проекта.""" filters = filters or {} + project = filters.get("project") or None where, args = [], [] for name, sql in _FILTERS.items(): if filters.get(name) not in (None, ""): @@ -92,7 +114,7 @@ def list_issues(conn, filters=None, limit=50, offset=0, closed_statuses=()) -> l where.append("EXISTS (SELECT 1 FROM json_each(i.labels) WHERE value = ?)") args.append(filters["label"]) if filters.get("neighbor") not in (None, ""): - where.append("i.neighbor = ?") + where.append("s.nb = ?" if project else "i.neighbor = ?") args.append(1 if filters["neighbor"] in (True, 1, "1", "true") else 0) if filters.get("hide_closed") in (True, 1, "1", "true") and closed_statuses: where.append(f"(i.status IS NULL OR i.status NOT IN ({', '.join('?' * len(closed_statuses))}))") @@ -100,10 +122,20 @@ def list_issues(conn, filters=None, limit=50, offset=0, closed_statuses=()) -> l if filters.get("q"): where.append("(i.summary LIKE ? OR i.key LIKE ?)") args += [f"%{filters['q']}%", f"%{filters['q']}%"] - sql = ("SELECT i.*, n.fact_status, n.work_kind FROM issue i LEFT JOIN issue_note n ON n.key = i.key" + if project: + sql = (SCOPE_CTE + "SELECT i.*, s.nb AS scope_nb, n.fact_status, n.work_kind FROM issue i " + "JOIN scope s ON s.key = i.key LEFT JOIN issue_note n ON n.key = i.key") + args = [project] + args + else: + sql = "SELECT i.*, n.fact_status, n.work_kind FROM issue i LEFT JOIN issue_note n ON n.key = i.key" + sql = (sql + (" WHERE " + " AND ".join(where) if where else "") + " ORDER BY i.duedate IS NULL, i.duedate, i.key LIMIT ? OFFSET ?") - return [_issue_dict(r) for r in conn.execute(sql, args + [int(limit), int(offset)])] + out = [_issue_dict(r) for r in conn.execute(sql, args + [int(limit), int(offset)])] + if project: # i.* уже несёт neighbor зеркала — в разрезе он относительно проекта + for d in out: + d["neighbor"] = bool(d.pop("scope_nb")) + return out def get_issue(conn, key) -> dict | None: @@ -133,20 +165,31 @@ BOARD_FIELDS = ["key", "summary", "issue_type", "status", "priority", "assignee" "planned_end", "epic_key", "labels", "neighbor"] -def board(conn, base_jql) -> dict: +def board(conn, base_jql, project: str | None = None) -> dict: """Снимок зеркала для доски связей (GET /board): все задачи с заметками fact_status/work_kind, все связи и последний успешный полный прогон (refresh по базовой JQL, команда done). Данные без преобразований — перевод в модель доски - делает интерфейс. Чтение в одной транзакции: задачи, связи и last_sync — из одного состояния базы.""" + делает интерфейс. С project — свои задачи проекта и соседи (neighbor относительно проекта), связи — те, у которых + source или target — своя задача; last_sync — как без проекта. Чтение в одной транзакции: задачи, связи и last_sync — из одного состояния базы.""" own = not conn.in_transaction if own: conn.execute("BEGIN") try: - issues = [_issue_dict(r) for r in conn.execute( - f"SELECT {', '.join('i.' + c for c in BOARD_FIELDS)}, n.fact_status, n.work_kind " - "FROM issue i LEFT JOIN issue_note n ON n.key = i.key ORDER BY i.key")] - links = [dict(r) for r in conn.execute( - "SELECT source_key AS source, target_key AS target, link_type FROM issue_link " - "ORDER BY source_key, target_key, link_type")] + if project: + cols = ", ".join("i." + c for c in BOARD_FIELDS if c != "neighbor") + issues = [_issue_dict(r) for r in conn.execute( + SCOPE_CTE + f"SELECT {cols}, s.nb AS neighbor, n.fact_status, n.work_kind FROM issue i " + "JOIN scope s ON s.key = i.key LEFT JOIN issue_note n ON n.key = i.key ORDER BY i.key", (project,))] + links = [dict(r) for r in conn.execute( + SCOPE_CTE + "SELECT source_key AS source, target_key AS target, link_type FROM issue_link " + "WHERE source_key IN (SELECT key FROM own) OR target_key IN (SELECT key FROM own) " + "ORDER BY source_key, target_key, link_type", (project,))] + else: + issues = [_issue_dict(r) for r in conn.execute( + f"SELECT {', '.join('i.' + c for c in BOARD_FIELDS)}, n.fact_status, n.work_kind " + "FROM issue i LEFT JOIN issue_note n ON n.key = i.key ORDER BY i.key")] + links = [dict(r) for r in conn.execute( + "SELECT source_key AS source, target_key AS target, link_type FROM issue_link " + "ORDER BY source_key, target_key, link_type")] run = conn.execute( "SELECT s.started_at, s.finished_at, s.issues_count FROM sync_run s JOIN command c ON c.id = s.command_id " "WHERE s.kind = 'jql' AND s.query = ? AND s.finished_at IS NOT NULL AND c.status = 'done' " diff --git a/tests/test_api.py b/tests/test_api.py index 6c39a60..2256189 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -830,3 +830,24 @@ def test_projects_need_client_token(hub): with pytest.raises(HubError) as e: hub["agent"].post("/projects", {"label": "STOCKS_DRRK", "name": "x"}) assert e.value.status == 401 + + +def test_board_and_issues_by_project(hub): + c, a = hub["client"], hub["agent"] + c.post("/projects", {"label": "AWPCA", "name": "АРМ КА"}) + c.post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"}) + a.post("/agent/state", {"issues": [ + {"key": "A-1", "summary": "Т1", "labels": ["STOCKS_DRRK"]}, + {"key": "A-2", "summary": "Т2", "labels": ["AWPCA"]}, + {"key": "A-3", "summary": "Т3", "labels": ["AWPCA"]}], + "links": [{"source_key": "A-1", "target_key": "A-2", "link_type": "L"}]}) + snap = c.get("/board?project=STOCKS_DRRK") + assert [(i["key"], i["neighbor"]) for i in snap["issues"]] == [("A-1", False), ("A-2", True)] + assert [(l["source"], l["target"]) for l in snap["links"]] == [("A-1", "A-2")] + assert snap["last_sync"] == c.get("/board")["last_sync"] + assert len(c.get("/board")["issues"]) == 3 + assert [i["key"] for i in c.get("/issues?project=STOCKS_DRRK&neighbor=1")["issues"]] == ["A-2"] + for path in ("/issues?project=NOPE", "/board?project=NOPE"): + with pytest.raises(HubError) as e: + c.get(path) + assert e.value.status == 404 and "project NOPE" in e.value.detail diff --git a/tests/test_mirror.py b/tests/test_mirror.py index c638107..b308c81 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -255,3 +255,59 @@ def test_list_issues_hide_closed(conn): assert keys({"hide_closed": "0"}) == ["A-1", "A-2", "A-3", "A-4"] assert keys({"hide_closed": "1"}, []) == ["A-1", "A-2", "A-3", "A-4"] # пустой набор — ничего не скрыто assert keys({"hide_closed": "1", "status": "Готово"}) == [] # отменяет скрытие приложение, не сервер + + +PROJ = "STOCKS_DRRK" + + +def _scene(conn): # сценарий data-model «Свои и соседи» + hub-api «Снимок проекта» + mirror.apply_state(conn, [ + issue("AURORA-1", labels=["STOCKS_DRRK"], epic_key="AURORA-9"), + issue("AURORA-2", labels=["AWPCA"], epic_key=None), + issue("AURORA-3", labels=["AWPCA"], epic_key=None), + issue("AURORA-4", labels=["STOCKS_DRRK", "kind-dev"], epic_key="AURORA-404"), # эпика нет в зеркале + issue("AURORA-5", labels=["AWPCA"], epic_key=None), + issue("AURORA-9", labels=["AWPCA"], issue_type="Фича", epic_key=None)], + [{"source_key": "AURORA-1", "target_key": "AURORA-2", "link_type": "L"}, + {"source_key": "AURORA-2", "target_key": "AURORA-3", "link_type": "L"}, + {"source_key": "AURORA-5", "target_key": "AURORA-4", "link_type": "L"}, # входящая — тоже сосед + {"source_key": "AURORA-4", "target_key": "AURORA-777", "link_type": "L"}], # конца нет в зеркале + neighbor_keys=["AURORA-1"]) # признак зеркала не важен в разрезе + + +def test_board_project_scope(conn): + _scene(conn) + snap = mirror.board(conn, BASE, project=PROJ) + assert [(i["key"], i["neighbor"]) for i in snap["issues"]] == [ + ("AURORA-1", False), ("AURORA-2", True), ("AURORA-4", False), ("AURORA-5", True), ("AURORA-9", True)] + assert {(l["source"], l["target"]) for l in snap["links"]} == { + ("AURORA-1", "AURORA-2"), ("AURORA-5", "AURORA-4"), ("AURORA-4", "AURORA-777")} + assert len(mirror.board(conn, BASE)["issues"]) == 6 # без проекта — всё зеркало + + +def test_board_project_last_sync_same(conn): + _scene(conn) + assert mirror.board(conn, BASE, project=PROJ)["last_sync"] == mirror.board(conn, BASE)["last_sync"] + + +def test_list_issues_project(conn): # сценарий «Список проекта» + _scene(conn) + keys = lambda **f: [i["key"] for i in mirror.list_issues(conn, {"project": PROJ, **f})] + assert sorted(keys()) == ["AURORA-1", "AURORA-2", "AURORA-4", "AURORA-5", "AURORA-9"] + assert sorted(keys(neighbor="1")) == ["AURORA-2", "AURORA-5", "AURORA-9"] + assert sorted(keys(neighbor="0")) == ["AURORA-1", "AURORA-4"] + assert keys(label="kind-dev") == ["AURORA-4"] + assert {i["key"]: i["neighbor"] for i in mirror.list_issues(conn, {"project": PROJ})}["AURORA-1"] is False + + +def test_list_issues_project_paging_and_filters(conn): + _scene(conn) + page = mirror.list_issues(conn, {"project": PROJ}, limit=2, offset=1) + assert [i["key"] for i in page] == ["AURORA-2", "AURORA-4"] + assert [i["key"] for i in mirror.list_issues(conn, {"project": PROJ, "q": "AURORA-5"})] == ["AURORA-5"] + + +def test_labels_of(conn): + _scene(conn) + assert mirror.labels_of(conn, "AURORA-4") == ["STOCKS_DRRK", "kind-dev"] + assert mirror.labels_of(conn, "AURORA-404") is None