GET /agile: доска, спринты и бэклог проекта (#108)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 18:48:14 +04:00
parent 4f9024d5c3
commit 1f78d70a02
6 changed files with 287 additions and 3 deletions

View File

@ -47,7 +47,8 @@
| `refresh` | журнал синхронизаций `sync_run`, полный прогон, автообновление | `core.db`, `core.queue`, `core.settings`, `projects` | | `refresh` | журнал синхронизаций `sync_run`, полный прогон, автообновление | `core.db`, `core.queue`, `core.settings`, `projects` |
| `projects` | базовая JQL из меток (`base_jql`), доски (`boards`), миграция v3 → v4 (`migrate_v3`), проект по умолчанию | `core.projects`, `core.settings` | | `projects` | базовая JQL из меток (`base_jql`), доски (`boards`), миграция v3 → v4 (`migrate_v3`), проект по умолчанию | `core.projects`, `core.settings` |
| `hooks` | `CommandHooks` для очереди ядра: проверки постановки (`bug` без настройки, файл вложения, проект пакета, `board_id` у `ensure_sprint`), паузы, `deliver_params` (`size`, `boards`, `history`), отчёты (`attachment_file`, `sync_run`) | `core.queue`, `core.files`, `core.projects`, `core.settings`, `settings`, `projects`, `commands`, `mirror`, `history`, `refresh`, `attachments`, `fetch` | | `hooks` | `CommandHooks` для очереди ядра: проверки постановки (`bug` без настройки, файл вложения, проект пакета, `board_id` у `ensure_sprint`), паузы, `deliver_params` (`size`, `boards`, `history`), отчёты (`attachment_file`, `sync_run`) | `core.queue`, `core.files`, `core.projects`, `core.settings`, `settings`, `projects`, `commands`, `mirror`, `history`, `refresh`, `attachments`, `fetch` |
| `api` | маршруты модуля `MODULE.routes`: `/issues`, `/board`, `/changes`, `/attachments`, `/refresh`, `/sync-runs` и др. | `core.db`, `core.files`, `core.queue`, `core.settings`, `core.http`, `core.errors`, `attachments`, `projects`, `mirror`, `history`, `fetch`, `refresh` | | `agile` | чтение для `GET /agile`: доска, спринты, бэклог, выполнено по последней колонке | `core.db`, `history` |
| `api` | маршруты модуля `MODULE.routes`: `/issues`, `/board`, `/agile`, `/changes`, `/attachments`, `/refresh`, `/sync-runs` и др. | `core.db`, `core.files`, `core.queue`, `core.settings`, `core.http`, `core.errors`, `attachments`, `projects`, `mirror`, `history`, `fetch`, `refresh` |
| `mcp` | инструменты MCP: `issues`, `issue`, `batch_create`, `batch_*`, `comment`, `attachment`, `changes`, `refresh`, `settings_*` и др.; общие `projects`/`project_create` подключает `register_core_tools` | `core.mcp`, `core.client`, `commands`, `attachments` | | `mcp` | инструменты MCP: `issues`, `issue`, `batch_create`, `batch_*`, `comment`, `attachment`, `changes`, `refresh`, `settings_*` и др.; общие `projects`/`project_create` подключает `register_core_tools` | `core.mcp`, `core.client`, `commands`, `attachments` |
| `importer`, `cli` | разовый импорт CSV-пакета в заметки (`sdlc-hub import FOLDER`) | `mirror`, `core.db`, `core.settings` | | `importer`, `cli` | разовый импорт CSV-пакета в заметки (`sdlc-hub import FOLDER`) | `mirror`, `core.db`, `core.settings` |
@ -120,7 +121,7 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк
Цепочки зависимостей (Python): Цепочки зависимостей (Python):
- ядро: `api → queue → settings/projects/files/registry → db → backup → paths`; `api → relay → settings`; `api → release → paths`; `files → paths`; `cli → api/mcp/registry`; `mcp → client → HTTP API`; `registry → (лениво) modules.*`; - ядро: `api → queue → settings/projects/files/registry → db → backup → paths`; `api → relay → settings`; `api → release → paths`; `files → paths`; `cli → api/mcp/registry`; `mcp → client → HTTP API`; `registry → (лениво) modules.*`;
- модуль wiki: `commands → stage/sync/attachments → convert/search/paths → core.*`; `projects → commands/paths`; `api → stage/attachments/search → core.http/core.queue`; `tick → commands/sync`; - модуль wiki: `commands → stage/sync/attachments → convert/search/paths → core.*`; `projects → commands/paths`; `api → stage/attachments/search → core.http/core.queue`; `tick → commands/sync`;
- модуль jira: `hooks → commands/mirror/history/refresh/attachments/fetch → core.*`; `mirror → history → projects`; `mirror → attachments → core.files`; `api → mirror/history/fetch/refresh → core.http/core.queue`; `importer → mirror`; - модуль jira: `hooks → commands/mirror/history/refresh/attachments/fetch → core.*`; `mirror → history → projects`; `mirror → attachments → core.files`; `api → mirror/history/fetch/refresh → core.http/core.queue`; `api → agile → history`; `importer → mirror`;
- веб: `shell → modules/index → modules/jira, modules/wiki → ui/api/projects` (модуль не импортирует оболочку, кроме типов контракта). - веб: `shell → modules/index → modules/jira, modules/wiki → ui/api/projects` (модуль не импортирует оболочку, кроме типов контракта).
## Потоки данных ## Потоки данных

View File

@ -217,6 +217,7 @@ uv run sdlc-hub agent publish --module wiki ../eva-agent/src/agent.py
| `GET /issues/{key}` | клиент | задача целиком, с комментариями и журналом изменений `changes` (новые сверху) | | `GET /issues/{key}` | клиент | задача целиком, с комментариями и журналом изменений `changes` (новые сверху) |
| `GET /changes` | клиент | лента изменений: `since`, `until` (ISO со смещением или `Z`, иначе 422; по умолчанию — последние 24 ч), `project` (только свои задачи), `key`, `kinds`, `limit` (до 2000) → `{since, until, truncated, changes}` | | `GET /changes` | клиент | лента изменений: `since`, `until` (ISO со смещением или `Z`, иначе 422; по умолчанию — последние 24 ч), `project` (только свои задачи), `key`, `kinds`, `limit` (до 2000) → `{since, until, truncated, changes}` |
| `GET /board` | клиент | `?project=<метка>` (неизвестная — 404): снимок для доски — свои задачи проекта, соседи (связи в обе стороны) и эпики своих задач, с `work_kind` из заметок; связи — те, у которых хотя бы один конец — своя задача проекта (без `project` — все задачи и связи), `last_sync` — последний успешный полный прогон | | `GET /board` | клиент | `?project=<метка>` (неизвестная — 404): снимок для доски — свои задачи проекта, соседи (связи в обе стороны) и эпики своих задач, с `work_kind` из заметок; связи — те, у которых хотя бы один конец — своя задача проекта (без `project` — все задачи и связи), `last_sync` — последний успешный полный прогон |
| `GET /agile` | клиент | `?project=<метка>` (без — 422, неизвестная — 404): доска проекта (`board`, тип из проекта; `null` — доски нет), все спринты доски со своими задачами и суммами оценок (`count`, `count_done`, `unestimated`, `points`, `points_done`), `backlog` (scrum — свои задачи вне активных и будущих спринтов, не выполненные; kanban — первая колонка, если бэклог доски включён), `board_issues` (kanban: остальные колонки, выполненные — за 14 дней); списки — по рангу |
| `GET /`, `GET /links` | — | перенаправление на `/app/` («Обзор») | | `GET /`, `GET /links` | — | перенаправление на `/app/` («Обзор») |
| `PUT /issues/{key}/note` | клиент | заметки (частично) | | `PUT /issues/{key}/note` | клиент | заметки (частично) |
| `POST /batches`, `GET /batches`, `GET /batches/{id}` | клиент | пакеты команд; `project` в теле `POST /batches` — пакет проекта (проверки метки, ограничение задач проектом), `?project=` у `GET /batches` — только пакеты проекта, `?module=<имя>` — только пакеты модуля (по типу первой команды; неизвестный модуль — 404), `limit`; в каждом пакете поле `module` (`null` — не определён) | | `POST /batches`, `GET /batches`, `GET /batches/{id}` | клиент | пакеты команд; `project` в теле `POST /batches` — пакет проекта (проверки метки, ограничение задач проектом), `?project=` у `GET /batches` — только пакеты проекта, `?module=<имя>` — только пакеты модуля (по типу первой команды; неизвестный модуль — 404), `limit`; в каждом пакете поле `module` (`null` — не определён) |

View File

@ -0,0 +1,105 @@
"""Чтение для GET /agile: доска проекта, спринты со своими задачами и суммами оценок, бэклог. Только чтение зеркала;
время Jira сравнивается в UTC через history.jira_utc (смещения ±HHMM и ±HH:MM, Z)."""
import json
from datetime import datetime, timedelta, timezone
from . import history
KANBAN_DONE_DAYS = 14
_FIELDS = ("key", "summary", "issue_type", "status", "priority", "assignee", "epic_key", "story_points", "rank")
def _rank_key(row: dict):
return (row["rank"] is None, row["rank"] or "", row["key"])
def _sprint_order(sprints: list[dict]) -> list[dict]:
"""active — по началу (без даты в конце), future — по id, closed — по закрытию по убыванию (без даты в конце), при равенстве — id."""
far = "9999-12-31T23:59:59+00:00"
def ts(value):
return history.jira_utc(value)
active = sorted((s for s in sprints if s["state"] == "active"), key=lambda s: (ts(s["start_date"]) or far, s["id"]))
future = sorted((s for s in sprints if s["state"] == "future"), key=lambda s: s["id"])
closed = [s for s in sprints if s["state"] == "closed"]
dated = sorted((s for s in closed if ts(s["complete_date"])),
key=lambda s: (-datetime.fromisoformat(ts(s["complete_date"])).timestamp(), s["id"]))
undated = sorted((s for s in closed if not ts(s["complete_date"])), key=lambda s: s["id"])
other = [s for s in sprints if s["state"] not in ("active", "future", "closed")]
return active + future + dated + undated + sorted(other, key=lambda s: s["id"])
def view(conn, label: str, now: datetime | None = None) -> dict:
now = now or datetime.now(timezone.utc)
own = not conn.in_transaction
if own:
conn.execute("BEGIN")
try:
return _view(conn, label, now)
finally:
if own:
conn.execute("COMMIT")
def _view(conn, label: str, now: datetime) -> dict:
proj = conn.execute("SELECT board_id, board_type FROM jira_project WHERE label = ?", (label,)).fetchone()
if proj is None or proj["board_id"] is None:
return {"board": None, "sprints": [], "backlog": None, "board_issues": None}
board_id, btype = proj["board_id"], proj["board_type"]
brow = conn.execute("SELECT * FROM jira_board WHERE board_id = ?", (board_id,)).fetchone()
columns = json.loads(brow["columns"]) if brow and brow["columns"] else None
kb = brow["kanban_backlog"] if brow else None
board = {"id": board_id, "type": btype, "columns": columns,
"kanban_backlog": None if kb is None else bool(kb), "fetched_at": brow["fetched_at"] if brow else None}
if btype is None:
return {"board": board, "sprints": [], "backlog": None, "board_issues": None}
done_statuses = set(columns[-1]["statuses"]) if columns else set()
rows = []
for r in conn.execute(
"SELECT i.key, i.summary, i.issue_type, i.status, i.priority, i.assignee, i.epic_key, i.story_points, i.rank, "
"i.jira_updated FROM issue i WHERE EXISTS (SELECT 1 FROM json_each(i.labels) WHERE value = ?)", (label,)):
d = dict(r)
d["done"] = d["status"] in done_statuses
rows.append(d)
rows.sort(key=_rank_key)
def out(r):
return {k: r[k] for k in _FIELDS} | {"done": r["done"]}
if btype == "kanban":
first = set(columns[0]["statuses"]) if columns else set()
cutoff = now - timedelta(days=KANBAN_DONE_DAYS)
backlog, board_issues = ([] if kb == 1 else None), []
for r in rows:
if kb == 1 and r["status"] in first:
backlog.append(out(r))
continue
if r["done"]:
u = history.jira_utc(r["jira_updated"])
if u is None or datetime.fromisoformat(u) < cutoff:
continue
board_issues.append(out(r))
return {"board": board, "sprints": [], "backlog": backlog, "board_issues": board_issues}
members: dict[int, list[dict]] = {}
for r in conn.execute("SELECT key, sprint_id FROM issue_sprint"):
members.setdefault(r["sprint_id"], []).append(r["key"])
by_key = {r["key"]: r for r in rows}
sprints = []
for s in conn.execute("SELECT * FROM jira_sprint WHERE board_id = ?", (board_id,)):
issues = [out(by_key[k]) for k in members.get(s["sprint_id"], []) if k in by_key]
issues.sort(key=_rank_key)
done = [i for i in issues if i["done"]]
pts = lambda xs: sum(i["story_points"] for i in xs if i["story_points"] is not None) # noqa: E731
sprints.append({"id": s["sprint_id"], "name": s["name"], "state": s["state"], "start_date": s["start_date"],
"end_date": s["end_date"], "complete_date": s["complete_date"], "goal": s["goal"],
"count": len(issues), "count_done": len(done),
"unestimated": sum(1 for i in issues if i["story_points"] is None),
"points": pts(issues), "points_done": pts(done), "issues": issues})
busy = {r["key"] for r in conn.execute(
"SELECT s.key FROM issue_sprint s JOIN jira_sprint p ON p.sprint_id = s.sprint_id WHERE p.state IN ('active', 'future')")}
backlog = [out(r) for r in rows if r["key"] not in busy and not r["done"]]
return {"board": board, "sprints": _sprint_order(sprints), "backlog": backlog, "board_issues": None}

View File

@ -9,7 +9,7 @@ from urllib.parse import quote
from ...core import db, files, queue, settings from ...core import db, files, queue, settings
from ...core.errors import CommandError, HttpMapped from ...core.errors import CommandError, HttpMapped
from ...core.http import ApiError, Raw, int_param, mapped, not_found, project_param from ...core.http import ApiError, Raw, int_param, mapped, not_found, project_param
from . import attachments, fetch, history, mirror, refresh from . import agile, attachments, fetch, history, mirror, refresh
from . import projects as jira_projects from . import projects as jira_projects
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -98,6 +98,13 @@ def h_board(hub, conn, m, q, body):
return 200, mirror.board(conn, jira_projects.base_jql(conn), project_param(conn, q)) return 200, mirror.board(conn, jira_projects.base_jql(conn), project_param(conn, q))
def h_agile(hub, conn, m, q, body):
label = project_param(conn, q)
if label is None:
raise ApiError(422, "bad_request", "project — метка проекта")
return 200, agile.view(conn, label)
def h_note_put(hub, conn, m, q, body): def h_note_put(hub, conn, m, q, body):
fields = {k: v for k, v in body.items() if k != "updated_by"} fields = {k: v for k, v in body.items() if k != "updated_by"}
try: try:
@ -180,6 +187,7 @@ ROUTES = (
("GET", r"^/issues$", h_issues, "client"), ("GET", r"^/issues$", h_issues, "client"),
("GET", r"^/issues/([^/]+)$", h_issue, "client"), ("GET", r"^/issues/([^/]+)$", h_issue, "client"),
("GET", r"^/board$", h_board, "client"), ("GET", r"^/board$", h_board, "client"),
("GET", r"^/agile$", h_agile, "client"),
("GET", r"^/changes$", h_changes, "client"), ("GET", r"^/changes$", h_changes, "client"),
("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"), ("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"),
("GET", r"^/attachments/(\d+)$", h_attachment_get, "client"), ("GET", r"^/attachments/(\d+)$", h_attachment_get, "client"),

View File

@ -83,6 +83,12 @@
"client", "client",
"h_agents_get" "h_agents_get"
], ],
[
"GET",
"^/agile$",
"client",
"h_agile"
],
[ [
"GET", "GET",
"^/app(?:/(.*))?$", "^/app(?:/(.*))?$",

163
tests/test_agile.py Normal file
View File

@ -0,0 +1,163 @@
from datetime import datetime, timezone
import pytest
from sdlc_hub.core import settings
from sdlc_hub.core.client import HubError
from sdlc_hub.modules.jira import agile, mirror
COLS = [{"name": "К выполнению", "statuses": ["Backlog", "Ожидает анализа"]}, {"name": "В работе", "statuses": ["В работе"]},
{"name": "Готово", "statuses": ["Готово", "Протестировано", "Снято"]}]
@pytest.fixture
def conn(open_db, add_project):
c = open_db()
settings.ensure_defaults(c)
add_project(c)
return c
def issue(key, status, **over):
base = {"key": key, "id": "1", "issue_type": "Задача", "summary": f"Тема {key}", "description": "", "status": status,
"priority": "Средний", "assignee": None, "reporter": "me", "duedate": None, "planned_start": None,
"planned_end": None, "epic_key": None, "labels": ["AWPCA"], "sprint": None,
"created": "2026-10-10T12:00:00.000+0300", "jira_updated": "2026-10-11T12:00:00.000+0300"}
return base | over
def kissue(key, status, **over):
return issue(key, status, labels=["K"], **over)
def board(id=4216, type="scrum", sprints=(), **over):
return {"id": id, "type": type, "columns": COLS, "kanban_backlog": None, "sprints": list(sprints), **over}
def sprint(id, state, name=None, **over):
return {"id": id, "name": name or f"S{id}", "state": state, "start_date": None, "end_date": None,
"complete_date": None, "goal": None, **over}
def load(conn, issues, boards):
mirror.apply_state(conn, issues, [], boards=boards)
def keys(rows):
return [r["key"] for r in rows]
def test_sprint_and_backlog_sums(conn): # сценарий «Спринт и бэклог»
load(conn, [issue("A", "Готово", story_points=3, sprint_ids=[102], rank="0|a"), issue("B", "В работе", sprint_ids=[102], rank="0|b"),
issue("C", "Backlog", sprint_ids=[]), issue("D", "Готово", sprint_ids=[])],
[board(sprints=[sprint(102, "active")])])
v = agile.view(conn, "AWPCA")
s = v["sprints"][0]
assert (s["count"], s["count_done"], s["points"], s["points_done"], s["unestimated"]) == (2, 1, 3, 3, 1)
assert keys(s["issues"]) == ["A", "B"] and keys(v["backlog"]) == ["C"] and v["board_issues"] is None
def test_done_is_last_column(conn): # сценарий «Колонки доски»
load(conn, [issue("P", "Протестировано", sprint_ids=[102]), issue("Q", "Backlog", sprint_ids=[102])], [board(sprints=[sprint(102, "active")])])
assert {i["key"]: i["done"] for i in agile.view(conn, "AWPCA")["sprints"][0]["issues"]} == {"P": True, "Q": False}
def test_project_without_board(conn, add_project): # сценарий «Проект без доски»
add_project(conn, "STOCKS_DRRK", board_id=None, board_type=None)
assert agile.view(conn, "STOCKS_DRRK") == {"board": None, "sprints": [], "backlog": None, "board_issues": None}
def test_board_without_agent_data(conn): # design #23, состояние 1
load(conn, [issue("A", "Готово")], [])
v = agile.view(conn, "AWPCA")
assert v["board"] == {"id": 4216, "type": "scrum", "columns": None, "kanban_backlog": None, "fetched_at": None}
assert [i["done"] for i in v["backlog"]] == [False] # без колонок выполненных нет
def test_sprint_order_mixed_offsets(conn): # порядок спринтов; Review Focus 1
load(conn, [], [board(sprints=[
sprint(3, "active", start_date="2026-10-14T09:00:00.000+0300"), sprint(5, "active", start_date="2026-10-01T09:00:00.000+03:00"),
sprint(9, "future"), sprint(7, "future"),
sprint(1, "closed", complete_date="2026-10-01T02:00:00.000+0300"), sprint(2, "closed", complete_date="2026-09-30T23:30:00.000Z")])])
assert [s["id"] for s in agile.view(conn, "AWPCA")["sprints"]] == [5, 3, 7, 9, 2, 1] # 2 закрыт позже 1 по UTC
def test_rank_order_nulls_last_then_key(conn):
load(conn, [issue("Z-1", "Backlog", rank="0|i0002:"), issue("Z-2", "Backlog", rank="0|i0001:"),
issue("B-1", "Backlog", rank=None), issue("A-1", "Backlog")], [board()])
assert keys(agile.view(conn, "AWPCA")["backlog"]) == ["Z-2", "Z-1", "A-1", "B-1"]
def test_only_own_issues_and_empty_sprint(conn): # design #8
load(conn, [issue("N", "Backlog", labels=["OTHER"], sprint_ids=[102])], [board(sprints=[sprint(102, "active")])])
s = agile.view(conn, "AWPCA")["sprints"][0]
assert s["issues"] == [] and s["count"] == 0
def test_shared_board_own_issues_only(conn, add_project): # Review Focus 3
add_project(conn, "C", board_id=4216)
load(conn, [issue("A", "Backlog", sprint_ids=[102]), issue("K", "Backlog", labels=["C"], sprint_ids=[102])], [board(sprints=[sprint(102, "active")])])
assert keys(agile.view(conn, "C")["sprints"][0]["issues"]) == ["K"] and keys(agile.view(conn, "AWPCA")["sprints"][0]["issues"]) == ["A"]
def test_closed_sprint_done_by_current_status(conn): # design #7
load(conn, [issue("X", "Готово", sprint_ids=[101])], [board(sprints=[sprint(101, "closed")])])
assert agile.view(conn, "AWPCA")["sprints"][0]["count_done"] == 1
def test_backlog_keeps_issue_of_closed_sprint(conn):
load(conn, [issue("X", "Backlog", sprint_ids=[101]), issue("Y", "Backlog", sprint_ids=[103])],
[board(sprints=[sprint(101, "closed"), sprint(103, "future")])])
assert keys(agile.view(conn, "AWPCA")["backlog"]) == ["X"]
KCOLS = [{"name": "Бэклог", "statuses": ["Backlog"]}, {"name": "В работе", "statuses": ["В работе"]},
{"name": "Готово", "statuses": ["Готово"]}]
NOW = datetime(2026, 10, 24, 12, tzinfo=timezone.utc)
def test_kanban_with_backlog(conn, add_project): # design #3, «Контракт данных»
add_project(conn, "K", board_id=5120, board_type="kanban")
now = datetime(2026, 10, 24, 12, tzinfo=timezone.utc)
load(conn, [kissue("K1", "Backlog"), kissue("K2", "В работе"), kissue("K3", "Готово", jira_updated="2026-10-21T10:00:00.000+0300"),
kissue("K4", "Готово", jira_updated="2026-10-01T10:00:00.000+0300"), kissue("K5", "Странный"),
kissue("K6", "Готово", jira_updated=None)],
[board(id=5120, type="kanban", kanban_backlog=True, columns=[{"name": "Бэклог", "statuses": ["Backlog"]},
{"name": "В работе", "statuses": ["В работе"]}, {"name": "Готово", "statuses": ["Готово"]}])])
v = agile.view(conn, "K", now=now)
assert v["sprints"] == [] and keys(v["backlog"]) == ["K1"] and sorted(keys(v["board_issues"])) == ["K2", "K3", "K5"]
assert v["board"]["kanban_backlog"] is True
def test_kanban_without_backlog(conn, add_project): # design #17
add_project(conn, "K", board_id=5120, board_type="kanban")
load(conn, [kissue("K1", "Backlog"), kissue("K2", "В работе")],
[board(id=5120, type="kanban", kanban_backlog=False, columns=KCOLS)])
v = agile.view(conn, "K", now=NOW)
assert v["backlog"] is None and sorted(keys(v["board_issues"])) == ["K1", "K2"]
assert v["board"]["kanban_backlog"] is False
def test_kanban_done_cutoff_with_offsets(conn, add_project): # Review Focus 1: граница 14 дней по UTC
add_project(conn, "K", board_id=5120, board_type="kanban")
load(conn, [kissue("OLD", "Готово", jira_updated="2026-10-10T14:30:00.000+0300"), # 11:30Z — старше 14 дней
kissue("NEW", "Готово", jira_updated="2026-10-10T15:30:00.000+0300")], # 12:30Z — в пределах
[board(id=5120, type="kanban", kanban_backlog=False, columns=KCOLS)])
assert keys(agile.view(conn, "K", now=NOW)["board_issues"]) == ["NEW"]
def test_agile_route(hub_server, add_project):
h = hub_server["hub"]
c = h.conn()
try:
add_project(c)
finally:
c.close()
client = hub_server["client"]
with pytest.raises(HubError) as e:
client.get("/agile")
assert e.value.status == 422 and e.value.error == "bad_request"
with pytest.raises(HubError) as e:
client.get("/agile", project="NOPE")
assert e.value.status == 404
assert set(client.get("/agile", project="AWPCA")) == {"board", "sprints", "backlog", "board_issues"}