feat(web-modules): API — свежесть Jira в проекте, модуль пакета, пакеты нового проекта по модулям

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 13:06:10 +04:00
parent d30a54a243
commit 04bbcb6595
9 changed files with 124 additions and 25 deletions

View File

@ -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` модули подменены заглушкой | перенести тесты |

View File

@ -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 = [

View File

@ -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
# --- выдача -----------------------------------------------------------------

View File

@ -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:

View File

@ -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}

View File

@ -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" # ключи на месте

View File

@ -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

View File

@ -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""

View File

@ -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]}]