106 KiB
Спринты и бэклог (хаб) 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
- Время 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). - Устаревший
?sprint=<id>в адресе «Спринта» (спринт закрыт, чужой id, мусор) → показан первый активный спринт, а не пустая доска. Тест: задача 10 («устаревший ?sprint= — первый активный»). - Доска, общая для двух проектов (метки
AWPCAиCна доске 4216): тип изboardsпишется обоим проектам,GET /agileкаждого — свои задачи, спринты общие. Тесты: задача 2 (test_state_board_type_to_all_projects_of_board), задача 3 (test_shared_board_own_issues_only). - Повтор команды спринта после смены доски проекта (другая доска или kanban) → новый
board_idили 422 «канбан-доска», не старыйboard_idиз прежнего пакета. Тест: задача 5 (test_retry_sprint_state_after_board_became_kanban). - Нечисловые оценки (
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и e2egeometry.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)
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.
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, где он используется)
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:
@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.
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 = 14agile.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, Ожидает анализа; В работе: В работе; Готово: Готово, Протестировано, Снято].
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.viewandh_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.
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)
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:
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.
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)
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.
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:
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:
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.
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— первый активный, иначе «активного спринта нет». Шапка: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)
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.
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:
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):
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.
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:
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 — фабрика с активным, будущим и закрытым спринтами):
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.
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:
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:
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.
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)
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.
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», доска 5120kanban,kanban_backlog: true, колонки «Бэклог» [Backlog], «В работе» [В работе], «Готово» [Готово]; задачи AURORA-9101 (Backlog), AURORA-9102 (В работе), AURORA-9103 (Готово,jira_updated— время сида) с меткойKANBAN_E2E.
- доска AWPCA 4216
-
Step 1: Write the failing e2e (
web/e2e/agile.spec.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.
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, которые тестом однозначно не определяются.