Хаб: настройка closed_statuses и фильтр GET /issues?hide_closed (#57)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-17 11:31:55 +04:00
parent 43fba6954a
commit 0abed70f44
8 changed files with 56 additions and 6 deletions

View File

@ -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`; страницы очереди, агента и настроек — позже.

View File

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

View File

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

View File

@ -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']}%"]

View File

@ -31,6 +31,7 @@ DEFAULTS = {
"link_types": {},
"board_id": None,
"relay_dir": None,
"closed_statuses": ["Готово", "Снято"], # статусы Jira закрытых задач: список и доска приложения (hide-closed)
}
# Что получает агент (без токенов и служебных ключей хаба)

View File

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

View File

@ -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": "Готово"}) == [] # отменяет скрытие приложение, не сервер

View File

@ -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", ["Готово"])