diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3abb3f0..988e97a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -47,7 +47,7 @@ - `mcp_server` не импортирует `queue`/`mirror`/`db` напрямую и ходит только через `client`; из `files` берёт только предел `MAX_SIZE` (файлы в хранилище кладёт хаб по `POST /files`). - `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `api`, `client` и `relay` (реле — клиент своего же HTTP). -- Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types`, `status_labels` берёт из `GET /settings` и своих копий не держит. +- Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types` и набор закрытых статусов `closed_statuses` берёт из `GET /settings` и своих копий не держит. ## Открытые вопросы - Правки из UI в Jira (спринт, срок, связи, исполнитель) — change `viewer-edit`; страницы очереди, агента и настроек — позже. diff --git a/README.md b/README.md index b4c0687..38b28c0 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py | `GET /agent/known-keys` | агент | ключи зеркала | | `GET /agent/release` | агент | опубликованная версия агента `{version, sha256, content}`; 404 `no_release` | | `GET /agent/files/{sha256}` | агент | файл вложения `{file, size, content}` (`content` — base64); 404 `no_file` | -| `GET /issues` | клиент | фильтры `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `limit`, `offset` | +| `GET /issues` | клиент | фильтры `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `hide_closed` (`1` — без задач в статусах из настройки `closed_statuses`, по умолчанию «Готово», «Снято»), `limit`, `offset` | | `GET /issues/{key}` | клиент | задача целиком | | `GET /board` | клиент | снимок для доски: все задачи с `fact_status`/`work_kind`, все связи, `last_sync` — последний успешный полный прогон | | `GET /`, `GET /links` | — | перенаправление на `/app/board` | diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index a1f71d4..4826f90 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -214,12 +214,15 @@ 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") +ISSUE_FILTERS = ("sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q", "hide_closed") def h_issues(hub, conn, m, q, body): filters = {k: q.get(k) for k in ISSUE_FILTERS} - return 200, {"issues": mirror.list_issues(conn, filters, _int_param(q, "limit", 50), _int_param(q, "offset", 0))} + 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), + closed_statuses=closed)} def h_issue(hub, conn, m, q, body): diff --git a/src/jira_hub/mirror.py b/src/jira_hub/mirror.py index 923b670..d616962 100644 --- a/src/jira_hub/mirror.py +++ b/src/jira_hub/mirror.py @@ -80,7 +80,8 @@ def _issue_dict(row) -> dict: return d -def list_issues(conn, filters=None, limit=50, offset=0) -> list[dict]: +def list_issues(conn, filters=None, limit=50, offset=0, closed_statuses=()) -> list[dict]: + """hide_closed — без задач, чей status из closed_statuses (задача без статуса не закрыта).""" filters = filters or {} where, args = [], [] for name, sql in _FILTERS.items(): @@ -93,6 +94,9 @@ def list_issues(conn, filters=None, limit=50, offset=0) -> list[dict]: if filters.get("neighbor") not in (None, ""): where.append("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))}))") + args += list(closed_statuses) if filters.get("q"): where.append("(i.summary LIKE ? OR i.key LIKE ?)") args += [f"%{filters['q']}%", f"%{filters['q']}%"] diff --git a/src/jira_hub/settings.py b/src/jira_hub/settings.py index 23fcf88..5a49561 100644 --- a/src/jira_hub/settings.py +++ b/src/jira_hub/settings.py @@ -31,6 +31,7 @@ DEFAULTS = { "link_types": {}, "board_id": None, "relay_dir": None, + "closed_statuses": ["Готово", "Снято"], # статусы Jira закрытых задач: список и доска приложения (hide-closed) } # Что получает агент (без токенов и служебных ключей хаба) diff --git a/tests/test_api.py b/tests/test_api.py index 9554794..1351418 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -266,7 +266,7 @@ def test_settings_put_protected_key_in_batch_rejects_whole_batch(hub): {"pacing": 5}, {"pacing": {"ensure_fields": "abc"}}, {"pacing": {"ensure_fields": -1}}, {"pacing": {"неизвестная": 1}}, {"port": "x"}, {"port": 0}, {"port": 70000}, {"backup_keep": -1}, {"auto_refresh_minutes": 1.5}, {"min_agent_version": "2"}, {"link_types": []}, {"base_jql": 5}, - {"labels": {}}, {"initialized": 1}, + {"labels": {}}, {"initialized": 1}, {"closed_statuses": "Готово"}, {"pacing": {"refresh": 3}, "backup_keep": -1}, # хорошее рядом с плохим не записывается ]) def test_settings_put_invalid_value_is_422(hub, body): @@ -727,3 +727,18 @@ def test_attachment_flow_over_api(hub): a.post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "HTTP 500"}) retry = c.post(f"/commands/{cmd['id']}/retry", {"author": "claude"}) assert c.get(f"/batches/{retry['id']}")["commands"][0]["params"] == {"key": "A-1", "file": sha, "filename": "ОСВ 60.xlsx"} + + +def test_issues_hide_closed(hub): + c = hub["client"] + hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "status": "В работе", "labels": []}, + {"key": "A-2", "summary": "Т", "status": "Готово", "labels": []}, + {"key": "A-3", "summary": "Т", "status": "Снято", "labels": []}, + {"key": "A-4", "summary": "Т", "labels": []}], "links": []}) + + def keys(**q): + return sorted(i["key"] for i in c.get("/issues", **q)["issues"]) + assert keys(hide_closed="1") == ["A-1", "A-4"] + assert keys() == ["A-1", "A-2", "A-3", "A-4"] + c.put("/settings", {"closed_statuses": ["Готово"]}) + assert keys(hide_closed="1") == ["A-1", "A-3", "A-4"] diff --git a/tests/test_mirror.py b/tests/test_mirror.py index 5215c94..c638107 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -240,3 +240,18 @@ def test_check_comments_rejects(bad, field): def test_check_comments_accepts_missing_and_valid(): mirror.check_comments([issue("A-1"), issue("A-2", comments=[comment("1"), comment("2", author=None, body="")])]) + + +def test_list_issues_hide_closed(conn): + mirror.apply_state(conn, [issue("A-1", status="В работе"), issue("A-2", status="Готово"), + issue("A-3", status="Снято"), issue("A-4", status=None)], []) + closed = ["Готово", "Снято"] + + def keys(f, c=closed): + return sorted(i["key"] for i in mirror.list_issues(conn, f, closed_statuses=c)) + assert keys({"hide_closed": "1"}) == ["A-1", "A-4"] # без статуса — не закрыта + assert keys({"hide_closed": "true"}) == ["A-1", "A-4"] + assert keys({}) == ["A-1", "A-2", "A-3", "A-4"] + 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": "Готово"}) == [] # отменяет скрытие приложение, не сервер diff --git a/tests/test_settings.py b/tests/test_settings.py index 9043c62..cbaa702 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -94,3 +94,15 @@ def test_attachment_pacing(conn): assert settings.pacing(conn)["ensure_attachment"] == 30 assert settings.pacing_from_config({"issue_seconds": 90, "comment_seconds": 20})["ensure_attachment"] == 20 settings.validate("pacing", {"ensure_attachment": 10}) + + +def test_closed_statuses_default(conn): + assert settings.get(conn, "closed_statuses") == ["Готово", "Снято"] + assert "closed_statuses" not in settings.AGENT_KEYS + assert "closed_statuses" not in settings.for_agent(conn) + + +def test_closed_statuses_must_be_list(): + with pytest.raises(ValueError): + settings.validate("closed_statuses", "Готово") + settings.validate("closed_statuses", ["Готово"])