1270 lines
106 KiB
Markdown
1270 lines
106 KiB
Markdown
# Спринты и бэклог (хаб) 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 <noreply@anthropic.com>`. Не пушить.
|
||
- Зелёным считается только `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>`** в адресе «Спринта» (спринт закрыт, чужой 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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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, {"<id>": type}) -> int`.
|
||
- Produces:
|
||
- `mirror.SPRINT_STATES = ("active", "future", "closed")`
|
||
- `mirror.check_agile_fields(issues: list[dict]) -> None` — `ValueError` с текстом `"<key>: rank — строка или null"`, `"<key>: story_points — число или null"` (bool, `NaN`, `±Infinity` — нарушение), `"<key>: sprint_ids — список целых"` (`null`, bool, строки — нарушение). Поле не прислано — не проверяется.
|
||
- `mirror.check_boards(boards) -> None` — `None` → ничего; не список → `"boards — список досок"`; элемент не объект или `id` не целое > 0 → `"boards[<n>]: …"`; дальше префикс `"доска <id>: "` и поле: `type — scrum, kanban или null`, `columns — список колонок или null`, `columns[<k>].name — строка`, `columns[<k>].statuses — список строк`, `kanban_backlog — true, false или null`, `sprints — список спринтов или null`, `sprints[<k>].id — целое > 0`, `sprints[<k>].name — непустая строка`, `sprints[<k>].state — active, future или closed`, `sprints[<k>].<start_date|end_date|complete_date|goal> — строка или 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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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 = <project_key> 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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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>» нет на доске»; без `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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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: <IconBoard /> }`. Порядок `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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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: <IconBacklog />, padded: true, when: (p) => boardType(p) === "scrum" }` после «Связи»; страница `/jira/backlog` — после `/jira/links`.
|
||
|
||
Состав «Бэклога» (спека web-app): секции активных, затем будущих спринтов — `<section>` с заголовком: имя, «активный»/
|
||
«будущий», `fmtRange`, цель (если есть), `${count} ${tasksWord(count)}`, `${fmtSp(points)} SP (выполнено
|
||
${fmtSp(points_done)})`, «без оценки: U» при `U > 0`; задачи по порядку ответа; пусто — «Нет задач». Затем секция
|
||
«Бэклог» (`data.backlog`), пусто — «Нет задач». Внизу `<details>` «Прошлые спринты (N)»: по `<details>` на закрытый
|
||
спринт, `<summary>` — `${name} · закрыт ${fmtDay(complete_date)} · выполнено ${count_done} из ${count} ·
|
||
${fmtSp(points)} SP`, внутри строки задач с `mark="не выполнено"` у `!done`. Ни `draggable`, ни кнопок, кроме
|
||
`<summary>` и `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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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=<id>`, нет такого активного (закрыт, чужой, мусор) — первый активный (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: <IconSprint />, 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=<id> и его задачи", ...)
|
||
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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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` над колонками `<details>` «Бэклог (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: <IconKanban />, 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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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<NNNN>:` по порядку фикстуры; `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=<id>`), «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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## 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`, которые
|
||
тестом однозначно не определяются.
|