# Спринты и бэклог (хаб) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Хаб хранит доски, спринты, ранг и оценки задач от агента v15, отдаёт их `GET /agile`, ставит команды спринтов и ранга, показывает спринты и бэклог в MCP и на трёх страницах веба (только просмотр); «Доска связей» → «Связи». **Architecture:** Схема v9 модуля jira (`issue.rank`, `issue.story_points`, `jira_board`, `jira_sprint`, `issue_sprint`); запись — только `mirror.apply_state` по `POST /agent/state`; чтение — новый `modules/jira/agile.py` (`view`) за маршрутом `GET /agile`, его читают MCP и веб. Команды — расширение `commands.SPEC` и хуков очереди (`hooks.py`), хвостовой `refresh` по JQL проекта требует проекта пакета в хуке `tail` (правка контракта ядра). Веб — модуль jira: общая часть `web/src/modules/jira/agile/` и страницы «Бэклог», «Спринт», «Kanban»; пункты меню с условием по проекту (`NavItem.when`). **Tech Stack:** Python 3.13 (uv), stdlib HTTP, SQLite, pytest, пакет `mcp` (FastMCP); React 19 + TypeScript + Vite, TanStack Query, react-router 8, Vitest, Playwright. **Spec:** `openspec/changes/sprints-backlog/` (proposal.md, design.md — решения 1–26 и «Контракт данных», specs/{data-model,hub-api,commands,mcp,web-app,agent}/spec.md). Агентская часть (jira-agent v15) — отдельный план в репозитории jira-agent; в этом плане её нет. ## Global Constraints - Комментарии, docstring, тексты интерфейса, документация и сообщения коммитов — на русском; идентификаторы — английские. - Коммит: первая строка заканчивается «(#108)», последняя строка — `Co-Authored-By: Claude Opus 5.5 `. Не пушить. - Зелёным считается только `make check` целиком (`uv run pytest -q`, затем `cd web && npm run lint && npm test`); перед коммитом каждой задачи — `make check`. В задачах 8 и 12 дополнительно `cd web && npm run e2e` (тестовый хаб :8791; живой хаб :8790 и `~/.local/share/sdlc-hub` не трогать). - Перед правкой области — `mcp__codegraph__codegraph_explore` с `projectPath` = `/Users/andrey.epifancev/projects/jira-bridge/sdlc-hub/.wt/sprints`; пустой ответ — один раз отметить в ledger и дальше Read. - Новых зависимостей (Python и npm) нет. - Хаб не ходит в Jira. Зеркало (`issue`, `issue_sprint`, `jira_board`, `jira_sprint`) пишет только `mirror.apply_state` по `POST /agent/state`; API интерфейсов и MCP в эти таблицы не пишут. - Ядро (`core/`) не получает терминов модулей (`tests/test_registry.py::test_core_has_no_jira_terms`); исключение — история миграций в `core/db.py`. - MCP (`modules/jira/mcp.py`) ходит только через `core.client()`; `mirror`, `history`, `db`, `queue`, `agile` не импортирует. - Совместимость с агентом v14: пачка без `boards`, `rank`, `story_points`, `sprint_ids` ничего не меняет в этих данных; `min_agent_version` не поднимать (design #14). - Веб только смотрит (design #1): на новых страницах нет перетаскивания, кнопок спринтов, смены статуса или ранга. - Цвета — только токены `web/src/styles/tokens.css` (проверяет `tokens.test.ts`); стиль страниц — скилл `aura-design` (задачи 9–11). - Эталоны `tests/fixtures/baseline/*.expected.json` перезаписываются только `BASELINE_WRITE=1 uv run pytest tests/test_baseline.py`, затем `git diff tests/fixtures/baseline` — в diff только изменения этой задачи. - Темп по умолчанию новых типов — 30 с: `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank` (и в `DEFAULT_PACING`, и в `pacing_from_config` — как у `ensure_comment`). - Тексты из спеки — дословно: «команды спринта — только в пакете проекта»; «у проекта P нет доски Jira»; «у проекта P канбан-доска, спринтов нет»; «тип доски проекта P ещё не известен — дождитесь обновления из Jira»; «У проекта нет scrum-доски» / «У проекта нет kanban-доски»; «Нужен агент v15 и обновление из Jira»; «Не удалось прочитать настройки доски из Jira»; «Нет задач»; «Прошлые спринты (N)»; «не выполнено»; «Нет активного спринта»; «Без колонки»; «за 14 дней»; «Бэклог (N)»; «осталось N дн.»; «срок прошёл»; «у проекта нет доски»; «ещё N закрытых»; «ещё N». - Решения, принятые без обсуждения (раздел «Rulings при планировании» и новые при исполнении), дописываются в `openspec/changes/sprints-backlog/design.md`, раздел `## Decisions made without discussion` (дата, решение, обоснование, цена ошибки). ## Review Focus 1. **Время Jira со смещением без двоеточия** (`2026-10-14T09:00:00.000+0300` у полей задачи, `+03:00` у Agile REST) в датах спринтов и `jira_updated`: веб показывает «ДД.ММ», а не «—»/`NaN`; хаб сортирует спринты и отсекает 14 дней kanban по UTC при смешанных смещениях. Тесты: задача 9 (`model.test.ts`, «±HHMM»), задача 3 (`test_sprint_order_mixed_offsets`, `test_kanban_done_cutoff_with_offsets`). 2. **Устаревший `?sprint=`** в адресе «Спринта» (спринт закрыт, чужой id, мусор) → показан первый активный спринт, а не пустая доска. Тест: задача 10 (`«устаревший ?sprint= — первый активный»`). 3. **Доска, общая для двух проектов** (метки `AWPCA` и `C` на доске 4216): тип из `boards` пишется обоим проектам, `GET /agile` каждого — свои задачи, спринты общие. Тесты: задача 2 (`test_state_board_type_to_all_projects_of_board`), задача 3 (`test_shared_board_own_issues_only`). 4. **Повтор команды спринта после смены доски** проекта (другая доска или kanban) → новый `board_id` или 422 «канбан-доска», не старый `board_id` из прежнего пакета. Тест: задача 5 (`test_retry_sprint_state_after_board_became_kanban`). 5. **Нечисловые оценки** (`NaN`, `Infinity`, `true`, строка `"3"`) в состоянии и в `ensure_fields` → 422, пачка/пакет не применены. Тесты: задача 2 (параметры `test_agent_state_bad_agile_fields_422`), задача 4 (параметры `test_new_types_errors`). ## Rulings при планировании Нумерация продолжает design.md (R-1…R-12). Задача 1 переносит их в design.md. - **R-13 (контракт ядра):** `CommandHooks.tail` получает `(conn, cmds, project)` вместо `(cmds)`: хвост пакета со спринтовыми командами — `refresh` по JQL проекта пакета (design #13), а без соединения и проекта хук JQL не построит. Правится ядро (`core/modules.py`, `core/queue.py`), wiki (`tail=lambda conn, cmds, project: None`) и две заглушки в тестах. Цена ошибки — вернуть сигнатуру и строить хвост в `check_batch` (дописывая `refresh` в конец пакета). - **R-14 (форма `boards`):** у доски в `boards` поле `sprints` отсутствует или `null` — спринты доски в зеркале не меняются (агент не смог их прочитать, agent spec «прежние правила для спринтов»); `[]` — у доски спринтов нет, строки удаляются. `columns` отсутствует или `null` — пишется `NULL`. `boards: null` в пачке — как отсутствие. Цена ошибки — устаревшие спринты на экране до следующего `refresh` с `sprints: []`. - **R-15 (проверки постановки):** хаб проверяет `start_date`/`end_date` (`ГГГГ-ММ-ДД`, настоящая дата, начало не позже конца), непустые `sprint`/`rename_to`, `state` ∈ {`active`, `closed`}, `move_open_to` (обязателен при `closed`, 422 при `active`, не равен `sprint`), у `ensure_rank` ровно одно из `before`/`after` и не сама задача, `story_points` — конечное число ≥ 0 или `null`. Цена ошибки — правка проверок. - **R-16 (подтверждение по зеркалу):** `ensure_fields` со `story_points` подтверждается, только если `issue.story_points == params.story_points` точно (`null` = `null`, `0` ≠ `null`); прочие поля — как раньше. Цена ошибки — лишняя довыдача. - **R-17 (`GET /agile`):** `board_type = null` → `sprints: []`, `backlog: null`, `board_issues: null`; scrum-бэклог исключает задачи в активном/будущем спринте любой доски из `jira_sprint` (спринт, которого нет в `jira_sprint`, не учитывается); kanban: выполненная задача без `jira_updated` не показывается; `kanban_backlog` в ответе — `true`/ `false`/`null`; суммы `points`/`points_done` — сумма чисел (без оценок — `0`). Цена ошибки — правка выборки. - **R-18 (Kanban с бэклогом):** при `kanban_backlog = true` первая колонка доски не рисуется — её задачи в «Бэклог (N)» (как kanban-бэклог Jira). Цена ошибки — вернуть колонку. - **R-19 (три состояния):** «Нужен агент v15 и обновление из Jira» — `board = null` или `board.fetched_at = null`; «Не удалось прочитать настройки доски из Jira» — `fetched_at` есть, `columns = null` (design #23 вместо противоречивой фразы web-app «`board.columns = null` или ответ без доски»). Цена ошибки — правка условия. - **R-20 (кнопка «Обновить»):** на экранах — существующая `RefreshButton` («Обновить из Jira», в шапке и в сообщениях трёх состояний). Правило «кнопок нет, кроме раскрытия секций и карточки» к ней не относится: она читает Jira, а не пишет. Цена ошибки — убрать кнопку из шапки. - **R-21 (переименование):** «Связи» — подпись пункта меню, заголовок вкладки и ссылка в карточке jira на «Обзоре»; заголовок `h1` страницы остаётся «Доска связей» (спека говорит о меню и вкладке). Цена ошибки — правка `BoardHeader` и e2e `geometry.spec.ts`. - **R-22 (смена проекта):** фраза «Проект в приложении» («смена проекта на странице, которой нет у нового проекта, открывает «Обзор»») не меняется в коде: других условных страниц нет, а для экранов доски действует design #21 (адрес остаётся, сообщение «У проекта нет …-доски») — `Layout.select` уже сохраняет раздел. Цена ошибки — переход на «Обзор» для новых страниц. - **R-23 (NotFound):** «не найдено» перечисляет только безусловные пункты (без `when`). Цена ошибки — нет. - **R-24 (склонение):** «N задач» — с русским склонением (1 задача, 2 задачи, 5 задач). Цена ошибки — нет. - **R-25 (тексты MCP):** шапки таблиц и отказы `sprints`/`sprint`/`backlog` — по задаче 7. Цена ошибки — правка текста. - **R-26 (даты):** хаб сортирует спринты по `history.jira_utc(дата)`; веб перед `new Date` приводит `±HHMM` к `±HH:MM`. Цена ошибки — нет. - **R-27 (e2e):** тестовый хаб засевает второй проект `KANBAN_E2E` (доска 5120, kanban, задачи AURORA-9101…9103); доска AWPCA — scrum со спринтами 101 (закрыт), 102 (активный), 103 (будущий). Цена ошибки — правка сида. --- ### Task 1: Схема v9 и миграция v8 → v9 **Files:** - Modify: `src/sdlc_hub/modules/jira/schema.py` (таблица `issue`, новые таблицы в конце `SQL`) - Modify: `src/sdlc_hub/core/db.py:11` (`SCHEMA_VERSION`), `:119-124` (`_add_column`), `:128-139` (`MIGRATIONS`) - Modify: `tests/test_db.py` (новый `_v8_database`, тесты, `== 8` → `== db.SCHEMA_VERSION` в строках 405, 419, 431) - Modify: `tests/fixtures/baseline/schema.expected.json` (перезапись) - Modify: `ARCHITECTURE.md` (строка `db` — «v9»; строка `schema` модуля jira — новые таблицы; раздел «Выкатка и откат версии с новой схемой» — абзац v9) - Modify: `openspec/changes/sprints-backlog/design.md` (раздел «Decisions made without discussion» — R-13…R-27 из этого плана) **Interfaces:** - Produces: колонки `issue.rank TEXT`, `issue.story_points REAL`; таблицы `jira_board(board_id INTEGER PRIMARY KEY, type TEXT, columns TEXT, kanban_backlog INTEGER, fetched_at TEXT NOT NULL)`, `jira_sprint(sprint_id INTEGER PRIMARY KEY, board_id INTEGER NOT NULL, name TEXT NOT NULL, state TEXT NOT NULL, start_date TEXT, end_date TEXT, complete_date TEXT, goal TEXT)` + индекс `ix_jira_sprint_board(board_id)`, `issue_sprint(key TEXT NOT NULL, sprint_id INTEGER NOT NULL, PRIMARY KEY (key, sprint_id))` + индекс `ix_issue_sprint_sprint(sprint_id)`; `db.SCHEMA_VERSION == 9`; `db._add_column(table: str, column: str, decl: str = "TEXT")`. - [ ] **Step 1: Write the failing tests** (`tests/test_db.py`) ```python def _v8_database(path): """База v8: задача X со спринтом «Спринт 2», без колонок rank/story_points и таблиц досок (sprints-backlog).""" conn = db.connect(path) settings.ensure_defaults(conn) conn.execute("INSERT INTO issue(key, summary, sprint, labels, fetched_at) VALUES ('X', 'т', 'Спринт 2', '[]', ?)", (db.now(),)) for col in ("rank", "story_points"): conn.execute(f"ALTER TABLE issue DROP COLUMN {col}") for t in ("jira_board", "jira_sprint", "issue_sprint"): conn.execute(f"DROP TABLE {t}") conn.execute("UPDATE setting SET value = '8' WHERE key = 'schema_version'") conn.close() def test_migrates_v8_adds_agile_columns(tmp_path): # сценарий «Миграция базы v8» path = tmp_path / "hub.sqlite"; _v8_database(path) conn = db.connect(path) assert db._schema_version(conn) == 9 row = conn.execute("SELECT rank, story_points, sprint FROM issue WHERE key = 'X'").fetchone() assert (row["rank"], row["story_points"], row["sprint"]) == (None, None, "Спринт 2") types = {r["name"]: r["type"] for r in conn.execute("PRAGMA table_info(issue)")} assert types["rank"] == "TEXT" and types["story_points"] == "REAL" for t in ("jira_board", "jira_sprint", "issue_sprint"): assert conn.execute(f"SELECT COUNT(*) AS n FROM {t}").fetchone()["n"] == 0 assert len(list((tmp_path / "backups").glob("*-schema-v8.sqlite"))) == 1 conn.close() def test_v9_reconnect_is_stable(tmp_path): # повторный connect не мигрирует и не копирует path = tmp_path / "hub.sqlite"; _v8_database(path) db.connect(path).close(); db.connect(path).close() assert len(list((tmp_path / "backups").glob("*-schema-v*.sqlite"))) == 1 ``` В `test_connect_creates_schema` добавить: `{"jira_board", "jira_sprint", "issue_sprint"} <= names` и `{"rank", "story_points"} <= {r["name"] for r in conn.execute("PRAGMA table_info(issue)")}`. - [ ] **Step 2: Run tests to verify they fail** Run: `uv run pytest tests/test_db.py -q` Expected: FAIL — `no such column: rank` в `_v8_database` / `schema_version == 8`. - [ ] **Step 3: Implement** `schema.py`: в `issue` после `sprint` — `rank TEXT`, `story_points REAL`; новые таблицы и индексы из Interfaces. Индексов по новым колонкам `issue` в `SQL` не заводить: `executescript(schema())` идёт до миграции, на базе v8 колонок ещё нет. `db.py`: `_add_column(table, column, decl="TEXT")` (`ALTER TABLE … ADD COLUMN {column} {decl}`, если колонки нет); `MIGRATIONS[8] = [_add_column("issue", "rank"), _add_column("issue", "story_points", "REAL")]` с комментарием «v8 → v9: ранг и оценка задачи; таблицы досок и спринтов создаются схемой (sprints-backlog)»; `SCHEMA_VERSION = 9`. Старые сборщики баз в тестах (`_v1_database`… `_v7_database`) не трогать: шаг идемпотентен. - [ ] **Step 4: Run tests, rewrite schema baseline** Run: `uv run pytest tests/test_db.py -q` → PASS. Run: `BASELINE_WRITE=1 uv run pytest tests/test_baseline.py::test_schema -q && git diff tests/fixtures/baseline/schema.expected.json` Expected: в diff — `schema_version` 9, колонки `rank`/`story_points` у `issue`, три таблицы и два индекса; больше ничего. - [ ] **Step 5: Docs, design.md, `make check`, commit** ARCHITECTURE: `db` — «миграции … (v9 …)»; `schema` jira — `jira_board`, `jira_sprint`, `issue_sprint`; в разделе выкатки (заголовок → «(v7, v8, v9)») абзац: v9 добавляет колонки и таблицы, прежний код с базой v9 работает (новые колонки и таблицы не читает), откат кода — без возврата копии базы. design.md — R-13…R-27 (дата 24.10.2026, по строке, с ценой). Run: `make check` → PASS. ```bash git add src/sdlc_hub/core/db.py src/sdlc_hub/modules/jira/schema.py tests/test_db.py tests/fixtures/baseline/schema.expected.json ARCHITECTURE.md openspec/changes/sprints-backlog/design.md git commit -m "Схема v9: ранг, оценка, доски и спринты (#108)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 2: Приём досок, спринтов, ранга и оценок в `POST /agent/state` **Files:** - Modify: `src/sdlc_hub/modules/jira/mirror.py` (новые `check_agile_fields`, `check_boards`; `apply_state`) - Modify: `src/sdlc_hub/modules/jira/api.py:23-35` (`h_agent_state`) - Test: `tests/test_mirror.py`, `tests/test_api.py` - Modify: `README.md` (строка `POST /agent/state` таблицы «HTTP API») **Interfaces:** - Consumes: таблицы задачи 1; `projects.record_board_types(conn, {"": type}) -> int`. - Produces: - `mirror.SPRINT_STATES = ("active", "future", "closed")` - `mirror.check_agile_fields(issues: list[dict]) -> None` — `ValueError` с текстом `": rank — строка или null"`, `": story_points — число или null"` (bool, `NaN`, `±Infinity` — нарушение), `": sprint_ids — список целых"` (`null`, bool, строки — нарушение). Поле не прислано — не проверяется. - `mirror.check_boards(boards) -> None` — `None` → ничего; не список → `"boards — список досок"`; элемент не объект или `id` не целое > 0 → `"boards[]: …"`; дальше префикс `"доска : "` и поле: `type — scrum, kanban или null`, `columns — список колонок или null`, `columns[].name — строка`, `columns[].statuses — список строк`, `kanban_backlog — true, false или null`, `sprints — список спринтов или null`, `sprints[].id — целое > 0`, `sprints[].name — непустая строка`, `sprints[].state — active, future или closed`, `sprints[]. — строка или null`. - `mirror.apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authoritative=True, boards=None) -> dict` (возврат прежний `{"issues", "links"}`). - [ ] **Step 1: Write the failing tests** (`tests/test_mirror.py`; в фикстуре `conn` добавить нужный `add_project`, где он используется) ```python COLS = [{"name": "К выполнению", "statuses": ["Backlog"]}, {"name": "Готово", "statuses": ["Готово"]}] 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 sprint_rows(conn, key): return sorted(r[0] for r in conn.execute("SELECT sprint_id FROM issue_sprint WHERE key = ?", (key,))) def test_state_issue_in_two_sprints(conn): # сценарий «Задача прошла два спринта» mirror.apply_state(conn, [issue("X", sprint_ids=[101, 102])], [], boards=[board(sprints=[sprint(101, "closed"), sprint(102, "active")])]) assert sprint_rows(conn, "X") == [101, 102] def test_state_sprint_ids_replace_keep_clear(conn): mirror.apply_state(conn, [issue("X", sprint_ids=[101, 102])], []) mirror.apply_state(conn, [issue("X", sprint_ids=[102])], []); assert sprint_rows(conn, "X") == [102] mirror.apply_state(conn, [issue("X")], []); assert sprint_rows(conn, "X") == [102] # агент v14 mirror.apply_state(conn, [issue("X", sprint_ids=[])], []); assert sprint_rows(conn, "X") == [] def test_state_rank_and_points_set_keep_clear(conn): mirror.apply_state(conn, [issue("X", rank="0|i0001:", story_points=3)], []) mirror.apply_state(conn, [issue("X", summary="другая")], []) # без полей — прежние значения row = conn.execute("SELECT rank, story_points FROM issue WHERE key = 'X'").fetchone() assert (row["rank"], row["story_points"]) == ("0|i0001:", 3.0) mirror.apply_state(conn, [issue("X", rank=None, story_points=None)], []) row = conn.execute("SELECT rank, story_points FROM issue WHERE key = 'X'").fetchone() assert (row["rank"], row["story_points"]) == (None, None) def test_state_board_replaces_row_and_sprints(conn): # сценарий «Пачка с досками» mirror.apply_state(conn, [], [], boards=[board(sprints=[sprint(101, "active", "старое"), sprint(102, "future")]), board(id=5120, type="kanban", sprints=[sprint(201, "active")])]) new = [sprint(101, "closed", complete_date="2026-10-14T18:00:00.000+03:00"), sprint(102, "active"), sprint(103, "future")] mirror.apply_state(conn, [], [], boards=[board(columns=COLS[:1], sprints=new)]) rows = {r["sprint_id"]: dict(r) for r in conn.execute("SELECT * FROM jira_sprint WHERE board_id = 4216")} assert set(rows) == {101, 102, 103} and rows[101]["state"] == "closed" and rows[101]["name"] == "S101" assert rows[101]["complete_date"] == "2026-10-14T18:00:00.000+03:00" b = conn.execute("SELECT * FROM jira_board WHERE board_id = 4216").fetchone() assert json.loads(b["columns"]) == COLS[:1] and b["kanban_backlog"] is None and b["fetched_at"] assert [r[0] for r in conn.execute("SELECT sprint_id FROM jira_sprint WHERE board_id = 5120")] == [201] # не в пачке — не тронута def test_state_board_without_sprints_keeps_them(conn): # R-14 mirror.apply_state(conn, [], [], boards=[board(sprints=[sprint(101, "active")])]) mirror.apply_state(conn, [], [], boards=[{**board(), "sprints": None, "columns": None}]) assert [r[0] for r in conn.execute("SELECT sprint_id FROM jira_sprint")] == [101] assert conn.execute("SELECT columns FROM jira_board").fetchone()[0] is None mirror.apply_state(conn, [], [], boards=[board(sprints=[])]) assert conn.execute("SELECT COUNT(*) FROM jira_sprint").fetchone()[0] == 0 def test_state_board_type_to_all_projects_of_board(conn, add_project): # Review Focus 3 add_project(conn, board_type=None); add_project(conn, "C", board_id=4216, board_type=None) mirror.apply_state(conn, [], [], boards=[board(type="kanban", kanban_backlog=True)]) assert {r["label"]: r["board_type"] for r in conn.execute("SELECT label, board_type FROM jira_project")} == {"AWPCA": "kanban", "C": "kanban"} assert tuple(conn.execute("SELECT type, kanban_backlog FROM jira_board").fetchone()) == ("kanban", 1) mirror.apply_state(conn, [], [], boards=[board(type=None)]) # null тип проекта не меняет assert conn.execute("SELECT board_type FROM jira_project WHERE label = 'AWPCA'").fetchone()[0] == "kanban" def test_state_v14_keeps_agile_data(conn): # сценарий «Агент v14» mirror.apply_state(conn, [issue("X", rank="r", story_points=2, sprint_ids=[101])], [], boards=[board(sprints=[sprint(101, "active")])]) mirror.apply_state(conn, [issue("X")], []) assert sprint_rows(conn, "X") == [101] and conn.execute("SELECT COUNT(*) FROM jira_sprint").fetchone()[0] == 1 assert tuple(conn.execute("SELECT rank, story_points FROM issue").fetchone()) == ("r", 2.0) ``` `tests/test_api.py`: ```python @pytest.mark.parametrize("over, detail", [ ({"sprint_ids": None}, "A-1: sprint_ids"), ({"sprint_ids": ["1"]}, "A-1: sprint_ids"), ({"sprint_ids": [True]}, "A-1: sprint_ids"), ({"story_points": "3"}, "A-1: story_points"), ({"story_points": True}, "A-1: story_points"), ({"story_points": float("nan")}, "A-1: story_points"), ({"story_points": float("inf")}, "A-1: story_points"), # Review Focus 5 ({"rank": 5}, "A-1: rank"), ]) def test_agent_state_bad_agile_fields_422(hub, over, detail): with pytest.raises(HubError) as e: hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": [], **over}], "links": []}) assert e.value.status == 422 and e.value.error == "bad_state" and detail in e.value.detail assert hub["client"].get("/issues")["issues"] == [] # пачка не применена @pytest.mark.parametrize("boards, detail", [ ("x", "boards — список досок"), ([{"id": "4216"}], "boards[0].id"), ([{"id": 4216, "type": "scrum", "columns": None, "kanban_backlog": None, "sprints": [{"id": 1, "name": "S", "state": "closing"}]}], "доска 4216: sprints[0].state — active, future или closed"), ([{"id": 4216, "type": "sprint"}], "доска 4216: type"), ([{"id": 4216, "type": "kanban", "kanban_backlog": "да"}], "доска 4216: kanban_backlog"), ]) def test_agent_state_bad_boards_422(hub, boards, detail): ... # тот же вызов с issues: [], links: [], boards — 422 bad_state, detail def test_agent_state_empty_batch_with_boards(hub): ... # {"issues": [], "links": [], "boards": [board]} → 200; GET /agile (задача 3) ещё нет — проверка строкой jira_board через hub["hub"].conn() ``` (Вызов `HubClient.post` сериализует `NaN`/`Infinity` литералами — сервер их разбирает `json.loads`, поэтому проверка конечности нужна в `check_agile_fields`.) - [ ] **Step 2: Run tests to verify they fail** Run: `uv run pytest tests/test_mirror.py tests/test_api.py -q -k "state"` Expected: FAIL — `apply_state() got an unexpected keyword argument 'boards'`, у задачи нет колонки в upsert. - [ ] **Step 3: Implement** `apply_state`: `rank` и `story_points` попадают в upsert только если ключ есть в словаре задачи (строка `cols` собирается по задаче; `ISSUE_COLUMNS` не расширять — иначе агент v14 затрёт значения `NULL`); `sprint_ids` — список → `DELETE FROM issue_sprint WHERE key = ?` и вставка. `boards` (список) — в той же транзакции: `INSERT OR REPLACE INTO jira_board` (`columns` — `json.dumps(..., ensure_ascii=False)` или `NULL`, `kanban_backlog` — 1/0/`NULL`, `fetched_at = fetched_at`); `sprints` — список → `DELETE FROM jira_sprint WHERE board_id = ?` и `INSERT OR REPLACE` по каждому, иначе не трогать (R-14); `type in projects.BOARD_TYPES` → `projects.record_board_types(conn, {str(id): type})`. `h_agent_state`: `boards = body.get("boards")`; до транзакции `mirror.check_agile_fields(issues)` и `mirror.check_boards(boards)` (в том же `try` → `ApiError(422, "bad_state", str(e))`); передать `boards=boards`. Docstring `apply_state` дополнить: поля v15 и правило «без поля — прежнее значение». - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/test_mirror.py tests/test_api.py -q` → PASS. - [ ] **Step 5: README, `make check`, commit** README, строка `POST /agent/state`: «загрузка состояния задач пачками; агент v15 — у задач `rank`, `story_points`, `sprint_ids` (заменяет спринты задачи), в пачке `boards` (доска заменяет свою строку и, если `sprints` — список, свои спринты; тип доски пишется проектам с этой доской); без полей — данные прежние; неверная форма — 422 `bad_state`». Run: `make check` → PASS. ```bash git add src/sdlc_hub/modules/jira/mirror.py src/sdlc_hub/modules/jira/api.py tests/test_mirror.py tests/test_api.py README.md git commit -m "Приём досок, спринтов, ранга и оценок от агента v15 (#108)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 3: `GET /agile` — доска, спринты, бэклог **Files:** - Create: `src/sdlc_hub/modules/jira/agile.py` - Modify: `src/sdlc_hub/modules/jira/api.py` (обработчик `h_agile`, строка в `ROUTES`) - Test: `tests/test_agile.py` (новый) - Modify: `tests/fixtures/baseline/routes.expected.json` (перезапись) - Modify: `README.md` (строка `GET /agile` в «HTTP API»), `ARCHITECTURE.md` (строка `agile` в таблице модуля jira, `api` — `/agile`) **Interfaces:** - Consumes: `mirror.apply_state(..., boards=)` (задача 2) — в тестах для данных; `history.jira_utc(value) -> str | None`. - Produces: - `agile.KANBAN_DONE_DAYS = 14` - `agile.view(conn, label: str, now: datetime | None = None) -> dict` — `{board, sprints, backlog, board_issues}` по «Контракту данных» design.md; строка задачи — `{key, summary, issue_type, status, priority, assignee, epic_key, story_points, rank, done}`; спринт — `{id, name, state, start_date, end_date, complete_date, goal, count, count_done, unestimated, points, points_done, issues}`; `board` — `{id, type, columns, kanban_backlog: bool | None, fetched_at}` (тип — из `jira_project.board_type`). - `api.h_agile(hub, conn, m, q, body)`: без `project` — `ApiError(422, "bad_request", "project — метка проекта")`; неизвестный — 404 (`project_param`). Маршрут `("GET", r"^/agile$", h_agile, "client")`. - [ ] **Step 1: Write the failing tests** (`tests/test_agile.py`) Фикстура `conn(open_db, add_project)`: база в памяти, `settings.ensure_defaults`, `add_project(conn)` (AWPCA 4216 scrum). Хелперы `issue(key, status, **over)` (метки `["AWPCA"]`), `load(conn, issues, boards)` через `mirror.apply_state`, `keys(rows) -> list[str]`. Колонки `COLS = [К выполнению: Backlog, Ожидает анализа; В работе: В работе; Готово: Готово, Протестировано, Снято]`. ```python 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"] 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 # та же доска с kanban_backlog=False → backlog is None, K1 в board_issues def test_kanban_done_cutoff_with_offsets(conn, add_project): # Review Focus 1: граница 14 дней по UTC # now = 2026-10-24T12:00Z; jira_updated "2026-10-10T14:30:00.000+0300" (11:30Z, старше 14 дней) — нет; # "2026-10-10T15:30:00.000+0300" (12:30Z) — есть def test_agile_route(hub_server, add_project): # без project → 422 bad_request; ?project=NOPE → 404; ?project=AWPCA → 200 и ключи {"board", "sprints", "backlog", "board_issues"} ``` - [ ] **Step 2: Run tests to verify they fail** Run: `uv run pytest tests/test_agile.py -q` Expected: FAIL — `ImportError: cannot import name 'agile'`. - [ ] **Step 3: Implement `agile.view` and `h_agile`** Чтение в одной транзакции чтения (как `mirror.board`: `BEGIN` … `COMMIT`, если транзакции нет). Свои задачи — метка проекта (`json_each(labels)`), как `own` в `SCOPE_CTE`. Членство — `issue_sprint` своих задач; спринты доски — `jira_sprint WHERE board_id`. «Выполнено» — `status` в `statuses` последней колонки (`columns` NULL — никто). Сортировка задач — ключ `(rank is None, rank or "", key)`; спринтов — `active` по `jira_utc(start_date)` (None — в конце), `future` по id, `closed` по `jira_utc(complete_date)` по убыванию (None — в конце), при равенстве — id. Scrum-бэклог — свои задачи, не состоящие в спринте с `state` `active`/`future` (по `jira_sprint` любой доски, R-17), и не выполненные. Kanban: `sprints = []`; бэклог — статусы первой колонки при `kanban_backlog = 1`, иначе `None`; `board_issues` — остальные свои задачи; выполненная — только если `jira_utc(jira_updated)` не старше `now - 14 дней` (без `jira_updated` — нет). `board_type = NULL` → `sprints: []`, `backlog: None`, `board_issues: None`. - [ ] **Step 4: Run tests, rewrite routes baseline** Run: `uv run pytest tests/test_agile.py -q` → PASS. Run: `BASELINE_WRITE=1 uv run pytest tests/test_baseline.py::test_routes -q && git diff tests/fixtures/baseline/routes.expected.json` Expected: одна новая строка `["GET", "^/agile$", "client", "h_agile"]`. - [ ] **Step 5: Docs, `make check`, commit** README: строка `GET /agile` — «`?project=<метка>` (без — 422, неизвестная — 404): доска проекта (`board`, тип из проекта; `null` — доски нет), все спринты доски со своими задачами и суммами оценок (`count`, `count_done`, `unestimated`, `points`, `points_done`), `backlog` (scrum — свои задачи вне активных и будущих спринтов, не выполненные; kanban — первая колонка, если бэклог доски включён), `board_issues` (kanban: остальные колонки, выполненные — за 14 дней); списки — по рангу». ARCHITECTURE: строка `agile` («чтение для `GET /agile`: доска, спринты, бэклог, выполнено по последней колонке; зависит от `core.db`, `history`»), в `api` — `/agile`, в цепочке модуля jira — `api → agile → history`. Run: `make check` → PASS. ```bash git add src/sdlc_hub/modules/jira/agile.py src/sdlc_hub/modules/jira/api.py tests/test_agile.py tests/fixtures/baseline/routes.expected.json README.md ARCHITECTURE.md git commit -m "GET /agile: доска, спринты и бэклог проекта (#108)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 4: Контракт команд: новые типы, `story_points`, темп **Files:** - Modify: `src/sdlc_hub/modules/jira/commands.py` (`SPEC`, `FIELD_HINTS`, `_type_error`, `validate`, `check_confirmed`) - Modify: `src/sdlc_hub/modules/jira/settings.py:5-6` (`DEFAULT_PACING`), `:74-78` (`pacing_from_config`) - Test: `tests/test_commands.py`, `tests/test_settings.py` - Modify: `tests/fixtures/baseline/settings.expected.json`, `tests/fixtures/baseline/mcp_tools.expected.json` (перезапись: описание `batch_create` собирается из `SPEC`) - Modify: `README.md` (раздел «Контракт команд»: строки таблицы и абзац о зазорах) **Interfaces:** - Produces: - `commands.SPEC["ensure_sprint_meta"] = {"required": ["sprint"], "optional": ["rename_to", "start_date", "end_date", "goal"], "keys": [], "hints": {"sprint": "имя спринта", "rename_to": "новое имя", "start_date": "YYYY-MM-DD", "end_date": "YYYY-MM-DD", "goal": "цель спринта"}}` - `commands.SPEC["ensure_sprint_state"] = {"required": ["sprint", "state"], "optional": ["move_open_to"], "keys": [], "hints": {"state": "active|closed", "move_open_to": "имя спринта или backlog; обязателен при closed"}}` - `commands.SPEC["ensure_rank"] = {"required": ["key"], "optional": ["before", "after"], "keys": ["key", "before", "after"], "hints": {"before": "ключ задачи, выше которой", "after": "ключ задачи, ниже которой"}, "note": "ровно одно из before, after"}` - `SPEC["ensure_fields"]["optional"]` += `"story_points"`, hint `"число ≥ 0 или null"`. - `commands.SPRINT_TYPES = ("ensure_sprint_meta", "ensure_sprint_state")`; `commands.NUMBER_PARAMS = {"story_points"}`. - `settings.DEFAULT_PACING` += `"ensure_sprint_meta": 30, "ensure_sprint_state": 30, "ensure_rank": 30`; `pacing_from_config` — то же значение, что `ensure_comment`. - [ ] **Step 1: Write the failing tests** (`tests/test_commands.py`, `tests/test_settings.py`) ```python META = {"type": "ensure_sprint_meta", "params": {"sprint": "AWPCA_Sprint_4", "start_date": "2026-10-28", "end_date": "2026-11-11", "goal": "свод ВГО"}} def state(**p): return {"type": "ensure_sprint_state", "params": {"sprint": "AWPCA_Sprint_3_Corrections", **p}} def rank(**p): return {"type": "ensure_rank", "params": {"key": "AURORA-1612", **p}} def test_new_types_validate_ok(): out = commands.validate([META, state(state="active"), state(state="closed", move_open_to="backlog"), rank(before="AURORA-1601"), {"type": "ensure_fields", "params": {"key": "A-1", "story_points": 2.5}}, {"type": "ensure_fields", "params": {"key": "A-1", "story_points": None}}]) assert [c["type"] for c in out][:4] == ["ensure_sprint_meta", "ensure_sprint_state", "ensure_sprint_state", "ensure_rank"] @pytest.mark.parametrize("cmd, msg", [ ({**META, "params": {**META["params"], "start_date": "28.10.2026"}}, "start_date — дата ГГГГ-ММ-ДД"), ({**META, "params": {**META["params"], "end_date": "2026-02-30"}}, "end_date — дата ГГГГ-ММ-ДД"), ({**META, "params": {**META["params"], "start_date": "2026-11-12"}}, "start_date позже end_date"), ({"type": "ensure_sprint_meta", "params": {"sprint": " "}}, "sprint — непустое имя"), ({"type": "ensure_sprint_meta", "params": {"sprint": "S", "rename_to": " "}}, "rename_to — непустое имя"), ({"type": "ensure_sprint_meta", "params": {"sprint": "S", "board_id": 4216}}, "лишние параметры board_id"), (state(state="open"), "state — active или closed"), (state(state="closed"), "move_open_to обязателен при state = closed"), (state(state="active", move_open_to="backlog"), "move_open_to — только при state = closed"), (state(state="closed", move_open_to="AWPCA_Sprint_3_Corrections"), "move_open_to — другой спринт"), (rank(), "ровно одно из before, after"), (rank(before="A-2", after="A-3"), "ровно одно из before, after"), (rank(before="AURORA-1612"), "before — другая задача"), (rank(after="AURORA-1612"), "after — другая задача"), ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": -1}}, "story_points — число ≥ 0 или null"), ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": "3"}}, "story_points — число ≥ 0 или null"), ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": True}}, "story_points — число ≥ 0 или null"), ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("nan")}}, "story_points — число ≥ 0 или null"), # Review Focus 5 ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("inf")}}, "story_points — число ≥ 0 или null"), ]) def test_new_types_errors(cmd, msg): with pytest.raises(CommandError, match=re.escape(msg)): commands.validate([cmd]) def test_rank_temp_keys(): ok = [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Н"}}, rank(after="new:1")] assert commands.temp_keys_used(commands.validate(ok)[1]) == ["new:1"] with pytest.raises(CommandError, match="временный ключ new:2 не объявлен"): commands.validate([rank(before="new:2")]) def test_new_types_not_confirmed_by_state(conn): # design #18 for cmd in (META, state(state="active"), rank(before="A-2")): assert commands.check_confirmed(conn, cmd, {}) == (False, None) def test_story_points_confirmation(conn): # R-16 mirror.apply_state(conn, [{"key": "A-1", "labels": [], "story_points": 3}, {"key": "A-2", "labels": []}], []) sp = lambda key, v: {"type": "ensure_fields", "params": {"key": key, "story_points": v}} assert commands.check_confirmed(conn, sp("A-1", 3), {})[0] is True assert commands.check_confirmed(conn, sp("A-2", 0), {})[0] is False assert commands.check_confirmed(conn, sp("A-2", None), {})[0] is True ``` `tests/test_settings.py`: ```python def test_sprint_types_pacing(conn): for t in ("ensure_sprint_meta", "ensure_sprint_state", "ensure_rank"): assert jira_settings.DEFAULT_PACING[t] == 30 and jira_settings.pacing(conn)[t] == 30 assert jira_settings.pacing_from_config({"comment_seconds": 45})[t] == 45 settings.set_(conn, "pacing", {"ensure_fields": 5}) # тип не в сохранённой настройке — умолчание assert jira_settings.pacing(conn)["ensure_rank"] == 30 settings.validate("pacing", {"ensure_rank": 10}) assert queue.estimate(conn, ["ensure_sprint_meta", "ensure_sprint_state", "ensure_rank"]) == 90 ``` - [ ] **Step 2: Run tests to verify they fail** Run: `uv run pytest tests/test_commands.py tests/test_settings.py -q` Expected: FAIL — `неизвестный тип «ensure_sprint_meta»`, `KeyError: 'ensure_rank'`. - [ ] **Step 3: Implement** `_type_error`: `NUMBER_PARAMS` — `int`/`float`, не `bool`, `math.isfinite`, `≥ 0`, иначе `"story_points — число ≥ 0 или null"`. Проверки R-15 — в `validate` после проверки типов, сообщения `f"команда {i} ({ctype}): <текст>"` (тексты — из теста). Дата — `^\d{4}-\d{2}-\d{2}$` и `date.fromisoformat`. `check_confirmed`: новые типы — в кортеж «не подтверждается»; для `ensure_fields` со `story_points` — точное сравнение (R-16), до цикла `FIELD_PARAMS`. - [ ] **Step 4: Run tests, rewrite baselines** Run: `uv run pytest tests/test_commands.py tests/test_settings.py -q` → PASS. Run: `BASELINE_WRITE=1 uv run pytest tests/test_baseline.py -q && git diff tests/fixtures/baseline` Expected: `settings.expected.json` — три новых ключа `pacing`; `mcp_tools.expected.json` — в описании `batch_create` строки `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank` и `story_points (число ≥ 0 или null)` у `ensure_fields`. - [ ] **Step 5: README, `make check`, commit** README «Контракт команд»: у `ensure_fields` — `story_points` (число ≥ 0 или `null` — очистить); строки `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank` (параметры и смысл по спеке commands, результаты `{sprint_id, created}`, `{sprint_id, moved}`, `{changed}`); абзац о зазорах — новые типы 30 с. Run: `make check` → PASS. ```bash git add src/sdlc_hub/modules/jira/commands.py src/sdlc_hub/modules/jira/settings.py tests/test_commands.py tests/test_settings.py tests/fixtures/baseline/settings.expected.json tests/fixtures/baseline/mcp_tools.expected.json README.md git commit -m "Команды спринта и ранга, оценка в ensure_fields (#108)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 5: Постановка команд спринта: доска пакета, проект, повтор **Files:** - Modify: `src/sdlc_hub/modules/jira/hooks.py:54-76` (тексты ошибок доски), `:79-123` (`_check_project`), `:126-130` (`check_batch`), `:251-253` (`retry_params`) - Test: `tests/test_queue.py` (раздел «доски») - Modify: `README.md` («Контракт команд»: `board_id` у команд спринта), `ARCHITECTURE.md` (строка `hooks`) **Interfaces:** - Consumes: `commands.SPRINT_TYPES` (задача 4); `jira_projects.light_list(conn) -> list[dict]` (`label`, `board_id`, `board_type`). - Produces: - `hooks._board_error(label: str, board_id: int | None, board_type: str | None) -> str | None` — общие тексты для `ensure_sprint` и команд спринта: «у проекта {label} нет доски Jira», «у проекта {label} канбан-доска, спринтов нет», «тип доски проекта {label} ещё не известен — дождитесь обновления из Jira»; `None` — доска годится. - `hooks._assign_project_board(conn, cmds: list[dict], project: str | None) -> None` — у `SPRINT_TYPES` новый словарь `params` с `board_id` доски проекта пакета; без проекта — `CommandError(f"команда {i} ({type}): команды спринта — только в пакете проекта")`; ошибка доски — `CommandError(f"команда {i}: {текст}")`. - `hooks.retry_params(cmd) -> dict` — снимает `board_id` у `ensure_sprint`, `ensure_sprint_meta`, `ensure_sprint_state`. - [ ] **Step 1: Write the failing tests** (`tests/test_queue.py`, после `test_retry_premigration_sprint_without_board`) ```python def meta(name="AWPCA_Sprint_4"): return {"type": "ensure_sprint_meta", "params": {"sprint": name, "start_date": "2026-10-28", "end_date": "2026-11-11"}} def close(name="AWPCA_Sprint_3_Corrections", to="AWPCA_Sprint_4"): return {"type": "ensure_sprint_state", "params": {"sprint": name, "state": "closed", "move_open_to": to}} def test_sprint_meta_gets_batch_project_board(conn, add_project): add_project(conn) b = queue.create_batch(conn, "x", "claude", [meta(), close()], project="AWPCA") cmds = queue.batch_view(conn, b["id"])["commands"] assert cmds[0]["params"]["board_id"] == 4216 and cmds[1]["params"]["board_id"] == 4216 def test_sprint_meta_without_project_rejected(conn, add_project): # сценарий «Спринт без проекта пакета» add_project(conn) with pytest.raises(CommandError) as e: queue.create_batch(conn, "x", "claude", [meta()]) assert str(e.value) == "команда 1 (ensure_sprint_meta): команды спринта — только в пакете проекта" assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 @pytest.mark.parametrize("setup, msg", [ (dict(board_id=5120, board_type="kanban"), "команда 1: у проекта AWPCA канбан-доска, спринтов нет"), (dict(board_id=5120, board_type=None), "команда 1: тип доски проекта AWPCA ещё не известен — дождитесь обновления из Jira"), (dict(board_id=None, board_type=None), "команда 1: у проекта AWPCA нет доски Jira"), ]) def test_sprint_state_board_rejected(conn, add_project, setup, msg): add_project(conn, **setup) with pytest.raises(CommandError) as e: queue.create_batch(conn, "x", "claude", [close()], project="AWPCA") assert str(e.value) == msg def test_rank_belongs_to_project_by_key(conn, add_project): # design #19 add_project(conn); add_project(conn, "C", board_id=5120) mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"]}, {"key": "Y", "labels": ["C"]}], []) queue.create_batch(conn, "x", "claude", [{"type": "ensure_rank", "params": {"key": "X", "before": "Y"}}], project="AWPCA") with pytest.raises(CommandError, match="команда 1: Y не в проекте AWPCA"): queue.create_batch(conn, "x", "claude", [{"type": "ensure_rank", "params": {"key": "Y", "after": "X"}}], project="AWPCA") def test_retry_sprint_meta_recomputes_board(conn, add_project): add_project(conn) b = queue.create_batch(conn, "x", "claude", [meta()], project="AWPCA") cid = queue.batch_view(conn, b["id"])["commands"][0]["id"] conn.execute("UPDATE jira_project SET board_id = 5120") r = queue.retry_command(conn, cid, "claude") assert queue.batch_view(conn, r["id"])["commands"][0]["params"]["board_id"] == 5120 def test_retry_sprint_state_after_board_became_kanban(conn, add_project): # Review Focus 4 add_project(conn) b = queue.create_batch(conn, "x", "claude", [close()], project="AWPCA") cid = queue.batch_view(conn, b["id"])["commands"][0]["id"] conn.execute("UPDATE jira_project SET board_type = 'kanban'") with pytest.raises(CommandError, match="канбан-доска, спринтов нет"): queue.retry_command(conn, cid, "claude") ``` - [ ] **Step 2: Run tests to verify they fail** Run: `uv run pytest tests/test_queue.py -q -k "sprint_meta or sprint_state or rank_belongs"` Expected: FAIL — `KeyError: 'key'` в `_check_project`, нет `board_id` в params. - [ ] **Step 3: Implement** `_check_project`: у `SPRINT_TYPES` проверка `own(p["key"])` не делается (команда не про задачу); `ensure_rank` идёт общей веткой (`own(key)`, `before`/`after` не проверяются). `_assign_boards` берёт тексты из `_board_error`. `check_batch(conn, cmds, project, files_dir)`: после `check_setup` — `_assign_project_board(conn, cmds, project)` (входные `params` не мутируются, как в `_assign_boards`). `retry_params` — по множеству из трёх типов. - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/test_queue.py -q` → PASS. - [ ] **Step 5: Docs, `make check`, commit** README «Контракт команд»: у `ensure_sprint_meta`/`ensure_sprint_state` — «`board_id` дописывает хаб по проекту пакета; пакет без проекта — 422 «команды спринта — только в пакете проекта»; доски нет, kanban или тип не известен — 422, как у `ensure_sprint`»; `ensure_rank` в пакете проекта — `key` своей задачи. ARCHITECTURE `hooks`: «`board_id` у `ensure_sprint` (по проекту задачи) и у `ensure_sprint_meta`/`ensure_sprint_state` (по проекту пакета)». Run: `make check` → PASS. ```bash git add src/sdlc_hub/modules/jira/hooks.py tests/test_queue.py README.md ARCHITECTURE.md git commit -m "Команды спринта: доска проекта пакета, повтор с новой доской (#108)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 6: Хвостовой `refresh` по JQL проекта (контракт `tail`) **Files:** - Modify: `src/sdlc_hub/core/modules.py:18` (тип `tail`) - Modify: `src/sdlc_hub/core/queue.py:79` (`h.tail(conn, cmds, project)`) - Modify: `src/sdlc_hub/modules/wiki/commands.py:97` (`tail=lambda conn, cmds, project: None`) - Modify: `src/sdlc_hub/modules/jira/projects.py` (новая `project_jql`) - Modify: `src/sdlc_hub/modules/jira/hooks.py:133-140` (`tail`) - Modify: `tests/conftest.py:59`, `tests/test_queue_core.py:24` (лямбды заглушек под новую сигнатуру) - Test: `tests/test_queue.py`, `tests/test_projects.py` - Modify: `ARCHITECTURE.md` (контракт `CommandHooks`: «`tail(conn, cmds, project)`»), `README.md` («Контракт команд»: хвост пакета) **Interfaces:** - Consumes: `commands.SPRINT_TYPES` (задача 4). - Produces: - `CommandHooks.tail: Callable[[Connection, list[dict], str | None], dict | None]` — комментарий «(conn, cmds, проект пакета): хвостовая команда пишущего пакета». - `jira_projects.project_jql(conn, label: str) -> str` — `project = AND labels in ("<метка>")` в формате `base_jql`. - `hooks.tail(conn, cmds, project) -> dict | None` — есть команда из `SPRINT_TYPES` и `project` → `{"type": "refresh", "params": {"jql": project_jql(conn, project)}}`; иначе прежнее правило по ключам. - [ ] **Step 1: Write the failing tests** `tests/test_projects.py`: ```python def test_project_jql(open_db, add_project): conn = open_db(); settings.ensure_defaults(conn) add_project(conn) assert jira_projects.project_jql(conn, "AWPCA") == 'project = AURORA AND labels in ("AWPCA")' == jira_projects.base_jql(conn) add_project(conn, "C", board_id=None) assert jira_projects.project_jql(conn, "C") == 'project = AURORA AND labels in ("C")' ``` `tests/test_queue.py`: ```python def test_sprint_batch_tail_is_project_jql(conn, add_project): # design #13, R-10 add_project(conn); add_project(conn, "C", board_id=None) mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"]}, {"key": "Y", "labels": ["AWPCA"]}], []) b = queue.create_batch(conn, "x", "claude", [meta(), {"type": "ensure_rank", "params": {"key": "X", "before": "Y"}}], project="AWPCA") cmds = queue.batch_view(conn, b["id"])["commands"] assert [c["type"] for c in cmds] == ["ensure_sprint_meta", "ensure_rank", "refresh"] assert cmds[-1]["params"] == {"jql": 'project = AURORA AND labels in ("AWPCA")'} def test_rank_batch_tail_by_keys(conn, add_project): add_project(conn) mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"]}], []) b = queue.create_batch(conn, "x", "claude", [{"type": "ensure_rank", "params": {"key": "X", "after": "Z"}}], project="AWPCA") assert queue.batch_view(conn, b["id"])["commands"][-1]["params"] == {"keys": ["X", "Z"]} def test_retry_sprint_meta_gets_jql_tail(conn, add_project): # повтор команды из пакета проекта — тоже хвост по JQL проекта (retry_command передаёт project пакета) ``` - [ ] **Step 2: Run tests to verify they fail** Run: `uv run pytest tests/test_queue.py tests/test_projects.py -q -k "tail or project_jql"` Expected: FAIL — `AttributeError: project_jql`; хвоста у пакета со спринтом нет. - [ ] **Step 3: Implement** Сигнатура и вызов в ядре; лямбды wiki и заглушек тестов — `lambda conn, cmds, project: …`. `project_jql` использует тот же `project_key` и кавычки, что `base_jql`. `hooks.tail` — по Interfaces; docstring: «пакет с командами спринта — `refresh` по JQL проекта пакета: у них нет ключей, а перенесённые при закрытии задачи заранее неизвестны (design #13)». - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest -q` → PASS (весь набор: сигнатура ядра). - [ ] **Step 5: Docs, `make check`, commit** ARCHITECTURE, «Контракт модуля»: `tail(conn, cmds, project)`. README «Контракт команд»: «пакет с `ensure_sprint_meta`/`ensure_sprint_state` получает хвост `refresh` по JQL проекта пакета; иначе — по затронутым ключам». Run: `make check` → PASS. ```bash git add src/sdlc_hub/core/modules.py src/sdlc_hub/core/queue.py src/sdlc_hub/modules/wiki/commands.py src/sdlc_hub/modules/jira/projects.py src/sdlc_hub/modules/jira/hooks.py tests/conftest.py tests/test_queue_core.py tests/test_queue.py tests/test_projects.py ARCHITECTURE.md README.md git commit -m "Хвост пакета со спринтом — refresh по JQL проекта (#108)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 7: MCP: `sprints`, `sprint`, `backlog`, колонка SP **Files:** - Modify: `src/sdlc_hub/modules/jira/mcp.py` (`fmt_issues`, новые форматтеры и инструменты, текст `batch_create`) - Test: `tests/test_mcp.py` - Modify: `tests/fixtures/baseline/mcp_tools.expected.json` (перезапись) - Modify: `README.md` (раздел «MCP в Claude»: новые инструменты), `ARCHITECTURE.md` (строка `mcp` модуля jira) **Interfaces:** - Consumes: ответ `GET /agile` (задача 3). - Produces: - `mcp.fmt_sp(v) -> str` — `None` → «—», иначе `f"{v:g}"` (`3.0` → «3», `2.5` → «2.5»). - `mcp.fmt_day(value: str | None) -> str` — время Jira (`±HHMM`, `±HH:MM`, `Z`) → «ДД.ММ» в местном поясе; пусто/неразбираемо — «—». - `mcp.fmt_sprints(data: dict) -> str`, `mcp.fmt_sprint(data: dict, name: str) -> str`, `mcp.fmt_backlog(data: dict, limit: int) -> str`. - инструменты (`@mcp.tool() @core.guard @core.scoped(True)`): `sprints() -> str`, `sprint(name: str = "") -> str`, `backlog(limit: int = 50) -> str` — `core.client().get("/agile", project=core.PROJECT)`. Тексты (R-25): - общие отказы (все три): `board is None` → «у проекта нет доски»; `board.fetched_at is None` → «данных доски нет — нужен агент v15 и обновление из Jira»; `sprints`/`sprint` у `board.type == "kanban"` → «у проекта канбан-доска, спринтов нет». - `fmt_sprints`: шапка «спринт | состояние | даты | задачи | SP | без оценки»; строка `f"{name} | {активный|будущий|закрыт} | {fmt_day(start)}–{fmt_day(end)} | {count_done}/{count} | {fmt_sp(points_done)}/{fmt_sp(points)} | {unestimated}"`; закрытые — первые 10 из ответа (новые сверху) и «ещё N закрытых»; спринтов нет — «спринтов нет». - `fmt_sprint`: по `name` — первый с этим именем, иначе «спринта «» нет на доске»; без `name` — первый активный, иначе «активного спринта нет». Шапка: `f"{name} — {состояние}; {d1}–{d2}; цель: {goal|—}"` и `f"задачи {count_done}/{count}; SP {pd}/{p}; без оценки: {u}"`; затем по колонкам доски `f"{колонка} ({n}):"` и строки `f" {key} {core.cut(summary, 80)} — {assignee|—}, SP {fmt_sp}"`; статусы вне колонок — блок «Без колонки (n):» (только если есть). - `fmt_backlog`: `backlog is None` → «у доски нет бэклога»; пусто — «бэклог пуст»; шапка «# | ключ | тема | статус | исполнитель | SP», позиции с 1, не больше `max(1, limit)`, в конце «ещё N». - `fmt_issues`: шапка «ключ | тема | статус | срок | исполнитель | спринт | SP», последняя ячейка `fmt_sp(story_points)`. - `batch_create`: фраза «board_id для ensure_sprint выставляет хаб.» → «board_id для ensure_sprint, ensure_sprint_meta и ensure_sprint_state выставляет хаб; ensure_sprint_meta и ensure_sprint_state — только в сессии с проектом, хвост такого пакета — refresh по JQL проекта; оценка — story_points в ensure_fields.» - [ ] **Step 1: Write the failing tests** (`tests/test_mcp.py`) ```python def _sp(id, name, state, **over): return {"id": id, "name": name, "state": state, "start_date": "2026-10-14T09:00:00.000+0300", "end_date": "2026-10-28T18:00:00.000+0300", "complete_date": None, "goal": None, "count": 0, "count_done": 0, "unestimated": 0, "points": 0, "points_done": 0, "issues": [], **over} BOARD = {"id": 4216, "type": "scrum", "columns": [{"name": "К выполнению", "statuses": ["Backlog"]}, {"name": "Готово", "statuses": ["Готово"]}], "kanban_backlog": None, "fetched_at": "2026-10-24T10:00:00+00:00"} def test_fmt_issues_sp_column(): out = jmcp.fmt_issues([{"key": "A-1", "story_points": 3.0}, {"key": "A-2", "story_points": 2.5}, {"key": "A-3", "story_points": None}]) assert out.splitlines()[0].endswith("| SP") and [l.rsplit("| ", 1)[1] for l in out.splitlines()[1:]] == ["3", "2.5", "—"] def test_fmt_sprints_order_and_sums(): # сценарий «Спринты проекта» data = {"board": BOARD, "backlog": [], "board_issues": None, "sprints": [_sp(3, "AWPCA_Sprint_3_Corrections", "active", count=37, count_done=25, points=40, points_done=25), _sp(4, "AWPCA_Sprint_4", "future")] + [_sp(100 - n, f"S{n}", "closed") for n in range(12)]} lines = jmcp.fmt_sprints(data).splitlines() assert lines[1].startswith("AWPCA_Sprint_3_Corrections | активный | ") and "| 25/37 | 25/40 |" in lines[1] assert lines[2].startswith("AWPCA_Sprint_4 | будущий") assert len([l for l in lines if "| закрыт |" in l]) == 10 and lines[-1] == "ещё 2 закрытых" @pytest.mark.parametrize("data, text", [ ({"board": None, "sprints": [], "backlog": None, "board_issues": None}, "у проекта нет доски"), ({"board": {**BOARD, "type": "kanban"}, "sprints": [], "backlog": None, "board_issues": []}, "у проекта канбан-доска, спринтов нет"), ({"board": {**BOARD, "fetched_at": None, "columns": None}, "sprints": [], "backlog": [], "board_issues": None}, "данных доски нет — нужен агент v15 и обновление из Jira"), ]) def test_fmt_sprints_refusals(data, text): assert jmcp.fmt_sprints(data) == text def test_fmt_sprint_by_columns(): # активный спринт с задачами в «Backlog», «Готово» и статусе «Странный» → блоки «К выполнению (1):», «Готово (1):», «Без колонки (1):»; # тема длиннее 80 обрезана core.cut; fmt_sprint(data, "S9") → «спринта «S9» нет на доске»; без активного → «активного спринта нет» def test_fmt_backlog_limit(): # сценарий «Бэклог с лимитом» rows = [{"key": f"A-{n}", "summary": "т", "status": "Backlog", "assignee": None, "story_points": None} for n in range(188)] out = jmcp.fmt_backlog({"board": BOARD, "sprints": [], "backlog": rows, "board_issues": None}, 2).splitlines() assert out[1].startswith("1 | A-0 |") and out[2].startswith("2 | A-1 |") and out[-1] == "ещё 186" and len(out) == 4 def test_fmt_day_offsets(): assert jmcp.fmt_day("2026-10-14T09:00:00.000+0300") == jmcp.fmt_day("2026-10-14T09:00:00.000+03:00") != "—" assert jmcp.fmt_day(None) == "—" and jmcp.fmt_day("мусор") == "—" def test_agile_tools_live(live): live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема", "status": "Backlog", "labels": ["AWPCA"], "rank": "0|a", "story_points": 2, "sprint_ids": [102]}], "links": [], "boards": [{"id": 4216, "type": "scrum", "columns": BOARD["columns"], "kanban_backlog": None, "sprints": [_sp(102, "S2", "active")]}]}) assert "S2 | активный" in jmcp.sprints() and "A-1 Тема" in jmcp.sprint() and "бэклог пуст" == jmcp.backlog() def test_agile_tools_unbound(live, monkeypatch): monkeypatch.setattr(core_mcp, "PROJECT", None) assert jmcp.sprints() == jmcp.sprint() == jmcp.backlog() == jmcp.issues() # тот же отказ, что у прочих def test_batch_create_description_lists_sprint_types(): doc = jmcp.batch_create.__doc__ for word in ("ensure_sprint_meta", "ensure_sprint_state", "ensure_rank", "story_points", "refresh по JQL проекта"): assert word in doc ``` - [ ] **Step 2: Run tests to verify they fail** Run: `uv run pytest tests/test_mcp.py -q -k "sp_column or sprints or sprint_ or backlog or fmt_day or agile or sprint_types"` Expected: FAIL — `AttributeError: fmt_sprints`. - [ ] **Step 3: Implement** форматтеры, инструменты и текст `batch_create` по Interfaces; docstring инструментов: `sprints` — «Спринты доски проекта: состояние, даты, задачи и SP «выполнено/всего», без оценки; закрытые — 10 последних»; `sprint` — «Спринт (по умолчанию активный): шапка с суммами и задачи по колонкам доски»; `backlog` — «Бэклог проекта по рангу: позиция, ключ, тема, статус, исполнитель, SP; limit — сколько строк (по умолчанию 50)». - [ ] **Step 4: Run tests, rewrite MCP baseline** Run: `uv run pytest tests/test_mcp.py -q` → PASS. Run: `BASELINE_WRITE=1 uv run pytest tests/test_baseline.py::test_mcp_tools -q && git diff tests/fixtures/baseline/mcp_tools.expected.json` Expected: три новых инструмента, изменённая фраза в `batch_create`, больше ничего. - [ ] **Step 5: Docs, `make check`, commit** README «MCP в Claude»: `sprints`, `sprint [name]`, `backlog [limit]` (чтение `GET /agile` проекта сессии), колонка SP в `issues`, команды спринтов и ранга — через `batch_create` (пример пакета `ensure_sprint_meta` + `ensure_sprint_state`). ARCHITECTURE `mcp`: «…`sprints`, `sprint`, `backlog`…». Run: `make check` → PASS. ```bash git add src/sdlc_hub/modules/jira/mcp.py tests/test_mcp.py tests/fixtures/baseline/mcp_tools.expected.json README.md ARCHITECTURE.md git commit -m "MCP: спринты, спринт и бэклог проекта, колонка SP (#108)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 8: Веб: «Связи» вместо «Доски связей», условные пункты меню **Files:** - Modify: `web/src/modules/types.ts:6-11` (`NavItem.when`) - Modify: `web/src/shell/nav.tsx` (`visibleNav`) - Modify: `web/src/shell/Sidebar.tsx:76-127` (фильтр пунктов по текущему проекту) - Modify: `web/src/shell/NotFound.tsx` (только безусловные пункты, R-23) - Modify: `web/src/modules/jira/index.tsx` (адрес `/jira/links`, перенаправления `/jira/board` и `/board`, пункт «Связи») - Modify: `web/src/modules/jira/issues/modal.ts:12-13` (`BOARD_CARD_STATE` → `/jira/links`), `web/src/modules/jira/Summary.tsx:27` (ссылка «Связи» на `/jira/links`) - Create: `web/src/shell/nav.test.ts` - Modify tests: `web/src/App.test.tsx`, `web/src/shell/Sidebar.test.tsx`, `web/src/projects/projects.test.tsx`, `web/src/modules/modules.test.tsx`, `web/src/shell/OverviewPage.test.tsx`, `web/src/modules/jira/issues/IssueModal.test.tsx`, `web/src/modules/jira/issues/modal.test.ts` - Modify e2e: `web/e2e/hub.ts` (`login` по умолчанию `/app/jira/links`, ссылка «Связи»), `web/e2e/layout.spec.ts`, `web/e2e/board.spec.ts`, `web/e2e/login.spec.ts`, `web/e2e/screens.spec.ts`, `web/e2e/projects.spec.ts` - Modify: `CLAUDE.md:29`, `README.md` (раздел «Веб-приложение», адреса), `ARCHITECTURE.md` (раздел «Веб», адреса и «Связи») **Interfaces:** - Produces: - `NavItem.when?: (p: Project) => boolean` — комментарий «пункт только для проектов, где условие верно; пока проект не определён (проекты не загружены) — скрыт». - `visibleNav(items: NavItem[], project: Project | null): NavItem[]` в `web/src/shell/nav.tsx`. - Страница доски связей — `/jira/links`; `/jira/board` и `/board` — `LegacyRedirect to="/jira/links"` (с `search`, `replace`); пункт `{ to: "/jira/links", label: "Связи", icon: }`. Порядок `pages`: `/jira/links`, `/jira/issues`, `/jira/feed`, затем перенаправления `/jira/board`, `/board`, `/issues`, `/issues/:key`, `/feed`. Заголовок `h1` доски — прежний «Доска связей» (R-21). - [ ] **Step 1: Write the failing tests** `web/src/shell/nav.test.ts`: ```ts const A = { label: "AWPCA", name: "", created_at: "", board_type: "scrum" }; const items = [{ to: "/a", label: "A", icon: null }, { to: "/b", label: "B", icon: null, when: (p: Project) => p.board_type === "scrum" }]; it("условный пункт скрыт без проекта и при ложном условии", () => { expect(visibleNav(items, null).map((n) => n.to)).toEqual(["/a"]); expect(visibleNav(items, { ...A, board_type: null }).map((n) => n.to)).toEqual(["/a"]); expect(visibleNav(items, A).map((n) => n.to)).toEqual(["/a", "/b"]); }); ``` `web/src/App.test.tsx` (новые тесты; прежние «Доска связей» → «Связи», `/app/jira/board` → `/app/jira/links`): ```ts it("прежний адрес доски связей", async () => { // сценарий «Прежний адрес доски связей» const router = at("/app/jira/board?project=AWPCA"); await waitFor(() => expect(router.state.location.pathname + router.state.location.search).toBe("/app/jira/links?project=AWPCA")); expect(await screen.findByRole("link", { name: "Связи" })).toHaveAttribute("aria-current", "page"); await waitFor(() => expect(document.title).toBe("Связи · AWPCA · SDLC Hub")); }); it("/app/board → сразу /app/jira/links", async () => { // design #26 const router = at("/app/board?project=AWPCA"); await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/links")); expect(router.state.location.search).toBe("?project=AWPCA"); }); ``` `modules.test.tsx`: `PAGE_ROUTES` — `["/", "/jira/links", "/jira/issues", "/jira/feed", "/jira/board", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]`; пункты — `[["Связи", "/jira/links"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"]]`. `Sidebar.test.tsx`: порядок ссылок `["Обзор", "Связи", "Задачи", "Лента", "Вики"]`, в группе «Jira» — `["Связи", "Задачи", "Лента"]`, href `/app/jira/links?project=AWPCA`. `OverviewPage.test.tsx`: ссылка «Связи» → `/app/jira/links?project=AWPCA`. `modal.test.ts`: фон `/jira/links`. - [ ] **Step 2: Run tests to verify they fail** Run: `cd web && npm test` Expected: FAIL — нет `visibleNav`, ссылки «Связи» нет, адрес остаётся `/app/jira/board`. - [ ] **Step 3: Implement** `Panel` берёт текущий проект из `useProjectContext().state` (`kind === "ok"` → `state.project`, иначе `null`) и рисует `visibleNav(g.nav, current)`. Остальное — по Interfaces. Тексты e2e и тестов, где встречается «Доска связей» как ссылка, — «Связи» (`exact: true`); `geometry.spec.ts` с `h1` «Доска связей» не меняется. - [ ] **Step 4: Run tests to verify they pass** Run: `cd web && npm run lint && npm test` → PASS. Run: `cd web && npm run e2e` → PASS (в `layout.spec.ts` тест перенаправления — `/app/board?project=AWPCA` → `/app/jira/links?project=AWPCA`, и новый — `/app/jira/board?project=AWPCA` → `/app/jira/links?project=AWPCA`). - [ ] **Step 5: Docs, `make check`, commit** CLAUDE.md:29, README «Веб-приложение», ARCHITECTURE «Веб»: «Связи» — `/app/jira/links?project=<метка>`; прежние `/app/board` и `/app/jira/board` перенаправляют на него; пункты меню могут зависеть от проекта (`NavItem.when`). Run: `make check` → PASS. ```bash git add web/src web/e2e CLAUDE.md README.md ARCHITECTURE.md git commit -m "Веб: «Связи» на /app/jira/links, условные пункты меню (#108)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 9: Веб: данные доски, общие части экранов и страница «Бэклог» Перед задачей — скилл `aura-design` (вёрстка секций, строк, сообщений). **Files:** - Modify: `web/src/modules/jira/api/types.ts` (типы ответа `GET /agile`) - Modify: `web/src/modules/jira/api/queries.ts` (`qk.agile`, `useAgile`), `web/src/modules/jira/status.ts` (`invalidate` + `["agile"]`) - Modify: `web/src/modules/jira/projectFields.ts` (`boardType`) - Modify: `web/vite.config.ts` (`API` += `"/agile"` — прокси разработки) - Modify: `web/src/ui/icons.tsx` (`IconBacklog`) - Create: `web/src/modules/jira/agile/model.ts`, `model.test.ts` - Create: `web/src/modules/jira/agile/AgileGate.tsx`, `TypeIcon.tsx`, `IssueRow.tsx`, `BacklogPage.tsx`, `agile.css` - Create: `web/src/modules/jira/agile/BacklogPage.test.tsx` - Modify: `web/src/modules/jira/index.tsx` (страница `/jira/backlog`, пункт «Бэклог»), `web/src/modules/modules.test.tsx` **Interfaces:** - Consumes: `GET /agile` (задача 3); `visibleNav`/`NavItem.when` (задача 8); `RefreshButton`; `useOpenIssue`, `IssueLink`. - Produces: - типы `AgileIssue`, `BoardColumn {name; statuses: string[]}`, `AgileBoard {id; type: "scrum" | "kanban" | null; columns: BoardColumn[] | null; kanban_backlog: boolean | null; fetched_at: string | null}`, `SprintState`, `AgileSprint`, `AgileResponse {board: AgileBoard | null; sprints: AgileSprint[]; backlog: AgileIssue[] | null; board_issues: AgileIssue[] | null}` — поля как в ответе хаба. - `qk.agile(project: string) = ["agile", project] as const`; `useAgile(project: string)`. - `boardType(p: Project): "scrum" | "kanban" | null`. - `model.ts`: `jiraDate(iso: string | null): Date | null` (`±HHMM` → `±HH:MM`; негодное — `null`); `fmtDay(iso: string | null): string` («ДД.ММ» в поясе браузера, иначе «—»); `fmtRange(start, end): string` («ДД.ММ–ДД.ММ»); `daysLeft(endIso: string | null, now: Date): number | null` (разница календарных дат в поясе браузера); `fmtSp(n: number | null): string` («—» или число); `tasksWord(n: number): string` («задача»/«задачи»/«задач», R-24); `boardState(d: AgileResponse): "no-data" | "no-columns" | "ok"` (R-19). - `AgileGate({ project, kind, children }: { project: Project; kind: "scrum" | "kanban"; children: (d: AgileResponse) => ReactNode })` — тип доски проекта не `kind` → `Notice` «У проекта нет scrum-доски» / «У проекта нет kanban-доски» со ссылкой «Связи» (`/jira/links?project=…`), запроса `/agile` нет; загрузка/ошибка — `Notice`; `no-data` → «Нужен агент v15 и обновление из Jira» + `RefreshButton`; `no-columns` → «Не удалось прочитать настройки доски из Jira» + `RefreshButton`; `ok` → `children`. - `TypeIcon({ type }: { type: string | null })` — значок с `aria-label`/`title` = тип (эпик — `/^(Эпик|Epic|Фича)$/i`, ошибка — `/^(Ошибка|Bug)$/i`, прочее — задача). - `AgileIssueRow({ issue, mark }: { issue: AgileIssue; mark?: string })` — ключ (`IssueLink`), `TypeIcon`, тема, исполнитель или «—», `fmtSp`; выполненная — класс `is-done` (приглушена); щелчок по строке (не по ссылке и не при выделении текста) — `useOpenIssue()(key)`; `mark` — пометка справа. - `BacklogPage({ project }: { project: Project })` — `data-testid="page-backlog"`, `PageHeader` «Бэклог» с `RefreshButton`. - пункт `{ to: "/jira/backlog", label: "Бэклог", icon: , padded: true, when: (p) => boardType(p) === "scrum" }` после «Связи»; страница `/jira/backlog` — после `/jira/links`. Состав «Бэклога» (спека web-app): секции активных, затем будущих спринтов — `
` с заголовком: имя, «активный»/ «будущий», `fmtRange`, цель (если есть), `${count} ${tasksWord(count)}`, `${fmtSp(points)} SP (выполнено ${fmtSp(points_done)})`, «без оценки: U» при `U > 0`; задачи по порядку ответа; пусто — «Нет задач». Затем секция «Бэклог» (`data.backlog`), пусто — «Нет задач». Внизу `
` «Прошлые спринты (N)»: по `
` на закрытый спринт, `` — `${name} · закрыт ${fmtDay(complete_date)} · выполнено ${count_done} из ${count} · ${fmtSp(points)} SP`, внутри строки задач с `mark="не выполнено"` у `!done`. Ни `draggable`, ни кнопок, кроме `` и `RefreshButton` (R-20). - [ ] **Step 1: Write the failing tests** `agile/model.test.ts`: ```ts it("время Jira с ±HHMM и ±HH:MM — один день", () => { // Review Focus 1 expect(fmtDay("2026-10-14T09:00:00.000+0300")).toBe(fmtDay("2026-10-14T09:00:00.000+03:00")); expect(fmtDay("2026-10-14T09:00:00.000+0300")).toMatch(/^\d{2}\.\d{2}$/); expect(fmtDay(null)).toBe("—"); expect(fmtDay("мусор")).toBe("—"); }); it("daysLeft — календарные дни до конца", () => { const now = new Date(2026, 9, 24, 23, 30); expect(daysLeft(new Date(2026, 9, 27, 1, 0).toISOString(), now)).toBe(3); expect(daysLeft(new Date(2026, 9, 23, 12, 0).toISOString(), now)).toBe(-1); expect(daysLeft(null, now)).toBeNull(); }); it("fmtSp, tasksWord", () => { expect([fmtSp(null), fmtSp(3), fmtSp(2.5)]).toEqual(["—", "3", "2.5"]); expect([1, 2, 5, 11, 21, 73].map(tasksWord)).toEqual(["задача", "задачи", "задач", "задач", "задача", "задачи"]); }); it("boardState — три состояния (design #23)", () => { expect(boardState({ board: null, sprints: [], backlog: null, board_issues: null })).toBe("no-data"); expect(boardState(resp({ fetched_at: null, columns: null }))).toBe("no-data"); expect(boardState(resp({ fetched_at: "2026-10-24T10:00:00+00:00", columns: null }))).toBe("no-columns"); expect(boardState(resp({}))).toBe("ok"); }); ``` `agile/BacklogPage.test.tsx` (через `App` и `mockFetch`, как `FeedPage.test.tsx`; проекты `A` — AWPCA scrum, `S` — STOCKS без доски; ответ `/agile` — фабрика с активным, будущим и закрытым спринтами): ```ts it("меню scrum-проекта: «Связи», «Бэклог», «Задачи», «Лента»", ...) // задача 10 добавит «Спринт» it("проект без доски: пунктов нет, «У проекта нет scrum-доски» со ссылкой «Связи», запроса /agile нет", ...) // сценарий «Проект без доски» it("пока проекты не загружены, «Бэклог» скрыт", ...) // GET /projects не отвечает: в группе «Jira» — «Связи», «Задачи», «Лента» it("секции: активный, будущий, «Бэклог», «Прошлые спринты (1)»; заголовок спринта с суммами", ...) // «12 задач», «25 SP (выполнено 8)», «без оценки: 3»; пустой будущий спринт — «Нет задач» it("прошлый спринт: строка и пометки «не выполнено»", ...) // сценарий «Прошлый спринт»: 73 задачи, 61 done, 25 SP → // summary `AWPCA_Sprint_2_VGO · закрыт ${fmtDay(c)} · выполнено 61 из 73 · 25 SP`; после раскрытия 73 строки, 12 с «не выполнено» it("только смотрит: нет draggable, кнопки — только summary и «Обновить из Jira»", ...) // сценарий «Бэклог только смотрит» // container.querySelectorAll('[draggable="true"]').length === 0; // within(page).queryAllByRole("button").every((b) => b.tagName === "SUMMARY" || /Обновить из Jira/.test(b.textContent ?? "")) it("щелчок по строке открывает карточку поверх «Бэклога»", ...) // адрес /app/jira/issues/AURORA-1, dialog; закрытие → /app/jira/backlog it("нет данных доски — «Нужен агент v15 и обновление из Jira» и «Обновить из Jira»", ...) it("колонок нет — «Не удалось прочитать настройки доски из Jira»", ...) it("смена проекта на STOCKS оставляет /app/jira/backlog и показывает «У проекта нет scrum-доски»", ...) // design #21 it("заголовок вкладки «Бэклог · AWPCA · SDLC Hub»", ...) ``` `modules.test.tsx`: `PAGE_ROUTES` — `/jira/backlog` вторым после `/jira/links`; пункты — `["Бэклог", "/jira/backlog"]` после «Связи». - [ ] **Step 2: Run tests to verify they fail** Run: `cd web && npm test -- src/modules/jira/agile src/modules/modules.test.tsx` Expected: FAIL — модулей `agile/*` нет. - [ ] **Step 3: Implement** по Interfaces; стили — `agile.css` только на токенах; `status.invalidate` перечитывает и `["agile"]`. - [ ] **Step 4: Run tests to verify they pass** Run: `cd web && npm run lint && npm test` → PASS. - [ ] **Step 5: `make check`, commit** Run: `make check` → PASS. ```bash git add web/src web/vite.config.ts git commit -m "Веб: страница «Бэклог» и общие части экранов доски (#108)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 10: Веб: страница «Спринт» Перед задачей — скилл `aura-design` (колонки доски, карточки). **Files:** - Create: `web/src/modules/jira/agile/columns.ts`, `columns.test.ts` - Create: `web/src/modules/jira/agile/BoardColumns.tsx`, `AgileCard.tsx`, `SprintPage.tsx`, `SprintPage.test.tsx` - Modify: `web/src/modules/jira/agile/agile.css`, `web/src/ui/icons.tsx` (`IconSprint`) - Modify: `web/src/modules/jira/index.tsx` (страница `/jira/sprint`, пункт «Спринт»), `web/src/modules/modules.test.tsx`, `web/src/modules/jira/agile/BacklogPage.test.tsx` (меню с «Спринт») **Interfaces:** - Consumes: `AgileGate`, `model.ts`, `TypeIcon`, `useOpenIssue` (задача 9). - Produces: - `type ColumnView = { name: string; issues: AgileIssue[]; extra: boolean }`. - `columnsOf(columns: BoardColumn[] | null, issues: AgileIssue[]): ColumnView[]` — колонки доски по порядку, задача — в колонку своего статуса, порядок задач — как во входе (ранг); статусы вне колонок — последняя `{ name: "Без колонки", extra: true }`, только если в ней есть задачи; `columns = null` — одна «Без колонки». - `BoardColumns({ columns, lastCaption }: { columns: ColumnView[]; lastCaption?: string })` — контейнер `.agile-columns` с горизонтальной прокруткой внутри (`overflow-x: auto`), у колонки — имя и число задач, `lastCaption` — подпись последней не-`extra` колонки. - `AgileCard({ issue }: { issue: AgileIssue })` — те же поля, что `AgileIssueRow`; щелчок открывает карточку задачи; выполненная приглушена. - `SprintPage({ project }: { project: Project })` — `data-testid="page-sprint"`; активные спринты из `data.sprints`; выбор — `?sprint=`, нет такого активного (закрыт, чужой, мусор) — первый активный (Review Focus 2); при двух и более — `Segmented` с `href` (`label="Спринты"`); заголовок: имя, `fmtRange`, «осталось N дн.» (`daysLeft ≥ 0`) или «срок прошёл» (`< 0`), цель, `${count_done} из ${count} ${tasksWord(count)}`, `${fmtSp(points_done)} из ${fmtSp(points)} SP`; ниже `BoardColumns(columnsOf(board.columns, sprint.issues))`. Активного нет — «Нет активного спринта» и ссылка «Бэклог». - пункт `{ to: "/jira/sprint", label: "Спринт", icon: , padded?: нет — во всю ширину, when: scrum }` после «Бэклог»; страница после `/jira/backlog`. - [ ] **Step 1: Write the failing tests** `agile/columns.test.ts`: ```ts const cols = [{ name: "К выполнению", statuses: ["Backlog"] }, { name: "В работе", statuses: ["В работе"] }, { name: "Готово", statuses: ["Готово"] }]; it("по колонкам в порядке ранга, «Без колонки» — только с задачами", () => { const v = columnsOf(cols, [iss("A", "Готово"), iss("B", "Backlog"), iss("C", "Странный"), iss("D", "Backlog")]); expect(v.map((c) => [c.name, c.issues.map((i) => i.key)])).toEqual([["К выполнению", ["B", "D"]], ["В работе", []], ["Готово", ["A"]], ["Без колонки", ["C"]]]); expect(columnsOf(cols, [iss("A", "Готово")]).map((c) => c.name)).toEqual(["К выполнению", "В работе", "Готово"]); }); ``` `agile/SprintPage.test.tsx`: ```ts it("меню scrum-проекта: «Связи», «Бэклог», «Спринт», «Задачи», «Лента»; «Kanban» нет", ...) // сценарий «Меню scrum-проекта» it("колонки доски с числом задач и «Без колонки»", ...) it("«осталось 3 дн.» и «срок прошёл»", ...) // end_date = сейчас + 3 дня / вчера (ISO с +0300) it("два активных: переключатель «Спринты», выбор — ?sprint= и его задачи", ...) it("устаревший ?sprint= — первый активный", ...) // Review Focus 2: ?sprint=101 (закрыт) и ?sprint=abc it("нет активного — «Нет активного спринта» и ссылка «Бэклог»", ...) it("агент без досок — «Нужен агент v15 и обновление из Jira» и «Обновить из Jira»", ...) // сценарий «Агент без досок» it("щелчок по карточке открывает окно задачи поверх «Спринта»", ...) ``` `modules.test.tsx` и меню в `BacklogPage.test.tsx` — с «Спринт» после «Бэклог». - [ ] **Step 2: Run tests to verify they fail** Run: `cd web && npm test -- src/modules/jira/agile src/modules/modules.test.tsx` Expected: FAIL — нет `columnsOf`, `SprintPage`. - [ ] **Step 3: Implement** по Interfaces. - [ ] **Step 4: Run tests to verify they pass** Run: `cd web && npm run lint && npm test` → PASS. - [ ] **Step 5: `make check`, commit** Run: `make check` → PASS. ```bash git add web/src git commit -m "Веб: страница «Спринт» с колонками доски (#108)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 11: Веб: страница «Kanban» Перед задачей — скилл `aura-design`. **Files:** - Create: `web/src/modules/jira/agile/KanbanPage.tsx`, `KanbanPage.test.tsx` - Modify: `web/src/modules/jira/agile/agile.css`, `web/src/ui/icons.tsx` (`IconKanban`) - Modify: `web/src/modules/jira/index.tsx` (страница `/jira/kanban`, пункт «Kanban»), `web/src/modules/modules.test.tsx` **Interfaces:** - Consumes: `AgileGate` (`kind="kanban"`), `columnsOf`, `BoardColumns`, `AgileIssueRow` (задачи 9–10). - Produces: - `KanbanPage({ project }: { project: Project })` — `data-testid="page-kanban"`, `PageHeader` «Kanban» с `RefreshButton`; при `kanban_backlog === true` над колонками `
` «Бэклог (N)» со строками `data.backlog` по рангу, а колонки — `columnsOf(board.columns.slice(1), board_issues)` (R-18); иначе `columnsOf(board.columns, board_issues)`; `lastCaption="за 14 дней"`. - пункт `{ to: "/jira/kanban", label: "Kanban", icon: , when: (p) => boardType(p) === "kanban" }` после «Спринт»; страница после `/jira/sprint`. - [ ] **Step 1: Write the failing tests** (`agile/KanbanPage.test.tsx`) ```ts it("меню kanban-проекта: «Связи», «Kanban», «Задачи», «Лента»", ...) it("колонки из board_issues, у последней — «за 14 дней», «Без колонки» при статусе вне доски", ...) it("бэклог включён: «Бэклог (2)» над колонками, первая колонка не рисуется", ...) // R-18 it("бэклог выключен или неизвестен: раздела «Бэклог» нет, первая колонка на доске", ...) // design #17 it("scrum-проект на /app/jira/kanban — «У проекта нет kanban-доски» со ссылкой «Связи»", ...) ``` `modules.test.tsx`: `/jira/kanban` после `/jira/sprint`; пункт `["Kanban", "/jira/kanban"]` после «Спринт». - [ ] **Step 2: Run tests to verify they fail** Run: `cd web && npm test -- src/modules/jira/agile/KanbanPage.test.tsx src/modules/modules.test.tsx` Expected: FAIL — нет `KanbanPage`. - [ ] **Step 3: Implement** по Interfaces. - [ ] **Step 4: Run tests to verify they pass** Run: `cd web && npm run lint && npm test` → PASS. - [ ] **Step 5: `make check`, commit** Run: `make check` → PASS. ```bash git add web/src git commit -m "Веб: страница «Kanban» (#108)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 12: Тестовый хаб с досками, e2e новых страниц, документация веба **Files:** - Modify: `web/scripts/seed-hub.py` (доски, спринты, ранг, оценки; проект `KANBAN_E2E`) - Create: `web/e2e/agile.spec.ts` - Modify: `README.md` («Веб-приложение»: три страницы, меню по типу доски, только просмотр; «Эксплуатация» — порядок выкатки), `CLAUDE.md:29` (адреса `/app/jira/backlog`, `/app/jira/sprint`, `/app/jira/kanban`), `ARCHITECTURE.md` («Веб», модуль jira: `agile/`) **Interfaces:** - Consumes: `mirror.apply_state(..., boards=)` (задача 2), `jira_projects.create` — в сиде; страницы задач 9–11. - Produces (данные тестового хаба, R-27): - доска AWPCA 4216 `scrum`, колонки «К выполнению» [`Backlog`, `К выполнению`, `Ожидание`, `Ожидание разработки`], «В работе» [`В работе`], «Готово» [`Готово`]; спринты 101 «AWPCA_Спринт 1» `closed`, 102 «AWPCA_Спринт 2» `active` (конец — через 7 дней от сида), 103 «AWPCA_Спринт 3» `future`; у задач фикстуры `sprint_ids` по имени `sprint`, AURORA-9001 — `[101, 102]`; `rank` — `0|i:` по порядку фикстуры; `story_points` у части задач. - проект `KANBAN_E2E` «Канбан e2e», доска 5120 `kanban`, `kanban_backlog: true`, колонки «Бэклог» [`Backlog`], «В работе» [`В работе`], «Готово» [`Готово`]; задачи AURORA-9101 (`Backlog`), AURORA-9102 (`В работе`), AURORA-9103 (`Готово`, `jira_updated` — время сида) с меткой `KANBAN_E2E`. - [ ] **Step 1: Write the failing e2e** (`web/e2e/agile.spec.ts`) ```ts test("«Бэклог» AWPCA: секции, «Прошлые спринты (1)», щелчок открывает карточку", ...) // login(page, "/app/jira/backlog?project=AWPCA"); видны «AWPCA_Спринт 2», «AWPCA_Спринт 3», «Бэклог», «Прошлые спринты (1)»; // раскрытие прошлого спринта показывает AURORA-9001; щелчок по строке → /app/jira/issues/AURORA-9001, dialog; Esc → /app/jira/backlog test("«Спринт» AWPCA: колонки доски, «осталось 7 дн.», карточка окном", ...) test("«Kanban»: «Бэклог (1)», колонки «В работе» и «Готово», подпись «за 14 дней»", ...) test("без scrum-доски: /app/jira/backlog?project=KANBAN_E2E — «У проекта нет scrum-доски», ссылка «Связи»", ...) test("меню по проекту: у AWPCA «Бэклог» и «Спринт», у KANBAN_E2E — «Kanban»", ...) test("900 px: колонки «Спринта» прокручиваются внутри доски, страница без горизонтальной прокрутки", ...) // page.setViewportSize({ width: 900, height: 900 }); scrollWidth документа ≤ clientWidth; у .agile-columns scrollWidth > clientWidth ``` - [ ] **Step 2: Run e2e to verify it fails** Run: `cd web && npm run e2e -- agile.spec.ts` Expected: FAIL — у AWPCA нет данных доски («Нужен агент v15 и обновление из Jira»), проекта `KANBAN_E2E` нет. - [ ] **Step 3: Implement seed** — в `seed-hub.py` после загрузки фикстуры: проект `KANBAN_E2E` через `jira_projects.create`, затем `mirror.apply_state` с `boards` и полями задач (тип досок проектам пишет сам `apply_state`). Даты спринтов — строки ISO со смещением `+03:00` от времени сида. - [ ] **Step 4: Run e2e to verify it passes** Run: `cd web && npm run e2e` → PASS (весь набор: второй проект не ломает прежние spec). - [ ] **Step 5: Docs, `make check`, commit** README «Веб-приложение»: страницы «Бэклог» `/app/jira/backlog`, «Спринт» `/app/jira/sprint` (`?sprint=`), «Kanban» `/app/jira/kanban` — только просмотр, пункты меню по типу доски проекта, три состояния данных; «Эксплуатация» — порядок выкатки: `git pull` → kickstart (владелец) → `make web` → `/mcp` reconnect → `agent publish` v15 → проверка полей Jira банка → `field_ids.story_points`, если агент поле не нашёл; `min_agent_version` — владелец после выкатки v15. CLAUDE.md:29 — адреса трёх страниц. ARCHITECTURE «Веб» — модуль jira: `agile/` (`model`, `columns`, `AgileGate`, страницы `BacklogPage`, `SprintPage`, `KanbanPage`), данные — `GET /agile`. Run: `make check` → PASS. ```bash git add web/scripts/seed-hub.py web/e2e/agile.spec.ts README.md CLAUDE.md ARCHITECTURE.md git commit -m "e2e спринтов и бэклога, тестовый хаб с досками, документация (#108)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ## Self-review - **Покрытие спеки:** data-model (схема v9, миграция v8 — задача 1; доски, спринты, членство, «выполнено» — задачи 2–3); hub-api (приём — 2, `GET /agile` — 3); commands (контракт, `story_points`, ошибки 422 — 4; доска по проекту пакета, `ensure_rank` по `key`, `command_retry` — 5; хвост по JQL — 6; темп — 4); mcp (`sprints`, `sprint`, `backlog`, SP, `batch_create` — 7); web-app (адреса и «Связи» — 8; меню по типу доски — 8–11; «Бэклог» — 9; «Спринт» — 10; «Kanban» — 11; карточка окном — 9–10; даты и «осталось N дн.» — 9–10; горизонтальная прокрутка — 10, e2e 12); agent — вне плана (jira-agent v15), здесь только приём его данных. Сценарий «Создание спринта повтором не дублируется» и «Завершение спринта с переносом», «Ранг уже такой» — поведение агента; со стороны хаба — темп, `board_id`, хвост (задачи 4–6) и отсутствие подтверждения по зеркалу (задача 4). - **Типы и имена:** `apply_state(..., boards=None)` (2) → сид (12); `agile.view` (3) → `h_agile` (3); `SPRINT_TYPES` (4) → `hooks` (5, 6); `tail(conn, cmds, project)` (6) — ядро, wiki, заглушки; `visibleNav`/`NavItem.when` (8) → 9–11; `AgileGate`, `fmtDay`, `fmtSp`, `tasksWord`, `boardState` (9) → 10–11; `columnsOf`, `BoardColumns` (10) → 11. - **Пропорция:** код — только тесты и интерфейсы; тела функций не расписаны, кроме правил выборки `GET /agile`, которые тестом однозначно не определяются.