feat(web-modules): API — свежесть Jira в проекте, модуль пакета, пакеты нового проекта по модулям
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d30a54a243
commit
04bbcb6595
@ -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` модули подменены заглушкой | перенести тесты |
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
# --- выдача -----------------------------------------------------------------
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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" # ключи на месте
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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""
|
||||
|
||||
|
||||
|
||||
@ -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]}]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user