From 25973ff97192969d4cfea0878706a0c26cf7ce2b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 13:06:10 +0400 Subject: [PATCH] =?UTF-8?q?feat(web-modules):=20API=20=E2=80=94=20=D1=81?= =?UTF-8?q?=D0=B2=D0=B5=D0=B6=D0=B5=D1=81=D1=82=D1=8C=20Jira=20=D0=B2=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B5,=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D1=83=D0=BB=D1=8C=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D0=B0?= =?UTF-8?q?,=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D1=8B=20=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8F=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- openspec/changes/web-modules/design.md | 3 ++ src/sdlc_hub/core/api.py | 22 +++++++---- src/sdlc_hub/core/queue.py | 16 ++++++-- src/sdlc_hub/modules/jira/mirror.py | 20 +++++++--- src/sdlc_hub/modules/jira/project_fields.py | 9 ++++- tests/test_db.py | 2 +- tests/test_project_fields.py | 30 ++++++++++++-- tests/test_web_app.py | 4 +- tests/test_wiki_projects.py | 43 ++++++++++++++++++++- 9 files changed, 124 insertions(+), 25 deletions(-) diff --git a/openspec/changes/web-modules/design.md b/openspec/changes/web-modules/design.md index 5a0c5b5..8d23646 100644 --- a/openspec/changes/web-modules/design.md +++ b/openspec/changes/web-modules/design.md @@ -116,3 +116,6 @@ type HubModule = { | 12 | 24.10.2026 | Форматтеры дат в ядре | три формата дат, импорт wiki → jira | — | | 13 | 24.10.2026 | Сводка «Обзора» от модуля | ядро не знает данных модулей | — | | 14 | 24.10.2026 | Архив после sdlc-rename | дельты поверх новых имён | правка дельт при архиве | +| 15 | 24.10.2026 | `last_full_sync(conn, base_jql=None)` в `modules/jira/mirror.py`: базовая JQL необязательна (по умолчанию `projects.base_jql`), `board()` передаёт свою; импорт в `project_fields.view` отложенный | интерфейс плана `(conn)` сохранён, `board()` не меняет сигнатуру; прямой импорт даёт цикл mirror → history → projects → project_fields | вернуть явный параметр | +| 16 | 24.10.2026 | Фильтр `module` в `list_batches` применяется до `limit` перебором пакетов (по типу первой команды), без SQL-колонки | у пакета нет колонки модуля, схему не менять; объём пакетов невелик | добавить колонку `batch.module` | +| 17 | 24.10.2026 | Тесты Task 1 лежат в `test_wiki_projects.py`/`test_project_fields.py` (нужны оба модуля), а не в `test_queue_core.py`/`test_api.py`; правлены старые ожидания в `test_db.py` (`jira_`-поля) | в `test_queue_core` модули подменены заглушкой | перенести тесты | diff --git a/src/sdlc_hub/core/api.py b/src/sdlc_hub/core/api.py index 74226ed..fa8f671 100644 --- a/src/sdlc_hub/core/api.py +++ b/src/sdlc_hub/core/api.py @@ -213,7 +213,7 @@ def h_app(hub, conn, m, q, body): def h_root(hub, conn, m, q, body): """Старые адреса доски (/, /links) — на страницу доски веб-приложения.""" - return 302, Raw("text/html; charset=utf-8", b"", {"Location": "/app/board", "Cache-Control": "no-cache"}) + return 302, Raw("text/html; charset=utf-8", b"", {"Location": "/app/", "Cache-Control": "no-cache"}) def h_batches_post(hub, conn, m, q, body): @@ -237,7 +237,11 @@ def h_batches_post(hub, conn, m, q, body): def h_batches_get(hub, conn, m, q, body): - return 200, {"batches": queue.list_batches(conn, int_param(q, "limit", 20), project=q.get("project") or None)} + module = q.get("module") or None + if module is not None and registry.module(module) is None: + raise ApiError(404, "not_found", f"module {module}") + return 200, {"batches": queue.list_batches(conn, int_param(q, "limit", 20), project=q.get("project") or None, + module=module)} def h_batch_get(hub, conn, m, q, body): @@ -420,7 +424,7 @@ def h_projects_post(hub, conn, m, q, body): except projects.ProjectError as e: raise ApiError(422, "bad_project", str(e)) by_module = _module_fields(body, {"label", "name"}, editable_only=False) - batch_ids = [] + batches = [] try: with db.transaction(conn): project = projects.create(conn, label, name) @@ -428,7 +432,7 @@ def h_projects_post(hub, conn, m, q, body): batch = mod.on_project_created(conn, project, by_module.get(mod.name, (mod, {}))[1], author) \ if mod.on_project_created else None if batch is not None: - batch_ids.append(batch["id"]) + batches.append({"module": mod.name, "id": batch["id"]}) row = project_rows(conn, [label])[0] except projects.ProjectExists as e: raise ApiError(409, "project_exists", str(e)) @@ -436,7 +440,8 @@ def h_projects_post(hub, conn, m, q, body): raise ApiError(422, "bad_project", str(e)) except CommandError as e: raise ApiError(422, "bad_batch", str(e)) - return 201, {"project": row, "batch_id": batch_ids[0] if batch_ids else None} + return 201, {"project": row, "batch_id": batches[0]["id"] if batches else None, + "batches": batches} def h_project_patch(hub, conn, m, q, body): @@ -445,16 +450,17 @@ def h_project_patch(hub, conn, m, q, body): project = not_found(projects.get(conn, label), f"project {label}") author = _author(body) by_module = _module_fields(body, {"label", "name", "created_at"}, editable_only=True) - batch_ids = [] + batches = [] try: with db.transaction(conn): for mod, fields in by_module.values(): if mod.on_project_updated: - batch_ids += [b["id"] for b in mod.on_project_updated(conn, project, fields, author)] + batches += [{"module": mod.name, "id": b["id"]} + for b in mod.on_project_updated(conn, project, fields, author)] row = project_rows(conn, [label])[0] except CommandError as e: raise ApiError(422, "bad_batch", str(e)) - return 200, {"project": row, "batch_ids": batch_ids} + return 200, {"project": row, "batch_ids": [b["id"] for b in batches], "batches": batches} CORE_ROUTES = [ diff --git a/src/sdlc_hub/core/queue.py b/src/sdlc_hub/core/queue.py index 2f6f6e8..f3b7f24 100644 --- a/src/sdlc_hub/core/queue.py +++ b/src/sdlc_hub/core/queue.py @@ -154,16 +154,26 @@ def batch_view(conn, batch_id, with_commands=True) -> dict | None: d = dict(b) d["status"] = _status_of(cmds, b["cancelled_at"]) d["counts"] = {s: sum(1 for c in cmds if c["status"] == s) for s in sorted({c["status"] for c in cmds})} + first = registry.module_of(cmds[0]["type"]) if cmds else None + d["module"] = first.name if first else None d["remaining_seconds"] = estimate(conn, [c["type"] for c in cmds if c["status"] in ("queued", "claimed")]) if with_commands: d["commands"] = cmds return d -def list_batches(conn, limit=20, project=None) -> list[dict]: +def list_batches(conn, limit=20, project=None, module=None) -> list[dict]: + """Последние пакеты (новые первыми); module — только пакеты этого модуля (по типу первой команды), фильтр + применяется до limit.""" where, args = ("WHERE project = ?", [project]) if project is not None else ("", []) - ids = [r["id"] for r in conn.execute(f"SELECT id FROM batch {where} ORDER BY id DESC LIMIT ?", (*args, int(limit)))] - return [batch_view(conn, i, with_commands=False) for i in ids] + out = [] + for r in conn.execute(f"SELECT id FROM batch {where} ORDER BY id DESC", args).fetchall(): + if len(out) >= int(limit): + break + view = batch_view(conn, r["id"], with_commands=False) + if module is None or view["module"] == module: + out.append(view) + return out # --- выдача ----------------------------------------------------------------- diff --git a/src/sdlc_hub/modules/jira/mirror.py b/src/sdlc_hub/modules/jira/mirror.py index 0637105..b90912c 100644 --- a/src/sdlc_hub/modules/jira/mirror.py +++ b/src/sdlc_hub/modules/jira/mirror.py @@ -205,6 +205,19 @@ def get_issue(conn, key, files_dir=None) -> dict | None: return d +def last_full_sync(conn, base_jql=None) -> dict | None: + """Последний успешный полный прогон (refresh по базовой JQL, команда done): started_at, finished_at, issues_count. + base_jql не задана — берётся текущая базовая JQL проектов; без проектов — None.""" + if base_jql is None: + from . import projects + base_jql = projects.base_jql(conn) + 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' " + "ORDER BY s.started_at DESC, s.id DESC LIMIT 1", (base_jql,)).fetchone() + return dict(run) if run else None + + BOARD_FIELDS = ["key", "summary", "issue_type", "status", "priority", "assignee", "sprint", "duedate", "planned_start", "planned_end", "epic_key", "labels", "neighbor"] @@ -234,14 +247,11 @@ def board(conn, base_jql, project: str | None = None) -> dict: 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' " - "ORDER BY s.started_at DESC, s.id DESC LIMIT 1", (base_jql,)).fetchone() + run = last_full_sync(conn, base_jql) finally: if own: conn.execute("COMMIT") - return {"generated_at": db.now(), "last_sync": dict(run) if run else None, "issues": issues, "links": links} + return {"generated_at": db.now(), "last_sync": run, "issues": issues, "links": links} def get_note(conn, key) -> dict | None: diff --git a/src/sdlc_hub/modules/jira/project_fields.py b/src/sdlc_hub/modules/jira/project_fields.py index 2a6e426..579680e 100644 --- a/src/sdlc_hub/modules/jira/project_fields.py +++ b/src/sdlc_hub/modules/jira/project_fields.py @@ -13,7 +13,8 @@ FIELDS = (ProjectField("board_id", validate_board, editable=False),) def view(conn, labels: list[str]) -> dict[str, dict]: - """Значения полей для списка проектов: метка → board_id, board_type, issues_count.""" + """Значения полей для списка проектов: метка → board_id, board_type, issues_count, jira_synced_at (finished_at + последнего полного обновления — общее для всех проектов; null до первого).""" if not labels: return {} marks = ", ".join("?" * len(labels)) @@ -21,5 +22,9 @@ def view(conn, labels: list[str]) -> dict[str, dict]: "SELECT p.label, jp.board_id, jp.board_type, (SELECT COUNT(*) FROM issue i WHERE EXISTS " "(SELECT 1 FROM json_each(i.labels) WHERE value = p.label)) AS issues_count " f"FROM project p LEFT JOIN jira_project jp ON jp.label = p.label WHERE p.label IN ({marks})", labels) - return {r["label"]: {"board_id": r["board_id"], "board_type": r["board_type"], "issues_count": r["issues_count"]} + from .mirror import last_full_sync # отложенный импорт: mirror → history → projects → project_fields + run = last_full_sync(conn) + synced = run["finished_at"] if run else None + return {r["label"]: {"board_id": r["board_id"], "board_type": r["board_type"], "issues_count": r["issues_count"], + "jira_synced_at": synced} for r in rows} diff --git a/tests/test_db.py b/tests/test_db.py index 92ecfe9..f1076f7 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -280,7 +280,7 @@ def test_migrates_v3_creates_awpca(tmp_path): _v3_database(path) conn = db.connect(path) new = 'project = AURORA AND labels in ("AWPCA")' - assert {k: v for k, v in api.project_rows(conn)[0].items() if not k.startswith("wiki_")} | { + assert {k: v for k, v in api.project_rows(conn)[0].items() if not k.startswith(("wiki_", "jira_"))} | { "created_at": None, "issues_count": None} == { "label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": None, "created_at": None, "issues_count": None} assert settings.get(conn, "base_jql") == "project = AURORA AND labels = AWPCA" # ключи на месте diff --git a/tests/test_project_fields.py b/tests/test_project_fields.py index b24a563..a2ed962 100644 --- a/tests/test_project_fields.py +++ b/tests/test_project_fields.py @@ -34,7 +34,8 @@ def test_projects_get_shape(hub_server, add_project): got = hub_server["client"].get("/projects")["projects"] assert got == [{"label": "AWPCA", "name": "АРМ КА", "created_at": got[0]["created_at"], "board_id": 4216, "board_type": "scrum", "issues_count": 1, "wiki_root": None, - "wiki_root_title": None, "wiki_pages": None, "wiki_synced_at": None, "wiki_sync_error": None}] + "wiki_root_title": None, "wiki_pages": None, "wiki_synced_at": None, "wiki_sync_error": None, + "jira_synced_at": None}] def test_post_unknown_field(hub_server): @@ -47,7 +48,7 @@ def test_post_unknown_field(hub_server): def test_post_response_shape(hub_server): out = hub_server["client"].post("/projects", {"label": "X", "name": "X", "board_id": 7}) - assert set(out) == {"project", "batch_id"} and out["project"]["board_id"] == 7 + assert set(out) == {"project", "batch_id", "batches"} and out["project"]["board_id"] == 7 assert hub_server["client"].get(f"/batches/{out['batch_id']}")["title"] == "Новый проект X" @@ -205,7 +206,8 @@ def test_module_fields_post_and_patch(two_modules): api, ApiError, conn, re = two_modules code, out = api.h_projects_post(None, conn, None, {}, {"label": "P", "name": "p", "board_id": 3, "root": "doc-1"}) assert code == 201 and out["project"]["root"] == "DOC-1" and out["project"]["board_id"] == 3 - assert set(out) == {"project", "batch_id"} and out["batch_id"] is not None # пакет только у jira + assert set(out) == {"project", "batch_id", "batches"} and out["batch_id"] is not None # пакет только у jira + assert [b["module"] for b in out["batches"]] == ["jira"] code, out = api.h_project_patch(None, conn, re.match(r"(.*)", "P"), {}, {"root": "doc-2"}) assert code == 200 and out["project"]["root"] == "DOC-2" and len(out["batch_ids"]) == 1 with pytest.raises(ApiError) as e: @@ -220,3 +222,25 @@ def test_module_field_error_writes_nothing(two_modules): api.h_projects_post(None, conn, None, {}, {"label": "P", "name": "p", "root": 5}) assert e.value.status == 422 and e.value.detail.startswith("root") assert api.project_rows(conn) == [] + + +def test_projects_jira_synced_at(hub_server, add_project): + """jira_synced_at — finished_at последнего полного обновления; до него — null у каждого проекта.""" + h, c = hub_server["hub"], hub_server["client"] + conn = h.conn() + try: + add_project(conn, "AWPCA") + add_project(conn, "OTHER", board_id=None) + finally: + conn.close() + assert [p["jira_synced_at"] for p in c.get("/projects")["projects"]] == [None, None] + c.post("/refresh", {"full": True}) + cmd = hub_server["agent"].post("/agent/claim", {})["command"] + hub_server["agent"].post("/agent/state", {"command_id": cmd["id"], "issues": [], "links": [], "neighbor_keys": []}) + hub_server["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "done"}) + conn = h.conn() + try: + conn.execute("UPDATE sync_run SET finished_at = '2026-10-24T11:07:00' WHERE command_id = ?", (cmd["id"],)) + finally: + conn.close() + assert [p["jira_synced_at"] for p in c.get("/projects")["projects"]] == ["2026-10-24T11:07:00"] * 2 diff --git a/tests/test_web_app.py b/tests/test_web_app.py index 7f55cb5..a74ec7d 100644 --- a/tests/test_web_app.py +++ b/tests/test_web_app.py @@ -104,10 +104,10 @@ def test_default_web_dir_is_package_build(tmp_path): @pytest.mark.parametrize("path", ["/", "/links"]) -def test_old_board_addresses_redirect_to_app(app_hub, path): +def test_root_redirect(app_hub, path): status, r, body = _get(app_hub, path) assert status == 302 - assert r.getheader("Location") == "/app/board" + assert r.getheader("Location") == "/app/" assert body == b"" diff --git a/tests/test_wiki_projects.py b/tests/test_wiki_projects.py index d8a053b..fe51f38 100644 --- a/tests/test_wiki_projects.py +++ b/tests/test_wiki_projects.py @@ -28,7 +28,7 @@ def _syncs(hub, label=None): def test_post_project_with_root(hub_server): out = hub_server["client"].post("/projects", {"label": "S", "name": "S", "wiki_root": "DOC-001234"}) - assert set(out) == {"project", "batch_id"} and out["project"]["wiki_root"] == "DOC-001234" + assert set(out) == {"project", "batch_id", "batches"} and out["project"]["wiki_root"] == "DOC-001234" hub = hub_server["hub"] conn = hub.conn() try: @@ -149,3 +149,44 @@ def test_wiki_mcp_imports_no_db_or_queue(): mods += [a.name for a in n.names] bad = [m for m in mods if m.endswith((".db", ".queue", ".projects")) or ".core.db" in m or ".core.queue" in m] assert not bad, (name, bad) + + +def test_batch_module(hub_server): + """Модуль пакета — по типу первой команды; пакет без команд — None (list_batches и batch_view).""" + c, hub = hub_server["client"], hub_server["hub"] + out = c.post("/projects", {"label": "S", "name": "S", "wiki_root": "DOC-001234"}) + wiki_id = out["batches"][1]["id"] + conn = hub.conn() + try: + cur = conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('пустой', 'api', '2026-10-24T10:00:00')") + empty_id = cur.lastrowid + finally: + conn.close() + assert c.get(f"/batches/{out['batch_id']}")["module"] == "jira" + assert c.get(f"/batches/{wiki_id}")["module"] == "wiki" + assert c.get(f"/batches/{empty_id}")["module"] is None + assert {b["id"]: b["module"] for b in c.get("/batches")["batches"]} == { + out["batch_id"]: "jira", wiki_id: "wiki", empty_id: None} + + +def test_batches_module_filter(hub_server): + c = hub_server["client"] + c.post("/projects", {"label": "AWPCA", "name": "A"}) + out = c.patch("/projects/AWPCA", {"wiki_root": "DOC-000777"}) + got = c.get("/batches", module="wiki", project="AWPCA")["batches"] + assert [b["id"] for b in got] == out["batch_ids"] and {b["module"] for b in got} == {"wiki"} + assert [b["module"] for b in c.get("/batches", module="jira")["batches"]] == ["jira"] + with pytest.raises(HubError) as e: + c.get("/batches", module="nope") + assert e.value.status == 404 and e.value.error == "not_found" + + +def test_create_project_batches(hub_server): + c = hub_server["client"] + out = c.post("/projects", {"label": "S", "name": "S", "wiki_root": "DOC-001234"}) + ids = [b["id"] for b in out["batches"]] + assert out["batches"] == [{"module": "jira", "id": ids[0]}, {"module": "wiki", "id": ids[1]}] + assert out["batch_id"] == ids[0] + c.post("/projects", {"label": "AWPCA", "name": "A"}) + patched = c.patch("/projects/AWPCA", {"wiki_root": "DOC-000777"}) + assert patched["batches"] == [{"module": "wiki", "id": patched["batch_ids"][0]}]