diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 879b2ab..491bdd2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -47,7 +47,8 @@ | `refresh` | журнал синхронизаций `sync_run`, полный прогон, автообновление | `core.db`, `core.queue`, `core.settings`, `projects` | | `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` | -| `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` | | `importer`, `cli` | разовый импорт CSV-пакета в заметки (`sdlc-hub import FOLDER`) | `mirror`, `core.db`, `core.settings` | @@ -120,7 +121,7 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк Цепочки зависимостей (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.*`; - модуль 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` (модуль не импортирует оболочку, кроме типов контракта). ## Потоки данных diff --git a/README.md b/README.md index 6fa8a96..048bc16 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,7 @@ uv run sdlc-hub agent publish --module wiki ../eva-agent/src/agent.py | `GET /issues/{key}` | клиент | задача целиком, с комментариями и журналом изменений `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 /agile` | клиент | `?project=<метка>` (без — 422, неизвестная — 404): доска проекта (`board`, тип из проекта; `null` — доски нет), все спринты доски со своими задачами и суммами оценок (`count`, `count_done`, `unestimated`, `points`, `points_done`), `backlog` (scrum — свои задачи вне активных и будущих спринтов, не выполненные; kanban — первая колонка, если бэклог доски включён), `board_issues` (kanban: остальные колонки, выполненные — за 14 дней); списки — по рангу | | `GET /`, `GET /links` | — | перенаправление на `/app/` («Обзор») | | `PUT /issues/{key}/note` | клиент | заметки (частично) | | `POST /batches`, `GET /batches`, `GET /batches/{id}` | клиент | пакеты команд; `project` в теле `POST /batches` — пакет проекта (проверки метки, ограничение задач проектом), `?project=` у `GET /batches` — только пакеты проекта, `?module=<имя>` — только пакеты модуля (по типу первой команды; неизвестный модуль — 404), `limit`; в каждом пакете поле `module` (`null` — не определён) | diff --git a/src/sdlc_hub/modules/jira/agile.py b/src/sdlc_hub/modules/jira/agile.py new file mode 100644 index 0000000..6beb44a --- /dev/null +++ b/src/sdlc_hub/modules/jira/agile.py @@ -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} diff --git a/src/sdlc_hub/modules/jira/api.py b/src/sdlc_hub/modules/jira/api.py index 74665b3..7e372c5 100644 --- a/src/sdlc_hub/modules/jira/api.py +++ b/src/sdlc_hub/modules/jira/api.py @@ -9,7 +9,7 @@ from urllib.parse import quote from ...core import db, files, queue, settings from ...core.errors import CommandError, HttpMapped 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 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)) +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): fields = {k: v for k, v in body.items() if k != "updated_by"} try: @@ -180,6 +187,7 @@ ROUTES = ( ("GET", r"^/issues$", h_issues, "client"), ("GET", r"^/issues/([^/]+)$", h_issue, "client"), ("GET", r"^/board$", h_board, "client"), + ("GET", r"^/agile$", h_agile, "client"), ("GET", r"^/changes$", h_changes, "client"), ("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"), ("GET", r"^/attachments/(\d+)$", h_attachment_get, "client"), diff --git a/tests/fixtures/baseline/routes.expected.json b/tests/fixtures/baseline/routes.expected.json index bf83729..e2e48ac 100644 --- a/tests/fixtures/baseline/routes.expected.json +++ b/tests/fixtures/baseline/routes.expected.json @@ -83,6 +83,12 @@ "client", "h_agents_get" ], + [ + "GET", + "^/agile$", + "client", + "h_agile" + ], [ "GET", "^/app(?:/(.*))?$", diff --git a/tests/test_agile.py b/tests/test_agile.py new file mode 100644 index 0000000..4ac9b4e --- /dev/null +++ b/tests/test_agile.py @@ -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"}