From 15db39d57df4b296906cf7b77ea5a2829f8cd56d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 18:06:15 +0400 Subject: [PATCH 01/21] OpenSpec: change sprints-backlog (#108) Co-Authored-By: Claude Opus 5.5 --- .../changes/sprints-backlog/.openspec.yaml | 2 + openspec/changes/sprints-backlog/design.md | 172 ++++++++++++++++ openspec/changes/sprints-backlog/proposal.md | 51 +++++ .../sprints-backlog/specs/agent/spec.md | 27 +++ .../sprints-backlog/specs/commands/spec.md | 129 ++++++++++++ .../sprints-backlog/specs/data-model/spec.md | 160 +++++++++++++++ .../sprints-backlog/specs/hub-api/spec.md | 54 +++++ .../changes/sprints-backlog/specs/mcp/spec.md | 22 +++ .../sprints-backlog/specs/web-app/spec.md | 186 ++++++++++++++++++ 9 files changed, 803 insertions(+) create mode 100644 openspec/changes/sprints-backlog/.openspec.yaml create mode 100644 openspec/changes/sprints-backlog/design.md create mode 100644 openspec/changes/sprints-backlog/proposal.md create mode 100644 openspec/changes/sprints-backlog/specs/agent/spec.md create mode 100644 openspec/changes/sprints-backlog/specs/commands/spec.md create mode 100644 openspec/changes/sprints-backlog/specs/data-model/spec.md create mode 100644 openspec/changes/sprints-backlog/specs/hub-api/spec.md create mode 100644 openspec/changes/sprints-backlog/specs/mcp/spec.md create mode 100644 openspec/changes/sprints-backlog/specs/web-app/spec.md diff --git a/openspec/changes/sprints-backlog/.openspec.yaml b/openspec/changes/sprints-backlog/.openspec.yaml new file mode 100644 index 0000000..1f5d280 --- /dev/null +++ b/openspec/changes/sprints-backlog/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-24 diff --git a/openspec/changes/sprints-backlog/design.md b/openspec/changes/sprints-backlog/design.md new file mode 100644 index 0000000..0cb841d --- /dev/null +++ b/openspec/changes/sprints-backlog/design.md @@ -0,0 +1,172 @@ +## Context + +Основа — `main` `a2bf3fc` (схема v8 после fact-status-removal #104). Issue #108, ветка `feat/108-sprints-backlog`. +Агент — jira-agent v14 (`../jira-agent`), его решения — в design-only change `agent-v15-sprints` репозитория jira-agent. + +Что есть сейчас (разведка 24.10.2026): +- у задачи в зеркале `issue.sprint` — имя одного спринта строкой; агент собирает имена со спринтов досок `scrum` + (активные и будущие, `GET /rest/agile/1.0/board/{id}/sprint?state=active,future` и `/sprint/{id}/issue`); у задач + закрытых спринтов `sprint = NULL` — принятое ограничение; +- спринтов как сущностей (id, состояние, даты, цель), ранга задач, оценок и колонок доски хаб не знает; +- `ensure_sprint {key, sprint}` переносит задачу в спринт или бэклог, `board_id` хаб дописывает по проекту задачи; +- доска проекта — `jira_project.board_id`, `board_type` (`scrum`/`kanban`/`NULL`), тип пишет только результат `refresh`; +- живые данные: доска только у `AWPCA` (4216, scrum), спринты `AWPCA_Sprint_2_VGO` (73 задачи) и + `AWPCA_Sprint_3_Corrections` (37), 188 своих задач вне спринтов; 11 статусов; у `STOCKS_DRRK` и `sfm_kedr` досок нет; +- оценок в AURORA пока нет (`field_ids` без story points); веб — `/app/jira/board` («Доска» — доска связей), + `/app/jira/issues`, `/app/jira/feed`. + +## Goals / Non-Goals + +**Goals:** в вебе — бэклог, доска спринта и kanban по образцу Jira, только просмотр; история закрытых спринтов; оценки +(story points) в зеркале, на экранах и в MCP; из чата — создание и правка спринта, запуск и завершение, ранжирование, +оценка. + +**Non-Goals и Anti-Goals:** см. proposal.md. + +## Decisions + +1. **Веб — только просмотр** (решение владельца 24.10.2026: «веб только смотреть… вэб это только вью»). Ни + перетаскивания, ни кнопок, меняющих Jira, на новых экранах нет; все изменения — пакетами команд из MCP. +2. **Экраны по типу доски проекта** (решение владельца): scrum — «Бэклог» и «Спринт»; kanban — «Kanban»; без доски — + пунктов нет. Тип доски неизвестен (`NULL`) — пунктов нет, как без доски. +3. **Kanban-бэклог — раздел страницы «Kanban», а не отдельный пункт меню** (решение агента): признак kanban-бэклога + известен только из данных доски (`GET /agile`), а меню строится по `GET /projects`; отдельный пункт мигал бы при + загрузке. Если бэклог у доски включён — над колонками свёрнутый список «Бэклог (N)» по рангу. +4. **«Доска» переименовывается в «Связи»** (решение владельца): подпись в меню и заголовке вкладки — «Связи», адрес — + `/app/jira/links`; `/app/jira/board` и прежний `/app/board` перенаправляют на него с сохранением строки запроса. +5. **Спринты — сущности зеркала.** Таблица модуля jira `jira_sprint` (id Jira, доска, имя, состояние `active` / + `future` / `closed`, даты начала, конца и завершения, цель) и `issue_sprint` (задача ↔ все её спринты, любого + состояния). `issue.sprint` (имя текущего спринта) остаётся как есть — фильтры списка задач и доска связей его + используют. История закрытых спринтов (решение владельца: «лучше иметь исторические данные») — из `issue_sprint`: + Jira хранит у задачи все её спринты, агент передаёт их id. +6. **Колонки доски — из настроек доски Jira** (`GET /rest/agile/1.0/board/{id}/configuration`): `jira_board` хранит + колонки `[{name, statuses: [имя статуса…]}]` и признак kanban-бэклога. Статус задачи без колонки — колонка «Без + колонки» в конце (видна, только если в ней есть задачи). +7. **«Выполнено» — статус в последней колонке доски** (правило Jira для scrum-доски). Для закрытого спринта — + по текущему статусу задачи (решение агента): статуса на момент закрытия хаб не знает (журнал изменений покрывает + только открытые спринты и с 22.10.2026). Задача, выполненная после закрытия спринта, в прошлом спринте покажется + выполненной — принятое ограничение. +8. **Свои задачи проекта.** Экраны и MCP показывают только свои задачи проекта (метка проекта), соседей нет. Спринты — + все спринты доски проекта, в том числе без своих задач. +9. **Ранг — строка Jira (LexoRank) как есть**, порядок — сравнение строк; задачи без ранга — в конце, по ключу. +10. **Оценка — story points**, число `REAL` (`issue.story_points`, `NULL` — нет оценки). Поле Jira: настройка + `field_ids.story_points`, если задана; иначе агент находит поле сам (решение агента в `agent-v15-sprints`). + Суммы по спринту: всего и выполнено; задача без оценки в сумму не входит, а число таких задач показывается. +11. **Новые типы команд**, все «проверь, потом сделай», темп по умолчанию 30 с: + - `ensure_sprint_meta {sprint, rename_to?, start_date?, end_date?, goal?}` — спринт доски проекта с этим именем + (среди активных и будущих) существует и имеет эти поля; нет — создаётся; + - `ensure_sprint_state {sprint, state: "active" | "closed", move_open_to?}` — спринт в этом состоянии; при + `closed` невыполненные задачи (не в последней колонке) сначала переносятся в `move_open_to` (имя активного или + будущего спринта или `backlog`; для `closed` обязателен); + - `ensure_rank {key, before | after}` — задача выше (`before`) или ниже (`after`) другой задачи; уже так — без + записи. + `board_id` у `ensure_sprint_meta` и `ensure_sprint_state` хаб дописывает при постановке из проекта пакета (команды + не про задачу — проекта задачи нет). Пакет без проекта, проект без доски, kanban-доска, неизвестный тип доски — 422 + с тем же текстом, что у `ensure_sprint`. +12. **Оценка ставится через `ensure_fields`** (новое поле `story_points`: число ≥ 0 или `null` — очистить), отдельной + команды нет. +13. **Данные досок — в каждом `refresh`.** Агент v15 к первой пачке состояния `refresh` прикладывает `boards` — + по каждой доске из параметров команды: тип, колонки, kanban-бэклог, все спринты (любого состояния); хаб заменяет + данные доски целиком. У задач — `rank`, `story_points`, `sprint_ids`. Результат команд видно после хвостового `refresh`, который + хаб уже дописывает к пишущему пакету (`hooks.tail`, по ключам задач команд); пакет с `ensure_sprint_meta` или + `ensure_sprint_state` получает хвост `refresh {jql: }` — у этих команд нет ключей, а + перенесённые при закрытии задачи заранее неизвестны. `refresh` без задач (все ключи пропали) агент v15 всё равно + отправляет одной пустой пачкой с `boards`. +14. **Совместимость с агентом v14.** Без `boards` и новых полей хаб ничего не теряет: прежние данные досок и задач + остаются; экраны без данных доски показывают «Нужен агент v15 и обновление из Jira». Новые типы команд агенту v14 — + `failed` «неизвестный тип» на его стороне; `min_agent_version` хаб не поднимает (поднимет владелец после выкатки v15). +15. **API — один маршрут `GET /agile?project=<метка>`** (данные доски, спринты со своими задачами и суммами, бэклог). + Веб и MCP читают его; отдельных маршрутов на спринт и бэклог нет. +16. **Схема v9**: `issue.rank`, `issue.story_points`, таблицы `jira_board`, `jira_sprint`, `issue_sprint`. + +17. **Kanban без бэклога** (`kanban_backlog` `false` или `null`): первая колонка — обычная колонка доски, её задачи — в + `board_issues`; `backlog = null`. +18. **`ensure_sprint_meta` ищет спринт по имени среди всех спринтов доски**: закрытый — `failed` «спринт закрыт»; + несколько с этим именем — `failed` с их id; `rename_to` на имя другого спринта доски — `failed` «имя занято». + `ensure_sprint_state {state: closed}` для будущего спринта — `failed` «спринт не начат»; `active` для закрытого — + `failed` «спринт закрыт». Результаты: `ensure_sprint_meta` — `{sprint_id, created}`, `ensure_sprint_state` — + `{sprint_id, moved}`, `ensure_rank` — `{changed}`. Подтверждения по зеркалу у новых типов нет (`check_confirmed` — + нет): повторная выдача без отчёта выполняется агентом заново, это безопасно по «проверь, потом сделай». +19. **Проектный пакет:** `ensure_rank` принадлежит проекту по `key` (своя задача или временный ключ пакета); `before` / + `after` — любая задача зеркала. `ensure_sprint_meta`/`ensure_sprint_state` в пакете проекта проверяются по доске + проекта (решение 11), не по задаче. `command_retry` снимает `board_id` у всех трёх типов со спринтом, как у + `ensure_sprint`. +20. **Форма новых полей состояния:** `story_points: null` и `rank: null` — «очистить»; `sprint_ids: []` — у задачи нет + спринтов; `sprint_ids: null` или не-список целых — 422 `bad_state`. +21. **Меню модуля зависит от проекта:** пункт раздела модуля веба может нести условие по проекту (тип доски из `GET + /projects`); пока проекты не загружены, условные пункты скрыты. Смена проекта на странице, которой у нового проекта + нет, оставляет адрес и показывает «У проекта нет …-доски» (как прямой переход). +22. **Тип доски — одна правда, `jira_project.board_type`:** тип из `boards` пачки состояния хаб записывает и в + `jira_board.type`, и в `jira_project.board_type` проектов этой доски (как результат `refresh`, только `scrum`/ + `kanban`). Меню и 422 постановки читают `jira_project`, экраны — `GET /agile`, тип в нём — из `jira_project`. +23. **Три состояния данных доски на экранах:** данных нет (`fetched_at = null`) — «Нужен агент v15 и обновление из + Jira»; данные есть, колонок нет (агент не смог прочитать настройки) — «Не удалось прочитать настройки доски из + Jira» и обновление; колонки есть — экран. Без колонок `done` у всех задач — `false`. +24. **Даты:** в командах — `ГГГГ-ММ-ДД` (агент переводит в формат Jira); в `jira_sprint` — строки Jira как есть + (ISO-8601 со смещением); веб показывает их датой «ДД.ММ» в часовом поясе браузера; «осталось N дн.» — от сегодня до + `end_date`. +25. **История закрытых спринтов — из базовой JQL:** базовая JQL проектов не отсекает закрытые задачи (`labels in + (…)`), полный `refresh` возвращает и их — членство всех своих задач в закрытых спринтах приходит первым же полным + `refresh` агента v15. +26. **«Доска связей» → «Связи»** (сейчас пункт меню называется «Доска связей», адрес `/jira/board`); `/app/board` + перенаправляет сразу на `/app/jira/links`. Документы, где адрес упомянут (README, CLAUDE.md репозитория, e2e), — + в том же change. + +## Контракт данных + +`POST /agent/state` (агент v15), необязательные поля: +- у задачи: `rank` (строка или `null`), `story_points` (число или `null`), `sprint_ids` (список целых — все спринты + задачи); присланное `sprint_ids` заменяет все строки `issue_sprint` задачи, задача без поля свои строки сохраняет; +- в пачке: `boards: [{id, type, columns: [{name, statuses: [строка]}] | null, kanban_backlog: bool | null, + sprints: [{id, name, state, start_date, end_date, complete_date, goal}]}]`; доска из `boards` заменяет свою строку + `jira_board` и все свои строки `jira_sprint`. Даты спринтов — строки Jira как есть или `null`. Пустой `refresh` — + одна пачка `{issues: [], links: [], boards}`. + +`GET /agile?project=P` → +``` +{board: {id, type, columns, kanban_backlog, fetched_at} | null, + sprints: [{id, name, state, start_date, end_date, complete_date, goal, + count, count_done, unestimated, points, points_done, issues: [строка]}], + backlog: [строка] | null, + board_issues: [строка] | null} +``` +Строка задачи: `key, summary, issue_type, status, priority, assignee, epic_key, story_points, rank, done` (`done` — +статус в последней колонке). Порядок спринтов: активные (по дате начала), будущие (по id), закрытые (по дате +завершения, новые сверху). `backlog`: scrum — свои задачи не в активном и не в будущем спринте и не выполненные; +kanban — свои задачи в статусах первой колонки, если kanban-бэклог включён, иначе `null`. `board_issues` (только +kanban) — свои задачи в статусах остальных колонок (при выключенном или неизвестном бэклоге — и первой) и без колонки; +выполненные — только с `jira_updated` за последние 14 дней. Всё +по рангу. + +## Risks / Trade-offs + +- **Поля Jira банка не проверены**: имя поля story points, тип поля ранга, признак kanban-бэклога в + `board/{id}/configuration` Jira Server. Проверка — на живой Jira при выкатке агента; не найдено — агент передаёт + `null`, экраны показывают «—», признак kanban-бэклога `null` — раздела «Бэклог» нет. +- **Объём закрытых спринтов**: агент читает список всех спринтов доски каждым `refresh` (постранично, без задач) — на + доске с десятками спринтов это несколько запросов; задачи закрытых спринтов не дочитываются, членство — из поля + спринта задач. +- **Story points на экране редактирования Jira**: если поле не на экране AURORA, `ensure_fields story_points` получит + ошибку Jira — `failed` с её текстом; нужна настройка Jira (у владельца/администратора). + +## Decisions made without discussion + +- 24.10.2026 — R-1 (design #3): kanban-бэклог — раздел страницы «Kanban», не пункт меню. Цена ошибки — перенести + раздел в отдельную страницу. +- 24.10.2026 — R-2 (design #7): «выполнено» у закрытого спринта — по текущему статусу. Цена ошибки — считать по + журналу изменений, когда он появится по закрытым спринтам. +- 24.10.2026 — R-3 (design #8): только свои задачи проекта на экранах и в MCP. Цена ошибки — добавить соседей флагом. +- 24.10.2026 — R-4 (design #10): поле оценки — `field_ids.story_points` или поиск агентом. Цена ошибки — настройка руками. +- 24.10.2026 — R-5 (design #11): имена и параметры команд `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank`; + «выше/ниже» — отношение, а не «вплотную». Цена ошибки — переименование типов до выкатки. +- 24.10.2026 — R-6 (design #13): данные досок — в каждом `refresh`, в том числе по ключам. Цена ошибки — лишние + запросы к Jira (несколько на доску). +- 24.10.2026 — R-7 (design #15): один маршрут `GET /agile`. Цена ошибки — разделить маршрут. +- 24.10.2026 — R-8 (контракт): kanban — выполненные задачи за 14 дней. Цена ошибки — число в настройке. +- 24.10.2026 — R-9 (design #6): колонка «Без колонки» для статусов вне настроек доски. Цена ошибки — нет. +- 24.10.2026 — R-10 (design #13): хвост пакета со спринтами — `refresh` по JQL проекта; пустой `refresh` шлёт пачку с + `boards`. Цена ошибки — лишнее чтение проекта (~3 запроса `search`). +- 24.10.2026 — R-11 (design #17–#20): правила kanban без бэклога, поиска спринта по имени, ошибок состояний, проектной + принадлежности `ensure_rank`, формы полей. Цена ошибки — правка проверок. +- 24.10.2026 — R-12 (design #21–#26): условные пункты меню, тип доски в `jira_project`, три состояния данных доски, + даты в поясе браузера, история из базовой JQL, переименование «Доска связей». Цена ошибки — правка веба. diff --git a/openspec/changes/sprints-backlog/proposal.md b/openspec/changes/sprints-backlog/proposal.md new file mode 100644 index 0000000..c6a9bf7 --- /dev/null +++ b/openspec/changes/sprints-backlog/proposal.md @@ -0,0 +1,51 @@ +## Why + +Владелец 23.10.2026 наметил для модуля jira спринты и бэклог «как в Jira». 24.10.2026 (brainstorming #108) уточнил: +в первую очередь — визуализация в вебе, похожая на Jira (бэклог и доска спринта, для kanban-досок — kanban), и +действия из чата через MCP: создавать спринты, запускать и завершать их, ранжировать задачи в бэклоге. Веб — только +просмотр. Нужна история закрытых спринтов и оценки: в AURORA пока не оценивали, владелец хочет ретроспективно прикинуть +оценки по прошедшему спринту, чтобы дальше планировать покером. + +Сейчас хаб знает спринт задачи только как имя строкой, у задач закрытых спринтов имя теряется; ни спринтов (даты, +состояние, цель), ни ранга, ни оценок, ни колонок доски в зеркале нет. + +## What Changes + +- **Зеркало (схема v9):** спринты досок (`jira_sprint`), членство задач во всех их спринтах (`issue_sprint`), колонки + и kanban-бэклог доски (`jira_board`), у задачи — ранг (`rank`) и оценка (`story_points`). +- **Агент v15** (design-only change `agent-v15-sprints` в jira-agent): в `refresh` читает настройки досок, все спринты, + ранг, story points и все спринты задачи; выполняет новые команды. +- **Команды:** `ensure_sprint_meta` (создать или поправить спринт: имя, даты, цель), `ensure_sprint_state` (запустить + или завершить, при завершении — куда невыполненные), `ensure_rank` (выше/ниже другой задачи); `ensure_fields` — + поле `story_points`. +- **API:** `GET /agile?project=` — доска, спринты (активные, будущие, закрытые) со своими задачами и суммами оценок, + бэклог, задачи kanban-доски. +- **MCP `sdlc-jira`:** `sprints`, `sprint`, `backlog` — чтение с оценками и суммами; новые типы команд — через + `batch_create` с подсказками параметров. +- **Веб модуля jira:** страницы «Бэклог», «Спринт» (scrum) и «Kanban» (kanban), только просмотр; пункты меню — по типу + доски проекта, без доски — нет; «Доска» → «Связи» (`/app/jira/links`, прежние адреса перенаправляют). + +## Non-Goals + +- Правки Jira из веба (перетаскивание, кнопки спринтов, смена статуса) — веб только смотрит. +- Отчёты Jira (burndown, velocity), покер планирования, эпики на бэклоге отдельной панелью, версии (fix version). +- Статус задачи на момент закрытия спринта (по журналу) — «выполнено» по текущему статусу. +- Соседи (задачи других проектов) на новых экранах. +- Подъём `min_agent_version` — владелец после выкатки v15. + +## Anti-Goals + +- Экраны, которые выглядят как Jira, но расходятся с ней по составу: спринт с задачами не того проекта, бэклог с + выполненными задачами, порядок не по рангу Jira. +- Команда спринта, которая при повторе делает второй спринт или второй перенос. +- Пункт меню, ведущий на пустую страницу у проекта без доски. +- Потеря работы хаба со старым агентом v14 до выкатки v15. + +## Impact + +- Хаб: `modules/jira/{schema,mirror,api,mcp,commands,hooks,settings}.py`, `core/db.py` (v9), веб + `web/src/modules/jira/**` (новые страницы, переименование, меню), спеки `data-model`, `commands`, `agent`, `hub-api`, + `mcp`, `web-app`. +- jira-agent: v15 (`src/agent.py`), свой design-only change. +- Выкатка: хаб (kickstart, `make web`, `/mcp` reconnect) → `agent publish` v15 → проверка полей Jira банка → + `field_ids.story_points`, если агент поле не нашёл. diff --git a/openspec/changes/sprints-backlog/specs/agent/spec.md b/openspec/changes/sprints-backlog/specs/agent/spec.md new file mode 100644 index 0000000..b357f85 --- /dev/null +++ b/openspec/changes/sprints-backlog/specs/agent/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: Спринты, ранг и оценки +Агент v15 SHALL в каждом `refresh` (по JQL и по ключам) для каждой доски из `boards` команды с типом `scrum` или +`kanban` (известным или только что определённым) прочитать настройки доски (`GET /rest/agile/1.0/board/{id}/configuration`: +колонки и их статусы — по именам статусов; признак kanban-бэклога, если Jira его отдаёт, иначе `null`) и, у `scrum`, +все спринты доски любого состояния (`GET /rest/agile/1.0/board/{id}/sprint` постранично) и приложить к первой пачке +состояния `boards` (см. `hub-api`, «Приём досок и спринтов»). Ошибка чтения настроек или спринтов доски — доска с +`columns: null` и прежними правилами для спринтов (v12), причина в лог; `refresh` продолжается. + +Каждая задача в состоянии (с v15) SHALL нести `rank` — значение поля ранга Jira (поле типа LexoRank), `story_points` — +значение поля оценки (`field_ids.story_points`, если задано, иначе поле Jira с именем «Story Points»; поля нет — +`null`), и `sprint_ids` — id всех спринтов задачи из поля спринта Jira (любого состояния). Поле не найдено — значение +`null` у всех задач и запись в лог один раз за `refresh`. + +Агент v15 SHALL выполнять `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank` и `story_points` в +`ensure_fields` по контракту `commands`; сравнение — до записи, повтор без изменений — `done` без записи. + +#### Scenario: Доска с закрытыми спринтами +- **WHEN** `refresh {jql: …, boards: [{id: 4216, type: "scrum"}]}`, у доски закрытые спринты 1–2, активный 3, будущий 4 +- **THEN** первая пачка состояния несёт `boards` с доской 4216, четырьмя спринтами и колонками; у задачи спринта 2, + перенесённой в 3, — `sprint_ids: [2, 3]` + +#### Scenario: Нет поля оценки +- **WHEN** в Jira нет поля «Story Points», `field_ids.story_points` не задан +- **THEN** у всех задач `story_points: null`; `ensure_fields {key: X, story_points: 3}` — `failed` «поле оценки не найдено + в Jira: задайте field_ids.story_points» diff --git a/openspec/changes/sprints-backlog/specs/commands/spec.md b/openspec/changes/sprints-backlog/specs/commands/spec.md new file mode 100644 index 0000000..9e7dd4f --- /dev/null +++ b/openspec/changes/sprints-backlog/specs/commands/spec.md @@ -0,0 +1,129 @@ +## MODIFIED Requirements + +### Requirement: Команда — желаемый результат на одной задаче +Каждая команда SHALL описывать желаемое состояние одной задачи Jira и выполняться агентом как «прочитать → сравнить → +записать только разницу». Повторное выполнение любой команды с тем же результатом в Jira SHALL завершаться `done` +без записи. `fetch_attachment` в Jira не пишет: она читает файл вложения и передаёт его хабу; повтор безопасен. Типы команд и их контракт: + +| Тип | Параметры | Что читает | Что пишет | +|---|---|---|---| +| `ensure_issue` | `temp_key` (`new:`), `issue_type` (`task`/`epic`/`bug`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | поиск `project = AND reporter = currentUser() AND created >= -7d`, точное сравнение `summary` | при отсутствии — `POST /issue` с типом из `issue_types.` (для эпика — с `epic_name_field_id`); результат `key` | +| `ensure_fields` | `key`, любое подмножество: `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels_add`, `labels_remove`, `epic_key`, `assignee`, `story_points` (число ≥ 0 или `null` — очистить) | `GET /issue/{key}` | `PUT /issue/{key}` только с отличающимися полями; эпик — по `epic_link.mode`; оценка — в поле story points (см. `agent`, «Спринты, ранг и оценки»); поля нет — `failed` «поле оценки не найдено в Jira: задайте field_ids.story_points» | +| `ensure_comment` | `key`, `marker`, `body` | `GET /issue/{key}/comment` | комментарий с `marker` в начале: `PUT` если текст отличается, `POST` если нет | +| `ensure_status` | `key`, `target` (имя статуса или список-путь) | `GET /issue/{key}` (статус), `GET /issue/{key}/transitions` на каждом шаге | `POST /transitions` по шагам пути; при отсутствии перехода — `failed` с перечнем доступных | +| `ensure_link` | `source`, `target`, `link_type` (логическое имя из `link_types`), `replace` (bool) | `GET /issue/{source}` (`issuelinks`) | нет нужной и нет конфликта — `POST /issueLink`; конфликт (другой тип из `link_types` или обратное направление) и `replace` — `DELETE` лишней, затем `POST`; конфликт без `replace` — `failed` с описанием | +| `ensure_sprint` | `key`, `sprint` (имя или `backlog`); `board_id` хаб дописывает при постановке (от клиента — 422) | `GET /issue/{key}` (поле спринта), список спринтов доски `board_id` (активные и будущие) | `POST /rest/agile/1.0/sprint/{id}/issue` или `POST /rest/agile/1.0/backlog/issue` | +| `ensure_sprint_meta` | `sprint` (имя), необязательные `rename_to`, `start_date`, `end_date` (`ГГГГ-ММ-ДД`), `goal`; `board_id` хаб дописывает при постановке (от клиента — 422) | спринты доски `board_id` (все состояния) | спринта с именем `sprint` нет — `POST /rest/agile/1.0/sprint` (`originBoardId`, имя `rename_to` или `sprint`, даты, цель); есть — `POST /rest/agile/1.0/sprint/{id}` только с отличающимися полями; спринт закрыт — `failed` «спринт закрыт»; результат `{sprint_id, created}` | +| `ensure_sprint_state` | `sprint`, `state` (`active` или `closed`), `move_open_to` (имя активного или будущего спринта той же доски или `backlog`; при `closed` обязателен, при `active` — 422); `board_id` — как у `ensure_sprint_meta` | спринты доски (все состояния), задачи спринта и колонки доски | `active`: спринт будущий — `POST /sprint/{id}` `state = active` (нет дат у спринта — `failed` «у спринта нет дат: задайте ensure_sprint_meta»); `closed`: задачи спринта не в последней колонке доски — в `move_open_to`, затем `state = closed`; уже в нужном состоянии — ничего; результат `{sprint_id, moved: [ключ…]}` | +| `ensure_rank` | `key` и ровно одно из `before`, `after` (ключ другой задачи) | ранг обеих задач | `key` уже выше `before` (ниже `after`) — ничего; иначе `PUT /rest/agile/1.0/issue/rank` (`rankBeforeIssue` / `rankAfterIssue`) | +| `ensure_attachment` | `key`, `file` (sha256 файла в хранилище хаба, 64 hex-символа), `filename` (имя вложения в Jira, без `/`, `\`, `"` и управляющих символов); `size` хаб дописывает при выдаче | `GET /issue/{key}?fields=attachment` | вложения с тем же `filename` и размером нет — файл у хаба (`GET /agent/files/{file}`), затем `POST /issue/{key}/attachments` (multipart); есть — ничего; результат `attachment_id` | +| `refresh` | `keys` (список) или `jql`; `boards` (`[{id, type}]` — доски проектов, `type` — `scrum`, `kanban` или `null`) хаб дописывает при выдаче | `search` с полями зеркала, по 100 на страницу; плюс дочитывание соседей по `issuelinks`; доски с неизвестным типом — `GET /rest/agile/1.0/board/{id}` | ничего в Jira; состояние уходит в хаб; результат — `boards: {"": "scrum" | "kanban" | null}` для досок, чей тип агент запрашивал | +| `fetch_attachment` | `key`, `attachment_id` (id вложения Jira, строка из цифр) | `GET /issue/{key}?fields=attachment` (имя и размер вложения) | ничего в Jira; содержимое — `GET /secure/attachment/{id}/{имя}`, затем хабу `POST /agent/files`; результат `{attachment_id, file, size}` | + +Доску для `ensure_sprint` хаб SHALL определять при постановке: проект задачи — проекты, чьи метки есть у задачи в +зеркале (для временного ключа — в `labels` её `ensure_issue` в пакете, после дописывания метки проекта); `board_id` — +доска этого проекта. 422 при постановке: задача ни в одном проекте; в нескольких проектах с разными досками; у проекта +нет доски; тип доски `kanban` («у проекта P канбан-доска, спринтов нет»); тип доски ещё не известен («тип доски проекта P +ещё не известен — дождитесь обновления из Jira»). `boards` у `refresh` хаб SHALL дописывать при каждой выдаче (в базе +не хранится): все доски проектов без повторов. По результату `refresh` хаб SHALL записать присланные типы `scrum` и +`kanban` в проекты с этой доской; `null` тип не меняет. + +Доску для `ensure_sprint_meta` и `ensure_sprint_state` хаб SHALL определять при постановке по проекту пакета +(`project` пакета): пакет без проекта — 422 «команды спринта — только в пакете проекта»; у проекта нет доски, доска +`kanban` или тип ещё не известен — 422 с теми же текстами, что у `ensure_sprint`. `ensure_rank` в пакете проекта +принадлежит проекту по `key`; `before`/`after` — любая задача. Повтор команды (`command_retry`) SHALL снимать `board_id` +у `ensure_sprint_meta` и `ensure_sprint_state`, как у `ensure_sprint`. Хвостовой `refresh` пишущего пакета с +`ensure_sprint_meta` или `ensure_sprint_state` SHALL быть `refresh {jql: }` (`project = + AND labels in ("<метка>")`); иначе — как раньше, по ключам команд. Подтверждения по состоянию зеркала у +`ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank` нет. + +`ensure_sprint_meta` SHALL искать спринт по имени среди всех спринтов доски: закрытый — `failed` «спринт закрыт»; +несколько с этим именем — `failed` «несколько спринтов с именем …: »; `rename_to` на имя другого спринта доски — +`failed` «имя занято». `ensure_sprint_state`: `closed` для будущего спринта — `failed` «спринт не начат»; `active` для +закрытого — `failed` «спринт закрыт»; результат `ensure_rank` — `{changed}`. + +Семантика `ensure_link`: `source` блокирует/ссылается на `target`; агент в `POST /issueLink` SHALL ставить `source` в +`inwardIssue`, `target` в `outwardIssue` (проверено на Jira банка 11.10.2026). + +Текст описания (`ensure_fields`) и тело комментария (`ensure_comment`) агент SHALL сравнивать с текущим значением в Jira +без различия переводов строк `\r\n` и `\n`; записывается текст команды как есть. Jira хранит присланный текст, а правленный +в веб-интерфейсе — с `\r\n` (проверено на Jira банка 13.10.2026). + +#### Scenario: Повтор уже выполненной команды +- **WHEN** `ensure_fields {key: AURORA-1533, duedate: 2026-10-23}` выполняется, а в Jira срок уже 23.10 +- **THEN** агент не делает `PUT`, отчёт `done`, `jira_calls = 1` + +#### Scenario: Описание отличается только переводами строк +- **WHEN** `ensure_fields {key: X, description: "а\nб"}`, а в Jira описание X — `"а\r\nб"` +- **THEN** агент не делает `PUT`, отчёт `done` с `changed = []` + +#### Scenario: Замена связи +- **WHEN** `ensure_link {source: AURORA-1537, target: AURORA-1538, link_type: "связанные", replace: true}` и в Jira между + ними «блокирует начало» 1537→1538 +- **THEN** `DELETE` старой связи, пауза по политике, `POST` новой; отчёт `done` с обеими операциями + +#### Scenario: Связь-конфликт без replace +- **WHEN** та же команда с `replace: false` +- **THEN** `failed`, в `error` найденная связь, Jira не изменена + +#### Scenario: Переход по пути +- **WHEN** `ensure_status {key: X, target: ["Разработка", "Готово"]}` и X в «Ожидание разработки» +- **THEN** два `POST /transitions` с паузой между ними; если X уже «Готово» — `done` без записи + +#### Scenario: Создание ошибки +- **WHEN** `ensure_issue {temp_key: new:1, issue_type: bug, summary: "ОСВ 60 не распознана", labels: [AWPCA]}`, в настройках + `issue_types.bug = {name: "Ошибка"}` без `id` +- **THEN** агент создаёт задачу с `issuetype = {name: "Ошибка"}`; отчёт `done` с ключом + +#### Scenario: Вложение уже есть +- **WHEN** `ensure_attachment {key: X, file: , filename: "ОСВ 60.xlsx"}`, у X в Jira есть вложение «ОСВ 60.xlsx» того + же размера +- **THEN** агент не скачивает файл у хаба и не загружает его в Jira; отчёт `done` с `attachment_id` найденного, `jira_calls = 1` + +#### Scenario: Вложение с тем же именем, другого размера +- **WHEN** та же команда, у X есть «ОСВ 60.xlsx» другого размера +- **THEN** агент загружает файл вторым вложением с тем же именем (Jira это допускает); отчёт `done` с новым `attachment_id` + +#### Scenario: Создание с проверкой дубля +- **WHEN** `ensure_issue` повторяется после падения агента, а задача с той же темой от нас уже создана час назад +- **THEN** агент не создаёт вторую, отчёт `done` с ключом найденной + +#### Scenario: Спринт задачи проекта +- **WHEN** у AURORA-1538 метка `AWPCA`, проект `AWPCA` с доской 4216 типа `scrum`; `ensure_sprint {key: AURORA-1538, sprint: "Спринт 2"}` +- **THEN** пакет создан, в параметрах команды `board_id = 4216` + +#### Scenario: Спринт на канбане +- **WHEN** у AURORA-1 метка `STOCKS_DRRK`, доска проекта — `kanban`; `ensure_sprint {key: AURORA-1, sprint: "Спринт 2"}` +- **THEN** 422 «у проекта STOCKS_DRRK канбан-доска, спринтов нет» + +#### Scenario: Тип доски определён +- **WHEN** проект `STOCKS_DRRK` с доской 5120 и `board_type = null`; выдаётся `refresh`, агент вернул `boards: {"5120": "kanban"}` +- **THEN** при выдаче в параметрах `boards` есть `{id: 5120, type: null}`; после отчёта у проекта `board_type = kanban` + +### Requirement: Политика темпа и пауза на команде +Хаб SHALL хранить `pacing` как минимальные зазоры в секундах по типу команды (по умолчанию: `ensure_issue` и +`ensure_fields` 120, `ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint`, `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank`, `ensure_attachment` 30, `refresh` и `fetch_attachment` 2; тип, которого нет в +сохранённой настройке `pacing`, получает зазор по умолчанию). При выдаче команды +хаб SHALL сообщить `pause_seconds` = зазор по типу выдаваемой команды; агент SHALL выдержать его от времени своего +последнего вызова Jira. Внутри одной команды с несколькими записями (`ensure_link` с `replace`, путь переходов) агент +SHALL выдерживать зазор типа команды между записями. + +#### Scenario: Нет следующей команды — нет паузы +- **WHEN** очередь пуста +- **THEN** `claim` возвращает пустой ответ, агент не спит сверх долгого запроса + +#### Scenario: Создание спринта повтором не дублируется +- **WHEN** пакет проекта `AWPCA` (доска 4216, scrum) `ensure_sprint_meta {sprint: "AWPCA_Sprint_4", start_date: "2026-10-28", end_date: "2026-11-11", goal: "свод ВГО"}` выполнен, затем поставлен повторно +- **THEN** первый раз спринт создан (`created: true`), второй — `done` без записи (`created: false`) + +#### Scenario: Завершение спринта с переносом +- **WHEN** `ensure_sprint_state {sprint: "AWPCA_Sprint_3_Corrections", state: "closed", move_open_to: "AWPCA_Sprint_4"}`, в спринте 37 задач, 12 не в последней колонке доски +- **THEN** 12 задач перенесены в `AWPCA_Sprint_4`, спринт закрыт; результат `moved` — их ключи; повтор — `done` без записи + +#### Scenario: Спринт без проекта пакета +- **WHEN** `POST /batches` без `project` с `ensure_sprint_meta {sprint: "S"}` +- **THEN** 422 «команда 1 (ensure_sprint_meta): команды спринта — только в пакете проекта» + +#### Scenario: Ранг уже такой +- **WHEN** `ensure_rank {key: AURORA-1612, before: AURORA-1601}`, ранг 1612 меньше ранга 1601 +- **THEN** `done` без записи в Jira diff --git a/openspec/changes/sprints-backlog/specs/data-model/spec.md b/openspec/changes/sprints-backlog/specs/data-model/spec.md new file mode 100644 index 0000000..6448162 --- /dev/null +++ b/openspec/changes/sprints-backlog/specs/data-model/spec.md @@ -0,0 +1,160 @@ +## MODIFIED Requirements + +### Requirement: Схема базы хаба +База хаба SHALL быть одним файлом SQLite (WAL) с таблицами `issue`, `issue_link`, `issue_comment`, `issue_change`, `issue_attachment`, +`attachment_file`, `issue_note`, `issue_sprint`, `jira_board`, `jira_sprint`, `batch`, `command`, `sync_run`, `agent_status`, `setting`, `project`. Схема SHALL создаваться и мигрироваться самим хабом при +старте по номеру версии в `setting.schema_version` (текущая — 9). Миграция существующей базы SHALL выполняться в одной +транзакции с блокировкой записи; в той же транзакции, после повторной проверки версии, хаб SHALL скопировать файл базы в +`backups/<дата-время>-schema-v<старая>.sqlite` рядом с базой — одна копия на миграцию, сколько бы соединений ни +открывалось одновременно. Каталог данных хаба и `backups/` SHALL иметь права `0700` (в базе токены): хаб выставляет их +при создании и при каждом старте; если выставить не удалось — предупреждение в лог, работа продолжается. Денежных и +дробных полей нет; даты — ISO-8601 строки в UTC, кроме `duedate` и плановых дат (даты без времени), строк времени +Jira как есть (`issue_comment.created`/`updated`, `issue_change.created`, `issue_attachment.created`) и значений полей журнала. + +Миграция v3 → v4 SHALL: создать `project` и колонку `batch.project` (у существующих пакетов `NULL`); разобрать прежнюю +настройку `base_jql` вида `… labels = ` или `… labels in (, …)` и завести проекты с этими метками (название проекта +`AWPCA` — «АРМ КА», остальных — метка; `board_type = NULL`); отдать прежний `board_id` проекту, если проект один, иначе +никому, с предупреждением в лог; у строк `sync_run` с `query`, равной прежней `base_jql`, и у команд `refresh` в +`queued`/`claimed` с `params.jql`, равной прежней `base_jql`, записать базовую JQL, вычисленную из проектов. Ключи +`base_jql` и `board_id` в `setting` миграция MUST NOT удалять: базу может открыть процесс новой версии раньше, чем +перезапущен хаб прежней версии, который их читает. Не разобранная `base_jql` — проектов нет, предупреждение в лог с +прежним значением. + +Миграция v4 → v5 SHALL: создать `issue_change` с индексами; добавить колонки `issue_comment.created_at` и +`sync_run.history_at`; заполнить `issue_comment.created_at` у имеющихся комментариев приведением `created` к UTC (не +разобралось — `NULL`). У имеющихся `sync_run` `history_at = NULL`. + +Миграция v5 → v6 SHALL: создать пустые `issue_attachment` (с индексом по `key`) и `attachment_file`. Списки вложений +появляются с первым `refresh` агента v14. + +Миграция v7 → v8 SHALL: удалить колонку `issue_note.fact_status` (если она есть; значения удаляются безвозвратно, копия — +в `backups/` по общему правилу) и ключи `status_labels` и `transitions_map` из `setting`, если они есть, и поднять +`settings_version`, если хотя бы один из ключей был удалён. Прочие поля заметок, задачи и настройки миграция MUST NOT +менять. + +Миграция v8 → v9 SHALL: добавить колонки `issue.rank` и `issue.story_points` (у имеющихся задач `NULL`); таблицы +`jira_board`, `jira_sprint`, `issue_sprint` создаются схемой пустыми. Данные появляются с первым `refresh` агента v15. + +#### Scenario: Первый запуск +- **WHEN** хаб стартует с несуществующим файлом базы +- **THEN** файл создаётся, таблицы и индексы на месте, `setting.schema_version` = текущая; каталог данных с правами `0700` + +#### Scenario: Миграция базы v1 +- **WHEN** хаб стартует с базой `schema_version = 1`, в которой есть задачи, заметки и команда в `claimed` +- **THEN** в `backups/` копия базы v1; добавлена `command.deliveries` (у `claimed` — 1, у остальных — 0); добавлена пустая + `issue_comment`; данные на месте; `schema_version` = текущая + +#### Scenario: Миграция базы v2 +- **WHEN** хаб стартует с базой `schema_version = 2`, в которой есть задачи, связи и заметки +- **THEN** в `backups/` копия базы v2; добавлена пустая `issue_comment`; данные на месте; `schema_version` = текущая + +#### Scenario: Миграция базы v3 +- **WHEN** хаб стартует с базой `schema_version = 3`, `base_jql = "project = AURORA AND labels = AWPCA"`, `board_id = 4216`, + в `sync_run` полный прогон с этой `query` +- **THEN** в `backups/` копия базы v3; проект `AWPCA` «АРМ КА» с `board_id = 4216`, `board_type = NULL`; ключи `base_jql` + и `board_id` в `setting` на месте; `query` того прогона — `project = AURORA AND labels in ("AWPCA")`; задачи, заметки, + пакеты на месте; `schema_version` = текущая + +#### Scenario: Миграция базы v4 +- **WHEN** хаб стартует с базой `schema_version = 4`, у задачи X комментарий с `created = "2026-10-22T13:05:11.000+0300"`, + в `sync_run` полный прогон +- **THEN** в `backups/` копия базы v4; есть пустая `issue_change`; у комментария `created_at = "2026-10-22T10:05:11+00:00"`, + `created` прежний; у прогона `history_at = NULL`; затем миграция v5 → v6; `schema_version` = текущая + +#### Scenario: Миграция базы v5 +- **WHEN** хаб стартует с базой `schema_version = 5` +- **THEN** в `backups/` копия базы v5; есть пустые `issue_attachment` и `attachment_file`; прочие таблицы не тронуты; + `schema_version` = текущая + +#### Scenario: Миграция базы v7 +- **WHEN** хаб стартует с базой `schema_version = 7`, у задачи X заметка `fact_status = "В работе"`, `work_kind = + "Аналитика"`, в `setting` есть `status_labels` и `transitions_map`, `settings_version = 12` +- **THEN** в `backups/` копия базы v7; в `issue_note` нет колонки `fact_status`, у X `work_kind = "Аналитика"`, + `updated_at` прежний; ключей `status_labels` и `transitions_map` в `setting` нет; `settings_version = 13`; + `schema_version` = текущая + +#### Scenario: Миграция базы v8 +- **WHEN** хаб стартует с базой `schema_version = 8`, в зеркале задача X со спринтом «Спринт 2» +- **THEN** в `backups/` копия базы v8; у X `rank` и `story_points` — `NULL`, `sprint` прежний; таблицы `jira_board`, + `jira_sprint`, `issue_sprint` пусты; `schema_version = 9` + +#### Scenario: refresh в очереди при миграции +- **WHEN** в базе v3 стоит `queued` `refresh {jql: "project = AURORA AND labels = AWPCA"}` +- **THEN** после миграции его `params.jql` — `project = AURORA AND labels in ("AWPCA")`, и по завершении он считается + полным прогоном + +#### Scenario: Два соединения к базе v1 +- **WHEN** к базе `schema_version = 1` одновременно открываются два соединения +- **THEN** миграция выполнена один раз, в `backups/` одна копия `schema-v1` + +#### Scenario: Открытый каталог данных +- **WHEN** хаб стартует, каталог данных существует с правами `0755` +- **THEN** права каталога и `backups/` — `0700` + +### Requirement: Зеркало задачи +Таблица `issue` SHALL хранить задачу Jira как есть: `key` (PK), `id`, `issue_type`, `summary`, `description` (wiki), +`status`, `priority`, `assignee`, `reporter`, `duedate`, `planned_start`, `planned_end`, `epic_key`, `labels` (JSON-список), +`sprint`, `rank` (ранг Jira строкой как есть или `NULL`), `story_points` (оценка, число или `NULL`), +`jira_updated`, `fetched_at`, `neighbor` (0/1 — вне базовой JQL, дочитана по связи), `created`. Таблица `issue_link` +SHALL хранить связи `(source_key, target_key, link_type)` с направлением по семантике Jira: `source` блокирует/ссылается +на `target` (у `source` в GET это `outwardIssue`). Таблица `issue_comment` SHALL хранить комментарии задачи как есть: +`key`, `comment_id` (PK вместе с `key`), `author`, `created`, `updated`, `body` (wiki). Таблица `issue_attachment` SHALL +хранить список вложений задачи без содержимого: `attachment_id` (PK, id вложения Jira), `key`, `filename`, `size` (байт +или `NULL`), `mime_type`, `author`, `created` (время Jira как есть), `created_at` (UTC, приведённое из `created`; не +разобралось — `NULL`). Обновлять `issue`, `issue_link`, `issue_comment` и +`issue_attachment` SHALL только загрузка состояния от агента; HTTP API интерфейсов и MCP MUST NOT писать в них. Присланные +комментарии задачи SHALL заменять все её прежние комментарии; задача, присланная без поля `comments`, свои комментарии +SHALL сохранять. Так же со вложениями: присланное поле `attachments` заменяет все прежние вложения задачи, задача без +поля (агент до v14) свои вложения сохраняет. Полный прогон (`refresh` по базовой JQL), завершившийся `done`, SHALL помечать `neighbor = 1` задачи, не +обновлённые им, — только если прогон загрузил хотя бы одну задачу. + +#### Scenario: Загрузка состояния заменяет связи +- **WHEN** агент прислал состояние задачи X с двумя связями +- **THEN** все строки `issue_link` с `source_key = X` заменены на присланные; строки с `target_key = X`, не присланные + со стороны X, не трогаются до загрузки их источника + +#### Scenario: Соседняя задача +- **WHEN** в `issuelinks` задачи из базовой JQL есть ключ Y вне JQL +- **THEN** Y сохраняется с `neighbor = 1`; при следующем полном `refresh`, если Y вошла в JQL, `neighbor = 0` + +#### Scenario: Пустой полный прогон +- **WHEN** полный `refresh` завершился `done`, загрузив 0 задач +- **THEN** признак `neighbor` задач зеркала не меняется + +#### Scenario: Комментарий удалён в Jira +- **WHEN** у X в зеркале комментарии 101 и 102, агент прислал X с `comments = [{id: "102", …}]` +- **THEN** у X в `issue_comment` только 102 + +#### Scenario: Состояние от агента до v10 +- **WHEN** у X в зеркале три комментария, агент v9 прислал X без поля `comments` +- **THEN** поля X обновлены, три комментария на месте + +#### Scenario: Вложения в состоянии +- **WHEN** у X в зеркале вложения #1 и #2, агент v14 прислал X с `attachments = [{id: "2", …}, {id: "3", …}]` +- **THEN** у X в `issue_attachment` #2 (поля из состояния) и #3; #1 удалено; `attachment_file` не тронута + +#### Scenario: Состояние от агента до v14 +- **WHEN** у X в зеркале два вложения, агент v13 прислал X без поля `attachments` +- **THEN** поля X обновлены, два вложения на месте + +## ADDED Requirements + +### Requirement: Доски, спринты и членство задач +Модуль jira SHALL хранить данные досок проектов, присланные агентом в `refresh` (см. `hub-api`, «Приём досок и +спринтов»): +- `jira_board`: `board_id` (PK), `type` (`scrum` | `kanban` | `NULL`), `columns` (JSON-список `[{name, statuses: + [имя статуса]}]` в порядке доски или `NULL`), `kanban_backlog` (1 / 0 / `NULL` — неизвестно), `fetched_at` (UTC); +- `jira_sprint`: `sprint_id` (PK, id спринта Jira), `board_id`, `name`, `state` (`active` | `future` | `closed`), + `start_date`, `end_date`, `complete_date` (строки Jira как есть или `NULL`), `goal`; +- `issue_sprint`: `key`, `sprint_id` (PK — пара) — все спринты задачи любого состояния. +Писать их SHALL только загрузка состояния от агента. Выполненной на доске SHALL считаться задача, чей `status` входит в +`statuses` последней колонки доски; колонок нет (`NULL`) — выполненных нет. + +#### Scenario: Задача прошла два спринта +- **WHEN** агент прислал задачу X с `sprint_ids: [101, 102]`, спринт 101 закрыт, 102 активен +- **THEN** у X две строки `issue_sprint`; X в составе закрытого спринта 101 и активного 102 + +#### Scenario: Колонки доски +- **WHEN** у доски 4216 колонки «К выполнению» [`Backlog`, `Ожидает анализа`], «В работе» […], «Готово» [`Готово`, + `Протестировано`, `Снято`] +- **THEN** задача в статусе `Протестировано` выполнена, в статусе `Backlog` — нет diff --git a/openspec/changes/sprints-backlog/specs/hub-api/spec.md b/openspec/changes/sprints-backlog/specs/hub-api/spec.md new file mode 100644 index 0000000..e918b26 --- /dev/null +++ b/openspec/changes/sprints-backlog/specs/hub-api/spec.md @@ -0,0 +1,54 @@ +## ADDED Requirements + +### Requirement: Приём досок и спринтов +`POST /agent/state` (агент v15) SHALL принимать необязательные поля: +- у задачи — `rank` (строка или `null`), `story_points` (число или `null`), `sprint_ids` (список целых); присланные + `rank` и `story_points` записываются в `issue`; присланный `sprint_ids` заменяет все строки `issue_sprint` задачи; + задача без поля свои значения сохраняет; +- в пачке — `boards`: список `{id, type, columns, kanban_backlog, sprints}`; `columns` — список `{name, statuses}` или + `null`, `kanban_backlog` — `true`/`false`/`null`, `sprints` — список `{id, name, state, start_date, end_date, + complete_date, goal}`. Доска из `boards` SHALL заменять свою строку `jira_board` и все строки `jira_sprint` этой + доски; доски, которых нет в `boards`, не меняются. +Форму хаб SHALL проверять до записи, как `comments`: нарушение — 422 `bad_state` с ключом задачи или номером доски и +именем поля, пачка не применяется. `state` спринта вне `active`/`future`/`closed`, `sprint_ids` не список целых (в том +числе `null`) — нарушение; `rank: null` и `story_points: null` — очистить поле. Тип доски из `boards` (`scrum` / +`kanban`) хаб SHALL записывать и в `jira_project.board_type` проектов этой доски; `null` тип не меняет. Пачка может +быть пустой (`issues: []`) и нести только `boards`. + +#### Scenario: Пачка с досками +- **WHEN** пачка `refresh` несёт `boards: [{id: 4216, type: "scrum", columns: […], kanban_backlog: null, sprints: [101 + closed, 102 active, 103 future]}]`, раньше у доски были спринты 101 и 102 +- **THEN** у доски 4216 строки `jira_sprint` 101, 102, 103 с присланными полями; колонки заменены + +#### Scenario: Агент v14 +- **WHEN** пачка без `boards`, задачи без `rank`, `story_points`, `sprint_ids` +- **THEN** данные досок и эти поля задач не меняются + +### Requirement: Маршрут планирования +`GET /agile?project=<метка>` (клиентский токен) SHALL отдавать для проекта (неизвестный — 404; без `project` — 422): +`{board, sprints, backlog, board_issues}`. +- `board` — `{id, type, columns, kanban_backlog, fetched_at}` доски проекта; доски нет — `null` (остальные поля — + `[]`/`null`); доска есть, а её данных от агента нет — `{id, type, columns: null, kanban_backlog: null, fetched_at: + null}`. +- `sprints` — все спринты доски: `{id, name, state, start_date, end_date, complete_date, goal, count, count_done, + unestimated, points, points_done, issues}`; `issues` — свои задачи проекта в этом спринте (по `issue_sprint`); + `count`/`count_done` — их число и число выполненных, `points`/`points_done` — сумма `story_points` всех и выполненных, + `unestimated` — число без оценки. Порядок: `active` по `start_date`, `future` по id, `closed` по `complete_date`, новые + сверху. У kanban-доски — `[]`. +- `backlog` — scrum: свои задачи не в активном и не в будущем спринте и не выполненные; kanban: свои задачи в статусах + первой колонки, если `kanban_backlog = true`, иначе `null`. +- `board_issues` — только kanban: свои задачи в статусах остальных колонок (при `kanban_backlog` не `true` — и + первой) и без колонки; выполненные — только с + `jira_updated` не старше 14 дней; scrum — `null`. +Строка задачи — `{key, summary, issue_type, status, priority, assignee, epic_key, story_points, rank, done}`; списки — по +`rank` (строки сравниваются как есть; `null` — в конце), затем по `key`. + +#### Scenario: Спринт и бэклог +- **WHEN** у `AWPCA` активный спринт 102 с задачами A (3 SP, в последней колонке), B (без оценки), в бэклоге C и + выполненная D без спринта +- **THEN** спринт 102: `count = 2`, `count_done = 1`, `points = 3`, `points_done = 3`, `unestimated = 1`; `backlog` — + только C + +#### Scenario: Проект без доски +- **WHEN** `GET /agile?project=STOCKS_DRRK`, у проекта нет доски +- **THEN** 200, `board = null`, `sprints = []`, `backlog = null`, `board_issues = null` diff --git a/openspec/changes/sprints-backlog/specs/mcp/spec.md b/openspec/changes/sprints-backlog/specs/mcp/spec.md new file mode 100644 index 0000000..67cceac --- /dev/null +++ b/openspec/changes/sprints-backlog/specs/mcp/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: Спринты и бэклог в MCP +MCP `sdlc-jira` SHALL давать инструменты чтения по `GET /agile` проекта сессии (сессия без проекта — тот же отказ, что у +прочих инструментов с проектом): +- `sprints` — таблица спринтов доски: имя, состояние, даты, задач (выполнено/всего), SP (выполнено/всего), без оценки; + закрытые — 10 последних и строка «ещё N закрытых», если их больше; без доски — «у проекта нет доски»; +- `sprint` (`name`, по умолчанию — активный) — шапка спринта (имя, состояние, даты, цель, суммы) и задачи по колонкам + доски: ключ, тема (до 80 символов), исполнитель, SP; +- `backlog` (`limit`, по умолчанию 50) — бэклог по рангу: позиция, ключ, тема, статус, исполнитель, SP; в конце «ещё N», + если задач больше `limit`. +Таблица `issues` SHALL получить колонку «SP». Описание `batch_create` SHALL перечислять `ensure_sprint_meta`, +`ensure_sprint_state`, `ensure_rank` и `story_points` в `ensure_fields` с параметрами; хвостовой `refresh` хаб +дописывает сам (см. `commands`). + +#### Scenario: Спринты проекта +- **WHEN** сессия привязана к `AWPCA`, вызван `sprints` +- **THEN** строки: активный `AWPCA_Sprint_3_Corrections`, будущие, затем закрытые; у каждой — задачи и SP «выполнено/всего» + +#### Scenario: Бэклог с лимитом +- **WHEN** `backlog {limit: 2}`, в бэклоге 188 задач +- **THEN** две строки по рангу и «ещё 186» diff --git a/openspec/changes/sprints-backlog/specs/web-app/spec.md b/openspec/changes/sprints-backlog/specs/web-app/spec.md new file mode 100644 index 0000000..2a6edac --- /dev/null +++ b/openspec/changes/sprints-backlog/specs/web-app/spec.md @@ -0,0 +1,186 @@ +## MODIFIED Requirements + +### Requirement: Оболочка и адреса +Приложение SHALL открываться под префиксом `/app/`. Главная страница — «Обзор» ядра (`/app/?project=<метка>`, +требование «Обзор проекта»). Страницы модулей SHALL лежать под префиксом имени модуля: модуль jira — `/app/jira/links` +(«Связи» — доска связей), `/app/jira/backlog` («Бэклог»), `/app/jira/sprint` («Спринт»), `/app/jira/kanban` («Kanban»; +три последних — требование «Спринты и бэклог»), `/app/jira/issues` (список задач), `/app/jira/feed` (лента изменений, требование «Лента изменений») и +`/app/jira/issues/{key}` (карточка задачи — окно поверх страницы, требование «Карточка задачи»); модуль wiki — +`/app/wiki` (требование «Вики проекта»). Прежние адреса `/app/board` и `/app/jira/board` (на `/app/jira/links`), +`/app/issues`, `/app/issues/{key}`, `/app/feed` SHALL перенаправлять на новые с сохранением строки запроса, без новой записи истории. Неизвестный путь — страница «не +найдено» со ссылками на «Обзор» и разделы модулей. + +Боковая панель SHALL быть закреплена у левого края окна на всю его высоту и MUST NOT прокручиваться вместе со +страницей; если её содержимое не помещается по высоте, прокручивается сама панель. Сверху вниз панель SHALL содержать: +название «SDLC Hub» и кнопку «Свернуть панель»; выпадающий список проектов («<метка> — <название>», текущий отмечен, в +конце «+ Новый проект…»; требование «Проект в приложении»); пункт «Обзор»; разделы модулей группами — заголовок группы +— название модуля («Jira», «Вики»), под ним ссылки разделов модуля со значками (текущий отмечен +`aria-current="page"`); внизу — блок состояния модулей (требование «Состояние и обновление модулей»), переключатель +темы (требование «Тема») и меню с действием «Сменить токен». Свёрнутая панель SHALL быть узкой полосой с теми же +действиями значками: «Развернуть панель», метка текущего проекта (полностью — в подсказке), «Обзор», разделы +(группы разделены линией), по значку состояния на модуль, тема, меню; названия — в подсказках и `aria-label`. Свёртка +SHALL храниться в `localStorage` под ключом `sdlc-hub-sidebar` (`collapsed`; без записи — развёрнута). + +На экранах уже 640 px панели в потоке нет: сверху SHALL быть закреплена строка с кнопкой «Меню», меткой текущего +проекта и сводной точкой состояния модулей (ошибка, если она есть у какого-нибудь модуля; иначе — пакет в работе; +иначе — все агенты на связи или нет), которая открывает панель. «Меню» открывает развёрнутую панель поверх страницы +модальным окном: фокус переходит в панель и остаётся в ней, страница под ней недоступна; панель закрывается выбором +раздела, клавишей Escape (если не открыто вложенное меню — тогда Escape закрывает только его) или щелчком мимо неё; фокус +возвращается на «Меню». Горизонтальной прокрутки страницы на любой ширине нет. + +Заголовок вкладки браузера SHALL быть «<название страницы> · <метка проекта> · SDLC Hub» (без проекта — без средней +части); название страницы даёт оболочка («Обзор») или модуль. Неожиданная ошибка отрисовки страницы SHALL показываться +сообщением с кнопкой «Перезагрузить», не пустым экраном. + +#### Scenario: Прежний адрес доски связей +- **WHEN** открыт `/app/jira/board?project=AWPCA` +- **THEN** адрес — `/app/jira/links?project=AWPCA`, показана доска связей, в меню отмечен пункт «Связи», заголовок + вкладки — «Связи · AWPCA · SDLC Hub» + +#### Scenario: Переход между разделами +- **WHEN** на доске в боковой панели выбран раздел «Задачи» группы «Jira» +- **THEN** адрес — `/app/jira/issues` с текущим проектом, показан список задач; «назад» возвращает на доску + +#### Scenario: Раздел «Лента» +- **WHEN** на доске в боковой панели выбран раздел «Лента» +- **THEN** адрес — `/app/jira/feed` с текущим проектом, показана лента изменений за сегодня; раздел отмечен в панели + +#### Scenario: Сменить токен +- **WHEN** в меню боковой панели выбрано «Сменить токен» +- **THEN** токен очищен, показан экран входа + +#### Scenario: Панель на месте при прокрутке +- **WHEN** доска прокручена до самого низа +- **THEN** боковая панель видна целиком на прежнем месте: разделы и блок состояния модулей доступны без прокрутки вверх + +#### Scenario: Свёрнутая панель +- **WHEN** нажата «Свернуть панель», страница перезагружена +- **THEN** панель свёрнута в полосу значков, доска шире; значок «Задачи» открывает список задач; видны два значка + состояния — Jira и Вики; «Развернуть панель» возвращает полную панель + +#### Scenario: Узкий экран +- **WHEN** ширина окна 390 px, нажата «Меню», в панели выбран раздел «Задачи» +- **THEN** панель открылась поверх страницы, фокус в панели; после выбора панель закрыта, фокус на «Меню», показан список + задач; горизонтальной прокрутки нет + +#### Scenario: Прежний адрес +- **WHEN** открыт `/app/issues/AURORA-1536?project=AWPCA` +- **THEN** адрес заменён на `/app/jira/issues/AURORA-1536?project=AWPCA`, карточка открыта поверх списка задач + +#### Scenario: Заголовок вкладки +- **WHEN** открыта страница «Вики» проекта `AWPCA` +- **THEN** заголовок вкладки — «Вики · AWPCA · SDLC Hub» + +### Requirement: Проект в приложении +«Обзор», доска, список задач, лента и вики SHALL показывать один проект — текущий. Текущий проект SHALL задаваться +параметром `project` в адресе страниц; без параметра — последний выбранный в этом браузере (`localStorage`), если он +есть в `GET /projects`, иначе первый из `GET /projects`; выбранный проект записывается в адрес без новой записи истории. +Проекта из адреса нет в `GET /projects` — страница SHALL показывать «проект <метка> не найден» и подсказку выбрать +проект в списке «Проект» боковой панели (на узком экране — в «Меню») вместо данных. Проектов нет — страницы SHALL +показывать приглашение создать проект («+ Новый проект…»). Переходы между страницами SHALL сохранять текущий проект; +смена проекта на странице, которой нет у нового проекта, открывает «Обзор». Карточка задачи +(`/app/jira/issues/{key}`) от проекта не зависит. + +Доска SHALL загружать `GET /board?project=<текущий>`. Раскладка, дорожки и фильтры доски в `localStorage` SHALL +храниться отдельно для каждого проекта; у проекта `AWPCA` — на прежних ключах (раскладка, сделанная до проектов, +сохраняется). Фильтр спринтов SHALL показываться, только если хотя бы у одной своей задачи снимка (`neighbor = false`) +есть спринт; иначе — «колонки «Спринт» нет — показаны все задачи». + +Форма «Новый проект» (пункт «+ Новый проект…» выпадающего списка) SHALL открываться окном поверх страницы: метка (с +подсказкой «метка задач в Jira и имя проекта в хабе») и название, затем поля каждого модуля в своей группе с +заголовком — названием модуля: «Jira» — номер доски Jira (необязательно; подсказка «scrum или kanban определится +сам»), «Вики» — корень вики (необязательно; подсказка «DOC-… или ссылка на страницу Eva»). Подсказка — текстом под +полем, не в placeholder. Пустое необязательное поле в запрос не попадает; числовое поле с нецелым значением — ошибка у +поля, запрос не уходит. «Создать» — `POST /projects` с автором `viewer`; ошибка хаба 409/422 — текст `detail` рядом с +кнопкой, окно остаётся открытым; успех — окно закрывается, текущим становится новый проект, каждый пакет из ответа +(`batches`) отслеживается своим модулем (требование «Состояние и обновление модулей»). + +#### Scenario: Первый вход после выкатки +- **WHEN** открыт `/app/jira/links` без `project`, в `localStorage` выбора нет, проекты `AWPCA` и `STOCKS_DRRK` +- **THEN** адрес — `/app/jira/links?project=AWPCA`, доска — `GET /board?project=AWPCA` с прежней раскладкой карточек + +#### Scenario: Переключение проекта +- **WHEN** на доске `AWPCA` в выпадающем списке выбран `STOCKS_DRRK`, затем открыт раздел «Задачи» +- **THEN** доска — `GET /board?project=STOCKS_DRRK` со своей раскладкой; адрес «Задач» — + `/app/jira/issues?project=STOCKS_DRRK` + +#### Scenario: Неизвестный проект в адресе +- **WHEN** открыт `/app/jira/issues?project=NOPE` +- **THEN** «проект NOPE не найден» и подсказка выбрать проект в боковой панели; запроса `GET /issues` нет + +#### Scenario: Создание проекта из формы +- **WHEN** в форме введены `STOCKS_DRRK`, «Запасы ДРРК», доска и корень вики пусто; нажато «Создать» +- **THEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", author: "viewer"}`; текущий проект — `STOCKS_DRRK`; + строка «Jira» блока состояния показывает «пакет #N выполняется» + +#### Scenario: Проект с корнем вики из формы +- **WHEN** в форме введены `STOCKS_DRRK`, «Запасы ДРРК», корень `DOC-001234`; нажато «Создать» +- **THEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", wiki_root: "DOC-001234", author: "viewer"}`; строка + «Jira» показывает пакет обновления, строка «Вики» — пакет синхронизации + +#### Scenario: Метка занята +- **WHEN** в форме введена метка `awpca`, хаб ответил 409 +- **THEN** окно открыто, рядом с «Создать» — текст ошибки хаба + +#### Scenario: Канбан-проект +- **WHEN** текущий проект — `STOCKS_DRRK`, ни у одной своей задачи снимка нет спринта (у соседа из `AWPCA` спринт есть) +- **THEN** вместо фильтра спринтов — «колонки «Спринт» нет — показаны все задачи» + +#### Scenario: Нецелый номер доски +- **WHEN** в поле «Номер доски Jira» введено `51.5`, нажато «Создать» +- **THEN** у поля — «нужно целое число», запроса `POST /projects` нет + +## ADDED Requirements + +### Requirement: Спринты и бэклог +Модуль jira SHALL показывать для проекта с доской страницы по образцу Jira — только для просмотра: на них MUST NOT быть +действий, меняющих Jira (перетаскивания, кнопок спринтов, смены статуса или ранга). Пункты меню группы «Jira» — +«Связи», «Задачи», «Лента» и по типу доски текущего проекта (`board_type` из `GET /projects`): `scrum` — «Бэклог» и +«Спринт»; `kanban` — «Kanban»; доски нет или тип неизвестен — новых пунктов нет, а прямой переход на их адрес +показывает «У проекта нет scrum-доски» («…kanban-доски» для «Kanban») со ссылкой на «Связи». Данные — `GET +/agile?project=<текущий>`; если доска есть, но её данных нет (`board.columns = null` или ответ без доски) — «Нужен агент +v15 и обновление из Jira» с кнопкой «Обновить» блока состояния модуля; данные доски есть, а колонок нет — «Не удалось +прочитать настройки доски из Jira» и та же кнопка. Пока проекты не загружены, условные пункты меню скрыты; смена проекта +на странице, которой у нового проекта нет, оставляет адрес и показывает сообщение «У проекта нет …-доски». Даты +спринтов (ISO-8601 Jira) показываются «ДД.ММ» в часовом поясе браузера; «осталось N дн.» — от сегодня до конца спринта. + +Строка задачи и карточка задачи на этих страницах: ключ, значок типа, тема, исполнитель (или «—»), оценка (число или +«—»); выполненная — приглушена. Щелчок открывает карточку задачи (окно поверх страницы, как в списке задач). + +**«Бэклог»** (`/app/jira/backlog`), сверху вниз: секции активных спринтов, будущих спринтов, «Бэклог», затем +свёрнутый список «Прошлые спринты (N)». Заголовок секции спринта: имя, состояние, даты «ДД.ММ–ДД.ММ», цель (если есть), +«N задач», «S SP (выполнено D)» и «без оценки: U», если `U > 0`. Задачи секции — по рангу. Секция без задач показывает +«Нет задач». Прошлый спринт в списке — строка «имя · закрыт ДД.ММ · выполнено X из N · S SP»; раскрытие показывает его +задачи с пометкой «не выполнено» у невыполненных. + +**«Спринт»** (`/app/jira/sprint`): активный спринт доски — заголовок (имя, даты, «осталось N дн.» или «срок прошёл», +цель, суммы оценок) и колонки доски Jira с числом задач, карточки в колонке — по рангу; колонка «Без колонки» — для +статусов вне настроек доски, только если в ней есть задачи. Несколько активных спринтов — переключатель спринтов над +доской (выбор в адресе `?sprint=`). Активного нет — «Нет активного спринта» и ссылка на «Бэклог». + +**«Kanban»** (`/app/jira/kanban`): колонки доски из `board_issues`, карточки по рангу, у последней колонки подпись +«за 14 дней»; если `kanban_backlog = true` — над колонками свёрнутый список «Бэклог (N)» по рангу. + +Колонки, не помещающиеся по ширине, SHALL прокручиваться по горизонтали внутри доски, не страницы. Заголовок вкладки — +«Бэклог», «Спринт», «Kanban» по общему правилу оболочки. + +#### Scenario: Меню scrum-проекта +- **WHEN** текущий проект `AWPCA`, `board_type = scrum` +- **THEN** в группе «Jira» пункты «Связи», «Бэклог», «Спринт», «Задачи», «Лента»; пункта «Kanban» нет + +#### Scenario: Проект без доски +- **WHEN** текущий проект `STOCKS_DRRK` без доски, открыт `/app/jira/backlog?project=STOCKS_DRRK` +- **THEN** пунктов «Бэклог», «Спринт», «Kanban» в меню нет; страница — «У проекта нет scrum-доски» со ссылкой на «Связи» + +#### Scenario: Бэклог только смотрит +- **WHEN** открыт «Бэклог» `AWPCA` +- **THEN** на странице нет перетаскиваемых элементов и кнопок, кроме раскрытия секций и открытия карточки задачи + +#### Scenario: Прошлый спринт +- **WHEN** спринт `AWPCA_Sprint_2_VGO` закрыт, в нём 73 задачи, 61 в последней колонке, оценки у 10 задач на 25 SP +- **THEN** в «Прошлые спринты» строка «AWPCA_Sprint_2_VGO · закрыт ДД.ММ · выполнено 61 из 73 · 25 SP»; раскрытие — + 73 задачи, 12 с пометкой «не выполнено» + +#### Scenario: Агент без досок +- **WHEN** `AWPCA` scrum, агент v14 (данных доски нет), открыт «Спринт» +- **THEN** «Нужен агент v15 и обновление из Jira» и кнопка «Обновить» -- 2.47.2 From 19912491cc1949ffa49050f628e53d1948f7a1ea Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 18:27:02 +0400 Subject: [PATCH 02/21] =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D0=BD:=20=D1=81=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D1=82=D1=8B=20=D0=B8=20=D0=B1=D1=8D=D0=BA?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3,=20=D1=85=D0=B0=D0=B1=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-24-sprints-backlog-hub.md | 1269 +++++++++++++++++ 1 file changed, 1269 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-24-sprints-backlog-hub.md diff --git a/docs/superpowers/plans/2026-10-24-sprints-backlog-hub.md b/docs/superpowers/plans/2026-10-24-sprints-backlog-hub.md new file mode 100644 index 0000000..1de53f9 --- /dev/null +++ b/docs/superpowers/plans/2026-10-24-sprints-backlog-hub.md @@ -0,0 +1,1269 @@ +# Спринты и бэклог (хаб) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Хаб хранит доски, спринты, ранг и оценки задач от агента v15, отдаёт их `GET /agile`, ставит команды +спринтов и ранга, показывает спринты и бэклог в MCP и на трёх страницах веба (только просмотр); «Доска связей» → «Связи». + +**Architecture:** Схема v9 модуля jira (`issue.rank`, `issue.story_points`, `jira_board`, `jira_sprint`, +`issue_sprint`); запись — только `mirror.apply_state` по `POST /agent/state`; чтение — новый `modules/jira/agile.py` +(`view`) за маршрутом `GET /agile`, его читают MCP и веб. Команды — расширение `commands.SPEC` и хуков очереди +(`hooks.py`), хвостовой `refresh` по JQL проекта требует проекта пакета в хуке `tail` (правка контракта ядра). +Веб — модуль jira: общая часть `web/src/modules/jira/agile/` и страницы «Бэклог», «Спринт», «Kanban»; пункты меню +с условием по проекту (`NavItem.when`). + +**Tech Stack:** Python 3.13 (uv), stdlib HTTP, SQLite, pytest, пакет `mcp` (FastMCP); React 19 + TypeScript + Vite, +TanStack Query, react-router 8, Vitest, Playwright. + +**Spec:** `openspec/changes/sprints-backlog/` (proposal.md, design.md — решения 1–26 и «Контракт данных», +specs/{data-model,hub-api,commands,mcp,web-app,agent}/spec.md). Агентская часть (jira-agent v15) — отдельный план в +репозитории jira-agent; в этом плане её нет. + +## Global Constraints + +- Комментарии, docstring, тексты интерфейса, документация и сообщения коммитов — на русском; идентификаторы — английские. +- Коммит: первая строка заканчивается «(#108)», последняя строка — `Co-Authored-By: Claude Opus 5.5 `. Не пушить. +- Зелёным считается только `make check` целиком (`uv run pytest -q`, затем `cd web && npm run lint && npm test`); перед коммитом каждой задачи — `make check`. В задачах 8 и 12 дополнительно `cd web && npm run e2e` (тестовый хаб :8791; живой хаб :8790 и `~/.local/share/sdlc-hub` не трогать). +- Перед правкой области — `mcp__codegraph__codegraph_explore` с `projectPath` = `/Users/andrey.epifancev/projects/jira-bridge/sdlc-hub/.wt/sprints`; пустой ответ — один раз отметить в ledger и дальше Read. +- Новых зависимостей (Python и npm) нет. +- Хаб не ходит в Jira. Зеркало (`issue`, `issue_sprint`, `jira_board`, `jira_sprint`) пишет только `mirror.apply_state` по `POST /agent/state`; API интерфейсов и MCP в эти таблицы не пишут. +- Ядро (`core/`) не получает терминов модулей (`tests/test_registry.py::test_core_has_no_jira_terms`); исключение — история миграций в `core/db.py`. +- MCP (`modules/jira/mcp.py`) ходит только через `core.client()`; `mirror`, `history`, `db`, `queue`, `agile` не импортирует. +- Совместимость с агентом v14: пачка без `boards`, `rank`, `story_points`, `sprint_ids` ничего не меняет в этих данных; `min_agent_version` не поднимать (design #14). +- Веб только смотрит (design #1): на новых страницах нет перетаскивания, кнопок спринтов, смены статуса или ранга. +- Цвета — только токены `web/src/styles/tokens.css` (проверяет `tokens.test.ts`); стиль страниц — скилл `aura-design` (задачи 9–11). +- Эталоны `tests/fixtures/baseline/*.expected.json` перезаписываются только `BASELINE_WRITE=1 uv run pytest tests/test_baseline.py`, затем `git diff tests/fixtures/baseline` — в diff только изменения этой задачи. +- Темп по умолчанию новых типов — 30 с: `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank` (и в `DEFAULT_PACING`, и в `pacing_from_config` — как у `ensure_comment`). +- Тексты из спеки — дословно: «команды спринта — только в пакете проекта»; «у проекта P нет доски Jira»; «у проекта P канбан-доска, спринтов нет»; «тип доски проекта P ещё не известен — дождитесь обновления из Jira»; «У проекта нет scrum-доски» / «У проекта нет kanban-доски»; «Нужен агент v15 и обновление из Jira»; «Не удалось прочитать настройки доски из Jira»; «Нет задач»; «Прошлые спринты (N)»; «не выполнено»; «Нет активного спринта»; «Без колонки»; «за 14 дней»; «Бэклог (N)»; «осталось N дн.»; «срок прошёл»; «у проекта нет доски»; «ещё N закрытых»; «ещё N». +- Решения, принятые без обсуждения (раздел «Rulings при планировании» и новые при исполнении), дописываются в `openspec/changes/sprints-backlog/design.md`, раздел `## Decisions made without discussion` (дата, решение, обоснование, цена ошибки). + +## Review Focus + +1. **Время Jira со смещением без двоеточия** (`2026-10-14T09:00:00.000+0300` у полей задачи, `+03:00` у Agile REST) в датах спринтов и `jira_updated`: веб показывает «ДД.ММ», а не «—»/`NaN`; хаб сортирует спринты и отсекает 14 дней kanban по UTC при смешанных смещениях. Тесты: задача 9 (`model.test.ts`, «±HHMM»), задача 3 (`test_sprint_order_mixed_offsets`, `test_kanban_done_cutoff_with_offsets`). +2. **Устаревший `?sprint=`** в адресе «Спринта» (спринт закрыт, чужой id, мусор) → показан первый активный спринт, а не пустая доска. Тест: задача 10 (`«устаревший ?sprint= — первый активный»`). +3. **Доска, общая для двух проектов** (метки `AWPCA` и `C` на доске 4216): тип из `boards` пишется обоим проектам, `GET /agile` каждого — свои задачи, спринты общие. Тесты: задача 2 (`test_state_board_type_to_all_projects_of_board`), задача 3 (`test_shared_board_own_issues_only`). +4. **Повтор команды спринта после смены доски** проекта (другая доска или kanban) → новый `board_id` или 422 «канбан-доска», не старый `board_id` из прежнего пакета. Тест: задача 5 (`test_retry_sprint_state_after_board_became_kanban`). +5. **Нечисловые оценки** (`NaN`, `Infinity`, `true`, строка `"3"`) в состоянии и в `ensure_fields` → 422, пачка/пакет не применены. Тесты: задача 2 (параметры `test_agent_state_bad_agile_fields_422`), задача 4 (параметры `test_new_types_errors`). + +## Rulings при планировании + +Нумерация продолжает design.md (R-1…R-12). Задача 1 переносит их в design.md. + +- **R-13 (контракт ядра):** `CommandHooks.tail` получает `(conn, cmds, project)` вместо `(cmds)`: хвост пакета со + спринтовыми командами — `refresh` по JQL проекта пакета (design #13), а без соединения и проекта хук JQL не построит. + Правится ядро (`core/modules.py`, `core/queue.py`), wiki (`tail=lambda conn, cmds, project: None`) и две заглушки в + тестах. Цена ошибки — вернуть сигнатуру и строить хвост в `check_batch` (дописывая `refresh` в конец пакета). +- **R-14 (форма `boards`):** у доски в `boards` поле `sprints` отсутствует или `null` — спринты доски в зеркале не + меняются (агент не смог их прочитать, agent spec «прежние правила для спринтов»); `[]` — у доски спринтов нет, строки + удаляются. `columns` отсутствует или `null` — пишется `NULL`. `boards: null` в пачке — как отсутствие. Цена ошибки — + устаревшие спринты на экране до следующего `refresh` с `sprints: []`. +- **R-15 (проверки постановки):** хаб проверяет `start_date`/`end_date` (`ГГГГ-ММ-ДД`, настоящая дата, начало не позже + конца), непустые `sprint`/`rename_to`, `state` ∈ {`active`, `closed`}, `move_open_to` (обязателен при `closed`, 422 + при `active`, не равен `sprint`), у `ensure_rank` ровно одно из `before`/`after` и не сама задача, `story_points` — + конечное число ≥ 0 или `null`. Цена ошибки — правка проверок. +- **R-16 (подтверждение по зеркалу):** `ensure_fields` со `story_points` подтверждается, только если + `issue.story_points == params.story_points` точно (`null` = `null`, `0` ≠ `null`); прочие поля — как раньше. + Цена ошибки — лишняя довыдача. +- **R-17 (`GET /agile`):** `board_type = null` → `sprints: []`, `backlog: null`, `board_issues: null`; scrum-бэклог + исключает задачи в активном/будущем спринте любой доски из `jira_sprint` (спринт, которого нет в `jira_sprint`, не + учитывается); kanban: выполненная задача без `jira_updated` не показывается; `kanban_backlog` в ответе — `true`/ + `false`/`null`; суммы `points`/`points_done` — сумма чисел (без оценок — `0`). Цена ошибки — правка выборки. +- **R-18 (Kanban с бэклогом):** при `kanban_backlog = true` первая колонка доски не рисуется — её задачи в «Бэклог (N)» + (как kanban-бэклог Jira). Цена ошибки — вернуть колонку. +- **R-19 (три состояния):** «Нужен агент v15 и обновление из Jira» — `board = null` или `board.fetched_at = null`; + «Не удалось прочитать настройки доски из Jira» — `fetched_at` есть, `columns = null` (design #23 вместо + противоречивой фразы web-app «`board.columns = null` или ответ без доски»). Цена ошибки — правка условия. +- **R-20 (кнопка «Обновить»):** на экранах — существующая `RefreshButton` («Обновить из Jira», в шапке и в сообщениях + трёх состояний). Правило «кнопок нет, кроме раскрытия секций и карточки» к ней не относится: она читает Jira, а не + пишет. Цена ошибки — убрать кнопку из шапки. +- **R-21 (переименование):** «Связи» — подпись пункта меню, заголовок вкладки и ссылка в карточке jira на «Обзоре»; + заголовок `h1` страницы остаётся «Доска связей» (спека говорит о меню и вкладке). Цена ошибки — правка `BoardHeader` + и e2e `geometry.spec.ts`. +- **R-22 (смена проекта):** фраза «Проект в приложении» («смена проекта на странице, которой нет у нового проекта, + открывает «Обзор»») не меняется в коде: других условных страниц нет, а для экранов доски действует design #21 + (адрес остаётся, сообщение «У проекта нет …-доски») — `Layout.select` уже сохраняет раздел. Цена ошибки — переход + на «Обзор» для новых страниц. +- **R-23 (NotFound):** «не найдено» перечисляет только безусловные пункты (без `when`). Цена ошибки — нет. +- **R-24 (склонение):** «N задач» — с русским склонением (1 задача, 2 задачи, 5 задач). Цена ошибки — нет. +- **R-25 (тексты MCP):** шапки таблиц и отказы `sprints`/`sprint`/`backlog` — по задаче 7. Цена ошибки — правка текста. +- **R-26 (даты):** хаб сортирует спринты по `history.jira_utc(дата)`; веб перед `new Date` приводит `±HHMM` к `±HH:MM`. + Цена ошибки — нет. +- **R-27 (e2e):** тестовый хаб засевает второй проект `KANBAN_E2E` (доска 5120, kanban, задачи AURORA-9101…9103); + доска AWPCA — scrum со спринтами 101 (закрыт), 102 (активный), 103 (будущий). Цена ошибки — правка сида. + +--- + +### Task 1: Схема v9 и миграция v8 → v9 + +**Files:** +- Modify: `src/sdlc_hub/modules/jira/schema.py` (таблица `issue`, новые таблицы в конце `SQL`) +- Modify: `src/sdlc_hub/core/db.py:11` (`SCHEMA_VERSION`), `:119-124` (`_add_column`), `:128-139` (`MIGRATIONS`) +- Modify: `tests/test_db.py` (новый `_v8_database`, тесты, `== 8` → `== db.SCHEMA_VERSION` в строках 405, 419, 431) +- Modify: `tests/fixtures/baseline/schema.expected.json` (перезапись) +- Modify: `ARCHITECTURE.md` (строка `db` — «v9»; строка `schema` модуля jira — новые таблицы; раздел «Выкатка и откат версии с новой схемой» — абзац v9) +- Modify: `openspec/changes/sprints-backlog/design.md` (раздел «Decisions made without discussion» — R-13…R-27 из этого плана) + +**Interfaces:** +- Produces: колонки `issue.rank TEXT`, `issue.story_points REAL`; таблицы + `jira_board(board_id INTEGER PRIMARY KEY, type TEXT, columns TEXT, kanban_backlog INTEGER, fetched_at TEXT NOT NULL)`, + `jira_sprint(sprint_id INTEGER PRIMARY KEY, board_id INTEGER NOT NULL, name TEXT NOT NULL, state TEXT NOT NULL, start_date TEXT, end_date TEXT, complete_date TEXT, goal TEXT)` + индекс `ix_jira_sprint_board(board_id)`, + `issue_sprint(key TEXT NOT NULL, sprint_id INTEGER NOT NULL, PRIMARY KEY (key, sprint_id))` + индекс `ix_issue_sprint_sprint(sprint_id)`; + `db.SCHEMA_VERSION == 9`; `db._add_column(table: str, column: str, decl: str = "TEXT")`. + +- [ ] **Step 1: Write the failing tests** (`tests/test_db.py`) + +```python +def _v8_database(path): + """База v8: задача X со спринтом «Спринт 2», без колонок rank/story_points и таблиц досок (sprints-backlog).""" + conn = db.connect(path) + settings.ensure_defaults(conn) + conn.execute("INSERT INTO issue(key, summary, sprint, labels, fetched_at) VALUES ('X', 'т', 'Спринт 2', '[]', ?)", (db.now(),)) + for col in ("rank", "story_points"): + conn.execute(f"ALTER TABLE issue DROP COLUMN {col}") + for t in ("jira_board", "jira_sprint", "issue_sprint"): + conn.execute(f"DROP TABLE {t}") + conn.execute("UPDATE setting SET value = '8' WHERE key = 'schema_version'") + conn.close() + +def test_migrates_v8_adds_agile_columns(tmp_path): # сценарий «Миграция базы v8» + path = tmp_path / "hub.sqlite"; _v8_database(path) + conn = db.connect(path) + assert db._schema_version(conn) == 9 + row = conn.execute("SELECT rank, story_points, sprint FROM issue WHERE key = 'X'").fetchone() + assert (row["rank"], row["story_points"], row["sprint"]) == (None, None, "Спринт 2") + types = {r["name"]: r["type"] for r in conn.execute("PRAGMA table_info(issue)")} + assert types["rank"] == "TEXT" and types["story_points"] == "REAL" + for t in ("jira_board", "jira_sprint", "issue_sprint"): + assert conn.execute(f"SELECT COUNT(*) AS n FROM {t}").fetchone()["n"] == 0 + assert len(list((tmp_path / "backups").glob("*-schema-v8.sqlite"))) == 1 + conn.close() + +def test_v9_reconnect_is_stable(tmp_path): # повторный connect не мигрирует и не копирует + path = tmp_path / "hub.sqlite"; _v8_database(path) + db.connect(path).close(); db.connect(path).close() + assert len(list((tmp_path / "backups").glob("*-schema-v*.sqlite"))) == 1 +``` + +В `test_connect_creates_schema` добавить: `{"jira_board", "jira_sprint", "issue_sprint"} <= names` и +`{"rank", "story_points"} <= {r["name"] for r in conn.execute("PRAGMA table_info(issue)")}`. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_db.py -q` +Expected: FAIL — `no such column: rank` в `_v8_database` / `schema_version == 8`. + +- [ ] **Step 3: Implement** + +`schema.py`: в `issue` после `sprint` — `rank TEXT`, `story_points REAL`; новые таблицы и индексы из Interfaces. +Индексов по новым колонкам `issue` в `SQL` не заводить: `executescript(schema())` идёт до миграции, на базе v8 колонок +ещё нет. `db.py`: `_add_column(table, column, decl="TEXT")` (`ALTER TABLE … ADD COLUMN {column} {decl}`, если колонки +нет); `MIGRATIONS[8] = [_add_column("issue", "rank"), _add_column("issue", "story_points", "REAL")]` с комментарием +«v8 → v9: ранг и оценка задачи; таблицы досок и спринтов создаются схемой (sprints-backlog)»; `SCHEMA_VERSION = 9`. +Старые сборщики баз в тестах (`_v1_database`… `_v7_database`) не трогать: шаг идемпотентен. + +- [ ] **Step 4: Run tests, rewrite schema baseline** + +Run: `uv run pytest tests/test_db.py -q` → PASS. +Run: `BASELINE_WRITE=1 uv run pytest tests/test_baseline.py::test_schema -q && git diff tests/fixtures/baseline/schema.expected.json` +Expected: в diff — `schema_version` 9, колонки `rank`/`story_points` у `issue`, три таблицы и два индекса; больше ничего. + +- [ ] **Step 5: Docs, design.md, `make check`, commit** + +ARCHITECTURE: `db` — «миграции … (v9 …)»; `schema` jira — `jira_board`, `jira_sprint`, `issue_sprint`; в разделе выкатки +(заголовок → «(v7, v8, v9)») абзац: v9 добавляет колонки и таблицы, прежний код с базой v9 работает (новые колонки +и таблицы не читает), откат кода — без возврата копии базы. design.md — R-13…R-27 (дата 24.10.2026, по строке, с ценой). +Run: `make check` → PASS. + +```bash +git add src/sdlc_hub/core/db.py src/sdlc_hub/modules/jira/schema.py tests/test_db.py tests/fixtures/baseline/schema.expected.json ARCHITECTURE.md openspec/changes/sprints-backlog/design.md +git commit -m "Схема v9: ранг, оценка, доски и спринты (#108)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: Приём досок, спринтов, ранга и оценок в `POST /agent/state` + +**Files:** +- Modify: `src/sdlc_hub/modules/jira/mirror.py` (новые `check_agile_fields`, `check_boards`; `apply_state`) +- Modify: `src/sdlc_hub/modules/jira/api.py:23-35` (`h_agent_state`) +- Test: `tests/test_mirror.py`, `tests/test_api.py` +- Modify: `README.md` (строка `POST /agent/state` таблицы «HTTP API») + +**Interfaces:** +- Consumes: таблицы задачи 1; `projects.record_board_types(conn, {"": type}) -> int`. +- Produces: + - `mirror.SPRINT_STATES = ("active", "future", "closed")` + - `mirror.check_agile_fields(issues: list[dict]) -> None` — `ValueError` с текстом `": rank — строка или null"`, `": story_points — число или null"` (bool, `NaN`, `±Infinity` — нарушение), `": sprint_ids — список целых"` (`null`, bool, строки — нарушение). Поле не прислано — не проверяется. + - `mirror.check_boards(boards) -> None` — `None` → ничего; не список → `"boards — список досок"`; элемент не объект или `id` не целое > 0 → `"boards[]: …"`; дальше префикс `"доска : "` и поле: `type — scrum, kanban или null`, `columns — список колонок или null`, `columns[].name — строка`, `columns[].statuses — список строк`, `kanban_backlog — true, false или null`, `sprints — список спринтов или null`, `sprints[].id — целое > 0`, `sprints[].name — непустая строка`, `sprints[].state — active, future или closed`, `sprints[]. — строка или null`. + - `mirror.apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authoritative=True, boards=None) -> dict` (возврат прежний `{"issues", "links"}`). + +- [ ] **Step 1: Write the failing tests** (`tests/test_mirror.py`; в фикстуре `conn` добавить нужный `add_project`, где он используется) + +```python +COLS = [{"name": "К выполнению", "statuses": ["Backlog"]}, {"name": "Готово", "statuses": ["Готово"]}] +def board(id=4216, type="scrum", sprints=(), **over): + return {"id": id, "type": type, "columns": COLS, "kanban_backlog": None, "sprints": list(sprints), **over} +def sprint(id, state, name=None, **over): + return {"id": id, "name": name or f"S{id}", "state": state, "start_date": None, "end_date": None, + "complete_date": None, "goal": None, **over} +def sprint_rows(conn, key): + return sorted(r[0] for r in conn.execute("SELECT sprint_id FROM issue_sprint WHERE key = ?", (key,))) + +def test_state_issue_in_two_sprints(conn): # сценарий «Задача прошла два спринта» + mirror.apply_state(conn, [issue("X", sprint_ids=[101, 102])], [], boards=[board(sprints=[sprint(101, "closed"), sprint(102, "active")])]) + assert sprint_rows(conn, "X") == [101, 102] + +def test_state_sprint_ids_replace_keep_clear(conn): + mirror.apply_state(conn, [issue("X", sprint_ids=[101, 102])], []) + mirror.apply_state(conn, [issue("X", sprint_ids=[102])], []); assert sprint_rows(conn, "X") == [102] + mirror.apply_state(conn, [issue("X")], []); assert sprint_rows(conn, "X") == [102] # агент v14 + mirror.apply_state(conn, [issue("X", sprint_ids=[])], []); assert sprint_rows(conn, "X") == [] + +def test_state_rank_and_points_set_keep_clear(conn): + mirror.apply_state(conn, [issue("X", rank="0|i0001:", story_points=3)], []) + mirror.apply_state(conn, [issue("X", summary="другая")], []) # без полей — прежние значения + row = conn.execute("SELECT rank, story_points FROM issue WHERE key = 'X'").fetchone() + assert (row["rank"], row["story_points"]) == ("0|i0001:", 3.0) + mirror.apply_state(conn, [issue("X", rank=None, story_points=None)], []) + row = conn.execute("SELECT rank, story_points FROM issue WHERE key = 'X'").fetchone() + assert (row["rank"], row["story_points"]) == (None, None) + +def test_state_board_replaces_row_and_sprints(conn): # сценарий «Пачка с досками» + mirror.apply_state(conn, [], [], boards=[board(sprints=[sprint(101, "active", "старое"), sprint(102, "future")]), + board(id=5120, type="kanban", sprints=[sprint(201, "active")])]) + new = [sprint(101, "closed", complete_date="2026-10-14T18:00:00.000+03:00"), sprint(102, "active"), sprint(103, "future")] + mirror.apply_state(conn, [], [], boards=[board(columns=COLS[:1], sprints=new)]) + rows = {r["sprint_id"]: dict(r) for r in conn.execute("SELECT * FROM jira_sprint WHERE board_id = 4216")} + assert set(rows) == {101, 102, 103} and rows[101]["state"] == "closed" and rows[101]["name"] == "S101" + assert rows[101]["complete_date"] == "2026-10-14T18:00:00.000+03:00" + b = conn.execute("SELECT * FROM jira_board WHERE board_id = 4216").fetchone() + assert json.loads(b["columns"]) == COLS[:1] and b["kanban_backlog"] is None and b["fetched_at"] + assert [r[0] for r in conn.execute("SELECT sprint_id FROM jira_sprint WHERE board_id = 5120")] == [201] # не в пачке — не тронута + +def test_state_board_without_sprints_keeps_them(conn): # R-14 + mirror.apply_state(conn, [], [], boards=[board(sprints=[sprint(101, "active")])]) + mirror.apply_state(conn, [], [], boards=[{**board(), "sprints": None, "columns": None}]) + assert [r[0] for r in conn.execute("SELECT sprint_id FROM jira_sprint")] == [101] + assert conn.execute("SELECT columns FROM jira_board").fetchone()[0] is None + mirror.apply_state(conn, [], [], boards=[board(sprints=[])]) + assert conn.execute("SELECT COUNT(*) FROM jira_sprint").fetchone()[0] == 0 + +def test_state_board_type_to_all_projects_of_board(conn, add_project): # Review Focus 3 + add_project(conn, board_type=None); add_project(conn, "C", board_id=4216, board_type=None) + mirror.apply_state(conn, [], [], boards=[board(type="kanban", kanban_backlog=True)]) + assert {r["label"]: r["board_type"] for r in conn.execute("SELECT label, board_type FROM jira_project")} == {"AWPCA": "kanban", "C": "kanban"} + assert tuple(conn.execute("SELECT type, kanban_backlog FROM jira_board").fetchone()) == ("kanban", 1) + mirror.apply_state(conn, [], [], boards=[board(type=None)]) # null тип проекта не меняет + assert conn.execute("SELECT board_type FROM jira_project WHERE label = 'AWPCA'").fetchone()[0] == "kanban" + +def test_state_v14_keeps_agile_data(conn): # сценарий «Агент v14» + mirror.apply_state(conn, [issue("X", rank="r", story_points=2, sprint_ids=[101])], [], boards=[board(sprints=[sprint(101, "active")])]) + mirror.apply_state(conn, [issue("X")], []) + assert sprint_rows(conn, "X") == [101] and conn.execute("SELECT COUNT(*) FROM jira_sprint").fetchone()[0] == 1 + assert tuple(conn.execute("SELECT rank, story_points FROM issue").fetchone()) == ("r", 2.0) +``` + +`tests/test_api.py`: + +```python +@pytest.mark.parametrize("over, detail", [ + ({"sprint_ids": None}, "A-1: sprint_ids"), ({"sprint_ids": ["1"]}, "A-1: sprint_ids"), ({"sprint_ids": [True]}, "A-1: sprint_ids"), + ({"story_points": "3"}, "A-1: story_points"), ({"story_points": True}, "A-1: story_points"), + ({"story_points": float("nan")}, "A-1: story_points"), ({"story_points": float("inf")}, "A-1: story_points"), # Review Focus 5 + ({"rank": 5}, "A-1: rank"), +]) +def test_agent_state_bad_agile_fields_422(hub, over, detail): + with pytest.raises(HubError) as e: + hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": [], **over}], "links": []}) + assert e.value.status == 422 and e.value.error == "bad_state" and detail in e.value.detail + assert hub["client"].get("/issues")["issues"] == [] # пачка не применена + +@pytest.mark.parametrize("boards, detail", [ + ("x", "boards — список досок"), ([{"id": "4216"}], "boards[0].id"), + ([{"id": 4216, "type": "scrum", "columns": None, "kanban_backlog": None, "sprints": [{"id": 1, "name": "S", "state": "closing"}]}], + "доска 4216: sprints[0].state — active, future или closed"), + ([{"id": 4216, "type": "sprint"}], "доска 4216: type"), + ([{"id": 4216, "type": "kanban", "kanban_backlog": "да"}], "доска 4216: kanban_backlog"), +]) +def test_agent_state_bad_boards_422(hub, boards, detail): ... # тот же вызов с issues: [], links: [], boards — 422 bad_state, detail + +def test_agent_state_empty_batch_with_boards(hub): ... # {"issues": [], "links": [], "boards": [board]} → 200; GET /agile (задача 3) ещё нет — проверка строкой jira_board через hub["hub"].conn() +``` + +(Вызов `HubClient.post` сериализует `NaN`/`Infinity` литералами — сервер их разбирает `json.loads`, поэтому проверка +конечности нужна в `check_agile_fields`.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_mirror.py tests/test_api.py -q -k "state"` +Expected: FAIL — `apply_state() got an unexpected keyword argument 'boards'`, у задачи нет колонки в upsert. + +- [ ] **Step 3: Implement** + +`apply_state`: `rank` и `story_points` попадают в upsert только если ключ есть в словаре задачи (строка `cols` +собирается по задаче; `ISSUE_COLUMNS` не расширять — иначе агент v14 затрёт значения `NULL`); `sprint_ids` — список → +`DELETE FROM issue_sprint WHERE key = ?` и вставка. `boards` (список) — в той же транзакции: `INSERT OR REPLACE INTO +jira_board` (`columns` — `json.dumps(..., ensure_ascii=False)` или `NULL`, `kanban_backlog` — 1/0/`NULL`, +`fetched_at = fetched_at`); `sprints` — список → `DELETE FROM jira_sprint WHERE board_id = ?` и `INSERT OR REPLACE` +по каждому, иначе не трогать (R-14); `type in projects.BOARD_TYPES` → `projects.record_board_types(conn, {str(id): +type})`. `h_agent_state`: `boards = body.get("boards")`; до транзакции `mirror.check_agile_fields(issues)` и +`mirror.check_boards(boards)` (в том же `try` → `ApiError(422, "bad_state", str(e))`); передать `boards=boards`. +Docstring `apply_state` дополнить: поля v15 и правило «без поля — прежнее значение». + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_mirror.py tests/test_api.py -q` → PASS. + +- [ ] **Step 5: README, `make check`, commit** + +README, строка `POST /agent/state`: «загрузка состояния задач пачками; агент v15 — у задач `rank`, `story_points`, +`sprint_ids` (заменяет спринты задачи), в пачке `boards` (доска заменяет свою строку и, если `sprints` — список, свои +спринты; тип доски пишется проектам с этой доской); без полей — данные прежние; неверная форма — 422 `bad_state`». +Run: `make check` → PASS. + +```bash +git add src/sdlc_hub/modules/jira/mirror.py src/sdlc_hub/modules/jira/api.py tests/test_mirror.py tests/test_api.py README.md +git commit -m "Приём досок, спринтов, ранга и оценок от агента v15 (#108)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: `GET /agile` — доска, спринты, бэклог + +**Files:** +- Create: `src/sdlc_hub/modules/jira/agile.py` +- Modify: `src/sdlc_hub/modules/jira/api.py` (обработчик `h_agile`, строка в `ROUTES`) +- Test: `tests/test_agile.py` (новый) +- Modify: `tests/fixtures/baseline/routes.expected.json` (перезапись) +- Modify: `README.md` (строка `GET /agile` в «HTTP API»), `ARCHITECTURE.md` (строка `agile` в таблице модуля jira, `api` — `/agile`) + +**Interfaces:** +- Consumes: `mirror.apply_state(..., boards=)` (задача 2) — в тестах для данных; `history.jira_utc(value) -> str | None`. +- Produces: + - `agile.KANBAN_DONE_DAYS = 14` + - `agile.view(conn, label: str, now: datetime | None = None) -> dict` — `{board, sprints, backlog, board_issues}` по «Контракту данных» design.md; строка задачи — `{key, summary, issue_type, status, priority, assignee, epic_key, story_points, rank, done}`; спринт — `{id, name, state, start_date, end_date, complete_date, goal, count, count_done, unestimated, points, points_done, issues}`; `board` — `{id, type, columns, kanban_backlog: bool | None, fetched_at}` (тип — из `jira_project.board_type`). + - `api.h_agile(hub, conn, m, q, body)`: без `project` — `ApiError(422, "bad_request", "project — метка проекта")`; неизвестный — 404 (`project_param`). Маршрут `("GET", r"^/agile$", h_agile, "client")`. + +- [ ] **Step 1: Write the failing tests** (`tests/test_agile.py`) + +Фикстура `conn(open_db, add_project)`: база в памяти, `settings.ensure_defaults`, `add_project(conn)` (AWPCA 4216 scrum). +Хелперы `issue(key, status, **over)` (метки `["AWPCA"]`), `load(conn, issues, boards)` через `mirror.apply_state`, +`keys(rows) -> list[str]`. Колонки `COLS = [К выполнению: Backlog, Ожидает анализа; В работе: В работе; Готово: Готово, +Протестировано, Снято]`. + +```python +def test_sprint_and_backlog_sums(conn): # сценарий «Спринт и бэклог» + load(conn, [issue("A", "Готово", story_points=3, sprint_ids=[102], rank="0|a"), issue("B", "В работе", sprint_ids=[102], rank="0|b"), + issue("C", "Backlog", sprint_ids=[]), issue("D", "Готово", sprint_ids=[])], + [board(sprints=[sprint(102, "active")])]) + v = agile.view(conn, "AWPCA") + s = v["sprints"][0] + assert (s["count"], s["count_done"], s["points"], s["points_done"], s["unestimated"]) == (2, 1, 3, 3, 1) + assert keys(s["issues"]) == ["A", "B"] and keys(v["backlog"]) == ["C"] and v["board_issues"] is None + +def test_done_is_last_column(conn): # сценарий «Колонки доски» + load(conn, [issue("P", "Протестировано", sprint_ids=[102]), issue("Q", "Backlog", sprint_ids=[102])], [board(sprints=[sprint(102, "active")])]) + assert {i["key"]: i["done"] for i in agile.view(conn, "AWPCA")["sprints"][0]["issues"]} == {"P": True, "Q": False} + +def test_project_without_board(conn, add_project): # сценарий «Проект без доски» + add_project(conn, "STOCKS_DRRK", board_id=None, board_type=None) + assert agile.view(conn, "STOCKS_DRRK") == {"board": None, "sprints": [], "backlog": None, "board_issues": None} + +def test_board_without_agent_data(conn): # design #23, состояние 1 + load(conn, [issue("A", "Готово")], []) + v = agile.view(conn, "AWPCA") + assert v["board"] == {"id": 4216, "type": "scrum", "columns": None, "kanban_backlog": None, "fetched_at": None} + assert [i["done"] for i in v["backlog"]] == [False] # без колонок выполненных нет + +def test_sprint_order_mixed_offsets(conn): # порядок спринтов; Review Focus 1 + load(conn, [], [board(sprints=[ + sprint(3, "active", start_date="2026-10-14T09:00:00.000+0300"), sprint(5, "active", start_date="2026-10-01T09:00:00.000+03:00"), + sprint(9, "future"), sprint(7, "future"), + sprint(1, "closed", complete_date="2026-10-01T02:00:00.000+0300"), sprint(2, "closed", complete_date="2026-09-30T23:30:00.000Z")])]) + assert [s["id"] for s in agile.view(conn, "AWPCA")["sprints"]] == [5, 3, 7, 9, 2, 1] # 2 закрыт позже 1 по UTC + +def test_rank_order_nulls_last_then_key(conn): + load(conn, [issue("Z-1", "Backlog", rank="0|i0002:"), issue("Z-2", "Backlog", rank="0|i0001:"), + issue("B-1", "Backlog", rank=None), issue("A-1", "Backlog")], [board()]) + assert keys(agile.view(conn, "AWPCA")["backlog"]) == ["Z-2", "Z-1", "A-1", "B-1"] + +def test_only_own_issues_and_empty_sprint(conn): # design #8 + load(conn, [issue("N", "Backlog", labels=["OTHER"], sprint_ids=[102])], [board(sprints=[sprint(102, "active")])]) + s = agile.view(conn, "AWPCA")["sprints"][0] + assert s["issues"] == [] and s["count"] == 0 + +def test_shared_board_own_issues_only(conn, add_project): # Review Focus 3 + add_project(conn, "C", board_id=4216) + load(conn, [issue("A", "Backlog", sprint_ids=[102]), issue("K", "Backlog", labels=["C"], sprint_ids=[102])], [board(sprints=[sprint(102, "active")])]) + assert keys(agile.view(conn, "C")["sprints"][0]["issues"]) == ["K"] and keys(agile.view(conn, "AWPCA")["sprints"][0]["issues"]) == ["A"] + +def test_closed_sprint_done_by_current_status(conn): # design #7 + load(conn, [issue("X", "Готово", sprint_ids=[101])], [board(sprints=[sprint(101, "closed")])]) + assert agile.view(conn, "AWPCA")["sprints"][0]["count_done"] == 1 + +def test_backlog_keeps_issue_of_closed_sprint(conn): + load(conn, [issue("X", "Backlog", sprint_ids=[101]), issue("Y", "Backlog", sprint_ids=[103])], + [board(sprints=[sprint(101, "closed"), sprint(103, "future")])]) + assert keys(agile.view(conn, "AWPCA")["backlog"]) == ["X"] + +def test_kanban_with_backlog(conn, add_project): # design #3, «Контракт данных» + add_project(conn, "K", board_id=5120, board_type="kanban") + now = datetime(2026, 10, 24, 12, tzinfo=timezone.utc) + load(conn, [kissue("K1", "Backlog"), kissue("K2", "В работе"), kissue("K3", "Готово", jira_updated="2026-10-21T10:00:00.000+0300"), + kissue("K4", "Готово", jira_updated="2026-10-01T10:00:00.000+0300"), kissue("K5", "Странный"), + kissue("K6", "Готово", jira_updated=None)], + [board(id=5120, type="kanban", kanban_backlog=True, columns=[{"name": "Бэклог", "statuses": ["Backlog"]}, + {"name": "В работе", "statuses": ["В работе"]}, {"name": "Готово", "statuses": ["Готово"]}])]) + v = agile.view(conn, "K", now=now) + assert v["sprints"] == [] and keys(v["backlog"]) == ["K1"] and sorted(keys(v["board_issues"])) == ["K2", "K3", "K5"] + assert v["board"]["kanban_backlog"] is True + +def test_kanban_without_backlog(conn, add_project): # design #17 + # та же доска с kanban_backlog=False → backlog is None, K1 в board_issues + +def test_kanban_done_cutoff_with_offsets(conn, add_project): # Review Focus 1: граница 14 дней по UTC + # now = 2026-10-24T12:00Z; jira_updated "2026-10-10T14:30:00.000+0300" (11:30Z, старше 14 дней) — нет; + # "2026-10-10T15:30:00.000+0300" (12:30Z) — есть + +def test_agile_route(hub_server, add_project): + # без project → 422 bad_request; ?project=NOPE → 404; ?project=AWPCA → 200 и ключи {"board", "sprints", "backlog", "board_issues"} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_agile.py -q` +Expected: FAIL — `ImportError: cannot import name 'agile'`. + +- [ ] **Step 3: Implement `agile.view` and `h_agile`** + +Чтение в одной транзакции чтения (как `mirror.board`: `BEGIN` … `COMMIT`, если транзакции нет). Свои задачи — метка +проекта (`json_each(labels)`), как `own` в `SCOPE_CTE`. Членство — `issue_sprint` своих задач; спринты доски — +`jira_sprint WHERE board_id`. «Выполнено» — `status` в `statuses` последней колонки (`columns` NULL — никто). +Сортировка задач — ключ `(rank is None, rank or "", key)`; спринтов — `active` по `jira_utc(start_date)` (None — в +конце), `future` по id, `closed` по `jira_utc(complete_date)` по убыванию (None — в конце), при равенстве — id. +Scrum-бэклог — свои задачи, не состоящие в спринте с `state` `active`/`future` (по `jira_sprint` любой доски, R-17), и +не выполненные. Kanban: `sprints = []`; бэклог — статусы первой колонки при `kanban_backlog = 1`, иначе `None`; +`board_issues` — остальные свои задачи; выполненная — только если `jira_utc(jira_updated)` не старше +`now - 14 дней` (без `jira_updated` — нет). `board_type = NULL` → `sprints: []`, `backlog: None`, `board_issues: None`. + +- [ ] **Step 4: Run tests, rewrite routes baseline** + +Run: `uv run pytest tests/test_agile.py -q` → PASS. +Run: `BASELINE_WRITE=1 uv run pytest tests/test_baseline.py::test_routes -q && git diff tests/fixtures/baseline/routes.expected.json` +Expected: одна новая строка `["GET", "^/agile$", "client", "h_agile"]`. + +- [ ] **Step 5: Docs, `make check`, commit** + +README: строка `GET /agile` — «`?project=<метка>` (без — 422, неизвестная — 404): доска проекта (`board`, тип из +проекта; `null` — доски нет), все спринты доски со своими задачами и суммами оценок (`count`, `count_done`, +`unestimated`, `points`, `points_done`), `backlog` (scrum — свои задачи вне активных и будущих спринтов, не +выполненные; kanban — первая колонка, если бэклог доски включён), `board_issues` (kanban: остальные колонки, +выполненные — за 14 дней); списки — по рангу». ARCHITECTURE: строка `agile` («чтение для `GET /agile`: доска, +спринты, бэклог, выполнено по последней колонке; зависит от `core.db`, `history`»), в `api` — `/agile`, в цепочке +модуля jira — `api → agile → history`. +Run: `make check` → PASS. + +```bash +git add src/sdlc_hub/modules/jira/agile.py src/sdlc_hub/modules/jira/api.py tests/test_agile.py tests/fixtures/baseline/routes.expected.json README.md ARCHITECTURE.md +git commit -m "GET /agile: доска, спринты и бэклог проекта (#108)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: Контракт команд: новые типы, `story_points`, темп + +**Files:** +- Modify: `src/sdlc_hub/modules/jira/commands.py` (`SPEC`, `FIELD_HINTS`, `_type_error`, `validate`, `check_confirmed`) +- Modify: `src/sdlc_hub/modules/jira/settings.py:5-6` (`DEFAULT_PACING`), `:74-78` (`pacing_from_config`) +- Test: `tests/test_commands.py`, `tests/test_settings.py` +- Modify: `tests/fixtures/baseline/settings.expected.json`, `tests/fixtures/baseline/mcp_tools.expected.json` (перезапись: описание `batch_create` собирается из `SPEC`) +- Modify: `README.md` (раздел «Контракт команд»: строки таблицы и абзац о зазорах) + +**Interfaces:** +- Produces: + - `commands.SPEC["ensure_sprint_meta"] = {"required": ["sprint"], "optional": ["rename_to", "start_date", "end_date", "goal"], "keys": [], "hints": {"sprint": "имя спринта", "rename_to": "новое имя", "start_date": "YYYY-MM-DD", "end_date": "YYYY-MM-DD", "goal": "цель спринта"}}` + - `commands.SPEC["ensure_sprint_state"] = {"required": ["sprint", "state"], "optional": ["move_open_to"], "keys": [], "hints": {"state": "active|closed", "move_open_to": "имя спринта или backlog; обязателен при closed"}}` + - `commands.SPEC["ensure_rank"] = {"required": ["key"], "optional": ["before", "after"], "keys": ["key", "before", "after"], "hints": {"before": "ключ задачи, выше которой", "after": "ключ задачи, ниже которой"}, "note": "ровно одно из before, after"}` + - `SPEC["ensure_fields"]["optional"]` += `"story_points"`, hint `"число ≥ 0 или null"`. + - `commands.SPRINT_TYPES = ("ensure_sprint_meta", "ensure_sprint_state")`; `commands.NUMBER_PARAMS = {"story_points"}`. + - `settings.DEFAULT_PACING` += `"ensure_sprint_meta": 30, "ensure_sprint_state": 30, "ensure_rank": 30`; `pacing_from_config` — то же значение, что `ensure_comment`. + +- [ ] **Step 1: Write the failing tests** (`tests/test_commands.py`, `tests/test_settings.py`) + +```python +META = {"type": "ensure_sprint_meta", "params": {"sprint": "AWPCA_Sprint_4", "start_date": "2026-10-28", "end_date": "2026-11-11", "goal": "свод ВГО"}} +def state(**p): return {"type": "ensure_sprint_state", "params": {"sprint": "AWPCA_Sprint_3_Corrections", **p}} +def rank(**p): return {"type": "ensure_rank", "params": {"key": "AURORA-1612", **p}} + +def test_new_types_validate_ok(): + out = commands.validate([META, state(state="active"), state(state="closed", move_open_to="backlog"), rank(before="AURORA-1601"), + {"type": "ensure_fields", "params": {"key": "A-1", "story_points": 2.5}}, + {"type": "ensure_fields", "params": {"key": "A-1", "story_points": None}}]) + assert [c["type"] for c in out][:4] == ["ensure_sprint_meta", "ensure_sprint_state", "ensure_sprint_state", "ensure_rank"] + +@pytest.mark.parametrize("cmd, msg", [ + ({**META, "params": {**META["params"], "start_date": "28.10.2026"}}, "start_date — дата ГГГГ-ММ-ДД"), + ({**META, "params": {**META["params"], "end_date": "2026-02-30"}}, "end_date — дата ГГГГ-ММ-ДД"), + ({**META, "params": {**META["params"], "start_date": "2026-11-12"}}, "start_date позже end_date"), + ({"type": "ensure_sprint_meta", "params": {"sprint": " "}}, "sprint — непустое имя"), + ({"type": "ensure_sprint_meta", "params": {"sprint": "S", "rename_to": " "}}, "rename_to — непустое имя"), + ({"type": "ensure_sprint_meta", "params": {"sprint": "S", "board_id": 4216}}, "лишние параметры board_id"), + (state(state="open"), "state — active или closed"), + (state(state="closed"), "move_open_to обязателен при state = closed"), + (state(state="active", move_open_to="backlog"), "move_open_to — только при state = closed"), + (state(state="closed", move_open_to="AWPCA_Sprint_3_Corrections"), "move_open_to — другой спринт"), + (rank(), "ровно одно из before, after"), (rank(before="A-2", after="A-3"), "ровно одно из before, after"), + (rank(before="AURORA-1612"), "before — другая задача"), (rank(after="AURORA-1612"), "after — другая задача"), + ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": -1}}, "story_points — число ≥ 0 или null"), + ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": "3"}}, "story_points — число ≥ 0 или null"), + ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": True}}, "story_points — число ≥ 0 или null"), + ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("nan")}}, "story_points — число ≥ 0 или null"), # Review Focus 5 + ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("inf")}}, "story_points — число ≥ 0 или null"), +]) +def test_new_types_errors(cmd, msg): + with pytest.raises(CommandError, match=re.escape(msg)): + commands.validate([cmd]) + +def test_rank_temp_keys(): + ok = [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Н"}}, rank(after="new:1")] + assert commands.temp_keys_used(commands.validate(ok)[1]) == ["new:1"] + with pytest.raises(CommandError, match="временный ключ new:2 не объявлен"): + commands.validate([rank(before="new:2")]) + +def test_new_types_not_confirmed_by_state(conn): # design #18 + for cmd in (META, state(state="active"), rank(before="A-2")): + assert commands.check_confirmed(conn, cmd, {}) == (False, None) + +def test_story_points_confirmation(conn): # R-16 + mirror.apply_state(conn, [{"key": "A-1", "labels": [], "story_points": 3}, {"key": "A-2", "labels": []}], []) + sp = lambda key, v: {"type": "ensure_fields", "params": {"key": key, "story_points": v}} + assert commands.check_confirmed(conn, sp("A-1", 3), {})[0] is True + assert commands.check_confirmed(conn, sp("A-2", 0), {})[0] is False + assert commands.check_confirmed(conn, sp("A-2", None), {})[0] is True +``` + +`tests/test_settings.py`: + +```python +def test_sprint_types_pacing(conn): + for t in ("ensure_sprint_meta", "ensure_sprint_state", "ensure_rank"): + assert jira_settings.DEFAULT_PACING[t] == 30 and jira_settings.pacing(conn)[t] == 30 + assert jira_settings.pacing_from_config({"comment_seconds": 45})[t] == 45 + settings.set_(conn, "pacing", {"ensure_fields": 5}) # тип не в сохранённой настройке — умолчание + assert jira_settings.pacing(conn)["ensure_rank"] == 30 + settings.validate("pacing", {"ensure_rank": 10}) + assert queue.estimate(conn, ["ensure_sprint_meta", "ensure_sprint_state", "ensure_rank"]) == 90 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_commands.py tests/test_settings.py -q` +Expected: FAIL — `неизвестный тип «ensure_sprint_meta»`, `KeyError: 'ensure_rank'`. + +- [ ] **Step 3: Implement** + +`_type_error`: `NUMBER_PARAMS` — `int`/`float`, не `bool`, `math.isfinite`, `≥ 0`, иначе `"story_points — число ≥ 0 или +null"`. Проверки R-15 — в `validate` после проверки типов, сообщения `f"команда {i} ({ctype}): <текст>"` (тексты — +из теста). Дата — `^\d{4}-\d{2}-\d{2}$` и `date.fromisoformat`. `check_confirmed`: новые типы — в кортеж «не +подтверждается»; для `ensure_fields` со `story_points` — точное сравнение (R-16), до цикла `FIELD_PARAMS`. + +- [ ] **Step 4: Run tests, rewrite baselines** + +Run: `uv run pytest tests/test_commands.py tests/test_settings.py -q` → PASS. +Run: `BASELINE_WRITE=1 uv run pytest tests/test_baseline.py -q && git diff tests/fixtures/baseline` +Expected: `settings.expected.json` — три новых ключа `pacing`; `mcp_tools.expected.json` — в описании `batch_create` +строки `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank` и `story_points (число ≥ 0 или null)` у `ensure_fields`. + +- [ ] **Step 5: README, `make check`, commit** + +README «Контракт команд»: у `ensure_fields` — `story_points` (число ≥ 0 или `null` — очистить); строки +`ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank` (параметры и смысл по спеке commands, результаты +`{sprint_id, created}`, `{sprint_id, moved}`, `{changed}`); абзац о зазорах — новые типы 30 с. +Run: `make check` → PASS. + +```bash +git add src/sdlc_hub/modules/jira/commands.py src/sdlc_hub/modules/jira/settings.py tests/test_commands.py tests/test_settings.py tests/fixtures/baseline/settings.expected.json tests/fixtures/baseline/mcp_tools.expected.json README.md +git commit -m "Команды спринта и ранга, оценка в ensure_fields (#108)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: Постановка команд спринта: доска пакета, проект, повтор + +**Files:** +- Modify: `src/sdlc_hub/modules/jira/hooks.py:54-76` (тексты ошибок доски), `:79-123` (`_check_project`), `:126-130` (`check_batch`), `:251-253` (`retry_params`) +- Test: `tests/test_queue.py` (раздел «доски») +- Modify: `README.md` («Контракт команд»: `board_id` у команд спринта), `ARCHITECTURE.md` (строка `hooks`) + +**Interfaces:** +- Consumes: `commands.SPRINT_TYPES` (задача 4); `jira_projects.light_list(conn) -> list[dict]` (`label`, `board_id`, `board_type`). +- Produces: + - `hooks._board_error(label: str, board_id: int | None, board_type: str | None) -> str | None` — общие тексты для `ensure_sprint` и команд спринта: «у проекта {label} нет доски Jira», «у проекта {label} канбан-доска, спринтов нет», «тип доски проекта {label} ещё не известен — дождитесь обновления из Jira»; `None` — доска годится. + - `hooks._assign_project_board(conn, cmds: list[dict], project: str | None) -> None` — у `SPRINT_TYPES` новый словарь `params` с `board_id` доски проекта пакета; без проекта — `CommandError(f"команда {i} ({type}): команды спринта — только в пакете проекта")`; ошибка доски — `CommandError(f"команда {i}: {текст}")`. + - `hooks.retry_params(cmd) -> dict` — снимает `board_id` у `ensure_sprint`, `ensure_sprint_meta`, `ensure_sprint_state`. + +- [ ] **Step 1: Write the failing tests** (`tests/test_queue.py`, после `test_retry_premigration_sprint_without_board`) + +```python +def meta(name="AWPCA_Sprint_4"): + return {"type": "ensure_sprint_meta", "params": {"sprint": name, "start_date": "2026-10-28", "end_date": "2026-11-11"}} +def close(name="AWPCA_Sprint_3_Corrections", to="AWPCA_Sprint_4"): + return {"type": "ensure_sprint_state", "params": {"sprint": name, "state": "closed", "move_open_to": to}} + +def test_sprint_meta_gets_batch_project_board(conn, add_project): + add_project(conn) + b = queue.create_batch(conn, "x", "claude", [meta(), close()], project="AWPCA") + cmds = queue.batch_view(conn, b["id"])["commands"] + assert cmds[0]["params"]["board_id"] == 4216 and cmds[1]["params"]["board_id"] == 4216 + +def test_sprint_meta_without_project_rejected(conn, add_project): # сценарий «Спринт без проекта пакета» + add_project(conn) + with pytest.raises(CommandError) as e: + queue.create_batch(conn, "x", "claude", [meta()]) + assert str(e.value) == "команда 1 (ensure_sprint_meta): команды спринта — только в пакете проекта" + assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 + +@pytest.mark.parametrize("setup, msg", [ + (dict(board_id=5120, board_type="kanban"), "команда 1: у проекта AWPCA канбан-доска, спринтов нет"), + (dict(board_id=5120, board_type=None), "команда 1: тип доски проекта AWPCA ещё не известен — дождитесь обновления из Jira"), + (dict(board_id=None, board_type=None), "команда 1: у проекта AWPCA нет доски Jira"), +]) +def test_sprint_state_board_rejected(conn, add_project, setup, msg): + add_project(conn, **setup) + with pytest.raises(CommandError) as e: + queue.create_batch(conn, "x", "claude", [close()], project="AWPCA") + assert str(e.value) == msg + +def test_rank_belongs_to_project_by_key(conn, add_project): # design #19 + add_project(conn); add_project(conn, "C", board_id=5120) + mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"]}, {"key": "Y", "labels": ["C"]}], []) + queue.create_batch(conn, "x", "claude", [{"type": "ensure_rank", "params": {"key": "X", "before": "Y"}}], project="AWPCA") + with pytest.raises(CommandError, match="команда 1: Y не в проекте AWPCA"): + queue.create_batch(conn, "x", "claude", [{"type": "ensure_rank", "params": {"key": "Y", "after": "X"}}], project="AWPCA") + +def test_retry_sprint_meta_recomputes_board(conn, add_project): + add_project(conn) + b = queue.create_batch(conn, "x", "claude", [meta()], project="AWPCA") + cid = queue.batch_view(conn, b["id"])["commands"][0]["id"] + conn.execute("UPDATE jira_project SET board_id = 5120") + r = queue.retry_command(conn, cid, "claude") + assert queue.batch_view(conn, r["id"])["commands"][0]["params"]["board_id"] == 5120 + +def test_retry_sprint_state_after_board_became_kanban(conn, add_project): # Review Focus 4 + add_project(conn) + b = queue.create_batch(conn, "x", "claude", [close()], project="AWPCA") + cid = queue.batch_view(conn, b["id"])["commands"][0]["id"] + conn.execute("UPDATE jira_project SET board_type = 'kanban'") + with pytest.raises(CommandError, match="канбан-доска, спринтов нет"): + queue.retry_command(conn, cid, "claude") +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_queue.py -q -k "sprint_meta or sprint_state or rank_belongs"` +Expected: FAIL — `KeyError: 'key'` в `_check_project`, нет `board_id` в params. + +- [ ] **Step 3: Implement** + +`_check_project`: у `SPRINT_TYPES` проверка `own(p["key"])` не делается (команда не про задачу); `ensure_rank` идёт +общей веткой (`own(key)`, `before`/`after` не проверяются). `_assign_boards` берёт тексты из `_board_error`. +`check_batch(conn, cmds, project, files_dir)`: после `check_setup` — `_assign_project_board(conn, cmds, project)` +(входные `params` не мутируются, как в `_assign_boards`). `retry_params` — по множеству из трёх типов. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_queue.py -q` → PASS. + +- [ ] **Step 5: Docs, `make check`, commit** + +README «Контракт команд»: у `ensure_sprint_meta`/`ensure_sprint_state` — «`board_id` дописывает хаб по проекту пакета; +пакет без проекта — 422 «команды спринта — только в пакете проекта»; доски нет, kanban или тип не известен — 422, +как у `ensure_sprint`»; `ensure_rank` в пакете проекта — `key` своей задачи. ARCHITECTURE `hooks`: «`board_id` у +`ensure_sprint` (по проекту задачи) и у `ensure_sprint_meta`/`ensure_sprint_state` (по проекту пакета)». +Run: `make check` → PASS. + +```bash +git add src/sdlc_hub/modules/jira/hooks.py tests/test_queue.py README.md ARCHITECTURE.md +git commit -m "Команды спринта: доска проекта пакета, повтор с новой доской (#108)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 6: Хвостовой `refresh` по JQL проекта (контракт `tail`) + +**Files:** +- Modify: `src/sdlc_hub/core/modules.py:18` (тип `tail`) +- Modify: `src/sdlc_hub/core/queue.py:79` (`h.tail(conn, cmds, project)`) +- Modify: `src/sdlc_hub/modules/wiki/commands.py:97` (`tail=lambda conn, cmds, project: None`) +- Modify: `src/sdlc_hub/modules/jira/projects.py` (новая `project_jql`) +- Modify: `src/sdlc_hub/modules/jira/hooks.py:133-140` (`tail`) +- Modify: `tests/conftest.py:59`, `tests/test_queue_core.py:24` (лямбды заглушек под новую сигнатуру) +- Test: `tests/test_queue.py`, `tests/test_projects.py` +- Modify: `ARCHITECTURE.md` (контракт `CommandHooks`: «`tail(conn, cmds, project)`»), `README.md` («Контракт команд»: хвост пакета) + +**Interfaces:** +- Consumes: `commands.SPRINT_TYPES` (задача 4). +- Produces: + - `CommandHooks.tail: Callable[[Connection, list[dict], str | None], dict | None]` — комментарий «(conn, cmds, проект пакета): хвостовая команда пишущего пакета». + - `jira_projects.project_jql(conn, label: str) -> str` — `project = AND labels in ("<метка>")` в формате `base_jql`. + - `hooks.tail(conn, cmds, project) -> dict | None` — есть команда из `SPRINT_TYPES` и `project` → `{"type": "refresh", "params": {"jql": project_jql(conn, project)}}`; иначе прежнее правило по ключам. + +- [ ] **Step 1: Write the failing tests** + +`tests/test_projects.py`: + +```python +def test_project_jql(open_db, add_project): + conn = open_db(); settings.ensure_defaults(conn) + add_project(conn) + assert jira_projects.project_jql(conn, "AWPCA") == 'project = AURORA AND labels in ("AWPCA")' == jira_projects.base_jql(conn) + add_project(conn, "C", board_id=None) + assert jira_projects.project_jql(conn, "C") == 'project = AURORA AND labels in ("C")' +``` + +`tests/test_queue.py`: + +```python +def test_sprint_batch_tail_is_project_jql(conn, add_project): # design #13, R-10 + add_project(conn); add_project(conn, "C", board_id=None) + mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"]}, {"key": "Y", "labels": ["AWPCA"]}], []) + b = queue.create_batch(conn, "x", "claude", [meta(), {"type": "ensure_rank", "params": {"key": "X", "before": "Y"}}], project="AWPCA") + cmds = queue.batch_view(conn, b["id"])["commands"] + assert [c["type"] for c in cmds] == ["ensure_sprint_meta", "ensure_rank", "refresh"] + assert cmds[-1]["params"] == {"jql": 'project = AURORA AND labels in ("AWPCA")'} + +def test_rank_batch_tail_by_keys(conn, add_project): + add_project(conn) + mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"]}], []) + b = queue.create_batch(conn, "x", "claude", [{"type": "ensure_rank", "params": {"key": "X", "after": "Z"}}], project="AWPCA") + assert queue.batch_view(conn, b["id"])["commands"][-1]["params"] == {"keys": ["X", "Z"]} + +def test_retry_sprint_meta_gets_jql_tail(conn, add_project): + # повтор команды из пакета проекта — тоже хвост по JQL проекта (retry_command передаёт project пакета) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_queue.py tests/test_projects.py -q -k "tail or project_jql"` +Expected: FAIL — `AttributeError: project_jql`; хвоста у пакета со спринтом нет. + +- [ ] **Step 3: Implement** + +Сигнатура и вызов в ядре; лямбды wiki и заглушек тестов — `lambda conn, cmds, project: …`. `project_jql` использует +тот же `project_key` и кавычки, что `base_jql`. `hooks.tail` — по Interfaces; docstring: «пакет с командами спринта — +`refresh` по JQL проекта пакета: у них нет ключей, а перенесённые при закрытии задачи заранее неизвестны (design #13)». + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest -q` → PASS (весь набор: сигнатура ядра). + +- [ ] **Step 5: Docs, `make check`, commit** + +ARCHITECTURE, «Контракт модуля»: `tail(conn, cmds, project)`. README «Контракт команд»: «пакет с +`ensure_sprint_meta`/`ensure_sprint_state` получает хвост `refresh` по JQL проекта пакета; иначе — по затронутым ключам». +Run: `make check` → PASS. + +```bash +git add src/sdlc_hub/core/modules.py src/sdlc_hub/core/queue.py src/sdlc_hub/modules/wiki/commands.py src/sdlc_hub/modules/jira/projects.py src/sdlc_hub/modules/jira/hooks.py tests/conftest.py tests/test_queue_core.py tests/test_queue.py tests/test_projects.py ARCHITECTURE.md README.md +git commit -m "Хвост пакета со спринтом — refresh по JQL проекта (#108)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 7: MCP: `sprints`, `sprint`, `backlog`, колонка SP + +**Files:** +- Modify: `src/sdlc_hub/modules/jira/mcp.py` (`fmt_issues`, новые форматтеры и инструменты, текст `batch_create`) +- Test: `tests/test_mcp.py` +- Modify: `tests/fixtures/baseline/mcp_tools.expected.json` (перезапись) +- Modify: `README.md` (раздел «MCP в Claude»: новые инструменты), `ARCHITECTURE.md` (строка `mcp` модуля jira) + +**Interfaces:** +- Consumes: ответ `GET /agile` (задача 3). +- Produces: + - `mcp.fmt_sp(v) -> str` — `None` → «—», иначе `f"{v:g}"` (`3.0` → «3», `2.5` → «2.5»). + - `mcp.fmt_day(value: str | None) -> str` — время Jira (`±HHMM`, `±HH:MM`, `Z`) → «ДД.ММ» в местном поясе; пусто/неразбираемо — «—». + - `mcp.fmt_sprints(data: dict) -> str`, `mcp.fmt_sprint(data: dict, name: str) -> str`, `mcp.fmt_backlog(data: dict, limit: int) -> str`. + - инструменты (`@mcp.tool() @core.guard @core.scoped(True)`): `sprints() -> str`, `sprint(name: str = "") -> str`, `backlog(limit: int = 50) -> str` — `core.client().get("/agile", project=core.PROJECT)`. + +Тексты (R-25): +- общие отказы (все три): `board is None` → «у проекта нет доски»; `board.fetched_at is None` → «данных доски нет — нужен агент v15 и обновление из Jira»; `sprints`/`sprint` у `board.type == "kanban"` → «у проекта канбан-доска, спринтов нет». +- `fmt_sprints`: шапка «спринт | состояние | даты | задачи | SP | без оценки»; строка `f"{name} | {активный|будущий|закрыт} | {fmt_day(start)}–{fmt_day(end)} | {count_done}/{count} | {fmt_sp(points_done)}/{fmt_sp(points)} | {unestimated}"`; закрытые — первые 10 из ответа (новые сверху) и «ещё N закрытых»; спринтов нет — «спринтов нет». +- `fmt_sprint`: по `name` — первый с этим именем, иначе «спринта «» нет на доске»; без `name` — первый активный, иначе «активного спринта нет». Шапка: `f"{name} — {состояние}; {d1}–{d2}; цель: {goal|—}"` и `f"задачи {count_done}/{count}; SP {pd}/{p}; без оценки: {u}"`; затем по колонкам доски `f"{колонка} ({n}):"` и строки `f" {key} {core.cut(summary, 80)} — {assignee|—}, SP {fmt_sp}"`; статусы вне колонок — блок «Без колонки (n):» (только если есть). +- `fmt_backlog`: `backlog is None` → «у доски нет бэклога»; пусто — «бэклог пуст»; шапка «# | ключ | тема | статус | исполнитель | SP», позиции с 1, не больше `max(1, limit)`, в конце «ещё N». +- `fmt_issues`: шапка «ключ | тема | статус | срок | исполнитель | спринт | SP», последняя ячейка `fmt_sp(story_points)`. +- `batch_create`: фраза «board_id для ensure_sprint выставляет хаб.» → «board_id для ensure_sprint, ensure_sprint_meta и ensure_sprint_state выставляет хаб; ensure_sprint_meta и ensure_sprint_state — только в сессии с проектом, хвост такого пакета — refresh по JQL проекта; оценка — story_points в ensure_fields.» + +- [ ] **Step 1: Write the failing tests** (`tests/test_mcp.py`) + +```python +def _sp(id, name, state, **over): + return {"id": id, "name": name, "state": state, "start_date": "2026-10-14T09:00:00.000+0300", "end_date": "2026-10-28T18:00:00.000+0300", + "complete_date": None, "goal": None, "count": 0, "count_done": 0, "unestimated": 0, "points": 0, "points_done": 0, "issues": [], **over} +BOARD = {"id": 4216, "type": "scrum", "columns": [{"name": "К выполнению", "statuses": ["Backlog"]}, {"name": "Готово", "statuses": ["Готово"]}], + "kanban_backlog": None, "fetched_at": "2026-10-24T10:00:00+00:00"} + +def test_fmt_issues_sp_column(): + out = jmcp.fmt_issues([{"key": "A-1", "story_points": 3.0}, {"key": "A-2", "story_points": 2.5}, {"key": "A-3", "story_points": None}]) + assert out.splitlines()[0].endswith("| SP") and [l.rsplit("| ", 1)[1] for l in out.splitlines()[1:]] == ["3", "2.5", "—"] + +def test_fmt_sprints_order_and_sums(): # сценарий «Спринты проекта» + data = {"board": BOARD, "backlog": [], "board_issues": None, "sprints": + [_sp(3, "AWPCA_Sprint_3_Corrections", "active", count=37, count_done=25, points=40, points_done=25), _sp(4, "AWPCA_Sprint_4", "future")] + + [_sp(100 - n, f"S{n}", "closed") for n in range(12)]} + lines = jmcp.fmt_sprints(data).splitlines() + assert lines[1].startswith("AWPCA_Sprint_3_Corrections | активный | ") and "| 25/37 | 25/40 |" in lines[1] + assert lines[2].startswith("AWPCA_Sprint_4 | будущий") + assert len([l for l in lines if "| закрыт |" in l]) == 10 and lines[-1] == "ещё 2 закрытых" + +@pytest.mark.parametrize("data, text", [ + ({"board": None, "sprints": [], "backlog": None, "board_issues": None}, "у проекта нет доски"), + ({"board": {**BOARD, "type": "kanban"}, "sprints": [], "backlog": None, "board_issues": []}, "у проекта канбан-доска, спринтов нет"), + ({"board": {**BOARD, "fetched_at": None, "columns": None}, "sprints": [], "backlog": [], "board_issues": None}, "данных доски нет — нужен агент v15 и обновление из Jira"), +]) +def test_fmt_sprints_refusals(data, text): + assert jmcp.fmt_sprints(data) == text + +def test_fmt_sprint_by_columns(): + # активный спринт с задачами в «Backlog», «Готово» и статусе «Странный» → блоки «К выполнению (1):», «Готово (1):», «Без колонки (1):»; + # тема длиннее 80 обрезана core.cut; fmt_sprint(data, "S9") → «спринта «S9» нет на доске»; без активного → «активного спринта нет» + +def test_fmt_backlog_limit(): # сценарий «Бэклог с лимитом» + rows = [{"key": f"A-{n}", "summary": "т", "status": "Backlog", "assignee": None, "story_points": None} for n in range(188)] + out = jmcp.fmt_backlog({"board": BOARD, "sprints": [], "backlog": rows, "board_issues": None}, 2).splitlines() + assert out[1].startswith("1 | A-0 |") and out[2].startswith("2 | A-1 |") and out[-1] == "ещё 186" and len(out) == 4 + +def test_fmt_day_offsets(): + assert jmcp.fmt_day("2026-10-14T09:00:00.000+0300") == jmcp.fmt_day("2026-10-14T09:00:00.000+03:00") != "—" + assert jmcp.fmt_day(None) == "—" and jmcp.fmt_day("мусор") == "—" + +def test_agile_tools_live(live): + live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема", "status": "Backlog", "labels": ["AWPCA"], + "rank": "0|a", "story_points": 2, "sprint_ids": [102]}], "links": [], + "boards": [{"id": 4216, "type": "scrum", "columns": BOARD["columns"], "kanban_backlog": None, "sprints": [_sp(102, "S2", "active")]}]}) + assert "S2 | активный" in jmcp.sprints() and "A-1 Тема" in jmcp.sprint() and "бэклог пуст" == jmcp.backlog() + +def test_agile_tools_unbound(live, monkeypatch): + monkeypatch.setattr(core_mcp, "PROJECT", None) + assert jmcp.sprints() == jmcp.sprint() == jmcp.backlog() == jmcp.issues() # тот же отказ, что у прочих + +def test_batch_create_description_lists_sprint_types(): + doc = jmcp.batch_create.__doc__ + for word in ("ensure_sprint_meta", "ensure_sprint_state", "ensure_rank", "story_points", "refresh по JQL проекта"): + assert word in doc +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_mcp.py -q -k "sp_column or sprints or sprint_ or backlog or fmt_day or agile or sprint_types"` +Expected: FAIL — `AttributeError: fmt_sprints`. + +- [ ] **Step 3: Implement** форматтеры, инструменты и текст `batch_create` по Interfaces; docstring инструментов: +`sprints` — «Спринты доски проекта: состояние, даты, задачи и SP «выполнено/всего», без оценки; закрытые — 10 последних»; +`sprint` — «Спринт (по умолчанию активный): шапка с суммами и задачи по колонкам доски»; `backlog` — «Бэклог проекта по +рангу: позиция, ключ, тема, статус, исполнитель, SP; limit — сколько строк (по умолчанию 50)». + +- [ ] **Step 4: Run tests, rewrite MCP baseline** + +Run: `uv run pytest tests/test_mcp.py -q` → PASS. +Run: `BASELINE_WRITE=1 uv run pytest tests/test_baseline.py::test_mcp_tools -q && git diff tests/fixtures/baseline/mcp_tools.expected.json` +Expected: три новых инструмента, изменённая фраза в `batch_create`, больше ничего. + +- [ ] **Step 5: Docs, `make check`, commit** + +README «MCP в Claude»: `sprints`, `sprint [name]`, `backlog [limit]` (чтение `GET /agile` проекта сессии), колонка SP в +`issues`, команды спринтов и ранга — через `batch_create` (пример пакета `ensure_sprint_meta` + `ensure_sprint_state`). +ARCHITECTURE `mcp`: «…`sprints`, `sprint`, `backlog`…». +Run: `make check` → PASS. + +```bash +git add src/sdlc_hub/modules/jira/mcp.py tests/test_mcp.py tests/fixtures/baseline/mcp_tools.expected.json README.md ARCHITECTURE.md +git commit -m "MCP: спринты, спринт и бэклог проекта, колонка SP (#108)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 8: Веб: «Связи» вместо «Доски связей», условные пункты меню + +**Files:** +- Modify: `web/src/modules/types.ts:6-11` (`NavItem.when`) +- Modify: `web/src/shell/nav.tsx` (`visibleNav`) +- Modify: `web/src/shell/Sidebar.tsx:76-127` (фильтр пунктов по текущему проекту) +- Modify: `web/src/shell/NotFound.tsx` (только безусловные пункты, R-23) +- Modify: `web/src/modules/jira/index.tsx` (адрес `/jira/links`, перенаправления `/jira/board` и `/board`, пункт «Связи») +- Modify: `web/src/modules/jira/issues/modal.ts:12-13` (`BOARD_CARD_STATE` → `/jira/links`), `web/src/modules/jira/Summary.tsx:27` (ссылка «Связи» на `/jira/links`) +- Create: `web/src/shell/nav.test.ts` +- Modify tests: `web/src/App.test.tsx`, `web/src/shell/Sidebar.test.tsx`, `web/src/projects/projects.test.tsx`, `web/src/modules/modules.test.tsx`, `web/src/shell/OverviewPage.test.tsx`, `web/src/modules/jira/issues/IssueModal.test.tsx`, `web/src/modules/jira/issues/modal.test.ts` +- Modify e2e: `web/e2e/hub.ts` (`login` по умолчанию `/app/jira/links`, ссылка «Связи»), `web/e2e/layout.spec.ts`, `web/e2e/board.spec.ts`, `web/e2e/login.spec.ts`, `web/e2e/screens.spec.ts`, `web/e2e/projects.spec.ts` +- Modify: `CLAUDE.md:29`, `README.md` (раздел «Веб-приложение», адреса), `ARCHITECTURE.md` (раздел «Веб», адреса и «Связи») + +**Interfaces:** +- Produces: + - `NavItem.when?: (p: Project) => boolean` — комментарий «пункт только для проектов, где условие верно; пока проект не определён (проекты не загружены) — скрыт». + - `visibleNav(items: NavItem[], project: Project | null): NavItem[]` в `web/src/shell/nav.tsx`. + - Страница доски связей — `/jira/links`; `/jira/board` и `/board` — `LegacyRedirect to="/jira/links"` (с `search`, `replace`); пункт `{ to: "/jira/links", label: "Связи", icon: }`. Порядок `pages`: `/jira/links`, `/jira/issues`, `/jira/feed`, затем перенаправления `/jira/board`, `/board`, `/issues`, `/issues/:key`, `/feed`. Заголовок `h1` доски — прежний «Доска связей» (R-21). + +- [ ] **Step 1: Write the failing tests** + +`web/src/shell/nav.test.ts`: + +```ts +const A = { label: "AWPCA", name: "", created_at: "", board_type: "scrum" }; +const items = [{ to: "/a", label: "A", icon: null }, { to: "/b", label: "B", icon: null, when: (p: Project) => p.board_type === "scrum" }]; +it("условный пункт скрыт без проекта и при ложном условии", () => { + expect(visibleNav(items, null).map((n) => n.to)).toEqual(["/a"]); + expect(visibleNav(items, { ...A, board_type: null }).map((n) => n.to)).toEqual(["/a"]); + expect(visibleNav(items, A).map((n) => n.to)).toEqual(["/a", "/b"]); +}); +``` + +`web/src/App.test.tsx` (новые тесты; прежние «Доска связей» → «Связи», `/app/jira/board` → `/app/jira/links`): + +```ts +it("прежний адрес доски связей", async () => { // сценарий «Прежний адрес доски связей» + const router = at("/app/jira/board?project=AWPCA"); + await waitFor(() => expect(router.state.location.pathname + router.state.location.search).toBe("/app/jira/links?project=AWPCA")); + expect(await screen.findByRole("link", { name: "Связи" })).toHaveAttribute("aria-current", "page"); + await waitFor(() => expect(document.title).toBe("Связи · AWPCA · SDLC Hub")); +}); +it("/app/board → сразу /app/jira/links", async () => { // design #26 + const router = at("/app/board?project=AWPCA"); + await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/links")); + expect(router.state.location.search).toBe("?project=AWPCA"); +}); +``` + +`modules.test.tsx`: `PAGE_ROUTES` — `["/", "/jira/links", "/jira/issues", "/jira/feed", "/jira/board", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]`; пункты — `[["Связи", "/jira/links"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"]]`. +`Sidebar.test.tsx`: порядок ссылок `["Обзор", "Связи", "Задачи", "Лента", "Вики"]`, в группе «Jira» — `["Связи", "Задачи", "Лента"]`, href `/app/jira/links?project=AWPCA`. +`OverviewPage.test.tsx`: ссылка «Связи» → `/app/jira/links?project=AWPCA`. `modal.test.ts`: фон `/jira/links`. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd web && npm test` +Expected: FAIL — нет `visibleNav`, ссылки «Связи» нет, адрес остаётся `/app/jira/board`. + +- [ ] **Step 3: Implement** + +`Panel` берёт текущий проект из `useProjectContext().state` (`kind === "ok"` → `state.project`, иначе `null`) и рисует +`visibleNav(g.nav, current)`. Остальное — по Interfaces. Тексты e2e и тестов, где встречается «Доска связей» как +ссылка, — «Связи» (`exact: true`); `geometry.spec.ts` с `h1` «Доска связей» не меняется. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd web && npm run lint && npm test` → PASS. +Run: `cd web && npm run e2e` → PASS (в `layout.spec.ts` тест перенаправления — `/app/board?project=AWPCA` → +`/app/jira/links?project=AWPCA`, и новый — `/app/jira/board?project=AWPCA` → `/app/jira/links?project=AWPCA`). + +- [ ] **Step 5: Docs, `make check`, commit** + +CLAUDE.md:29, README «Веб-приложение», ARCHITECTURE «Веб»: «Связи» — `/app/jira/links?project=<метка>`; прежние +`/app/board` и `/app/jira/board` перенаправляют на него; пункты меню могут зависеть от проекта (`NavItem.when`). +Run: `make check` → PASS. + +```bash +git add web/src web/e2e CLAUDE.md README.md ARCHITECTURE.md +git commit -m "Веб: «Связи» на /app/jira/links, условные пункты меню (#108)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 9: Веб: данные доски, общие части экранов и страница «Бэклог» + +Перед задачей — скилл `aura-design` (вёрстка секций, строк, сообщений). + +**Files:** +- Modify: `web/src/modules/jira/api/types.ts` (типы ответа `GET /agile`) +- Modify: `web/src/modules/jira/api/queries.ts` (`qk.agile`, `useAgile`), `web/src/modules/jira/status.ts` (`invalidate` + `["agile"]`) +- Modify: `web/src/modules/jira/projectFields.ts` (`boardType`) +- Modify: `web/vite.config.ts` (`API` += `"/agile"` — прокси разработки) +- Modify: `web/src/ui/icons.tsx` (`IconBacklog`) +- Create: `web/src/modules/jira/agile/model.ts`, `model.test.ts` +- Create: `web/src/modules/jira/agile/AgileGate.tsx`, `TypeIcon.tsx`, `IssueRow.tsx`, `BacklogPage.tsx`, `agile.css` +- Create: `web/src/modules/jira/agile/BacklogPage.test.tsx` +- Modify: `web/src/modules/jira/index.tsx` (страница `/jira/backlog`, пункт «Бэклог»), `web/src/modules/modules.test.tsx` + +**Interfaces:** +- Consumes: `GET /agile` (задача 3); `visibleNav`/`NavItem.when` (задача 8); `RefreshButton`; `useOpenIssue`, `IssueLink`. +- Produces: + - типы `AgileIssue`, `BoardColumn {name; statuses: string[]}`, `AgileBoard {id; type: "scrum" | "kanban" | null; columns: BoardColumn[] | null; kanban_backlog: boolean | null; fetched_at: string | null}`, `SprintState`, `AgileSprint`, `AgileResponse {board: AgileBoard | null; sprints: AgileSprint[]; backlog: AgileIssue[] | null; board_issues: AgileIssue[] | null}` — поля как в ответе хаба. + - `qk.agile(project: string) = ["agile", project] as const`; `useAgile(project: string)`. + - `boardType(p: Project): "scrum" | "kanban" | null`. + - `model.ts`: `jiraDate(iso: string | null): Date | null` (`±HHMM` → `±HH:MM`; негодное — `null`); `fmtDay(iso: string | null): string` («ДД.ММ» в поясе браузера, иначе «—»); `fmtRange(start, end): string` («ДД.ММ–ДД.ММ»); `daysLeft(endIso: string | null, now: Date): number | null` (разница календарных дат в поясе браузера); `fmtSp(n: number | null): string` («—» или число); `tasksWord(n: number): string` («задача»/«задачи»/«задач», R-24); `boardState(d: AgileResponse): "no-data" | "no-columns" | "ok"` (R-19). + - `AgileGate({ project, kind, children }: { project: Project; kind: "scrum" | "kanban"; children: (d: AgileResponse) => ReactNode })` — тип доски проекта не `kind` → `Notice` «У проекта нет scrum-доски» / «У проекта нет kanban-доски» со ссылкой «Связи» (`/jira/links?project=…`), запроса `/agile` нет; загрузка/ошибка — `Notice`; `no-data` → «Нужен агент v15 и обновление из Jira» + `RefreshButton`; `no-columns` → «Не удалось прочитать настройки доски из Jira» + `RefreshButton`; `ok` → `children`. + - `TypeIcon({ type }: { type: string | null })` — значок с `aria-label`/`title` = тип (эпик — `/^(Эпик|Epic|Фича)$/i`, ошибка — `/^(Ошибка|Bug)$/i`, прочее — задача). + - `AgileIssueRow({ issue, mark }: { issue: AgileIssue; mark?: string })` — ключ (`IssueLink`), `TypeIcon`, тема, исполнитель или «—», `fmtSp`; выполненная — класс `is-done` (приглушена); щелчок по строке (не по ссылке и не при выделении текста) — `useOpenIssue()(key)`; `mark` — пометка справа. + - `BacklogPage({ project }: { project: Project })` — `data-testid="page-backlog"`, `PageHeader` «Бэклог» с `RefreshButton`. + - пункт `{ to: "/jira/backlog", label: "Бэклог", icon: , padded: true, when: (p) => boardType(p) === "scrum" }` после «Связи»; страница `/jira/backlog` — после `/jira/links`. + +Состав «Бэклога» (спека web-app): секции активных, затем будущих спринтов — `
` с заголовком: имя, «активный»/ +«будущий», `fmtRange`, цель (если есть), `${count} ${tasksWord(count)}`, `${fmtSp(points)} SP (выполнено +${fmtSp(points_done)})`, «без оценки: U» при `U > 0`; задачи по порядку ответа; пусто — «Нет задач». Затем секция +«Бэклог» (`data.backlog`), пусто — «Нет задач». Внизу `
` «Прошлые спринты (N)»: по `
` на закрытый +спринт, `` — `${name} · закрыт ${fmtDay(complete_date)} · выполнено ${count_done} из ${count} · +${fmtSp(points)} SP`, внутри строки задач с `mark="не выполнено"` у `!done`. Ни `draggable`, ни кнопок, кроме +`` и `RefreshButton` (R-20). + +- [ ] **Step 1: Write the failing tests** + +`agile/model.test.ts`: + +```ts +it("время Jira с ±HHMM и ±HH:MM — один день", () => { // Review Focus 1 + expect(fmtDay("2026-10-14T09:00:00.000+0300")).toBe(fmtDay("2026-10-14T09:00:00.000+03:00")); + expect(fmtDay("2026-10-14T09:00:00.000+0300")).toMatch(/^\d{2}\.\d{2}$/); + expect(fmtDay(null)).toBe("—"); expect(fmtDay("мусор")).toBe("—"); +}); +it("daysLeft — календарные дни до конца", () => { + const now = new Date(2026, 9, 24, 23, 30); + expect(daysLeft(new Date(2026, 9, 27, 1, 0).toISOString(), now)).toBe(3); + expect(daysLeft(new Date(2026, 9, 23, 12, 0).toISOString(), now)).toBe(-1); + expect(daysLeft(null, now)).toBeNull(); +}); +it("fmtSp, tasksWord", () => { + expect([fmtSp(null), fmtSp(3), fmtSp(2.5)]).toEqual(["—", "3", "2.5"]); + expect([1, 2, 5, 11, 21, 73].map(tasksWord)).toEqual(["задача", "задачи", "задач", "задач", "задача", "задачи"]); +}); +it("boardState — три состояния (design #23)", () => { + expect(boardState({ board: null, sprints: [], backlog: null, board_issues: null })).toBe("no-data"); + expect(boardState(resp({ fetched_at: null, columns: null }))).toBe("no-data"); + expect(boardState(resp({ fetched_at: "2026-10-24T10:00:00+00:00", columns: null }))).toBe("no-columns"); + expect(boardState(resp({}))).toBe("ok"); +}); +``` + +`agile/BacklogPage.test.tsx` (через `App` и `mockFetch`, как `FeedPage.test.tsx`; проекты `A` — AWPCA scrum, +`S` — STOCKS без доски; ответ `/agile` — фабрика с активным, будущим и закрытым спринтами): + +```ts +it("меню scrum-проекта: «Связи», «Бэклог», «Задачи», «Лента»", ...) // задача 10 добавит «Спринт» +it("проект без доски: пунктов нет, «У проекта нет scrum-доски» со ссылкой «Связи», запроса /agile нет", ...) // сценарий «Проект без доски» +it("пока проекты не загружены, «Бэклог» скрыт", ...) // GET /projects не отвечает: в группе «Jira» — «Связи», «Задачи», «Лента» +it("секции: активный, будущий, «Бэклог», «Прошлые спринты (1)»; заголовок спринта с суммами", ...) + // «12 задач», «25 SP (выполнено 8)», «без оценки: 3»; пустой будущий спринт — «Нет задач» +it("прошлый спринт: строка и пометки «не выполнено»", ...) // сценарий «Прошлый спринт»: 73 задачи, 61 done, 25 SP → + // summary `AWPCA_Sprint_2_VGO · закрыт ${fmtDay(c)} · выполнено 61 из 73 · 25 SP`; после раскрытия 73 строки, 12 с «не выполнено» +it("только смотрит: нет draggable, кнопки — только summary и «Обновить из Jira»", ...) // сценарий «Бэклог только смотрит» + // container.querySelectorAll('[draggable="true"]').length === 0; + // within(page).queryAllByRole("button").every((b) => b.tagName === "SUMMARY" || /Обновить из Jira/.test(b.textContent ?? "")) +it("щелчок по строке открывает карточку поверх «Бэклога»", ...) // адрес /app/jira/issues/AURORA-1, dialog; закрытие → /app/jira/backlog +it("нет данных доски — «Нужен агент v15 и обновление из Jira» и «Обновить из Jira»", ...) +it("колонок нет — «Не удалось прочитать настройки доски из Jira»", ...) +it("смена проекта на STOCKS оставляет /app/jira/backlog и показывает «У проекта нет scrum-доски»", ...) // design #21 +it("заголовок вкладки «Бэклог · AWPCA · SDLC Hub»", ...) +``` + +`modules.test.tsx`: `PAGE_ROUTES` — `/jira/backlog` вторым после `/jira/links`; пункты — `["Бэклог", "/jira/backlog"]` после «Связи». + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd web && npm test -- src/modules/jira/agile src/modules/modules.test.tsx` +Expected: FAIL — модулей `agile/*` нет. + +- [ ] **Step 3: Implement** по Interfaces; стили — `agile.css` только на токенах; `status.invalidate` перечитывает и `["agile"]`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd web && npm run lint && npm test` → PASS. + +- [ ] **Step 5: `make check`, commit** + +Run: `make check` → PASS. + +```bash +git add web/src web/vite.config.ts +git commit -m "Веб: страница «Бэклог» и общие части экранов доски (#108)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 10: Веб: страница «Спринт» + +Перед задачей — скилл `aura-design` (колонки доски, карточки). + +**Files:** +- Create: `web/src/modules/jira/agile/columns.ts`, `columns.test.ts` +- Create: `web/src/modules/jira/agile/BoardColumns.tsx`, `AgileCard.tsx`, `SprintPage.tsx`, `SprintPage.test.tsx` +- Modify: `web/src/modules/jira/agile/agile.css`, `web/src/ui/icons.tsx` (`IconSprint`) +- Modify: `web/src/modules/jira/index.tsx` (страница `/jira/sprint`, пункт «Спринт»), `web/src/modules/modules.test.tsx`, `web/src/modules/jira/agile/BacklogPage.test.tsx` (меню с «Спринт») + +**Interfaces:** +- Consumes: `AgileGate`, `model.ts`, `TypeIcon`, `useOpenIssue` (задача 9). +- Produces: + - `type ColumnView = { name: string; issues: AgileIssue[]; extra: boolean }`. + - `columnsOf(columns: BoardColumn[] | null, issues: AgileIssue[]): ColumnView[]` — колонки доски по порядку, задача — в колонку своего статуса, порядок задач — как во входе (ранг); статусы вне колонок — последняя `{ name: "Без колонки", extra: true }`, только если в ней есть задачи; `columns = null` — одна «Без колонки». + - `BoardColumns({ columns, lastCaption }: { columns: ColumnView[]; lastCaption?: string })` — контейнер `.agile-columns` с горизонтальной прокруткой внутри (`overflow-x: auto`), у колонки — имя и число задач, `lastCaption` — подпись последней не-`extra` колонки. + - `AgileCard({ issue }: { issue: AgileIssue })` — те же поля, что `AgileIssueRow`; щелчок открывает карточку задачи; выполненная приглушена. + - `SprintPage({ project }: { project: Project })` — `data-testid="page-sprint"`; активные спринты из `data.sprints`; выбор — `?sprint=`, нет такого активного (закрыт, чужой, мусор) — первый активный (Review Focus 2); при двух и более — `Segmented` с `href` (`label="Спринты"`); заголовок: имя, `fmtRange`, «осталось N дн.» (`daysLeft ≥ 0`) или «срок прошёл» (`< 0`), цель, `${count_done} из ${count} ${tasksWord(count)}`, `${fmtSp(points_done)} из ${fmtSp(points)} SP`; ниже `BoardColumns(columnsOf(board.columns, sprint.issues))`. Активного нет — «Нет активного спринта» и ссылка «Бэклог». + - пункт `{ to: "/jira/sprint", label: "Спринт", icon: , padded?: нет — во всю ширину, when: scrum }` после «Бэклог»; страница после `/jira/backlog`. + +- [ ] **Step 1: Write the failing tests** + +`agile/columns.test.ts`: + +```ts +const cols = [{ name: "К выполнению", statuses: ["Backlog"] }, { name: "В работе", statuses: ["В работе"] }, { name: "Готово", statuses: ["Готово"] }]; +it("по колонкам в порядке ранга, «Без колонки» — только с задачами", () => { + const v = columnsOf(cols, [iss("A", "Готово"), iss("B", "Backlog"), iss("C", "Странный"), iss("D", "Backlog")]); + expect(v.map((c) => [c.name, c.issues.map((i) => i.key)])).toEqual([["К выполнению", ["B", "D"]], ["В работе", []], ["Готово", ["A"]], ["Без колонки", ["C"]]]); + expect(columnsOf(cols, [iss("A", "Готово")]).map((c) => c.name)).toEqual(["К выполнению", "В работе", "Готово"]); +}); +``` + +`agile/SprintPage.test.tsx`: + +```ts +it("меню scrum-проекта: «Связи», «Бэклог», «Спринт», «Задачи», «Лента»; «Kanban» нет", ...) // сценарий «Меню scrum-проекта» +it("колонки доски с числом задач и «Без колонки»", ...) +it("«осталось 3 дн.» и «срок прошёл»", ...) // end_date = сейчас + 3 дня / вчера (ISO с +0300) +it("два активных: переключатель «Спринты», выбор — ?sprint= и его задачи", ...) +it("устаревший ?sprint= — первый активный", ...) // Review Focus 2: ?sprint=101 (закрыт) и ?sprint=abc +it("нет активного — «Нет активного спринта» и ссылка «Бэклог»", ...) +it("агент без досок — «Нужен агент v15 и обновление из Jira» и «Обновить из Jira»", ...) // сценарий «Агент без досок» +it("щелчок по карточке открывает окно задачи поверх «Спринта»", ...) +``` + +`modules.test.tsx` и меню в `BacklogPage.test.tsx` — с «Спринт» после «Бэклог». + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd web && npm test -- src/modules/jira/agile src/modules/modules.test.tsx` +Expected: FAIL — нет `columnsOf`, `SprintPage`. + +- [ ] **Step 3: Implement** по Interfaces. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd web && npm run lint && npm test` → PASS. + +- [ ] **Step 5: `make check`, commit** + +Run: `make check` → PASS. + +```bash +git add web/src +git commit -m "Веб: страница «Спринт» с колонками доски (#108)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 11: Веб: страница «Kanban» + +Перед задачей — скилл `aura-design`. + +**Files:** +- Create: `web/src/modules/jira/agile/KanbanPage.tsx`, `KanbanPage.test.tsx` +- Modify: `web/src/modules/jira/agile/agile.css`, `web/src/ui/icons.tsx` (`IconKanban`) +- Modify: `web/src/modules/jira/index.tsx` (страница `/jira/kanban`, пункт «Kanban»), `web/src/modules/modules.test.tsx` + +**Interfaces:** +- Consumes: `AgileGate` (`kind="kanban"`), `columnsOf`, `BoardColumns`, `AgileIssueRow` (задачи 9–10). +- Produces: + - `KanbanPage({ project }: { project: Project })` — `data-testid="page-kanban"`, `PageHeader` «Kanban» с `RefreshButton`; при `kanban_backlog === true` над колонками `
` «Бэклог (N)» со строками `data.backlog` по рангу, а колонки — `columnsOf(board.columns.slice(1), board_issues)` (R-18); иначе `columnsOf(board.columns, board_issues)`; `lastCaption="за 14 дней"`. + - пункт `{ to: "/jira/kanban", label: "Kanban", icon: , when: (p) => boardType(p) === "kanban" }` после «Спринт»; страница после `/jira/sprint`. + +- [ ] **Step 1: Write the failing tests** (`agile/KanbanPage.test.tsx`) + +```ts +it("меню kanban-проекта: «Связи», «Kanban», «Задачи», «Лента»", ...) +it("колонки из board_issues, у последней — «за 14 дней», «Без колонки» при статусе вне доски", ...) +it("бэклог включён: «Бэклог (2)» над колонками, первая колонка не рисуется", ...) // R-18 +it("бэклог выключен или неизвестен: раздела «Бэклог» нет, первая колонка на доске", ...) // design #17 +it("scrum-проект на /app/jira/kanban — «У проекта нет kanban-доски» со ссылкой «Связи»", ...) +``` + +`modules.test.tsx`: `/jira/kanban` после `/jira/sprint`; пункт `["Kanban", "/jira/kanban"]` после «Спринт». + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd web && npm test -- src/modules/jira/agile/KanbanPage.test.tsx src/modules/modules.test.tsx` +Expected: FAIL — нет `KanbanPage`. + +- [ ] **Step 3: Implement** по Interfaces. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd web && npm run lint && npm test` → PASS. + +- [ ] **Step 5: `make check`, commit** + +Run: `make check` → PASS. + +```bash +git add web/src +git commit -m "Веб: страница «Kanban» (#108)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 12: Тестовый хаб с досками, e2e новых страниц, документация веба + +**Files:** +- Modify: `web/scripts/seed-hub.py` (доски, спринты, ранг, оценки; проект `KANBAN_E2E`) +- Create: `web/e2e/agile.spec.ts` +- Modify: `README.md` («Веб-приложение»: три страницы, меню по типу доски, только просмотр; «Эксплуатация» — порядок выкатки), `CLAUDE.md:29` (адреса `/app/jira/backlog`, `/app/jira/sprint`, `/app/jira/kanban`), `ARCHITECTURE.md` («Веб», модуль jira: `agile/`) + +**Interfaces:** +- Consumes: `mirror.apply_state(..., boards=)` (задача 2), `jira_projects.create` — в сиде; страницы задач 9–11. +- Produces (данные тестового хаба, R-27): + - доска AWPCA 4216 `scrum`, колонки «К выполнению» [`Backlog`, `К выполнению`, `Ожидание`, `Ожидание разработки`], «В работе» [`В работе`], «Готово» [`Готово`]; спринты 101 «AWPCA_Спринт 1» `closed`, 102 «AWPCA_Спринт 2» `active` (конец — через 7 дней от сида), 103 «AWPCA_Спринт 3» `future`; у задач фикстуры `sprint_ids` по имени `sprint`, AURORA-9001 — `[101, 102]`; `rank` — `0|i:` по порядку фикстуры; `story_points` у части задач. + - проект `KANBAN_E2E` «Канбан e2e», доска 5120 `kanban`, `kanban_backlog: true`, колонки «Бэклог» [`Backlog`], «В работе» [`В работе`], «Готово» [`Готово`]; задачи AURORA-9101 (`Backlog`), AURORA-9102 (`В работе`), AURORA-9103 (`Готово`, `jira_updated` — время сида) с меткой `KANBAN_E2E`. + +- [ ] **Step 1: Write the failing e2e** (`web/e2e/agile.spec.ts`) + +```ts +test("«Бэклог» AWPCA: секции, «Прошлые спринты (1)», щелчок открывает карточку", ...) + // login(page, "/app/jira/backlog?project=AWPCA"); видны «AWPCA_Спринт 2», «AWPCA_Спринт 3», «Бэклог», «Прошлые спринты (1)»; + // раскрытие прошлого спринта показывает AURORA-9001; щелчок по строке → /app/jira/issues/AURORA-9001, dialog; Esc → /app/jira/backlog +test("«Спринт» AWPCA: колонки доски, «осталось 7 дн.», карточка окном", ...) +test("«Kanban»: «Бэклог (1)», колонки «В работе» и «Готово», подпись «за 14 дней»", ...) +test("без scrum-доски: /app/jira/backlog?project=KANBAN_E2E — «У проекта нет scrum-доски», ссылка «Связи»", ...) +test("меню по проекту: у AWPCA «Бэклог» и «Спринт», у KANBAN_E2E — «Kanban»", ...) +test("900 px: колонки «Спринта» прокручиваются внутри доски, страница без горизонтальной прокрутки", ...) + // page.setViewportSize({ width: 900, height: 900 }); scrollWidth документа ≤ clientWidth; у .agile-columns scrollWidth > clientWidth +``` + +- [ ] **Step 2: Run e2e to verify it fails** + +Run: `cd web && npm run e2e -- agile.spec.ts` +Expected: FAIL — у AWPCA нет данных доски («Нужен агент v15 и обновление из Jira»), проекта `KANBAN_E2E` нет. + +- [ ] **Step 3: Implement seed** — в `seed-hub.py` после загрузки фикстуры: проект `KANBAN_E2E` через +`jira_projects.create`, затем `mirror.apply_state` с `boards` и полями задач (тип досок проектам пишет сам +`apply_state`). Даты спринтов — строки ISO со смещением `+03:00` от времени сида. + +- [ ] **Step 4: Run e2e to verify it passes** + +Run: `cd web && npm run e2e` → PASS (весь набор: второй проект не ломает прежние spec). + +- [ ] **Step 5: Docs, `make check`, commit** + +README «Веб-приложение»: страницы «Бэклог» `/app/jira/backlog`, «Спринт» `/app/jira/sprint` (`?sprint=`), «Kanban» +`/app/jira/kanban` — только просмотр, пункты меню по типу доски проекта, три состояния данных; «Эксплуатация» — порядок +выкатки: `git pull` → kickstart (владелец) → `make web` → `/mcp` reconnect → `agent publish` v15 → проверка полей +Jira банка → `field_ids.story_points`, если агент поле не нашёл; `min_agent_version` — владелец после выкатки v15. +CLAUDE.md:29 — адреса трёх страниц. ARCHITECTURE «Веб» — модуль jira: `agile/` (`model`, `columns`, `AgileGate`, +страницы `BacklogPage`, `SprintPage`, `KanbanPage`), данные — `GET /agile`. +Run: `make check` → PASS. + +```bash +git add web/scripts/seed-hub.py web/e2e/agile.spec.ts README.md CLAUDE.md ARCHITECTURE.md +git commit -m "e2e спринтов и бэклога, тестовый хаб с досками, документация (#108)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +## Self-review + +- **Покрытие спеки:** data-model (схема v9, миграция v8 — задача 1; доски, спринты, членство, «выполнено» — задачи 2–3); + hub-api (приём — 2, `GET /agile` — 3); commands (контракт, `story_points`, ошибки 422 — 4; доска по проекту пакета, + `ensure_rank` по `key`, `command_retry` — 5; хвост по JQL — 6; темп — 4); mcp (`sprints`, `sprint`, `backlog`, SP, + `batch_create` — 7); web-app (адреса и «Связи» — 8; меню по типу доски — 8–11; «Бэклог» — 9; «Спринт» — 10; + «Kanban» — 11; карточка окном — 9–10; даты и «осталось N дн.» — 9–10; горизонтальная прокрутка — 10, e2e 12); + agent — вне плана (jira-agent v15), здесь только приём его данных. Сценарий «Создание спринта повтором не дублируется» + и «Завершение спринта с переносом», «Ранг уже такой» — поведение агента; со стороны хаба — темп, `board_id`, хвост + (задачи 4–6) и отсутствие подтверждения по зеркалу (задача 4). +- **Типы и имена:** `apply_state(..., boards=None)` (2) → сид (12); `agile.view` (3) → `h_agile` (3); `SPRINT_TYPES` + (4) → `hooks` (5, 6); `tail(conn, cmds, project)` (6) — ядро, wiki, заглушки; `visibleNav`/`NavItem.when` (8) → 9–11; + `AgileGate`, `fmtDay`, `fmtSp`, `tasksWord`, `boardState` (9) → 10–11; `columnsOf`, `BoardColumns` (10) → 11. +- **Пропорция:** код — только тесты и интерфейсы; тела функций не расписаны, кроме правил выборки `GET /agile`, которые + тестом однозначно не определяются. -- 2.47.2 From a6c62bbf82938302fd0460814baef39417dbaa2a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 18:27:54 +0400 Subject: [PATCH 03/21] =?UTF-8?q?OpenSpec=20sprints-backlog:=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D0=BD=D0=B0=D0=BC=20=D1=85=D0=B0=D0=B1=D0=B0=20=D0=B8=20?= =?UTF-8?q?=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/sprints-backlog/design.md | 3 +++ .../changes/sprints-backlog/specs/agent/spec.md | 13 +++++++------ .../changes/sprints-backlog/specs/web-app/spec.md | 7 ++++--- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/openspec/changes/sprints-backlog/design.md b/openspec/changes/sprints-backlog/design.md index 0cb841d..c76eca7 100644 --- a/openspec/changes/sprints-backlog/design.md +++ b/openspec/changes/sprints-backlog/design.md @@ -170,3 +170,6 @@ kanban) — свои задачи в статусах остальных кол принадлежности `ensure_rank`, формы полей. Цена ошибки — правка проверок. - 24.10.2026 — R-12 (design #21–#26): условные пункты меню, тип доски в `jira_project`, три состояния данных доски, даты в поясе браузера, история из базовой JQL, переименование «Доска связей». Цена ошибки — правка веба. +- 24.10.2026 — R-28 (план агента R-64, R-65): поле Jira не найдено — ключ у задач не передаётся (хаб сохраняет прежнее), + а не `null`; ошибка чтения списка спринтов — доска не входит в `boards` (иначе стёрлась бы история спринтов). + Цена ошибки — нет. diff --git a/openspec/changes/sprints-backlog/specs/agent/spec.md b/openspec/changes/sprints-backlog/specs/agent/spec.md index b357f85..8729207 100644 --- a/openspec/changes/sprints-backlog/specs/agent/spec.md +++ b/openspec/changes/sprints-backlog/specs/agent/spec.md @@ -5,13 +5,14 @@ `kanban` (известным или только что определённым) прочитать настройки доски (`GET /rest/agile/1.0/board/{id}/configuration`: колонки и их статусы — по именам статусов; признак kanban-бэклога, если Jira его отдаёт, иначе `null`) и, у `scrum`, все спринты доски любого состояния (`GET /rest/agile/1.0/board/{id}/sprint` постранично) и приложить к первой пачке -состояния `boards` (см. `hub-api`, «Приём досок и спринтов»). Ошибка чтения настроек или спринтов доски — доска с -`columns: null` и прежними правилами для спринтов (v12), причина в лог; `refresh` продолжается. +состояния `boards` (см. `hub-api`, «Приём досок и спринтов»). Ошибка чтения настроек доски — доска с `columns: null`, +`kanban_backlog: null`; ошибка чтения списка спринтов scrum-доски — доска в `boards` не входит (хаб сохраняет прежние +данные), имена спринтов задач — по правилам v12; причина в лог; `refresh` продолжается. Каждая задача в состоянии (с v15) SHALL нести `rank` — значение поля ранга Jira (поле типа LexoRank), `story_points` — -значение поля оценки (`field_ids.story_points`, если задано, иначе поле Jira с именем «Story Points»; поля нет — -`null`), и `sprint_ids` — id всех спринтов задачи из поля спринта Jira (любого состояния). Поле не найдено — значение -`null` у всех задач и запись в лог один раз за `refresh`. +значение поля оценки (`field_ids.story_points`, если задано, иначе поле Jira с именем «Story Points»), и `sprint_ids` — +id всех спринтов задачи из поля спринта Jira (любого состояния). Поле не найдено — ключ у задач не передаётся (хаб +сохраняет прежнее значение) и запись в лог один раз за `refresh`. Агент v15 SHALL выполнять `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank` и `story_points` в `ensure_fields` по контракту `commands`; сравнение — до записи, повтор без изменений — `done` без записи. @@ -23,5 +24,5 @@ #### Scenario: Нет поля оценки - **WHEN** в Jira нет поля «Story Points», `field_ids.story_points` не задан -- **THEN** у всех задач `story_points: null`; `ensure_fields {key: X, story_points: 3}` — `failed` «поле оценки не найдено +- **THEN** у задач нет ключа `story_points`; `ensure_fields {key: X, story_points: 3}` — `failed` «поле оценки не найдено в Jira: задайте field_ids.story_points» diff --git a/openspec/changes/sprints-backlog/specs/web-app/spec.md b/openspec/changes/sprints-backlog/specs/web-app/spec.md index 2a6edac..fb51e48 100644 --- a/openspec/changes/sprints-backlog/specs/web-app/spec.md +++ b/openspec/changes/sprints-backlog/specs/web-app/spec.md @@ -78,7 +78,8 @@ SHALL храниться в `localStorage` под ключом `sdlc-hub-sidebar Проекта из адреса нет в `GET /projects` — страница SHALL показывать «проект <метка> не найден» и подсказку выбрать проект в списке «Проект» боковой панели (на узком экране — в «Меню») вместо данных. Проектов нет — страницы SHALL показывать приглашение создать проект («+ Новый проект…»). Переходы между страницами SHALL сохранять текущий проект; -смена проекта на странице, которой нет у нового проекта, открывает «Обзор». Карточка задачи +смена проекта на странице, которой нет у нового проекта, открывает «Обзор»; исключение — «Бэклог», «Спринт», «Kanban»: +адрес остаётся, страница показывает «У проекта нет …-доски» (требование «Спринты и бэклог»). Карточка задачи (`/app/jira/issues/{key}`) от проекта не зависит. Доска SHALL загружать `GET /board?project=<текущий>`. Раскладка, дорожки и фильтры доски в `localStorage` SHALL @@ -138,8 +139,8 @@ SHALL храниться в `localStorage` под ключом `sdlc-hub-sidebar «Связи», «Задачи», «Лента» и по типу доски текущего проекта (`board_type` из `GET /projects`): `scrum` — «Бэклог» и «Спринт»; `kanban` — «Kanban»; доски нет или тип неизвестен — новых пунктов нет, а прямой переход на их адрес показывает «У проекта нет scrum-доски» («…kanban-доски» для «Kanban») со ссылкой на «Связи». Данные — `GET -/agile?project=<текущий>`; если доска есть, но её данных нет (`board.columns = null` или ответ без доски) — «Нужен агент -v15 и обновление из Jira» с кнопкой «Обновить» блока состояния модуля; данные доски есть, а колонок нет — «Не удалось +/agile?project=<текущий>`; если доска есть, но её данных от агента нет (`board = null` или `board.fetched_at = null`) — +«Нужен агент v15 и обновление из Jira» с кнопкой «Обновить» блока состояния модуля; данные доски есть (`fetched_at` задан), а колонок нет (`columns = null`) — «Не удалось прочитать настройки доски из Jira» и та же кнопка. Пока проекты не загружены, условные пункты меню скрыты; смена проекта на странице, которой у нового проекта нет, оставляет адрес и показывает сообщение «У проекта нет …-доски». Даты спринтов (ISO-8601 Jira) показываются «ДД.ММ» в часовом поясе браузера; «осталось N дн.» — от сегодня до конца спринта. -- 2.47.2 From ef48e62953a642f214f16f7f37d6859beb82c108 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 18:34:04 +0400 Subject: [PATCH 04/21] =?UTF-8?q?=D0=A1=D1=85=D0=B5=D0=BC=D0=B0=20v9:=20?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B3,=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=B0,=20=D0=B4=D0=BE=D1=81=D0=BA=D0=B8=20=D0=B8=20=D1=81?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=BD=D1=82=D1=8B=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- ARCHITECTURE.md | 7 +-- openspec/changes/sprints-backlog/design.md | 46 ++++++++++++++++++++ src/sdlc_hub/core/db.py | 7 +-- src/sdlc_hub/modules/jira/schema.py | 26 +++++++++++ tests/fixtures/baseline/schema.expected.json | 29 +++++++++++- tests/test_db.py | 41 +++++++++++++++-- 6 files changed, 145 insertions(+), 11 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1004f56..879b2ab 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -18,7 +18,7 @@ | `http` | общее для обработчиков HTTP: `ApiError`, `Raw`, разбор параметров, `mapped`, `not_found` | `errors`, `projects` | | `modules` | контракт модуля: `Module` и `CommandHooks` (только структуры) | stdlib | | `registry` | реестр модулей: ленивая сборка, `modules()`, `module(name)`, `module_of(тип_команды)`, `agent_modules()`, `DEFAULT_AGENT`, проверка конфликтов (типы команд, маршруты, ключи настроек, поля проекта) | `modules`; модули — ленивым импортом внутри функции | -| `db` | `connect`, схема ядра (`setting`, `batch`, `command`, `project`, `agent_status`) и схемы модулей из `Module.schema`, миграции по `schema_version` (v8; копия базы перед миграцией; история шагов `MIGRATIONS`) | `backup`, `paths`; модули — только внутри шагов истории миграций (ленивый импорт) | +| `db` | `connect`, схема ядра (`setting`, `batch`, `command`, `project`, `agent_status`) и схемы модулей из `Module.schema`, миграции по `schema_version` (v9; копия базы перед миграцией; история шагов `MIGRATIONS`) | `backup`, `paths`; модули — только внутри шагов истории миграций (ленивый импорт) | | `settings` | настройки (`setting`), токены (клиентский + по агенту на модуль: `setting.tokens = {client, agents: {<модуль>: <токен>}}`), умолчания модулей (`ensure_defaults`) | `registry` (база — через `conn` вызывающего) | | `projects` | проекты-метки: таблица `project` (`label`, `name`, `created_at`), проверка метки, создание и правка (`PATCH`) с полями модулей, список = поля ядра + `project_view` модулей | `db`, `registry` | | `queue` | механика очереди: пакеты, выдача (`claim`), отчёты, отмена, повтор, временные ключи `new:`; смысл типов команд берёт у модуля через `CommandHooks` (`registry.module_of`) | `backup`, `db`, `registry`, `settings`, `errors` | @@ -37,7 +37,7 @@ | Модуль | Ответственность | Зависит от | |---|---|---| -| `schema` | таблицы модуля: `issue`, `issue_link`, `issue_comment`, `issue_attachment`, `attachment_file`, `issue_change`, `issue_note`, `sync_run` и др. | stdlib | +| `schema` | таблицы модуля: `issue`, `issue_link`, `issue_comment`, `issue_attachment`, `attachment_file`, `issue_change`, `issue_note`, `sync_run`, `jira_board`, `jira_sprint`, `issue_sprint` и др. | stdlib | | `settings` | умолчания настроек модуля, ключи для агента, проверка значений, темп (`pacing`), перенос из `config.example.json` (`init_from_config`) | `core.settings`, `core.projects`, `projects` | | `mirror` | зеркало задач, связей, комментариев и вложений; пишет его только `apply_state` (с проверками `check_comments`, `check_attachments`), разрез по проекту, заметки, фильтры, снимок доски `board`, карточка `get_issue` | `core.db`, `history`, `attachments` | | `history` | журнал изменений `issue_change`: время Jira → UTC (`jira_utc`), проверка и запись пачек агента, `delivery_param` для выдачи `refresh`, лента `feed`, история задачи | `core.db`, `core.settings`, `projects` | @@ -152,10 +152,11 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк - `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `core/api`, `modules/*/api`, `client` и `relay` (реле — клиент своего же HTTP). - Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types` и набор закрытых статусов `closed_statuses` берёт из `GET /settings` и своих копий не держит. Модуль веба не импортирует другой модуль; общее — в `shell`, `ui`, `api`, `projects`. -## Выкатка и откат версии с новой схемой (v7, v8) +## Выкатка и откат версии с новой схемой (v7, v8, v9) - Порядок: `git pull` → сразу `launchctl kickstart -k gui/$(id -u)/ru.aepif.sdlc-hub` (владелец) → `make web` → `/mcp` reconnect. Старый фронт с новым API работает (поля `board_id` и прочие на месте), поэтому сборку можно отложить. - До kickstart не запускать MCP и CLI `sdlc-hub` (в том числе `tokens`): `HubClient.from_home()` и CLI открывают базу через `db.connect` и мигрируют её, а хаб на прежнем коде с такой базой не работает (v7 — `agent_status`, токены; v8 — нет колонки `issue_note.fact_status`: 500 на `GET /issues`, `/board`, запись заметки). - После kickstart MCP-сессии со старым кодом до reconnect открывают базу своими умолчаниями: ключи, удалённые миграцией, они могут вернуть — их убирает `ensure_defaults` нового кода (`Module.settings_obsolete`). +- v9 (sprints-backlog): колонки `issue.rank`, `issue.story_points` и таблицы `jira_board`, `jira_sprint`, `issue_sprint` добавляются миграцией; прежний код с базой v9 работает — новые колонки и таблицы он не читает, поэтому откат кода делается без возврата копии базы. - Откат на копию v6 из `backups/` (`*schema-v6*`): вместе с базой вернуть публикацию агента `release/jira/*` в `release/` (прежняя раскладка `release/agent.py`, `manifest.json`); иначе старый код увидит «публикации нет» и самообновление агента молчит. ## Открытые вопросы diff --git a/openspec/changes/sprints-backlog/design.md b/openspec/changes/sprints-backlog/design.md index c76eca7..89d62ed 100644 --- a/openspec/changes/sprints-backlog/design.md +++ b/openspec/changes/sprints-backlog/design.md @@ -173,3 +173,49 @@ kanban) — свои задачи в статусах остальных кол - 24.10.2026 — R-28 (план агента R-64, R-65): поле Jira не найдено — ключ у задач не передаётся (хаб сохраняет прежнее), а не `null`; ошибка чтения списка спринтов — доска не входит в `boards` (иначе стёрлась бы история спринтов). Цена ошибки — нет. +- 24.10.2026 — 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` в конец + пакета). +- 24.10.2026 — R-14 (форма `boards`): у доски в `boards` поле `sprints` отсутствует или `null` — спринты доски в + зеркале не меняются (агент не смог их прочитать, agent spec «прежние правила для спринтов»); `[]` — у доски спринтов + нет, строки удаляются. `columns` отсутствует или `null` — пишется `NULL`. `boards: null` в пачке — как отсутствие. + Цена ошибки — устаревшие спринты на экране до следующего `refresh` с `sprints: []`. +- 24.10.2026 — 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`. Цена ошибки — правка проверок. +- 24.10.2026 — R-16 (подтверждение по зеркалу): `ensure_fields` со `story_points` подтверждается, только если + `issue.story_points == params.story_points` точно (`null` = `null`, `0` ≠ `null`); прочие поля — как раньше. Цена + ошибки — лишняя довыдача. +- 24.10.2026 — 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`). Цена ошибки — правка + выборки. +- 24.10.2026 — R-18 (Kanban с бэклогом): при `kanban_backlog = true` первая колонка доски не рисуется — её задачи в + «Бэклог (N)» (как kanban-бэклог Jira). Цена ошибки — вернуть колонку. +- 24.10.2026 — R-19 (три состояния): «Нужен агент v15 и обновление из Jira» — `board = null` или `board.fetched_at = + null`; «Не удалось прочитать настройки доски из Jira» — `fetched_at` есть, `columns = null` (design #23 вместо + противоречивой фразы web-app «`board.columns = null` или ответ без доски»). Цена ошибки — правка условия. +- 24.10.2026 — R-20 (кнопка «Обновить»): на экранах — существующая `RefreshButton` («Обновить из Jira», в шапке и в + сообщениях трёх состояний). Правило «кнопок нет, кроме раскрытия секций и карточки» к ней не относится: она читает + Jira, а не пишет. Цена ошибки — убрать кнопку из шапки. +- 24.10.2026 — R-21 (переименование): «Связи» — подпись пункта меню, заголовок вкладки и ссылка в карточке jira на + «Обзоре»; заголовок `h1` страницы остаётся «Доска связей» (спека говорит о меню и вкладке). Цена ошибки — правка + `BoardHeader` и e2e `geometry.spec.ts`. +- 24.10.2026 — R-22 (смена проекта): фраза «Проект в приложении» («смена проекта на странице, которой нет у нового + проекта, открывает «Обзор»») не меняется в коде: других условных страниц нет, а для экранов доски действует design + #21 (адрес остаётся, сообщение «У проекта нет …-доски») — `Layout.select` уже сохраняет раздел. Цена ошибки — + переход на «Обзор» для новых страниц. +- 24.10.2026 — R-23 (NotFound): «не найдено» перечисляет только безусловные пункты (без `when`). Цена ошибки — нет. +- 24.10.2026 — R-24 (склонение): «N задач» — с русским склонением (1 задача, 2 задачи, 5 задач). Цена ошибки — нет. +- 24.10.2026 — R-25 (тексты MCP): шапки таблиц и отказы `sprints`/`sprint`/`backlog` — по задаче 7. Цена ошибки — + правка текста. +- 24.10.2026 — R-26 (даты): хаб сортирует спринты по `history.jira_utc(дата)`; веб перед `new Date` приводит `±HHMM` к + `±HH:MM`. Цена ошибки — нет. +- 24.10.2026 — R-27 (e2e): тестовый хаб засевает второй проект `KANBAN_E2E` (доска 5120, kanban, задачи + AURORA-9101…9103); доска AWPCA — scrum со спринтами 101 (закрыт), 102 (активный), 103 (будущий). Цена ошибки — + правка сида. +- 24.10.2026 — R-29: трейлер коммитов — Co-Authored-By: Claude Sonnet 5.5 (инструкция сессии точнее перечня плана). Цена ошибки — переписать трейлеры, ничего не пушилось. diff --git a/src/sdlc_hub/core/db.py b/src/sdlc_hub/core/db.py index 45a88fc..b4b34b1 100644 --- a/src/sdlc_hub/core/db.py +++ b/src/sdlc_hub/core/db.py @@ -8,7 +8,7 @@ from pathlib import Path from . import backup, paths -SCHEMA_VERSION = 8 +SCHEMA_VERSION = 9 CORE_SCHEMA = """ CREATE TABLE IF NOT EXISTS setting ( @@ -116,11 +116,11 @@ def schema() -> str: return CORE_SCHEMA + "".join(m.schema for m in registry.modules()) -def _add_column(table: str, column: str): +def _add_column(table: str, column: str, decl: str = "TEXT"): """Шаг миграции: добавить колонку, если её нет. Базы v1/v2 получают issue_comment из схемы уже с новой колонкой.""" def step(conn) -> None: if column not in {r["name"] for r in conn.execute(f"PRAGMA table_info({table})")}: - conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} TEXT") + conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {decl}") return step @@ -136,6 +136,7 @@ MIGRATIONS = { 6: [_tokens_v7, _agent_status_v7, _project_fields_v7], # v6 → v7: токены и статус по агентам модулей (wiki-module, решение 15) 7: [_note_fields_v8], # v7 → v8: без статуса по факту и его настроек (fact-status-removal) + 8: [_add_column("issue", "rank"), _add_column("issue", "story_points", "REAL")], # v8 → v9: ранг и оценка задачи; таблицы досок и спринтов создаются схемой (sprints-backlog) } diff --git a/src/sdlc_hub/modules/jira/schema.py b/src/sdlc_hub/modules/jira/schema.py index e728f11..44c4fc6 100644 --- a/src/sdlc_hub/modules/jira/schema.py +++ b/src/sdlc_hub/modules/jira/schema.py @@ -17,6 +17,8 @@ CREATE TABLE IF NOT EXISTS issue ( epic_key TEXT, labels TEXT NOT NULL DEFAULT '[]', sprint TEXT, + rank TEXT, + story_points REAL, created TEXT, jira_updated TEXT, fetched_at TEXT NOT NULL, @@ -102,4 +104,28 @@ CREATE TABLE IF NOT EXISTS jira_project ( board_id INTEGER, board_type TEXT ); +CREATE TABLE IF NOT EXISTS jira_board ( + board_id INTEGER PRIMARY KEY, + type TEXT, + columns TEXT, + kanban_backlog INTEGER, + fetched_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS 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 +); +CREATE INDEX IF NOT EXISTS ix_jira_sprint_board ON jira_sprint(board_id); +CREATE TABLE IF NOT EXISTS issue_sprint ( + key TEXT NOT NULL, + sprint_id INTEGER NOT NULL, + PRIMARY KEY (key, sprint_id) +); +CREATE INDEX IF NOT EXISTS ix_issue_sprint_sprint ON issue_sprint(sprint_id); """ diff --git a/tests/fixtures/baseline/schema.expected.json b/tests/fixtures/baseline/schema.expected.json index c7288d8..ca2c5fe 100644 --- a/tests/fixtures/baseline/schema.expected.json +++ b/tests/fixtures/baseline/schema.expected.json @@ -25,6 +25,16 @@ "ix_issue_link_target", "CREATE INDEX ix_issue_link_target ON issue_link(target_key)" ], + [ + "index", + "ix_issue_sprint_sprint", + "CREATE INDEX ix_issue_sprint_sprint ON issue_sprint(sprint_id)" + ], + [ + "index", + "ix_jira_sprint_board", + "CREATE INDEX ix_jira_sprint_board ON jira_sprint(board_id)" + ], [ "table", "agent_status", @@ -48,7 +58,7 @@ [ "table", "issue", - "CREATE TABLE issue (\n key TEXT PRIMARY KEY,\n id TEXT,\n issue_type TEXT,\n summary TEXT,\n description TEXT,\n status TEXT,\n priority TEXT,\n assignee TEXT,\n reporter TEXT,\n duedate TEXT,\n planned_start TEXT,\n planned_end TEXT,\n epic_key TEXT,\n labels TEXT NOT NULL DEFAULT '[]',\n sprint TEXT,\n created TEXT,\n jira_updated TEXT,\n fetched_at TEXT NOT NULL,\n neighbor INTEGER NOT NULL DEFAULT 0\n)" + "CREATE TABLE issue (\n key TEXT PRIMARY KEY,\n id TEXT,\n issue_type TEXT,\n summary TEXT,\n description TEXT,\n status TEXT,\n priority TEXT,\n assignee TEXT,\n reporter TEXT,\n duedate TEXT,\n planned_start TEXT,\n planned_end TEXT,\n epic_key TEXT,\n labels TEXT NOT NULL DEFAULT '[]',\n sprint TEXT,\n rank TEXT,\n story_points REAL,\n created TEXT,\n jira_updated TEXT,\n fetched_at TEXT NOT NULL,\n neighbor INTEGER NOT NULL DEFAULT 0\n)" ], [ "table", @@ -75,11 +85,26 @@ "issue_note", "CREATE TABLE issue_note (\n key TEXT PRIMARY KEY,\n work_kind TEXT,\n done_text TEXT,\n rest_text TEXT,\n owner_note TEXT,\n updated_at TEXT,\n updated_by TEXT\n)" ], + [ + "table", + "issue_sprint", + "CREATE TABLE issue_sprint (\n key TEXT NOT NULL,\n sprint_id INTEGER NOT NULL,\n PRIMARY KEY (key, sprint_id)\n)" + ], + [ + "table", + "jira_board", + "CREATE TABLE jira_board (\n board_id INTEGER PRIMARY KEY,\n type TEXT,\n columns TEXT,\n kanban_backlog INTEGER,\n fetched_at TEXT NOT NULL\n)" + ], [ "table", "jira_project", "CREATE TABLE jira_project (\n label TEXT PRIMARY KEY REFERENCES project(label),\n board_id INTEGER,\n board_type TEXT\n)" ], + [ + "table", + "jira_sprint", + "CREATE TABLE jira_sprint (\n sprint_id INTEGER PRIMARY KEY,\n board_id INTEGER NOT NULL,\n name TEXT NOT NULL,\n state TEXT NOT NULL,\n start_date TEXT,\n end_date TEXT,\n complete_date TEXT,\n goal TEXT\n)" + ], [ "table", "project", @@ -116,5 +141,5 @@ "CREATE TABLE wiki_sync_stage (\n command_id INTEGER NOT NULL,\n code TEXT NOT NULL,\n payload TEXT NOT NULL,\n PRIMARY KEY (command_id, code)\n)" ] ], - "schema_version": 8 + "schema_version": 9 } diff --git a/tests/test_db.py b/tests/test_db.py index 3c67f36..4b86003 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -19,6 +19,8 @@ def test_connect_creates_schema(open_db): assert "created_at" in {r["name"] for r in conn.execute("PRAGMA table_info(issue_comment)")} assert "history_at" in {r["name"] for r in conn.execute("PRAGMA table_info(sync_run)")} assert "project" in {r["name"] for r in conn.execute("PRAGMA table_info(batch)")} + assert {"jira_board", "jira_sprint", "issue_sprint"} <= names + assert {"rank", "story_points"} <= {r["name"] for r in conn.execute("PRAGMA table_info(issue)")} assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == str(db.SCHEMA_VERSION) assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 2 cols = {r["name"] for r in conn.execute("PRAGMA table_info(command)")} @@ -402,7 +404,7 @@ def test_migrates_v7_drops_fact_status(tmp_path): path = tmp_path / "hub.sqlite" _v7_database(path) conn = db.connect(path) - assert db._schema_version(conn) == 8 + assert db._schema_version(conn) == db.SCHEMA_VERSION assert _note_after_v8(conn, "A-1") == "Аналитика" row = conn.execute("SELECT updated_at, updated_by FROM issue_note WHERE key = 'A-1'").fetchone() assert (row["updated_at"], row["updated_by"]) == ("2026-10-20T00:00:00+00:00", "import") @@ -416,7 +418,7 @@ def test_migrates_v7_without_keys_keeps_settings_version(tmp_path): path = tmp_path / "hub.sqlite" _v7_database(path, with_keys=False) conn = db.connect(path) - assert db._schema_version(conn) == 8 and settings.get(conn, "settings_version") == 12 + assert db._schema_version(conn) == db.SCHEMA_VERSION and settings.get(conn, "settings_version") == 12 conn.close() @@ -428,7 +430,7 @@ def test_migrates_v7_without_settings_version(tmp_path): raw.commit() raw.close() conn = db.connect(path) - assert db._schema_version(conn) == 8 + assert db._schema_version(conn) == db.SCHEMA_VERSION assert conn.execute("SELECT COUNT(*) AS n FROM setting WHERE key = 'settings_version'").fetchone()["n"] == 0 conn.close() @@ -457,3 +459,36 @@ def test_obsolete_keys_removed_by_ensure_defaults(tmp_path): assert {"status_labels", "transitions_map"}.isdisjoint(settings.get_all(conn)) assert settings.get(conn, "settings_version") == version conn.close() + + +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 -- 2.47.2 From 4f9024d5c38633dd3ddd5caa9f58e7a7f9538028 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 18:39:56 +0400 Subject: [PATCH 05/21] =?UTF-8?q?=D0=9F=D1=80=D0=B8=D1=91=D0=BC=20=D0=B4?= =?UTF-8?q?=D0=BE=D1=81=D0=BE=D0=BA,=20=D1=81=D0=BF=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D1=82=D0=BE=D0=B2,=20=D1=80=D0=B0=D0=BD=D0=B3=D0=B0=20=D0=B8?= =?UTF-8?q?=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BE=D0=BA=20=D0=BE=D1=82=20=D0=B0?= =?UTF-8?q?=D0=B3=D0=B5=D0=BD=D1=82=D0=B0=20v15=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- src/sdlc_hub/modules/jira/api.py | 5 +- src/sdlc_hub/modules/jira/mirror.py | 101 +++++++++++++++++++++++++++- tests/test_api.py | 44 ++++++++++++ tests/test_mirror.py | 88 ++++++++++++++++++++++++ 5 files changed, 235 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a012c99..6fa8a96 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ uv run sdlc-hub agent publish --module wiki ../eva-agent/src/agent.py | `GET /agent/settings` | агент | настройки для агента (без токенов) | | `GET /agent/commands/{id}` | агент | команда по id | | `POST /agent/commands/{id}/result` | агент | отчёт о выполнении (идемпотентно) | -| `POST /agent/state` | агент | загрузка состояния задач пачками | +| `POST /agent/state` | агент | загрузка состояния задач пачками; агент v15 — у задач `rank`, `story_points`, `sprint_ids` (заменяет спринты задачи), в пачке `boards` (доска заменяет свою строку и, если `sprints` — список, свои спринты; тип доски пишется проектам с этой доской); без полей — данные прежние; неверная форма — 422 `bad_state` | | `POST /agent/history` | агент | журнал изменений Jira пачками (агент v13+, по `history` в `refresh`) → `{accepted, duplicates}`; повторы отбрасываются, неверная пачка — 422 `bad_history` | | `GET /agent/known-keys` | агент | ключи зеркала | | `GET /agent/release` | агент | опубликованная версия агента `{version, sha256, content}`; 404 `no_release` | diff --git a/src/sdlc_hub/modules/jira/api.py b/src/sdlc_hub/modules/jira/api.py index c001c6d..74665b3 100644 --- a/src/sdlc_hub/modules/jira/api.py +++ b/src/sdlc_hub/modules/jira/api.py @@ -22,14 +22,17 @@ _OFFSET = re.compile(r"(Z|[+-]\d{2}:?\d{2})$") def h_agent_state(hub, conn, m, q, body): issues = body.get("issues") or [] + boards = body.get("boards") try: mirror.check_comments(issues) # до записи: неверная пачка не применяется целиком mirror.check_attachments(issues) + mirror.check_agile_fields(issues) + mirror.check_boards(boards) except ValueError as e: raise ApiError(422, "bad_state", str(e)) with db.transaction(conn): out = mirror.apply_state(conn, issues, body.get("links") or [], body.get("neighbor_keys") or [], - authoritative=refresh.is_full_refresh(conn, body.get("command_id"))) + authoritative=refresh.is_full_refresh(conn, body.get("command_id")), boards=boards) if body.get("command_id"): refresh.record_state(conn, body["command_id"], out["issues"], out["links"]) return 200, out diff --git a/src/sdlc_hub/modules/jira/mirror.py b/src/sdlc_hub/modules/jira/mirror.py index be3cd08..eb1ec25 100644 --- a/src/sdlc_hub/modules/jira/mirror.py +++ b/src/sdlc_hub/modules/jira/mirror.py @@ -1,13 +1,15 @@ """Зеркало задач Jira (issue, issue_link, issue_comment, issue_attachment) и служебные заметки (issue_note). Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает.""" import json +import math -from . import attachments, history +from . import attachments, history, projects from ...core import db ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter", "duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"] NOTE_FIELDS = ["work_kind", "done_text", "rest_text", "owner_note"] +SPRINT_STATES = ("active", "future", "closed") def check_comments(issues) -> None: """Форма поля comments у задач состояния (агент v10+); поле необязательно. Нарушение — ValueError с ключом и полем.""" @@ -53,16 +55,87 @@ def check_attachments(issues) -> None: raise ValueError(f"{where}.{f} — строка или null") +def check_agile_fields(issues) -> None: + """Форма полей rank, story_points, sprint_ids у задач состояния (агент v15+); поля необязательны (не присланное + не проверяется). Нарушение — ValueError с ключом и полем.""" + for it in issues: + key = it.get("key") + if "rank" in it and it["rank"] is not None and not isinstance(it["rank"], str): + raise ValueError(f"{key}: rank — строка или null") + if "story_points" in it and it["story_points"] is not None: + sp = it["story_points"] + if isinstance(sp, bool) or not isinstance(sp, (int, float)) or not math.isfinite(sp): + raise ValueError(f"{key}: story_points — число или null") + if "sprint_ids" in it: + ids = it["sprint_ids"] + if not isinstance(ids, list) or any(isinstance(i, bool) or not isinstance(i, int) for i in ids): + raise ValueError(f"{key}: sprint_ids — список целых") + + +def _is_id(v) -> bool: + return isinstance(v, int) and not isinstance(v, bool) and v > 0 + + +def check_boards(boards) -> None: + """Форма списка boards состояния (агент v15+); None — доски не присланы. Нарушение — ValueError с доской и полем.""" + if boards is None: + return + if not isinstance(boards, list): + raise ValueError("boards — список досок") + for n, b in enumerate(boards): + if not isinstance(b, dict): + raise ValueError(f"boards[{n}] — объект доски") + if not _is_id(b.get("id")): + raise ValueError(f"boards[{n}].id — целое > 0") + where = f"доска {b['id']}: " + if b.get("type") is not None and b["type"] not in projects.BOARD_TYPES: + raise ValueError(where + "type — scrum, kanban или null") + cols = b.get("columns") + if cols is not None: + if not isinstance(cols, list): + raise ValueError(where + "columns — список колонок или null") + for k, c in enumerate(cols): + if not isinstance(c, dict) or not isinstance(c.get("name"), str): + raise ValueError(where + f"columns[{k}].name — строка") + st = c.get("statuses") + if not isinstance(st, list) or any(not isinstance(s, str) for s in st): + raise ValueError(where + f"columns[{k}].statuses — список строк") + if b.get("kanban_backlog") is not None and not isinstance(b["kanban_backlog"], bool): + raise ValueError(where + "kanban_backlog — true, false или null") + sprints = b.get("sprints") + if sprints is None: + continue + if not isinstance(sprints, list): + raise ValueError(where + "sprints — список спринтов или null") + for k, s in enumerate(sprints): + w = where + f"sprints[{k}]" + if not isinstance(s, dict): + raise ValueError(f"{w} — объект спринта") + if not _is_id(s.get("id")): + raise ValueError(f"{w}.id — целое > 0") + if not isinstance(s.get("name"), str) or not s["name"]: + raise ValueError(f"{w}.name — непустая строка") + if s.get("state") not in SPRINT_STATES: + raise ValueError(f"{w}.state — active, future или closed") + for f in ("start_date", "end_date", "complete_date", "goal"): + if s.get(f) is not None and not isinstance(s[f], str): + raise ValueError(f"{w}.{f} — строка или null") + + _FILTERS = {"sprint": "i.sprint = ?", "assignee": "i.assignee = ?", "status": "i.status = ?", "epic": "i.epic_key = ?", "work_kind": "n.work_kind = ?"} -def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authoritative=True) -> dict: +def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authoritative=True, boards=None) -> dict: """Upsert задач; связи с source_key среди присланных задач заменяются присланными. Связи, у которых источник не прислан, не трогаются до загрузки их источника. authoritative=True — загрузка по базовой JQL: флаг neighbor пересчитывается по neighbor_keys (задача, вошедшая в базовую выборку, перестаёт быть соседней). authoritative=False — точечное обновление (по ключам, по - произвольной JQL): флаг только поднимается, никогда не снижается.""" + произвольной JQL): флаг только поднимается, никогда не снижается. + Поля v15 (агент v15+): у задачи rank, story_points — пишутся, только если ключ есть в словаре задачи (null очищает); + sprint_ids — список заменяет спринты задачи в issue_sprint. boards — список досок: доска заменяет свою строку + jira_board и, если sprints — список, свои спринты (sprints null/нет — спринты не трогаются); тип доски + записывается проектам с этой доской. Без поля или без boards прежние данные не меняются (агент v14).""" fetched_at = fetched_at or db.now() neighbors = set(neighbor_keys) keys = [] @@ -72,6 +145,9 @@ def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authorit row["labels"] = json.dumps(row.get("labels") or [], ensure_ascii=False) row["fetched_at"] = fetched_at row["neighbor"] = 1 if it["key"] in neighbors else 0 + for f in ("rank", "story_points"): + if f in it: + row[f] = it[f] cols = list(row) sets = [f"{c} = excluded.{c}" for c in cols if c not in ("key", "neighbor")] sets.append("neighbor = excluded.neighbor" if authoritative else "neighbor = MAX(issue.neighbor, excluded.neighbor)") @@ -80,6 +156,10 @@ def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authorit "ON CONFLICT(key) DO UPDATE SET " + ", ".join(sets), [row[c] for c in cols]) keys.append(it["key"]) + if isinstance(it.get("sprint_ids"), list): # без поля (агент до v15) спринты задачи не трогаем + conn.execute("DELETE FROM issue_sprint WHERE key = ?", (it["key"],)) + conn.executemany("INSERT OR IGNORE INTO issue_sprint(key, sprint_id) VALUES (?, ?)", + [(it["key"], sid) for sid in it["sprint_ids"]]) if isinstance(it.get("comments"), list): # без поля (агент до v10) комментарии не трогаем conn.execute("DELETE FROM issue_comment WHERE key = ?", (it["key"],)) conn.executemany( @@ -98,6 +178,21 @@ def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authorit for link in links: conn.execute("INSERT OR IGNORE INTO issue_link(source_key, target_key, link_type) VALUES (?, ?, ?)", (link["source_key"], link["target_key"], link["link_type"])) + for b in boards or []: + conn.execute( + "INSERT OR REPLACE INTO jira_board(board_id, type, columns, kanban_backlog, fetched_at) VALUES (?, ?, ?, ?, ?)", + (b["id"], b.get("type"), + json.dumps(b["columns"], ensure_ascii=False) if b.get("columns") is not None else None, + None if b.get("kanban_backlog") is None else int(b["kanban_backlog"]), fetched_at)) + if isinstance(b.get("sprints"), list): # null/нет — спринты доски не меняются (R-14) + conn.execute("DELETE FROM jira_sprint WHERE board_id = ?", (b["id"],)) + conn.executemany( + "INSERT OR REPLACE INTO jira_sprint(sprint_id, board_id, name, state, start_date, end_date, complete_date, goal) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [(s["id"], b["id"], s["name"], s["state"], s.get("start_date"), s.get("end_date"), + s.get("complete_date"), s.get("goal")) for s in b["sprints"]]) + if b.get("type") in projects.BOARD_TYPES: + projects.record_board_types(conn, {str(b["id"]): b["type"]}) return {"issues": len(keys), "links": len(links)} diff --git a/tests/test_api.py b/tests/test_api.py index 6abe76b..94300bb 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1221,3 +1221,47 @@ def test_sweep_files_downloads(hub): finally: conn.close() assert not d.exists() + + +@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): + with pytest.raises(HubError) as e: + hub["agent"].post("/agent/state", {"issues": [], "links": [], "boards": boards}) + assert e.value.status == 422 and e.value.error == "bad_state" and detail in e.value.detail + c = hub["hub"].conn() + try: + assert c.execute("SELECT COUNT(*) FROM jira_board").fetchone()[0] == 0 + finally: + c.close() + + +def test_agent_state_empty_batch_with_boards(hub): + b = {"id": 4216, "type": "scrum", "columns": [{"name": "К", "statuses": ["Backlog"]}], "kanban_backlog": None, + "sprints": [{"id": 101, "name": "S1", "state": "active", "start_date": None, "end_date": None, + "complete_date": None, "goal": None}]} + assert hub["agent"].post("/agent/state", {"issues": [], "links": [], "boards": [b]}) == {"issues": 0, "links": 0} + c = hub["hub"].conn() + try: + assert c.execute("SELECT type FROM jira_board WHERE board_id = 4216").fetchone()[0] == "scrum" + assert c.execute("SELECT COUNT(*) FROM jira_sprint").fetchone()[0] == 1 + finally: + c.close() diff --git a/tests/test_mirror.py b/tests/test_mirror.py index 6baec01..78c5558 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -1,3 +1,5 @@ +import json + import pytest from sdlc_hub.core import db @@ -406,3 +408,89 @@ def test_get_issue_commands_skip_fetch(conn): conn.execute("INSERT INTO command(batch_id, seq, type, params) VALUES (1, ?, ?, ?)", (seq, typ, '{"key": "A-1", "attachment_id": "1"}')) assert [c["type"] for c in mirror.get_issue(conn, "A-1")["commands"]] == ["ensure_fields"] + + +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) + + +def test_check_agile_fields_ok_and_absent(): + mirror.check_agile_fields([{"key": "A"}, {"key": "B", "rank": None, "story_points": 0.5, "sprint_ids": []}, + {"key": "C", "rank": "r", "story_points": 3, "sprint_ids": [1, 2]}]) + + +def test_check_boards_ok_and_none(): + mirror.check_boards(None) + mirror.check_boards([board(sprints=[sprint(1, "closed", start_date="2026-10-01")]), {"id": 5, "type": None}]) -- 2.47.2 From 1f78d70a022b4d6368f337a4887bdc99999682d8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 18:48:14 +0400 Subject: [PATCH 06/21] =?UTF-8?q?GET=20/agile:=20=D0=B4=D0=BE=D1=81=D0=BA?= =?UTF-8?q?=D0=B0,=20=D1=81=D0=BF=D1=80=D0=B8=D0=BD=D1=82=D1=8B=20=D0=B8?= =?UTF-8?q?=20=D0=B1=D1=8D=D0=BA=D0=BB=D0=BE=D0=B3=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B0=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- ARCHITECTURE.md | 5 +- README.md | 1 + src/sdlc_hub/modules/jira/agile.py | 105 ++++++++++++ src/sdlc_hub/modules/jira/api.py | 10 +- tests/fixtures/baseline/routes.expected.json | 6 + tests/test_agile.py | 163 +++++++++++++++++++ 6 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 src/sdlc_hub/modules/jira/agile.py create mode 100644 tests/test_agile.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 879b2ab..491bdd2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -47,7 +47,8 @@ | `refresh` | журнал синхронизаций `sync_run`, полный прогон, автообновление | `core.db`, `core.queue`, `core.settings`, `projects` | | `projects` | базовая JQL из меток (`base_jql`), доски (`boards`), миграция v3 → v4 (`migrate_v3`), проект по умолчанию | `core.projects`, `core.settings` | | `hooks` | `CommandHooks` для очереди ядра: проверки постановки (`bug` без настройки, файл вложения, проект пакета, `board_id` у `ensure_sprint`), паузы, `deliver_params` (`size`, `boards`, `history`), отчёты (`attachment_file`, `sync_run`) | `core.queue`, `core.files`, `core.projects`, `core.settings`, `settings`, `projects`, `commands`, `mirror`, `history`, `refresh`, `attachments`, `fetch` | -| `api` | маршруты модуля `MODULE.routes`: `/issues`, `/board`, `/changes`, `/attachments`, `/refresh`, `/sync-runs` и др. | `core.db`, `core.files`, `core.queue`, `core.settings`, `core.http`, `core.errors`, `attachments`, `projects`, `mirror`, `history`, `fetch`, `refresh` | +| `agile` | чтение для `GET /agile`: доска, спринты, бэклог, выполнено по последней колонке | `core.db`, `history` | +| `api` | маршруты модуля `MODULE.routes`: `/issues`, `/board`, `/agile`, `/changes`, `/attachments`, `/refresh`, `/sync-runs` и др. | `core.db`, `core.files`, `core.queue`, `core.settings`, `core.http`, `core.errors`, `attachments`, `projects`, `mirror`, `history`, `fetch`, `refresh` | | `mcp` | инструменты MCP: `issues`, `issue`, `batch_create`, `batch_*`, `comment`, `attachment`, `changes`, `refresh`, `settings_*` и др.; общие `projects`/`project_create` подключает `register_core_tools` | `core.mcp`, `core.client`, `commands`, `attachments` | | `importer`, `cli` | разовый импорт CSV-пакета в заметки (`sdlc-hub import FOLDER`) | `mirror`, `core.db`, `core.settings` | @@ -120,7 +121,7 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк Цепочки зависимостей (Python): - ядро: `api → queue → settings/projects/files/registry → db → backup → paths`; `api → relay → settings`; `api → release → paths`; `files → paths`; `cli → api/mcp/registry`; `mcp → client → HTTP API`; `registry → (лениво) modules.*`; - модуль wiki: `commands → stage/sync/attachments → convert/search/paths → core.*`; `projects → commands/paths`; `api → stage/attachments/search → core.http/core.queue`; `tick → commands/sync`; -- модуль jira: `hooks → commands/mirror/history/refresh/attachments/fetch → core.*`; `mirror → history → projects`; `mirror → attachments → core.files`; `api → mirror/history/fetch/refresh → core.http/core.queue`; `importer → mirror`; +- модуль jira: `hooks → commands/mirror/history/refresh/attachments/fetch → core.*`; `mirror → history → projects`; `mirror → attachments → core.files`; `api → mirror/history/fetch/refresh → core.http/core.queue`; `api → agile → history`; `importer → mirror`; - веб: `shell → modules/index → modules/jira, modules/wiki → ui/api/projects` (модуль не импортирует оболочку, кроме типов контракта). ## Потоки данных diff --git a/README.md b/README.md index 6fa8a96..048bc16 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,7 @@ uv run sdlc-hub agent publish --module wiki ../eva-agent/src/agent.py | `GET /issues/{key}` | клиент | задача целиком, с комментариями и журналом изменений `changes` (новые сверху) | | `GET /changes` | клиент | лента изменений: `since`, `until` (ISO со смещением или `Z`, иначе 422; по умолчанию — последние 24 ч), `project` (только свои задачи), `key`, `kinds`, `limit` (до 2000) → `{since, until, truncated, changes}` | | `GET /board` | клиент | `?project=<метка>` (неизвестная — 404): снимок для доски — свои задачи проекта, соседи (связи в обе стороны) и эпики своих задач, с `work_kind` из заметок; связи — те, у которых хотя бы один конец — своя задача проекта (без `project` — все задачи и связи), `last_sync` — последний успешный полный прогон | +| `GET /agile` | клиент | `?project=<метка>` (без — 422, неизвестная — 404): доска проекта (`board`, тип из проекта; `null` — доски нет), все спринты доски со своими задачами и суммами оценок (`count`, `count_done`, `unestimated`, `points`, `points_done`), `backlog` (scrum — свои задачи вне активных и будущих спринтов, не выполненные; kanban — первая колонка, если бэклог доски включён), `board_issues` (kanban: остальные колонки, выполненные — за 14 дней); списки — по рангу | | `GET /`, `GET /links` | — | перенаправление на `/app/` («Обзор») | | `PUT /issues/{key}/note` | клиент | заметки (частично) | | `POST /batches`, `GET /batches`, `GET /batches/{id}` | клиент | пакеты команд; `project` в теле `POST /batches` — пакет проекта (проверки метки, ограничение задач проектом), `?project=` у `GET /batches` — только пакеты проекта, `?module=<имя>` — только пакеты модуля (по типу первой команды; неизвестный модуль — 404), `limit`; в каждом пакете поле `module` (`null` — не определён) | diff --git a/src/sdlc_hub/modules/jira/agile.py b/src/sdlc_hub/modules/jira/agile.py new file mode 100644 index 0000000..6beb44a --- /dev/null +++ b/src/sdlc_hub/modules/jira/agile.py @@ -0,0 +1,105 @@ +"""Чтение для GET /agile: доска проекта, спринты со своими задачами и суммами оценок, бэклог. Только чтение зеркала; +время Jira сравнивается в UTC через history.jira_utc (смещения ±HHMM и ±HH:MM, Z).""" +import json +from datetime import datetime, timedelta, timezone + +from . import history + +KANBAN_DONE_DAYS = 14 + +_FIELDS = ("key", "summary", "issue_type", "status", "priority", "assignee", "epic_key", "story_points", "rank") + + +def _rank_key(row: dict): + return (row["rank"] is None, row["rank"] or "", row["key"]) + + +def _sprint_order(sprints: list[dict]) -> list[dict]: + """active — по началу (без даты в конце), future — по id, closed — по закрытию по убыванию (без даты в конце), при равенстве — id.""" + far = "9999-12-31T23:59:59+00:00" + + def ts(value): + return history.jira_utc(value) + + active = sorted((s for s in sprints if s["state"] == "active"), key=lambda s: (ts(s["start_date"]) or far, s["id"])) + future = sorted((s for s in sprints if s["state"] == "future"), key=lambda s: s["id"]) + closed = [s for s in sprints if s["state"] == "closed"] + dated = sorted((s for s in closed if ts(s["complete_date"])), + key=lambda s: (-datetime.fromisoformat(ts(s["complete_date"])).timestamp(), s["id"])) + undated = sorted((s for s in closed if not ts(s["complete_date"])), key=lambda s: s["id"]) + other = [s for s in sprints if s["state"] not in ("active", "future", "closed")] + return active + future + dated + undated + sorted(other, key=lambda s: s["id"]) + + +def view(conn, label: str, now: datetime | None = None) -> dict: + now = now or datetime.now(timezone.utc) + own = not conn.in_transaction + if own: + conn.execute("BEGIN") + try: + return _view(conn, label, now) + finally: + if own: + conn.execute("COMMIT") + + +def _view(conn, label: str, now: datetime) -> dict: + proj = conn.execute("SELECT board_id, board_type FROM jira_project WHERE label = ?", (label,)).fetchone() + if proj is None or proj["board_id"] is None: + return {"board": None, "sprints": [], "backlog": None, "board_issues": None} + board_id, btype = proj["board_id"], proj["board_type"] + brow = conn.execute("SELECT * FROM jira_board WHERE board_id = ?", (board_id,)).fetchone() + columns = json.loads(brow["columns"]) if brow and brow["columns"] else None + kb = brow["kanban_backlog"] if brow else None + board = {"id": board_id, "type": btype, "columns": columns, + "kanban_backlog": None if kb is None else bool(kb), "fetched_at": brow["fetched_at"] if brow else None} + if btype is None: + return {"board": board, "sprints": [], "backlog": None, "board_issues": None} + + done_statuses = set(columns[-1]["statuses"]) if columns else set() + rows = [] + for r in conn.execute( + "SELECT i.key, i.summary, i.issue_type, i.status, i.priority, i.assignee, i.epic_key, i.story_points, i.rank, " + "i.jira_updated FROM issue i WHERE EXISTS (SELECT 1 FROM json_each(i.labels) WHERE value = ?)", (label,)): + d = dict(r) + d["done"] = d["status"] in done_statuses + rows.append(d) + rows.sort(key=_rank_key) + + def out(r): + return {k: r[k] for k in _FIELDS} | {"done": r["done"]} + + if btype == "kanban": + first = set(columns[0]["statuses"]) if columns else set() + cutoff = now - timedelta(days=KANBAN_DONE_DAYS) + backlog, board_issues = ([] if kb == 1 else None), [] + for r in rows: + if kb == 1 and r["status"] in first: + backlog.append(out(r)) + continue + if r["done"]: + u = history.jira_utc(r["jira_updated"]) + if u is None or datetime.fromisoformat(u) < cutoff: + continue + board_issues.append(out(r)) + return {"board": board, "sprints": [], "backlog": backlog, "board_issues": board_issues} + + members: dict[int, list[dict]] = {} + for r in conn.execute("SELECT key, sprint_id FROM issue_sprint"): + members.setdefault(r["sprint_id"], []).append(r["key"]) + by_key = {r["key"]: r for r in rows} + sprints = [] + for s in conn.execute("SELECT * FROM jira_sprint WHERE board_id = ?", (board_id,)): + issues = [out(by_key[k]) for k in members.get(s["sprint_id"], []) if k in by_key] + issues.sort(key=_rank_key) + done = [i for i in issues if i["done"]] + pts = lambda xs: sum(i["story_points"] for i in xs if i["story_points"] is not None) # noqa: E731 + sprints.append({"id": s["sprint_id"], "name": s["name"], "state": s["state"], "start_date": s["start_date"], + "end_date": s["end_date"], "complete_date": s["complete_date"], "goal": s["goal"], + "count": len(issues), "count_done": len(done), + "unestimated": sum(1 for i in issues if i["story_points"] is None), + "points": pts(issues), "points_done": pts(done), "issues": issues}) + busy = {r["key"] for r in conn.execute( + "SELECT s.key FROM issue_sprint s JOIN jira_sprint p ON p.sprint_id = s.sprint_id WHERE p.state IN ('active', 'future')")} + backlog = [out(r) for r in rows if r["key"] not in busy and not r["done"]] + return {"board": board, "sprints": _sprint_order(sprints), "backlog": backlog, "board_issues": None} diff --git a/src/sdlc_hub/modules/jira/api.py b/src/sdlc_hub/modules/jira/api.py index 74665b3..7e372c5 100644 --- a/src/sdlc_hub/modules/jira/api.py +++ b/src/sdlc_hub/modules/jira/api.py @@ -9,7 +9,7 @@ from urllib.parse import quote from ...core import db, files, queue, settings from ...core.errors import CommandError, HttpMapped from ...core.http import ApiError, Raw, int_param, mapped, not_found, project_param -from . import attachments, fetch, history, mirror, refresh +from . import agile, attachments, fetch, history, mirror, refresh from . import projects as jira_projects log = logging.getLogger(__name__) @@ -98,6 +98,13 @@ def h_board(hub, conn, m, q, body): return 200, mirror.board(conn, jira_projects.base_jql(conn), project_param(conn, q)) +def h_agile(hub, conn, m, q, body): + label = project_param(conn, q) + if label is None: + raise ApiError(422, "bad_request", "project — метка проекта") + return 200, agile.view(conn, label) + + def h_note_put(hub, conn, m, q, body): fields = {k: v for k, v in body.items() if k != "updated_by"} try: @@ -180,6 +187,7 @@ ROUTES = ( ("GET", r"^/issues$", h_issues, "client"), ("GET", r"^/issues/([^/]+)$", h_issue, "client"), ("GET", r"^/board$", h_board, "client"), + ("GET", r"^/agile$", h_agile, "client"), ("GET", r"^/changes$", h_changes, "client"), ("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"), ("GET", r"^/attachments/(\d+)$", h_attachment_get, "client"), diff --git a/tests/fixtures/baseline/routes.expected.json b/tests/fixtures/baseline/routes.expected.json index bf83729..e2e48ac 100644 --- a/tests/fixtures/baseline/routes.expected.json +++ b/tests/fixtures/baseline/routes.expected.json @@ -83,6 +83,12 @@ "client", "h_agents_get" ], + [ + "GET", + "^/agile$", + "client", + "h_agile" + ], [ "GET", "^/app(?:/(.*))?$", diff --git a/tests/test_agile.py b/tests/test_agile.py new file mode 100644 index 0000000..4ac9b4e --- /dev/null +++ b/tests/test_agile.py @@ -0,0 +1,163 @@ +from datetime import datetime, timezone + +import pytest + +from sdlc_hub.core import settings +from sdlc_hub.core.client import HubError +from sdlc_hub.modules.jira import agile, mirror + +COLS = [{"name": "К выполнению", "statuses": ["Backlog", "Ожидает анализа"]}, {"name": "В работе", "statuses": ["В работе"]}, + {"name": "Готово", "statuses": ["Готово", "Протестировано", "Снято"]}] + + +@pytest.fixture +def conn(open_db, add_project): + c = open_db() + settings.ensure_defaults(c) + add_project(c) + return c + + +def issue(key, status, **over): + base = {"key": key, "id": "1", "issue_type": "Задача", "summary": f"Тема {key}", "description": "", "status": status, + "priority": "Средний", "assignee": None, "reporter": "me", "duedate": None, "planned_start": None, + "planned_end": None, "epic_key": None, "labels": ["AWPCA"], "sprint": None, + "created": "2026-10-10T12:00:00.000+0300", "jira_updated": "2026-10-11T12:00:00.000+0300"} + return base | over + + +def kissue(key, status, **over): + return issue(key, status, labels=["K"], **over) + + +def board(id=4216, type="scrum", sprints=(), **over): + return {"id": id, "type": type, "columns": COLS, "kanban_backlog": None, "sprints": list(sprints), **over} + + +def sprint(id, state, name=None, **over): + return {"id": id, "name": name or f"S{id}", "state": state, "start_date": None, "end_date": None, + "complete_date": None, "goal": None, **over} + + +def load(conn, issues, boards): + mirror.apply_state(conn, issues, [], boards=boards) + + +def keys(rows): + return [r["key"] for r in rows] + + +def test_sprint_and_backlog_sums(conn): # сценарий «Спринт и бэклог» + load(conn, [issue("A", "Готово", story_points=3, sprint_ids=[102], rank="0|a"), issue("B", "В работе", sprint_ids=[102], rank="0|b"), + issue("C", "Backlog", sprint_ids=[]), issue("D", "Готово", sprint_ids=[])], + [board(sprints=[sprint(102, "active")])]) + v = agile.view(conn, "AWPCA") + s = v["sprints"][0] + assert (s["count"], s["count_done"], s["points"], s["points_done"], s["unestimated"]) == (2, 1, 3, 3, 1) + assert keys(s["issues"]) == ["A", "B"] and keys(v["backlog"]) == ["C"] and v["board_issues"] is None + + +def test_done_is_last_column(conn): # сценарий «Колонки доски» + load(conn, [issue("P", "Протестировано", sprint_ids=[102]), issue("Q", "Backlog", sprint_ids=[102])], [board(sprints=[sprint(102, "active")])]) + assert {i["key"]: i["done"] for i in agile.view(conn, "AWPCA")["sprints"][0]["issues"]} == {"P": True, "Q": False} + + +def test_project_without_board(conn, add_project): # сценарий «Проект без доски» + add_project(conn, "STOCKS_DRRK", board_id=None, board_type=None) + assert agile.view(conn, "STOCKS_DRRK") == {"board": None, "sprints": [], "backlog": None, "board_issues": None} + + +def test_board_without_agent_data(conn): # design #23, состояние 1 + load(conn, [issue("A", "Готово")], []) + v = agile.view(conn, "AWPCA") + assert v["board"] == {"id": 4216, "type": "scrum", "columns": None, "kanban_backlog": None, "fetched_at": None} + assert [i["done"] for i in v["backlog"]] == [False] # без колонок выполненных нет + + +def test_sprint_order_mixed_offsets(conn): # порядок спринтов; Review Focus 1 + load(conn, [], [board(sprints=[ + sprint(3, "active", start_date="2026-10-14T09:00:00.000+0300"), sprint(5, "active", start_date="2026-10-01T09:00:00.000+03:00"), + sprint(9, "future"), sprint(7, "future"), + sprint(1, "closed", complete_date="2026-10-01T02:00:00.000+0300"), sprint(2, "closed", complete_date="2026-09-30T23:30:00.000Z")])]) + assert [s["id"] for s in agile.view(conn, "AWPCA")["sprints"]] == [5, 3, 7, 9, 2, 1] # 2 закрыт позже 1 по UTC + + +def test_rank_order_nulls_last_then_key(conn): + load(conn, [issue("Z-1", "Backlog", rank="0|i0002:"), issue("Z-2", "Backlog", rank="0|i0001:"), + issue("B-1", "Backlog", rank=None), issue("A-1", "Backlog")], [board()]) + assert keys(agile.view(conn, "AWPCA")["backlog"]) == ["Z-2", "Z-1", "A-1", "B-1"] + + +def test_only_own_issues_and_empty_sprint(conn): # design #8 + load(conn, [issue("N", "Backlog", labels=["OTHER"], sprint_ids=[102])], [board(sprints=[sprint(102, "active")])]) + s = agile.view(conn, "AWPCA")["sprints"][0] + assert s["issues"] == [] and s["count"] == 0 + + +def test_shared_board_own_issues_only(conn, add_project): # Review Focus 3 + add_project(conn, "C", board_id=4216) + load(conn, [issue("A", "Backlog", sprint_ids=[102]), issue("K", "Backlog", labels=["C"], sprint_ids=[102])], [board(sprints=[sprint(102, "active")])]) + assert keys(agile.view(conn, "C")["sprints"][0]["issues"]) == ["K"] and keys(agile.view(conn, "AWPCA")["sprints"][0]["issues"]) == ["A"] + + +def test_closed_sprint_done_by_current_status(conn): # design #7 + load(conn, [issue("X", "Готово", sprint_ids=[101])], [board(sprints=[sprint(101, "closed")])]) + assert agile.view(conn, "AWPCA")["sprints"][0]["count_done"] == 1 + + +def test_backlog_keeps_issue_of_closed_sprint(conn): + load(conn, [issue("X", "Backlog", sprint_ids=[101]), issue("Y", "Backlog", sprint_ids=[103])], + [board(sprints=[sprint(101, "closed"), sprint(103, "future")])]) + assert keys(agile.view(conn, "AWPCA")["backlog"]) == ["X"] + + +KCOLS = [{"name": "Бэклог", "statuses": ["Backlog"]}, {"name": "В работе", "statuses": ["В работе"]}, + {"name": "Готово", "statuses": ["Готово"]}] +NOW = datetime(2026, 10, 24, 12, tzinfo=timezone.utc) + + +def test_kanban_with_backlog(conn, add_project): # design #3, «Контракт данных» + add_project(conn, "K", board_id=5120, board_type="kanban") + now = datetime(2026, 10, 24, 12, tzinfo=timezone.utc) + load(conn, [kissue("K1", "Backlog"), kissue("K2", "В работе"), kissue("K3", "Готово", jira_updated="2026-10-21T10:00:00.000+0300"), + kissue("K4", "Готово", jira_updated="2026-10-01T10:00:00.000+0300"), kissue("K5", "Странный"), + kissue("K6", "Готово", jira_updated=None)], + [board(id=5120, type="kanban", kanban_backlog=True, columns=[{"name": "Бэклог", "statuses": ["Backlog"]}, + {"name": "В работе", "statuses": ["В работе"]}, {"name": "Готово", "statuses": ["Готово"]}])]) + v = agile.view(conn, "K", now=now) + assert v["sprints"] == [] and keys(v["backlog"]) == ["K1"] and sorted(keys(v["board_issues"])) == ["K2", "K3", "K5"] + assert v["board"]["kanban_backlog"] is True + + +def test_kanban_without_backlog(conn, add_project): # design #17 + add_project(conn, "K", board_id=5120, board_type="kanban") + load(conn, [kissue("K1", "Backlog"), kissue("K2", "В работе")], + [board(id=5120, type="kanban", kanban_backlog=False, columns=KCOLS)]) + v = agile.view(conn, "K", now=NOW) + assert v["backlog"] is None and sorted(keys(v["board_issues"])) == ["K1", "K2"] + assert v["board"]["kanban_backlog"] is False + + +def test_kanban_done_cutoff_with_offsets(conn, add_project): # Review Focus 1: граница 14 дней по UTC + add_project(conn, "K", board_id=5120, board_type="kanban") + load(conn, [kissue("OLD", "Готово", jira_updated="2026-10-10T14:30:00.000+0300"), # 11:30Z — старше 14 дней + kissue("NEW", "Готово", jira_updated="2026-10-10T15:30:00.000+0300")], # 12:30Z — в пределах + [board(id=5120, type="kanban", kanban_backlog=False, columns=KCOLS)]) + assert keys(agile.view(conn, "K", now=NOW)["board_issues"]) == ["NEW"] + + +def test_agile_route(hub_server, add_project): + h = hub_server["hub"] + c = h.conn() + try: + add_project(c) + finally: + c.close() + client = hub_server["client"] + with pytest.raises(HubError) as e: + client.get("/agile") + assert e.value.status == 422 and e.value.error == "bad_request" + with pytest.raises(HubError) as e: + client.get("/agile", project="NOPE") + assert e.value.status == 404 + assert set(client.get("/agile", project="AWPCA")) == {"board", "sprints", "backlog", "board_issues"} -- 2.47.2 From bb133081b1b25903401699d193c42b15f08199cc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 18:49:36 +0400 Subject: [PATCH 07/21] =?UTF-8?q?design.md:=20R-30=20(=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=86=D1=8B=20GET=20/agile)=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- openspec/changes/sprints-backlog/design.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openspec/changes/sprints-backlog/design.md b/openspec/changes/sprints-backlog/design.md index 89d62ed..ab4de7c 100644 --- a/openspec/changes/sprints-backlog/design.md +++ b/openspec/changes/sprints-backlog/design.md @@ -219,3 +219,7 @@ kanban) — свои задачи в статусах остальных кол AURORA-9101…9103); доска AWPCA — scrum со спринтами 101 (закрыт), 102 (активный), 103 (будущий). Цена ошибки — правка сида. - 24.10.2026 — R-29: трейлер коммитов — Co-Authored-By: Claude Sonnet 5.5 (инструкция сессии точнее перечня плана). Цена ошибки — переписать трейлеры, ничего не пушилось. +- 24.10.2026 — R-30 (задача 3, `GET /agile`, по R-17): доска с заданным `board_id`, но неизвестным типом (`board_type` NULL) — + `board` с `type: null`, `sprints: []`, `backlog: null`, `board_issues: null`; kanban без `columns` при + `kanban_backlog = 1` — `backlog: []`, все задачи в `board_issues`. Выбрано исполнителем задачи 3, подтверждено + ревью как следствие R-17. Цена ошибки — правка выборки в `agile.view`. -- 2.47.2 From 212e9eae55550c0cf1bbca398b323337fd83e54c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 18:54:46 +0400 Subject: [PATCH 08/21] =?UTF-8?q?=D0=9A=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D1=8B=20=D1=81=D0=BF=D1=80=D0=B8=D0=BD=D1=82=D0=B0=20=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B3=D0=B0,=20=D0=BE=D1=86=D0=B5=D0=BD?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B2=20ensure=5Ffields=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- README.md | 11 ++- src/sdlc_hub/modules/jira/commands.py | 78 ++++++++++++++++++- src/sdlc_hub/modules/jira/settings.py | 6 +- .../fixtures/baseline/mcp_tools.expected.json | 2 +- .../fixtures/baseline/settings.expected.json | 3 + tests/test_commands.py | 53 +++++++++++++ tests/test_settings.py | 10 +++ 7 files changed, 154 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 048bc16..934c954 100644 --- a/README.md +++ b/README.md @@ -290,13 +290,16 @@ make web | Тип | Параметры | |---|---| | `ensure_issue` | `temp_key` (`new:`), `issue_type` (`task`/`epic`/`bug`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | -| `ensure_fields` | `key` и подмножество: `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels_add`, `labels_remove`, `epic_key`, `assignee` | +| `ensure_fields` | `key` и подмножество: `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels_add`, `labels_remove`, `epic_key`, `assignee`, `story_points` (число ≥ 0 или `null` — очистить) | | `ensure_comment` | `key`, `marker`, `body` | | `ensure_status` | `key`, `target` (имя статуса или список-путь) | | `ensure_link` | `source`, `target`, `link_type`, `replace` | | `ensure_sprint` | `key`, `sprint` (имя или `backlog`); доску проекта задачи (`board_id`) дописывает хаб, от клиента параметр отвергается | | `ensure_attachment` | `key` (ключ или `new:`), `file` (sha256 из `POST /files`), `filename` (имя вложения в Jira, без `/`, `\`, `"` и управляющих символов); `size` хаб дописывает при выдаче агенту | | `refresh` | `keys` или `jql`; при выдаче агенту хаб дописывает `boards` — доски проектов | +| `ensure_sprint_meta` | `sprint` (имя), необязательные `rename_to`, `start_date`, `end_date` (`ГГГГ-ММ-ДД`), `goal`; `board_id` хаб дописывает при постановке; результат `{sprint_id, created}` | +| `ensure_sprint_state` | `sprint`, `state` (`active` или `closed`), `move_open_to` (имя спринта или `backlog`, обязателен при `closed`, при `active` — 422); результат `{sprint_id, moved: [ключ…]}` | +| `ensure_rank` | `key` и ровно одно из `before`, `after` (ключ другой задачи); результат `{changed}` | Тип `bug` — «Ошибка» в Jira, обязательные поля те же, что у задачи. Ставится, только если в настройках есть `issue_types.bug` с непустым `name` или `id`, иначе 422 «команда N: нет настройки issue_types.bug». `init` берёт его из @@ -315,7 +318,11 @@ make web ``` Ответ: `{id, estimate_seconds}`. Зазоры между записями в Jira по умолчанию: `ensure_issue` и `ensure_fields` 120 с, -`ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint`, `ensure_attachment` 30 с, `refresh` 2 с (настройка `pacing`). +`ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint`, `ensure_attachment`, `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank` +30 с, `refresh` 2 с (настройка `pacing`). + +Команды спринта (`ensure_sprint_meta`, `ensure_sprint_state`) и `ensure_rank` ставятся только в пакете проекта (поле `project`): +доску определяет хаб по проекту пакета. Даты проверяются хабом: настоящая дата, начало не позже конца. `ensure_attachment` ставится, только если файл уже загружен (`POST /files`), иначе 422 «команда N: файл не загружен: POST /files». Агент ищет у задачи вложение с тем же именем и размером; нет — скачивает файл у хаба diff --git a/src/sdlc_hub/modules/jira/commands.py b/src/sdlc_hub/modules/jira/commands.py index eac51d6..d8c5e7b 100644 --- a/src/sdlc_hub/modules/jira/commands.py +++ b/src/sdlc_hub/modules/jira/commands.py @@ -1,7 +1,9 @@ """Контракт команд: каждая — желаемый результат на одной задаче Jira (specs/commands/spec.md). Здесь валидация, работа с ключами (в том числе временными new:), оценка длительности и проверка «результат уже виден в зеркале» для подтверждения по состоянию.""" +import math import re +from datetime import date from ...core.errors import CommandError # noqa: F401 (реэкспорт: прежнее место) from . import mirror @@ -20,9 +22,10 @@ SPEC = { "epic_key", "assignee"], "keys": ["temp_key", "epic_key"], "hints": {"temp_key": "new:", "issue_type": "task|epic|bug", "labels": "список", **FIELD_HINTS}}, - "ensure_fields": {"required": ["key"], "optional": FIELD_PARAMS + ["labels_add", "labels_remove"], + "ensure_fields": {"required": ["key"], "optional": FIELD_PARAMS + ["labels_add", "labels_remove", "story_points"], "keys": ["key", "epic_key"], - "hints": {**FIELD_HINTS, "labels_add": "список", "labels_remove": "список"}, + "hints": {**FIELD_HINTS, "labels_add": "список", "labels_remove": "список", + "story_points": "число ≥ 0 или null"}, "note": "хотя бы одно поле"}, "ensure_comment": {"required": ["key", "marker", "body"], "optional": [], "keys": ["key"], "hints": {"marker": "метка в начале комментария"}}, @@ -39,10 +42,22 @@ SPEC = { "hints": {"attachment_id": "id вложения из issue (цифры)"}}, "refresh": {"required": [], "optional": ["keys", "jql"], "keys": ["keys"], "hints": {"keys": "список"}, "note": "keys или jql"}, + "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": "цель спринта"}}, + "ensure_sprint_state": {"required": ["sprint", "state"], "optional": ["move_open_to"], "keys": [], + "hints": {"state": "active|closed", + "move_open_to": "имя спринта или backlog; обязателен при closed"}}, + "ensure_rank": {"required": ["key"], "optional": ["before", "after"], "keys": ["key", "before", "after"], + "hints": {"before": "ключ задачи, выше которой", "after": "ключ задачи, ниже которой"}, + "note": "ровно одно из before, after"}, } WRITE_TYPES = [t for t in SPEC if t not in ("refresh", "fetch_attachment")] LIST_PARAMS = {"labels", "labels_add", "labels_remove", "keys"} BOOL_PARAMS = {"replace"} +NUMBER_PARAMS = {"story_points"} +SPRINT_TYPES = ("ensure_sprint_meta", "ensure_sprint_state") # только в пакете проекта (check_batch) +DATE_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$") def is_temp(key) -> bool: @@ -75,11 +90,59 @@ def _type_error(ctype, name, value) -> str | None: return None if _is_str_list(value) else f"{name} — список строк" if name in BOOL_PARAMS: return None if isinstance(value, bool) else f"{name} — true или false" + if name in NUMBER_PARAMS: # конечное число ≥ 0; bool, NaN и Infinity — не число (R-15) + ok = isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) and value >= 0 + return None if ok else f"{name} — число ≥ 0 или null" if ctype == "ensure_status" and name == "target": return None if isinstance(value, str) or _is_str_list(value) else f"{name} — строка или список строк" return None if isinstance(value, str) else f"{name} — строка" +def _date(value) -> date | None: + """Строка ГГГГ-ММ-ДД, которая задаёт настоящую дату; иначе None.""" + if not isinstance(value, str) or not DATE_RE.match(value): + return None + try: + return date.fromisoformat(value) + except ValueError: + return None + + +def _rule_error(ctype, p) -> str | None: + """Смысловые проверки постановки (R-15): типы уже проверены в _type_error.""" + if ctype == "ensure_sprint_meta": + if not p["sprint"].strip(): + return "sprint — непустое имя" + if p.get("rename_to") is not None and not p["rename_to"].strip(): + return "rename_to — непустое имя" + for name in ("start_date", "end_date"): + if p.get(name) is not None and _date(p[name]) is None: + return f"{name} — дата ГГГГ-ММ-ДД" + start, end = _date(p.get("start_date")), _date(p.get("end_date")) + if start and end and start > end: + return "start_date позже end_date" + if ctype == "ensure_sprint_state": + if p["state"] not in ("active", "closed"): + return "state — active или closed" + move = p.get("move_open_to") + if p["state"] == "closed": + if move in (None, ""): + return "move_open_to обязателен при state = closed" + if move == p["sprint"]: + return "move_open_to — другой спринт" + elif move not in (None, ""): + return "move_open_to — только при state = closed" + if ctype == "ensure_rank": + before, after = p.get("before") or None, p.get("after") or None + if bool(before) == bool(after): + return "ровно одно из before, after" + if before == p["key"]: + return "before — другая задача" + if after == p["key"]: + return "after — другая задача" + return None + + def _is_str_list(value) -> bool: return isinstance(value, list) and all(isinstance(v, str) for v in value) @@ -132,6 +195,9 @@ def validate(commands) -> list[dict]: bad = [e for e in (_type_error(ctype, n, v) for n, v in params.items()) if e] if bad: raise CommandError(f"команда {i} ({ctype}): " + "; ".join(bad)) + rule = _rule_error(ctype, params) + if rule: + raise CommandError(f"команда {i} ({ctype}): {rule}") if ctype == "ensure_fields" and not any(f in params for f in spec["optional"]): raise CommandError(f"команда {i}: ensure_fields без полей") if ctype == "refresh" and not (params.get("keys") or params.get("jql")): @@ -183,14 +249,18 @@ def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]: """Виден ли желаемый результат в зеркале. ensure_issue, ensure_comment, ensure_attachment и refresh по состоянию не подтверждаются: потерянный отчёт ensure_issue закрывается довыдачей — агент находит свою задачу поиском дубля (bridge-reliability, решение 4); совпадение темы с задачей неизвестного автора не доказательство; вложений в - зеркале нет (bug-and-attachments).""" + зеркале нет (bug-and-attachments); команды спринта и ранга в зеркале не видны (design #18). + story_points сравнивается точно: null равен только null, 0 не равен null (R-16).""" ctype, p = cmd["type"], cmd["params"] - if ctype in ("ensure_issue", "ensure_comment", "ensure_attachment", "fetch_attachment", "refresh"): + if ctype in ("ensure_issue", "ensure_comment", "ensure_attachment", "fetch_attachment", "refresh", "ensure_rank", + *SPRINT_TYPES): return False, None issue = mirror.get_issue(conn, p.get("key") or p.get("source")) if issue is None: return False, None if ctype == "ensure_fields": + if "story_points" in p and issue.get("story_points") != p["story_points"]: + return False, None for field in FIELD_PARAMS: if field in p and _norm_text(issue.get(field)) != _norm_text(p[field]): return False, None diff --git a/src/sdlc_hub/modules/jira/settings.py b/src/sdlc_hub/modules/jira/settings.py index 6f110e9..127390c 100644 --- a/src/sdlc_hub/modules/jira/settings.py +++ b/src/sdlc_hub/modules/jira/settings.py @@ -3,7 +3,8 @@ from ...core import settings as core_settings from . import projects DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 30, "ensure_status": 30, - "ensure_link": 30, "ensure_sprint": 30, "ensure_attachment": 30, "fetch_attachment": 2, "refresh": 2} + "ensure_link": 30, "ensure_sprint": 30, "ensure_attachment": 30, "fetch_attachment": 2, "refresh": 2, + "ensure_sprint_meta": 30, "ensure_sprint_state": 30, "ensure_rank": 30} DEFAULTS = { "min_agent_version": 1, @@ -75,7 +76,8 @@ def pacing_from_config(p: dict) -> dict: """Старая форма pacing (issue_seconds / comment_seconds) → паузы по типам команд.""" issue, comment = p.get("issue_seconds", 120), p.get("comment_seconds", 30) return {"ensure_issue": issue, "ensure_fields": issue, "ensure_comment": comment, "ensure_status": comment, - "ensure_link": comment, "ensure_sprint": comment, "ensure_attachment": comment, "fetch_attachment": 2, "refresh": 2} + "ensure_link": comment, "ensure_sprint": comment, "ensure_attachment": comment, "fetch_attachment": 2, "refresh": 2, + "ensure_sprint_meta": comment, "ensure_sprint_state": comment, "ensure_rank": comment} def _issue_types(cfg: dict) -> dict: diff --git a/tests/fixtures/baseline/mcp_tools.expected.json b/tests/fixtures/baseline/mcp_tools.expected.json index dd6551e..77a7337 100644 --- a/tests/fixtures/baseline/mcp_tools.expected.json +++ b/tests/fixtures/baseline/mcp_tools.expected.json @@ -228,7 +228,7 @@ "name": "notes_set" }, { - "description": "Поставить пакет команд. Каждая команда — {\"type\": <тип>, \"params\": {...}}. Хвостовой refresh хаб добавит сам.\nПараметры по типам (необязательные — в [ ]):\n- ensure_issue: temp_key (new:), issue_type (task|epic|bug), summary; [description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), labels (список), epic_key (ключ или new:), assignee (логин Jira)]\n- ensure_fields: key; [summary, description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), epic_key (ключ или new:), assignee (логин Jira), labels_add (список), labels_remove (список)] — хотя бы одно поле\n- ensure_comment: key, marker (метка в начале комментария), body\n- ensure_status: key, target (статус или список-путь)\n- ensure_link: source, target, link_type (логическое имя из link_types); [replace (true|false)]\n- ensure_sprint: key, sprint (имя спринта или backlog)\n- ensure_attachment: key, file (sha256 из POST /files; в MCP — path к локальному файлу), filename (имя вложения)\n- fetch_attachment: key, attachment_id (id вложения из issue (цифры))\n- refresh: —; [keys (список), jql] — keys или jql\nПроект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint выставляет хаб.\nensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит\nфайл в хаб и подставит file, filename по умолчанию — имя файла.", + "description": "Поставить пакет команд. Каждая команда — {\"type\": <тип>, \"params\": {...}}. Хвостовой refresh хаб добавит сам.\nПараметры по типам (необязательные — в [ ]):\n- ensure_issue: temp_key (new:), issue_type (task|epic|bug), summary; [description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), labels (список), epic_key (ключ или new:), assignee (логин Jira)]\n- ensure_fields: key; [summary, description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), epic_key (ключ или new:), assignee (логин Jira), labels_add (список), labels_remove (список), story_points (число ≥ 0 или null)] — хотя бы одно поле\n- ensure_comment: key, marker (метка в начале комментария), body\n- ensure_status: key, target (статус или список-путь)\n- ensure_link: source, target, link_type (логическое имя из link_types); [replace (true|false)]\n- ensure_sprint: key, sprint (имя спринта или backlog)\n- ensure_attachment: key, file (sha256 из POST /files; в MCP — path к локальному файлу), filename (имя вложения)\n- fetch_attachment: key, attachment_id (id вложения из issue (цифры))\n- refresh: —; [keys (список), jql] — keys или jql\n- ensure_sprint_meta: sprint (имя спринта); [rename_to (новое имя), start_date (YYYY-MM-DD), end_date (YYYY-MM-DD), goal (цель спринта)]\n- ensure_sprint_state: sprint, state (active|closed); [move_open_to (имя спринта или backlog; обязателен при closed)]\n- ensure_rank: key; [before (ключ задачи, выше которой), after (ключ задачи, ниже которой)] — ровно одно из before, after\nПроект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint выставляет хаб.\nensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит\nфайл в хаб и подставит file, filename по умолчанию — имя файла.", "inputSchema": { "properties": { "commands": { diff --git a/tests/fixtures/baseline/settings.expected.json b/tests/fixtures/baseline/settings.expected.json index 2e3eaa1..88ea33d 100644 --- a/tests/fixtures/baseline/settings.expected.json +++ b/tests/fixtures/baseline/settings.expected.json @@ -38,7 +38,10 @@ "ensure_fields": 120, "ensure_issue": 120, "ensure_link": 30, + "ensure_rank": 30, "ensure_sprint": 30, + "ensure_sprint_meta": 30, + "ensure_sprint_state": 30, "ensure_status": 30, "fetch_attachment": 2, "refresh": 2 diff --git a/tests/test_commands.py b/tests/test_commands.py index b047460..3254a7e 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,3 +1,5 @@ +import re + import pytest from sdlc_hub.modules.jira import commands, mirror @@ -266,3 +268,54 @@ def test_fetch_not_confirmed_by_state(conn): assert (ok, result) == (False, None) assert "fetch_attachment" not in commands.WRITE_TYPES assert commands.SPEC["fetch_attachment"]["required"] == ["key", "attachment_id"] + + +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 diff --git a/tests/test_settings.py b/tests/test_settings.py index ecf0971..50c6f20 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -128,3 +128,13 @@ def test_pacing_fallback_for_new_type(conn): settings.set_(conn, "pacing", {"ensure_fields": 5}) assert jira_settings.pacing(conn)["fetch_attachment"] == 2 settings.validate("pacing", {"fetch_attachment": 3}) + + +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 -- 2.47.2 From 592b2c855c128a32e37ff8fe6c6e25dae8a76be6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 18:56:30 +0400 Subject: [PATCH 09/21] =?UTF-8?q?design.md:=20R-31=20(=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D1=82=D1=8B=D0=B5=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B2=20=D0=BF=D0=B0=D1=80=D0=B0=D0=BC=D0=B5=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D1=85=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4)=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- openspec/changes/sprints-backlog/design.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openspec/changes/sprints-backlog/design.md b/openspec/changes/sprints-backlog/design.md index ab4de7c..1284a0d 100644 --- a/openspec/changes/sprints-backlog/design.md +++ b/openspec/changes/sprints-backlog/design.md @@ -223,3 +223,6 @@ kanban) — свои задачи в статусах остальных кол `board` с `type: null`, `sprints: []`, `backlog: null`, `board_issues: null`; kanban без `columns` при `kanban_backlog = 1` — `backlog: []`, все задачи в `board_issues`. Выбрано исполнителем задачи 3, подтверждено ревью как следствие R-17. Цена ошибки — правка выборки в `agile.view`. +- 24.10.2026 — R-31 (задача 4, по R-15): пустая строка в `move_open_to` при `state = active` и в `before`/`after` у + `ensure_rank` трактуется как «не задано»; `ensure_sprint_meta` без параметров, кроме `sprint`, допустима. Выбрано + исполнителем задачи 4. Цена ошибки — ужесточить проверку (422 вместо `failed` у агента). -- 2.47.2 From c6221437b93b87679f3ec8e0f25776254b844487 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 19:06:43 +0400 Subject: [PATCH 10/21] =?UTF-8?q?=D0=9A=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D1=8B=20=D1=81=D0=BF=D1=80=D0=B8=D0=BD=D1=82=D0=B0:=20=D0=B4?= =?UTF-8?q?=D0=BE=D1=81=D0=BA=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D0=B0,=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B2=D1=82=D0=BE=D1=80=20=D1=81=20=D0=BD=D0=BE=D0=B2=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=B4=D0=BE=D1=81=D0=BA=D0=BE=D0=B9=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- ARCHITECTURE.md | 2 +- README.md | 4 ++- src/sdlc_hub/modules/jira/hooks.py | 46 +++++++++++++++++++++----- tests/test_queue.py | 53 ++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 10 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 491bdd2..ffe55d9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -46,7 +46,7 @@ | `commands` | контракт типов команд jira (`ensure_*`, `refresh`, `fetch_attachment`), валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` | | `refresh` | журнал синхронизаций `sync_run`, полный прогон, автообновление | `core.db`, `core.queue`, `core.settings`, `projects` | | `projects` | базовая JQL из меток (`base_jql`), доски (`boards`), миграция v3 → v4 (`migrate_v3`), проект по умолчанию | `core.projects`, `core.settings` | -| `hooks` | `CommandHooks` для очереди ядра: проверки постановки (`bug` без настройки, файл вложения, проект пакета, `board_id` у `ensure_sprint`), паузы, `deliver_params` (`size`, `boards`, `history`), отчёты (`attachment_file`, `sync_run`) | `core.queue`, `core.files`, `core.projects`, `core.settings`, `settings`, `projects`, `commands`, `mirror`, `history`, `refresh`, `attachments`, `fetch` | +| `hooks` | `CommandHooks` для очереди ядра: проверки постановки (`bug` без настройки, файл вложения, проект пакета, `board_id` у `ensure_sprint` (по проекту задачи) и у `ensure_sprint_meta`/`ensure_sprint_state` (по проекту пакета)), паузы, `deliver_params` (`size`, `boards`, `history`), отчёты (`attachment_file`, `sync_run`) | `core.queue`, `core.files`, `core.projects`, `core.settings`, `settings`, `projects`, `commands`, `mirror`, `history`, `refresh`, `attachments`, `fetch` | | `agile` | чтение для `GET /agile`: доска, спринты, бэклог, выполнено по последней колонке | `core.db`, `history` | | `api` | маршруты модуля `MODULE.routes`: `/issues`, `/board`, `/agile`, `/changes`, `/attachments`, `/refresh`, `/sync-runs` и др. | `core.db`, `core.files`, `core.queue`, `core.settings`, `core.http`, `core.errors`, `attachments`, `projects`, `mirror`, `history`, `fetch`, `refresh` | | `mcp` | инструменты MCP: `issues`, `issue`, `batch_create`, `batch_*`, `comment`, `attachment`, `changes`, `refresh`, `settings_*` и др.; общие `projects`/`project_create` подключает `register_core_tools` | `core.mcp`, `core.client`, `commands`, `attachments` | diff --git a/README.md b/README.md index 934c954..1426f35 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,9 @@ make web 30 с, `refresh` 2 с (настройка `pacing`). Команды спринта (`ensure_sprint_meta`, `ensure_sprint_state`) и `ensure_rank` ставятся только в пакете проекта (поле `project`): -доску определяет хаб по проекту пакета. Даты проверяются хабом: настоящая дата, начало не позже конца. +`board_id` дописывает хаб по проекту пакета; пакет без проекта — 422 «команды спринта — только в пакете проекта»; +доски нет, kanban или тип не известен — 422, как у `ensure_sprint`. `ensure_rank` в пакете проекта — `key` своей задачи. +Даты проверяются хабом: настоящая дата, начало не позже конца. `ensure_attachment` ставится, только если файл уже загружен (`POST /files`), иначе 422 «команда N: файл не загружен: POST /files». Агент ищет у задачи вложение с тем же именем и размером; нет — скачивает файл у хаба diff --git a/src/sdlc_hub/modules/jira/hooks.py b/src/sdlc_hub/modules/jira/hooks.py index 46bf060..a84af92 100644 --- a/src/sdlc_hub/modules/jira/hooks.py +++ b/src/sdlc_hub/modules/jira/hooks.py @@ -67,15 +67,41 @@ def _assign_boards(conn, cmds) -> None: if len({p["board_id"] for p in own}) > 1: raise CommandError(f"команда {i}: {key} в проектах {', '.join(p['label'] for p in own)} с разными досками") p = own[0] - if p["board_id"] is None: - raise CommandError(f"команда {i}: у проекта {p['label']} нет доски Jira") - if p["board_type"] == "kanban": - raise CommandError(f"команда {i}: у проекта {p['label']} канбан-доска, спринтов нет") - if p["board_type"] is None: - raise CommandError(f"команда {i}: тип доски проекта {p['label']} ещё не известен — дождитесь обновления из Jira") + err = _board_error(p["label"], p["board_id"], p["board_type"]) + if err: + raise CommandError(f"команда {i}: {err}") c["params"] = {**c["params"], "board_id": p["board_id"]} +def _board_error(label, board_id, board_type) -> str | None: + """Общие тексты 422 для ensure_sprint и команд спринта: доски нет, канбан, тип ещё не известен; None — доска годится.""" + if board_id is None: + return f"у проекта {label} нет доски Jira" + if board_type == "kanban": + return f"у проекта {label} канбан-доска, спринтов нет" + if board_type is None: + return f"тип доски проекта {label} ещё не известен — дождитесь обновления из Jira" + return None + + +def _assign_project_board(conn, cmds, project) -> None: + """Доска проекта пакета в params команд спринта (ensure_sprint_meta/state): спринт не про задачу, проект — из пакета. + Новые словари params — входные не мутируются. Пакет без проекта, нет доски, канбан, тип не известен — 422.""" + board = None + for i, c in enumerate(cmds, 1): + if c["type"] not in commands.SPRINT_TYPES: + continue + if project is None: + raise CommandError(f"команда {i} ({c['type']}): команды спринта — только в пакете проекта") + if board is None: + found = next((p for p in jira_projects.light_list(conn) if p["label"] == project), None) + board = found or {"label": project, "board_id": None, "board_type": None} + err = _board_error(board["label"], board["board_id"], board["board_type"]) + if err: + raise CommandError(f"команда {i}: {err}") + c["params"] = {**c["params"], "board_id": board["board_id"]} + + def _check_project(conn, cmds, project) -> list[dict]: """Проверки пакета проекта (spec commands, «Пакет проекта»): новые словари команд — входные params не мутируются. Номер команды — как в присланном пакете.""" @@ -104,6 +130,8 @@ def _check_project(conn, cmds, project) -> list[dict]: elif t == "ensure_link": if not (own(p["source"]) or own(p["target"])): raise CommandError(f"команда {i}: связь {p['source']} → {p['target']} не касается проекта {project}") + elif t in commands.SPRINT_TYPES: + pass # спринт — не задача проекта: доску ставит _assign_project_board elif t not in READ_TYPES: # fetch_attachment — не запись в Jira, проекта не касается if t == "ensure_fields": if project.casefold() in {l.casefold() for l in p.get("labels_remove") or []}: @@ -127,6 +155,7 @@ def check_batch(conn, cmds, project, files_dir) -> list[dict]: if project is not None: cmds = _check_project(conn, cmds, project) check_setup(conn, cmds, files_dir) + _assign_project_board(conn, cmds, project) return cmds @@ -249,8 +278,9 @@ def check_confirmed(conn, cmd) -> tuple[bool, dict | None]: def retry_params(cmd) -> dict: - """Повтор ensure_sprint берёт доску заново (она в params ставится при постановке).""" - return {k: v for k, v in cmd["params"].items() if not (cmd["type"] == "ensure_sprint" and k == "board_id")} + """Повтор ensure_sprint и команд спринта берёт доску заново (она в params ставится при постановке).""" + drop = cmd["type"] in ("ensure_sprint", *commands.SPRINT_TYPES) + return {k: v for k, v in cmd["params"].items() if not (drop and k == "board_id")} def file_refs(conn) -> set[str]: diff --git a/tests/test_queue.py b/tests/test_queue.py index 779d5d0..6216bfd 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -895,6 +895,59 @@ def test_retry_premigration_sprint_without_board(conn, add_project): # Review assert queue.batch_view(conn, r["id"])["commands"][0]["params"] == {"key": "A-1", "sprint": "Спринт 2", "board_id": 4216} +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") + + def test_refresh_delivered_with_boards(conn, add_project): # сценарий «Тип доски определён», часть 1 add_project(conn) add_project(conn, "STOCKS_DRRK", board_id=5120, board_type=None) -- 2.47.2 From 07357732da1901cf3f7e911647cf80f08b873cfe Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 19:23:46 +0400 Subject: [PATCH 11/21] =?UTF-8?q?=D0=A5=D0=B2=D0=BE=D1=81=D1=82=20=D0=BF?= =?UTF-8?q?=D0=B0=D0=BA=D0=B5=D1=82=D0=B0=20=D1=81=D0=BE=20=D1=81=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D1=82=D0=BE=D0=BC=20=E2=80=94=20refresh=20?= =?UTF-8?q?=D0=BF=D0=BE=20JQL=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0?= =?UTF-8?q?=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- ARCHITECTURE.md | 2 +- README.md | 2 +- src/sdlc_hub/core/modules.py | 2 +- src/sdlc_hub/core/queue.py | 2 +- src/sdlc_hub/modules/jira/hooks.py | 8 ++++++-- src/sdlc_hub/modules/jira/projects.py | 6 ++++++ src/sdlc_hub/modules/wiki/commands.py | 2 +- tests/conftest.py | 2 +- tests/test_projects.py | 8 ++++++++ tests/test_queue.py | 26 ++++++++++++++++++++++++++ tests/test_queue_core.py | 2 +- 11 files changed, 53 insertions(+), 9 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ffe55d9..ede38e9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -78,7 +78,7 @@ - `Module(name, schema, settings_defaults, settings_obsolete, agent_keys, agent_min_version_key, validate_setting, settings_view, on_init, commands, routes, tick, file_refs, project_fields, project_view, on_project_created, on_project_updated, mcp_server, cli_register, cli_run)` — всё необязательное, кроме `name`. Модуль с `commands` — «вид агента»: у него свой агент, токен, `agent_status` и релиз. - Поля проекта: ядро знает только `label`, `name`, `created_at`. Остальное объявляет модуль: `ProjectField(name, validate, editable)`; значения для `GET /projects` (в т.ч. вычисляемые) отдаёт `project_view(conn, labels)`; `on_project_created(conn, project, fields, author)` и `on_project_updated(...)` пишут свои поля и возвращают пакеты. `POST /projects` принимает поля всех модулей (неизвестное — 422), `PATCH /projects/{label}` — только изменяемые (`editable`). `registry.check` падает при совпадении имени поля у двух модулей или с полем ядра. -- `CommandHooks` — что модуль знает о своих типах команд: `types`, `read_types` (не пишут во внешнюю систему: без хвоста и копии базы), `validate`, `check_batch`, `tail`, `pause`, `deliver_params`, `temp_definer`, `temp_keys_used`, `substitute`, `drop_unresolved`, `retry_params`, `check_confirmed`, `on_claim`, `on_failed`, `check_result`, `on_result`, `after_result`, `on_late_result` и два необязательных — `before_result`, `abort_result`. Очередь вызывает их по типу команды через `registry.module_of`. +- `CommandHooks` — что модуль знает о своих типах команд: `types`, `read_types` (не пишут во внешнюю систему: без хвоста и копии базы), `validate`, `check_batch`, `tail(conn, cmds, project)`, `pause`, `deliver_params`, `temp_definer`, `temp_keys_used`, `substitute`, `drop_unresolved`, `retry_params`, `check_confirmed`, `on_claim`, `on_failed`, `check_result`, `on_result`, `after_result`, `on_late_result` и два необязательных — `before_result`, `abort_result`. Очередь вызывает их по типу команды через `registry.module_of`. - `before_result(conn, cmd, status, result, files_dir)` ядро вызывает в `report_result` до транзакции отчёта, только для выданной (`claimed`) команды. Нужен, чтобы тяжёлая подготовка (сборка копии вики, запись файлов) шла вне `BEGIN IMMEDIATE` и не держала блокировку базы, в которой ждут `claim` другого агента и веб (рулинг R-6). `ValueError` из него ядро превращает в `RuntimeError` (500, агент повторит отчёт), чтобы не спутать с неверным статусом отчёта. - `abort_result(conn, cmd)` ядро вызывает, если отчёт не дошёл до `after_result` (поздний или идемпотентный выход, исключение в транзакции): модуль снимает подготовку `before_result` и её блокировку. При успехе подготовку снимает `after_result`. diff --git a/README.md b/README.md index 1426f35..c68fe80 100644 --- a/README.md +++ b/README.md @@ -307,7 +307,7 @@ make web целиком: `{"task": {…}, "epic": {…}, "bug": {"name": "Ошибка", "id": null}}`. Ключ в параметре может быть временным `new:`: команда выдаётся после `ensure_issue` с тем же `temp_key` из того же -пакета. В хвост пакета хаб сам добавляет `refresh` по затронутым ключам. Пример пакета (`POST /batches`): +пакета. В хвост пакета хаб сам добавляет `refresh`: пакет с `ensure_sprint_meta`/`ensure_sprint_state` получает хвост по JQL проекта пакета; иначе — по затронутым ключам. Пример пакета (`POST /batches`): ```json {"title": "1538 в спринт 2", "author": "claude", "commands": [ diff --git a/src/sdlc_hub/core/modules.py b/src/sdlc_hub/core/modules.py index e26f1c7..6bef970 100644 --- a/src/sdlc_hub/core/modules.py +++ b/src/sdlc_hub/core/modules.py @@ -15,7 +15,7 @@ class CommandHooks: read_types: frozenset[str] # не пишут во внешнюю систему: без хвоста и копии базы validate: Callable[[list], list[dict]] # CommandError check_batch: Callable[[Connection, list[dict], str | None, Path | None], list[dict]] # project, files_dir - tail: Callable[[list[dict]], dict | None] # хвостовая команда пишущего пакета + tail: Callable[[Connection, list[dict], str | None], dict | None] # (conn, cmds, проект пакета): хвостовая команда пишущего пакета pause: Callable[[Connection, str], int] # пауза типа, с deliver_params: Callable[[Connection, dict, Path | None], dict] temp_definer: tuple[str, str, str] | None # (тип, поле params с временным ключом, поле result с ключом) diff --git a/src/sdlc_hub/core/queue.py b/src/sdlc_hub/core/queue.py index f3b7f24..44fe504 100644 --- a/src/sdlc_hub/core/queue.py +++ b/src/sdlc_hub/core/queue.py @@ -76,7 +76,7 @@ def create_batch(conn, title, author, cmds, files_dir=None, project=None) -> dic h = _batch_hooks(cmds) cmds = h.validate(cmds) cmds = h.check_batch(conn, cmds, project, files_dir) - tail = h.tail(cmds) + tail = h.tail(conn, cmds, project) if tail is not None: cmds.append(tail) return insert_batch(conn, title, author, cmds, project) diff --git a/src/sdlc_hub/modules/jira/hooks.py b/src/sdlc_hub/modules/jira/hooks.py index a84af92..6fd0af1 100644 --- a/src/sdlc_hub/modules/jira/hooks.py +++ b/src/sdlc_hub/modules/jira/hooks.py @@ -159,8 +159,12 @@ def check_batch(conn, cmds, project, files_dir) -> list[dict]: return cmds -def tail(cmds) -> dict | None: - """Хвостовой refresh пишущего пакета: ключи из всех команд, чтобы зеркало увидело результат.""" +def tail(conn, cmds, project) -> dict | None: + """Хвостовой refresh пишущего пакета: ключи из всех команд, чтобы зеркало увидело результат. Пакет с командами + спринта — refresh по JQL проекта пакета: у них нет ключей, а перенесённые при закрытии задачи заранее + неизвестны (design #13).""" + if project and any(c["type"] in commands.SPRINT_TYPES for c in cmds): + return {"type": "refresh", "params": {"jql": jira_projects.project_jql(conn, project)}} keys = [] for c in cmds: keys += [k for k in commands.referenced_keys(c) if k not in keys] diff --git a/src/sdlc_hub/modules/jira/projects.py b/src/sdlc_hub/modules/jira/projects.py index 06a5241..a90f458 100644 --- a/src/sdlc_hub/modules/jira/projects.py +++ b/src/sdlc_hub/modules/jira/projects.py @@ -24,6 +24,12 @@ def base_jql(conn) -> str | None: return f"project = {key} AND labels in ({', '.join(f'"{x}"' for x in found)})" +def project_jql(conn, label: str) -> str: + """JQL одного проекта-метки — в формате base_jql (тот же project_key и кавычки).""" + key = settings.get(conn, "project_key") or "AURORA" + return f'project = {key} AND labels in ("{label}")' + + def create(conn, label: str, name: str, board_id: int | None = None) -> dict: """Проект с доской: проект ядра и строка jira_project (прямой путь без POST /projects: init, миграция, тесты). Неверная доска — ProjectError("board_id", …) до записи.""" diff --git a/src/sdlc_hub/modules/wiki/commands.py b/src/sdlc_hub/modules/wiki/commands.py index c105594..6084c97 100644 --- a/src/sdlc_hub/modules/wiki/commands.py +++ b/src/sdlc_hub/modules/wiki/commands.py @@ -94,7 +94,7 @@ def after_result(conn, cmd, status) -> None: # wiki_sync — sync.py (хуки before/abort/after только у него), wiki_fetch — attachments.py HOOKS = CommandHooks( types=frozenset(TYPES), read_types=frozenset(TYPES), validate=validate, check_batch=check_batch, - tail=lambda cmds: None, pause=lambda conn, ctype: 0, deliver_params=lambda conn, cmd, files_dir: cmd["params"], + tail=lambda conn, cmds, project: None, pause=lambda conn, ctype: 0, deliver_params=lambda conn, cmd, files_dir: cmd["params"], temp_definer=None, temp_keys_used=lambda cmd: [], substitute=lambda ctype, params, mapping: params, drop_unresolved=lambda cmd, unresolved: unresolved, retry_params=lambda cmd: cmd["params"], check_confirmed=lambda conn, cmd: (False, None), on_claim=sync.on_claim, on_failed=on_failed, diff --git a/tests/conftest.py b/tests/conftest.py index d3ac10d..f7f8dc7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -56,7 +56,7 @@ def _stub_hooks() -> CommandHooks: noop = lambda *a, **k: None # noqa: E731 return CommandHooks( types=frozenset({"stub_cmd"}), read_types=frozenset({"stub_cmd"}), validate=lambda cmds: cmds, - check_batch=lambda conn, cmds, project, files_dir: cmds, tail=lambda cmds: None, pause=lambda conn, t: 0, + check_batch=lambda conn, cmds, project, files_dir: cmds, tail=lambda conn, cmds, project: None, pause=lambda conn, t: 0, deliver_params=lambda conn, cmd, files_dir: cmd["params"], temp_definer=None, temp_keys_used=lambda c: [], substitute=lambda t, p, mapping: p, drop_unresolved=lambda c, u: u, retry_params=lambda c: c["params"], check_confirmed=lambda conn, c: (True, None), on_claim=noop, on_failed=noop, diff --git a/tests/test_projects.py b/tests/test_projects.py index e06691c..20d018e 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -148,3 +148,11 @@ def test_ensure_legacy_skips_case_variant_and_bad_board(open_db): c2 = open_db() settings.ensure_defaults(c2) assert jira_projects.ensure_legacy(c2, "oops") is True and api.project_rows(c2, ["AWPCA"])[0]["board_id"] is None + + +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")' diff --git a/tests/test_queue.py b/tests/test_queue.py index 6216bfd..661d559 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -1265,3 +1265,29 @@ def test_estimate_sums_module_pauses_replaces_commands_estimate(conn): через pacing -> hooks.pause -> queue.estimate.""" settings.set_(conn, "pacing", {"ensure_fields": 120, "ensure_comment": 30, "refresh": 2}) assert queue.estimate(conn, ["ensure_fields", "ensure_comment", "refresh"]) == 152 + + +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): + add_project(conn) + b = queue.create_batch(conn, "x", "claude", [meta()], project="AWPCA") + cid = queue.batch_view(conn, b["id"])["commands"][0]["id"] + r = queue.retry_command(conn, cid, "claude") + cmds = queue.batch_view(conn, r["id"])["commands"] + assert [c["type"] for c in cmds] == ["ensure_sprint_meta", "refresh"] + assert cmds[-1]["params"] == {"jql": 'project = AURORA AND labels in ("AWPCA")'} diff --git a/tests/test_queue_core.py b/tests/test_queue_core.py index a8cefc5..db92646 100644 --- a/tests/test_queue_core.py +++ b/tests/test_queue_core.py @@ -21,7 +21,7 @@ def make_stub(events, refs=()): hooks = CommandHooks( types=frozenset({"w", "r"}), read_types=frozenset({"r"}), validate=_validate, check_batch=lambda conn, cmds, project, files_dir: cmds, - tail=lambda cmds: {"type": "r", "params": {}} if any(c["type"] == "w" for c in cmds) else None, + tail=lambda conn, cmds, project: {"type": "r", "params": {}} if any(c["type"] == "w" for c in cmds) else None, pause=lambda conn, ctype: 7, deliver_params=lambda conn, cmd, files_dir: cmd["params"], temp_definer=None, temp_keys_used=lambda cmd: [], substitute=lambda t, p, m: p, drop_unresolved=lambda cmd, unresolved: unresolved, retry_params=lambda cmd: cmd["params"], -- 2.47.2 From f709e9b89315727d16ad9a254576bb7aab09a430 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 19:33:31 +0400 Subject: [PATCH 12/21] =?UTF-8?q?MCP:=20=D1=81=D0=BF=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D1=82=D1=8B,=20=D1=81=D0=BF=D1=80=D0=B8=D0=BD=D1=82=20=D0=B8?= =?UTF-8?q?=20=D0=B1=D1=8D=D0=BA=D0=BB=D0=BE=D0=B3=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B0,=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD?= =?UTF-8?q?=D0=BA=D0=B0=20SP=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- ARCHITECTURE.md | 2 +- README.md | 11 ++ src/sdlc_hub/modules/jira/mcp.py | 146 +++++++++++++++++- .../fixtures/baseline/mcp_tools.expected.json | 41 ++++- tests/test_mcp.py | 98 +++++++++++- 5 files changed, 290 insertions(+), 8 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ede38e9..854bb6f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -49,7 +49,7 @@ | `hooks` | `CommandHooks` для очереди ядра: проверки постановки (`bug` без настройки, файл вложения, проект пакета, `board_id` у `ensure_sprint` (по проекту задачи) и у `ensure_sprint_meta`/`ensure_sprint_state` (по проекту пакета)), паузы, `deliver_params` (`size`, `boards`, `history`), отчёты (`attachment_file`, `sync_run`) | `core.queue`, `core.files`, `core.projects`, `core.settings`, `settings`, `projects`, `commands`, `mirror`, `history`, `refresh`, `attachments`, `fetch` | | `agile` | чтение для `GET /agile`: доска, спринты, бэклог, выполнено по последней колонке | `core.db`, `history` | | `api` | маршруты модуля `MODULE.routes`: `/issues`, `/board`, `/agile`, `/changes`, `/attachments`, `/refresh`, `/sync-runs` и др. | `core.db`, `core.files`, `core.queue`, `core.settings`, `core.http`, `core.errors`, `attachments`, `projects`, `mirror`, `history`, `fetch`, `refresh` | -| `mcp` | инструменты MCP: `issues`, `issue`, `batch_create`, `batch_*`, `comment`, `attachment`, `changes`, `refresh`, `settings_*` и др.; общие `projects`/`project_create` подключает `register_core_tools` | `core.mcp`, `core.client`, `commands`, `attachments` | +| `mcp` | инструменты MCP: `issues` (колонка SP), `issue`, `sprints`, `sprint`, `backlog` (читают `GET /agile`), `batch_create`, `batch_*`, `comment`, `attachment`, `changes`, `refresh`, `settings_*` и др.; общие `projects`/`project_create` подключает `register_core_tools` | `core.mcp`, `core.client`, `commands`, `attachments` | | `importer`, `cli` | разовый импорт CSV-пакета в заметки (`sdlc-hub import FOLDER`) | `mirror`, `core.db`, `core.settings` | ### Модуль `wiki` (`modules/wiki/`) diff --git a/README.md b/README.md index c68fe80..4dc19b4 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,17 @@ MCP вики — отдельный сервер `sdlc-wiki` (`sdlc-hub mcp wiki Инструменты: `projects`, `project_create`, `root_set`, `tree`, `page`, `search`, `attachment`, `sync`, `status`. Пока у проекта нет корня вики (`root_set DOC-…` или `wiki_root` в `project_create`), они отвечают «у проекта <метка> нет корня вики». +Спринты и бэклог (нужна привязка к проекту; читают `GET /agile` проекта сессии): `sprints` — спринты доски с суммами +задач и SP «выполнено/всего»; `sprint [name]` — спринт (по умолчанию активный) с задачами по колонкам доски; +`backlog [limit]` — бэклог по рангу (по умолчанию 50 строк). В `issues` есть колонка SP. Команды спринтов и ранга ставятся +через `batch_create`, например смена состояния спринта с переносом открытых задач (хвост пакета хаб заменит на `refresh` по JQL проекта): + +```json +{"title": "Закрыть спринт 2", "commands": [ + {"type": "ensure_sprint_meta", "params": {"sprint": "S2", "goal": "Цель", "start_date": "2026-10-14", "end_date": "2026-10-28"}}, + {"type": "ensure_sprint_state", "params": {"sprint": "S2", "state": "closed", "move_open_to": "S3"}}]} +``` + Сессия MCP работает в разрезе одного проекта-метки. Привязка — `--project <метка>` в `.mcp.json` репозитория проекта (метка сравнивается точно, с учётом регистра): diff --git a/src/sdlc_hub/modules/jira/mcp.py b/src/sdlc_hub/modules/jira/mcp.py index c07bb17..b4fd06e 100644 --- a/src/sdlc_hub/modules/jira/mcp.py +++ b/src/sdlc_hub/modules/jira/mcp.py @@ -26,10 +26,126 @@ fmt_agent = core.fmt_agent def fmt_issues(issues) -> str: if not issues: return "задач нет" - lines = ["ключ | тема | статус | срок | исполнитель | спринт"] + lines = ["ключ | тема | статус | срок | исполнитель | спринт | SP"] for i in issues: lines.append(" | ".join([i["key"], core.cut(i.get("summary")), core.value(i.get("status")), core.value(i.get("duedate")), - core.value(i.get("assignee")), core.value(i.get("sprint"))])) + core.value(i.get("assignee")), core.value(i.get("sprint")), fmt_sp(i.get("story_points"))])) + return "\n".join(lines) + + +def fmt_sp(v) -> str: + """Оценка в story points: пусто — «—», целое без «.0».""" + return "—" if v is None else f"{v:g}" + + +def fmt_day(value) -> str: + """Время Jira (±HHMM, ±HH:MM, Z) → «ДД.ММ» в местном поясе; пусто или неразбираемо — «—».""" + if not isinstance(value, str) or not value: + return "—" + text = value.strip() + if text.endswith(("Z", "z")): + text = text[:-1] + "+00:00" + if len(text) > 5 and text[-5] in "+-" and text[-4:].isdigit(): + text = text[:-2] + ":" + text[-2:] + try: + at = datetime.fromisoformat(text) + except ValueError: + return "—" + if at.tzinfo is not None: + at = at.astimezone() + return at.strftime("%d.%m") + + +NO_BOARD = "у проекта нет доски" +NO_DATA = "данных доски нет — нужен агент v15 и обновление из Jira" +KANBAN = "у проекта канбан-доска, спринтов нет" +SPRINT_STATES = {"active": "активный", "future": "будущий", "closed": "закрыт"} +CLOSED_SHOWN = 10 + + +def _agile_refusal(data: dict, sprints: bool) -> str | None: + board = data.get("board") + if board is None: + return NO_BOARD + if board.get("fetched_at") is None: + return NO_DATA + if sprints and board.get("type") == "kanban": + return KANBAN + return None + + +def _sprint_dates(s: dict) -> str: + return f"{fmt_day(s.get('start_date'))}–{fmt_day(s.get('end_date'))}" + + +def fmt_sprints(data: dict) -> str: + refusal = _agile_refusal(data, True) + if refusal: + return refusal + items = data.get("sprints") or [] + if not items: + return "спринтов нет" + lines = ["спринт | состояние | даты | задачи | SP | без оценки"] + closed = 0 + for s in items: + if s["state"] == "closed": + closed += 1 + if closed > CLOSED_SHOWN: + continue + lines.append(f"{s['name']} | {SPRINT_STATES.get(s['state'], s['state'])} | {_sprint_dates(s)} | {s['count_done']}/{s['count']} | " + f"{fmt_sp(s['points_done'])}/{fmt_sp(s['points'])} | {s['unestimated']}") + if closed > CLOSED_SHOWN: + lines.append(f"ещё {closed - CLOSED_SHOWN} закрытых") + return "\n".join(lines) + + +def fmt_sprint(data: dict, name: str) -> str: + refusal = _agile_refusal(data, True) + if refusal: + return refusal + items = data.get("sprints") or [] + if name: + s = next((x for x in items if x["name"] == name), None) + if s is None: + return f"спринта «{name}» нет на доске" + else: + s = next((x for x in items if x["state"] == "active"), None) + if s is None: + return "активного спринта нет" + lines = [f"{s['name']} — {SPRINT_STATES.get(s['state'], s['state'])}; {_sprint_dates(s)}; цель: {core.value(s.get('goal'))}", + f"задачи {s['count_done']}/{s['count']}; SP {fmt_sp(s['points_done'])}/{fmt_sp(s['points'])}; без оценки: {s['unestimated']}"] + + def row(i): + return f" {i['key']} {core.cut(i.get('summary'), 80)} — {core.value(i.get('assignee'))}, SP {fmt_sp(i.get('story_points'))}" + rest = list(s.get("issues") or []) + for col in data["board"].get("columns") or []: + names = set(col.get("statuses") or []) + mine = [i for i in rest if i.get("status") in names] + rest = [i for i in rest if i.get("status") not in names] + lines.append(f"{col['name']} ({len(mine)}):") + lines.extend(row(i) for i in mine) + if rest: + lines.append(f"Без колонки ({len(rest)}):") + lines.extend(row(i) for i in rest) + return "\n".join(lines) + + +def fmt_backlog(data: dict, limit: int) -> str: + refusal = _agile_refusal(data, False) + if refusal: + return refusal + rows = data.get("backlog") + if rows is None: + return "у доски нет бэклога" + if not rows: + return "бэклог пуст" + shown = rows[:max(1, limit)] + lines = ["# | ключ | тема | статус | исполнитель | SP"] + for n, i in enumerate(shown, 1): + lines.append(" | ".join([str(n), i["key"], core.cut(i.get("summary")), core.value(i.get("status")), + core.value(i.get("assignee")), fmt_sp(i.get("story_points"))])) + if len(rows) > len(shown): + lines.append(f"ещё {len(rows) - len(shown)}") return "\n".join(lines) @@ -381,7 +497,7 @@ def _doc(text: str): @_doc("""Поставить пакет команд. Каждая команда — {"type": <тип>, "params": {...}}. Хвостовой refresh хаб добавит сам. Параметры по типам (необязательные — в [ ]): """ + describe() + """ -Проект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint выставляет хаб. +Проект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint, ensure_sprint_meta и ensure_sprint_state выставляет хаб; ensure_sprint_meta и ensure_sprint_state — только в сессии с проектом, хвост такого пакета — refresh по JQL проекта; оценка — story_points в ensure_fields. ensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит файл в хаб и подставит file, filename по умолчанию — имя файла.""") def batch_create(title: str, commands: list[dict]) -> str: @@ -393,6 +509,30 @@ def batch_create(title: str, commands: list[dict]) -> str: return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин" +@mcp.tool() +@core.guard +@core.scoped(True) +def sprints() -> str: + """Спринты доски проекта: состояние, даты, задачи и SP «выполнено/всего», без оценки; закрытые — 10 последних""" + return fmt_sprints(core.client().get("/agile", project=core.PROJECT)) + + +@mcp.tool() +@core.guard +@core.scoped(True) +def sprint(name: str = "") -> str: + """Спринт (по умолчанию активный): шапка с суммами и задачи по колонкам доски""" + return fmt_sprint(core.client().get("/agile", project=core.PROJECT), name) + + +@mcp.tool() +@core.guard +@core.scoped(True) +def backlog(limit: int = 50) -> str: + """Бэклог проекта по рангу: позиция, ключ, тема, статус, исполнитель, SP; limit — сколько строк (по умолчанию 50)""" + return fmt_backlog(core.client().get("/agile", project=core.PROJECT), limit) + + @mcp.tool() @core.guard @core.scoped(False) diff --git a/tests/fixtures/baseline/mcp_tools.expected.json b/tests/fixtures/baseline/mcp_tools.expected.json index 77a7337..d70e36d 100644 --- a/tests/fixtures/baseline/mcp_tools.expected.json +++ b/tests/fixtures/baseline/mcp_tools.expected.json @@ -228,7 +228,7 @@ "name": "notes_set" }, { - "description": "Поставить пакет команд. Каждая команда — {\"type\": <тип>, \"params\": {...}}. Хвостовой refresh хаб добавит сам.\nПараметры по типам (необязательные — в [ ]):\n- ensure_issue: temp_key (new:), issue_type (task|epic|bug), summary; [description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), labels (список), epic_key (ключ или new:), assignee (логин Jira)]\n- ensure_fields: key; [summary, description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), epic_key (ключ или new:), assignee (логин Jira), labels_add (список), labels_remove (список), story_points (число ≥ 0 или null)] — хотя бы одно поле\n- ensure_comment: key, marker (метка в начале комментария), body\n- ensure_status: key, target (статус или список-путь)\n- ensure_link: source, target, link_type (логическое имя из link_types); [replace (true|false)]\n- ensure_sprint: key, sprint (имя спринта или backlog)\n- ensure_attachment: key, file (sha256 из POST /files; в MCP — path к локальному файлу), filename (имя вложения)\n- fetch_attachment: key, attachment_id (id вложения из issue (цифры))\n- refresh: —; [keys (список), jql] — keys или jql\n- ensure_sprint_meta: sprint (имя спринта); [rename_to (новое имя), start_date (YYYY-MM-DD), end_date (YYYY-MM-DD), goal (цель спринта)]\n- ensure_sprint_state: sprint, state (active|closed); [move_open_to (имя спринта или backlog; обязателен при closed)]\n- ensure_rank: key; [before (ключ задачи, выше которой), after (ключ задачи, ниже которой)] — ровно одно из before, after\nПроект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint выставляет хаб.\nensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит\nфайл в хаб и подставит file, filename по умолчанию — имя файла.", + "description": "Поставить пакет команд. Каждая команда — {\"type\": <тип>, \"params\": {...}}. Хвостовой refresh хаб добавит сам.\nПараметры по типам (необязательные — в [ ]):\n- ensure_issue: temp_key (new:), issue_type (task|epic|bug), summary; [description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), labels (список), epic_key (ключ или new:), assignee (логин Jira)]\n- ensure_fields: key; [summary, description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), epic_key (ключ или new:), assignee (логин Jira), labels_add (список), labels_remove (список), story_points (число ≥ 0 или null)] — хотя бы одно поле\n- ensure_comment: key, marker (метка в начале комментария), body\n- ensure_status: key, target (статус или список-путь)\n- ensure_link: source, target, link_type (логическое имя из link_types); [replace (true|false)]\n- ensure_sprint: key, sprint (имя спринта или backlog)\n- ensure_attachment: key, file (sha256 из POST /files; в MCP — path к локальному файлу), filename (имя вложения)\n- fetch_attachment: key, attachment_id (id вложения из issue (цифры))\n- refresh: —; [keys (список), jql] — keys или jql\n- ensure_sprint_meta: sprint (имя спринта); [rename_to (новое имя), start_date (YYYY-MM-DD), end_date (YYYY-MM-DD), goal (цель спринта)]\n- ensure_sprint_state: sprint, state (active|closed); [move_open_to (имя спринта или backlog; обязателен при closed)]\n- ensure_rank: key; [before (ключ задачи, выше которой), after (ключ задачи, ниже которой)] — ровно одно из before, after\nПроект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint, ensure_sprint_meta и ensure_sprint_state выставляет хаб; ensure_sprint_meta и ensure_sprint_state — только в сессии с проектом, хвост такого пакета — refresh по JQL проекта; оценка — story_points в ensure_fields.\nensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит\nфайл в хаб и подставит file, filename по умолчанию — имя файла.", "inputSchema": { "properties": { "commands": { @@ -253,6 +253,45 @@ }, "name": "batch_create" }, + { + "description": "Спринты доски проекта: состояние, даты, задачи и SP «выполнено/всего», без оценки; закрытые — 10 последних", + "inputSchema": { + "properties": {}, + "title": "sprintsArguments", + "type": "object" + }, + "name": "sprints" + }, + { + "description": "Спринт (по умолчанию активный): шапка с суммами и задачи по колонкам доски", + "inputSchema": { + "properties": { + "name": { + "default": "", + "title": "Name", + "type": "string" + } + }, + "title": "sprintArguments", + "type": "object" + }, + "name": "sprint" + }, + { + "description": "Бэклог проекта по рангу: позиция, ключ, тема, статус, исполнитель, SP; limit — сколько строк (по умолчанию 50)", + "inputSchema": { + "properties": { + "limit": { + "default": 50, + "title": "Limit", + "type": "integer" + } + }, + "title": "backlogArguments", + "type": "object" + }, + "name": "backlog" + }, { "description": "Состояние пакета и его команд.", "inputSchema": { diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 42ee818..9e2c76b 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -85,8 +85,8 @@ def test_formatters(): "sprint": None, "fact_status": "В работе"}]) assert "x" * 79 + "…" in text and "x" * 80 not in text and "—" in text head, row = text.splitlines() - assert head == "ключ | тема | статус | срок | исполнитель | спринт" # статуса по факту нет (fact-status-removal) - assert len(row.split(" | ")) == 6 and "В работе" not in row + assert head == "ключ | тема | статус | срок | исполнитель | спринт | SP" # статуса по факту нет (fact-status-removal) + assert len(row.split(" | ")) == 7 and "В работе" not in row assert "очередь" in jmcp.fmt_agent({"online": False, "agent_version": None, "current_command_id": None, "rate_limited_24h": 0, "redelivered": [], "warning": None, "last_seen_at": None, "settings_version": None}) @@ -221,7 +221,7 @@ def test_fmt_batch_warns_about_claimed(): def test_batch_create_mentions_labels(): doc = jmcp.batch_create.__doc__ or "" assert "метку проекта сессии хаб дописывает в ensure_issue сам" in doc - assert "метки других проектов запрещены" in doc and "board_id для ensure_sprint выставляет хаб" in doc + assert "метки других проектов запрещены" in doc and "board_id для ensure_sprint, ensure_sprint_meta и ensure_sprint_state выставляет хаб" in doc assert "AWPCA" not in doc @@ -859,3 +859,95 @@ def test_attachment_existing_copy_same_size(downloads, monkeypatch): asyncio.run(jmcp.attachment(ATT_KEY, "31337")) assert path.read_bytes() == b"OLD" * 3 assert path.parent.stat().st_mtime > 1_000_000 + 100 # M-6: папка «освежена» — уборка её не заденет + + +# --- спринты, спринт, бэклог, колонка SP (#108, Task 7) --------------------------------------------------------- + +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(): + def it(key, status, summary="т", sp=None, assignee=None): + return {"key": key, "summary": summary, "status": status, "assignee": assignee, "story_points": sp} + issues = [it("A-1", "Backlog", "д" * 100, 2, "Иванов"), it("A-2", "Готово", sp=2.5), it("A-3", "Странный")] + data = {"board": BOARD, "backlog": [], "board_issues": None, "sprints": [ + _sp(5, "S5", "active", goal="Цель", count=3, count_done=1, points=4.5, points_done=2.5, unestimated=1, issues=issues), + _sp(4, "S4", "future")]} + out = jmcp.fmt_sprint(data, "").splitlines() + assert out[0].startswith("S5 — активный; ") and out[0].endswith("; цель: Цель") + assert out[1] == "задачи 1/3; SP 2.5/4.5; без оценки: 1" + assert out[2:] == ["К выполнению (1):", out[3], "Готово (1):", + " A-2 т — —, SP 2.5", "Без колонки (1):", " A-3 т — —, SP —"] + assert out[3].startswith(" A-1 ") and out[3].endswith(" — Иванов, SP 2") and len(out[3].split(" — ")[0]) <= len(" A-1 ") + 80 + assert jmcp.fmt_sprint(data, "S9") == "спринта «S9» нет на доске" + assert jmcp.fmt_sprint(data, "S4").splitlines()[0].startswith("S4 — будущий; ") + data["sprints"] = [_sp(4, "S4", "future")] + assert jmcp.fmt_sprint(data, "") == "активного спринта нет" + assert jmcp.fmt_sprint({"board": {**BOARD, "type": "kanban"}, "sprints": [], "backlog": None, "board_issues": []}, "") \ + == "у проекта канбан-доска, спринтов нет" + + +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 + assert jmcp.fmt_backlog({"board": BOARD, "sprints": [], "backlog": [], "board_issues": None}, 5) == "бэклог пуст" + assert jmcp.fmt_backlog({"board": BOARD, "sprints": [], "backlog": None, "board_issues": None}, 5) == "у доски нет бэклога" + + +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("2026-10-14T09:00:00.000Z") != "—" + assert jmcp.fmt_day(None) == "—" and jmcp.fmt_day("мусор") == "—" + + +def test_fmt_sp(): + assert [jmcp.fmt_sp(v) for v in (None, 3.0, 2.5, 0)] == ["—", "3", "2.5", "0"] + + +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 -- 2.47.2 From 8062dc76683521f9a3b1a72e75769e2c1038e503 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 19:41:32 +0400 Subject: [PATCH 13/21] =?UTF-8?q?=D0=92=D0=B5=D0=B1:=20=C2=AB=D0=A1=D0=B2?= =?UTF-8?q?=D1=8F=D0=B7=D0=B8=C2=BB=20=D0=BD=D0=B0=20/app/jira/links,=20?= =?UTF-8?q?=D1=83=D1=81=D0=BB=D0=BE=D0=B2=D0=BD=D1=8B=D0=B5=20=D0=BF=D1=83?= =?UTF-8?q?=D0=BD=D0=BA=D1=82=D1=8B=20=D0=BC=D0=B5=D0=BD=D1=8E=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- ARCHITECTURE.md | 6 +- CLAUDE.md | 2 +- README.md | 4 +- web/e2e/board.spec.ts | 6 +- web/e2e/hub.ts | 4 +- web/e2e/issues.spec.ts | 2 +- web/e2e/layout.spec.ts | 12 +++- web/e2e/login.spec.ts | 4 +- web/e2e/projects.spec.ts | 4 +- web/e2e/screens.spec.ts | 2 +- web/src/App.test.tsx | 31 +++++++--- web/src/modules/jira/Summary.tsx | 2 +- web/src/modules/jira/index.tsx | 7 ++- .../modules/jira/issues/IssueModal.test.tsx | 16 +++--- web/src/modules/jira/issues/modal.test.ts | 2 +- web/src/modules/jira/issues/modal.ts | 4 +- web/src/modules/modules.test.tsx | 4 +- web/src/modules/types.ts | 1 + web/src/projects/projects.test.tsx | 25 ++++++--- web/src/shell/NotFound.tsx | 2 +- web/src/shell/OverviewPage.test.tsx | 2 +- web/src/shell/Sidebar.test.tsx | 56 +++++++++---------- web/src/shell/Sidebar.tsx | 7 ++- web/src/shell/nav.test.ts | 16 ++++++ web/src/shell/nav.tsx | 5 ++ 25 files changed, 140 insertions(+), 86 deletions(-) create mode 100644 web/src/shell/nav.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 854bb6f..f4c788d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -98,15 +98,15 @@ его ядро: трекер пакетов и агентов — `web/src/shell/activity/` (`ActivityProvider`: подхват идущего пакета и `GET /agents` раз в 60 с, опрос пакета каждые 3 с, `useModuleActivity(module)`, тексты `events.ts`/`agentText.ts`), блок состояния панели — `ModuleStatusBlock`, а модуль даёт только `Summary` — карточку на «Обзоре» (`OverviewPage`: карточки модулей и «Последние пакеты проекта»). Адреса: «Обзор» — `/app/`, -страницы модуля — `/app/<модуль>/…` (`/app/jira/board`, `/app/jira/issues[/{key}]`, `/app/jira/feed`, `/app/wiki`); прежние `/app/board`, -`/app/issues`, `/app/issues/{key}`, `/app/feed` перенаправляются с сохранением `search` и `replace`. +страницы модуля — `/app/<модуль>/…` (`/app/jira/links`, `/app/jira/issues[/{key}]`, `/app/jira/feed`, `/app/wiki`); прежние `/app/board`, `/app/jira/board`, +`/app/issues`, `/app/issues/{key}`, `/app/feed` перенаправляются с сохранением `search` и `replace`. Пункты меню могут зависеть от проекта (`NavItem.when`, `visibleNav` в `shell/nav.tsx`): пока проект не определён, условный пункт скрыт; «не найдено» перечисляет только безусловные. Модуль `wiki` (`web/src/modules/wiki/index.tsx`): страница «Вики» (`/app/wiki`): дерево (`Tree`) с поиском (`Search`), страница (`WikiPage`, `PageView` — `react-markdown` + `remark-gfm`, без `rehype-raw`; ссылки `page:` ведут на страницу вики, `attachment:` скачивают файл через `download.ts`, `urlTransform.ts` пропускает только эти схемы и http(s)), форма корня без привязки (`RootForm`), сводка для «Обзора» (`Summary`) и `status` для ядра; поле «Корень вики» в окне «Новый проект» — через `projectFields`. -Модуль `jira` (`web/src/modules/jira/index.tsx`): страницы «Доска связей» (`/app/jira/board`), «Задачи» (`/app/jira/issues`), «Лента» +Модуль `jira` (`web/src/modules/jira/index.tsx`): страницы «Связи» (`/app/jira/links`, заголовок страницы — «Доска связей»), «Задачи» (`/app/jira/issues`), «Лента» (`/app/jira/feed`), карточка `/app/jira/issues/{key}` окном поверх страницы (`issues/overlay.tsx`, `IssueModal.tsx`; оболочка рисует страницу по `location.state.background`). Внутри: `board/` (чистые `model`, `analysis`, `layout`, `storage.ts` — ключи `localStorage` прежней страницы, у проекта AWPCA прежние, у остальных с суффиксом `@<метка>`; отрисовка `components/`; diff --git a/CLAUDE.md b/CLAUDE.md index 8c66522..244cdce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ SDLC-хаб с модулями jira и wiki. Мост Jira для АРМ КА: - `uv sync` — установка окружения. - `uv run sdlc-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов. - `uv run sdlc-hub serve [--host] [--port]` — локальный запуск хаба. -- Веб-приложение — `http://127.0.0.1:8790/app/` («Обзор» — `/app/?project=<метка>`, доска — `/app/jira/board?project=<метка>`, задачи — `/app/jira/issues?project=<метка>`, лента изменений — `/app/jira/feed?project=<метка>`, вики — `/app/wiki?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на «Обзор», прежние `/app/board`, `/app/issues`, `/app/feed` — на страницы модуля Jira. +- Веб-приложение — `http://127.0.0.1:8790/app/` («Обзор» — `/app/?project=<метка>`, «Связи» (доска связей) — `/app/jira/links?project=<метка>`, задачи — `/app/jira/issues?project=<метка>`, лента изменений — `/app/jira/feed?project=<метка>`, вики — `/app/wiki?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на «Обзор», прежние `/app/board`, `/app/jira/board`, `/app/issues`, `/app/feed` — на страницы модуля Jira; пункты меню могут зависеть от проекта (`NavItem.when`). - `make web` — сборка приложения (после `git pull` на Mac mini, до перезапуска хаба); `make check` — pytest + lint и тесты фронта; `cd web && npm run dev` — разработка с прокси на тестовый хаб `bash web/scripts/test-hub.sh` (:8791); `cd web && npm run e2e` — Playwright; `cd web && npm run golden` — перегенерировать эталоны доски из `links.html@3931a88` (только при смене фикстур). - `uv run sdlc-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run sdlc-hub mcp <модуль> [--project <метка>]` — MCP модуля по stdio (модуль обязателен: `jira` — сервер `sdlc-jira`, `wiki` — сервер `sdlc-wiki`; с привязкой сессии к проекту); `uv run sdlc-hub tokens` — показать токены (клиентский и по агенту на модуль). - `uv run sdlc-hub agent publish [--module jira|wiki] PATH` — опубликовать версию агента модуля для самообновления (только вверх; по умолчанию `jira`; публикация лежит в `release/<модуль>/`). diff --git a/README.md b/README.md index 4dc19b4..a5c71b6 100644 --- a/README.md +++ b/README.md @@ -246,8 +246,8 @@ curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8790/issues?sprint= ## Веб-приложение -Адреса: `http://127.0.0.1:8790/app/` — «Обзор» `/app/?project=<метка>` (карточки модулей Jira и «Вики» с состоянием агента, свежестью данных и обновлением, последние пакеты проекта), доска `/app/jira/board?project=<метка>`, задачи `/app/jira/issues?project=<метка>`, лента изменений `/app/jira/feed?project=<метка>` (период и виды событий, история — и в карточке задачи), вики `/app/wiki?project=<метка>`, карточка `/app/jira/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и -`/links` перенаправляют на «Обзор»; прежние адреса `/app/board`, `/app/issues`, `/app/feed` ведут на страницы модуля Jira. Вход по клиентскому токену (`uv run sdlc-hub tokens`), токен хранится в браузере; +Адреса: `http://127.0.0.1:8790/app/` — «Обзор» `/app/?project=<метка>` (карточки модулей Jira и «Вики» с состоянием агента, свежестью данных и обновлением, последние пакеты проекта), «Связи» (доска связей) `/app/jira/links?project=<метка>`, задачи `/app/jira/issues?project=<метка>`, лента изменений `/app/jira/feed?project=<метка>` (период и виды событий, история — и в карточке задачи), вики `/app/wiki?project=<метка>`, карточка `/app/jira/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и +`/links` перенаправляют на «Обзор»; прежние адреса `/app/board`, `/app/jira/board`, `/app/issues`, `/app/feed` ведут на страницы модуля Jira; пункты меню могут зависеть от проекта (`NavItem.when`). Вход по клиентскому токену (`uv run sdlc-hub tokens`), токен хранится в браузере; `localhost` и `127.0.0.1` — разные сайты для браузера, токен и раскладка у каждого свои. Выкатка на Mac mini: ``` diff --git a/web/e2e/board.spec.ts b/web/e2e/board.spec.ts index 6ccfce5..74cb14c 100644 --- a/web/e2e/board.spec.ts +++ b/web/e2e/board.spec.ts @@ -59,7 +59,7 @@ test("ⓘ открывает карточку окном поверх доски await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001$/); await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible(); await expect(page.getByTestId("page-board")).toBeAttached(); - await expect(page.getByRole("link", { name: "Доска связей", exact: true })).toHaveAttribute("aria-current", "page"); + await expect(page.getByRole("link", { name: "Связи", exact: true })).toHaveAttribute("aria-current", "page"); }); test("M-3: прокрутка и данные доски после окна карточки и после другого раздела", async ({ page }) => { @@ -80,13 +80,13 @@ test("M-3: прокрутка и данные доски после окна к await page.locator(`.board .card[data-key="${key}"] a.open-card`).click(); await expect(page.getByRole("dialog")).toBeVisible(); await page.keyboard.press("Escape"); - await expect(page).toHaveURL(/\/app\/jira\/board\?project=AWPCA$/); + await expect(page).toHaveURL(/\/app\/jira\/links\?project=AWPCA$/); expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2); expect(boardLoads).toBe(0); // доска не перезагружалась // боковая панель закреплена (#69): ссылки разделов видны и после прокрутки, щелчок не двигает доску await page.getByRole("link", { name: "Задачи", exact: true }).click(); await expect(page).toHaveURL(/\/app\/jira\/issues\?project=AWPCA$/); - await page.getByRole("link", { name: "Доска связей", exact: true }).click(); + await page.getByRole("link", { name: "Связи", exact: true }).click(); await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2); }); diff --git a/web/e2e/hub.ts b/web/e2e/hub.ts index 9641d4e..bb177da 100644 --- a/web/e2e/hub.ts +++ b/web/e2e/hub.ts @@ -5,12 +5,12 @@ import { HUB_HOME, HUB_URL } from "../playwright.config.ts"; export const tokens = () => JSON.parse(readFileSync(`${HUB_HOME}/tokens.json`, "utf8")) as { client: string; agents: Record }; const BASE = HUB_URL; -export async function login(page: Page, path = "/app/jira/board") { +export async function login(page: Page, path = "/app/jira/links") { await page.goto(path); await page.getByLabel("Клиентский токен хаба").fill(tokens().client); await page.getByRole("button", { name: "Войти" }).click(); // оболочка: на широком экране — ссылки разделов в боковой панели, на узком — кнопка «Меню» (#69) - await expect(page.getByRole("complementary", { name: "Панель приложения" }).getByRole("link", { name: "Доска связей" }).or(page.getByRole("button", { name: "Меню" }))).toBeVisible(); + await expect(page.getByRole("complementary", { name: "Панель приложения" }).getByRole("link", { name: "Связи" }).or(page.getByRole("button", { name: "Меню" }))).toBeVisible(); } async function call(path: string, body: unknown) { diff --git a/web/e2e/issues.spec.ts b/web/e2e/issues.spec.ts index a369251..999ca30 100644 --- a/web/e2e/issues.spec.ts +++ b/web/e2e/issues.spec.ts @@ -76,7 +76,7 @@ test("переход по связи и «назад»; ✕ и щелчок по await page.mouse.click(5, 300); // фон слева от окна await expect(page.getByRole("dialog")).toHaveCount(0); await page.goBack(); - await expect(page).toHaveURL(/\/app\/jira\/board\?project=AWPCA$/); + await expect(page).toHaveURL(/\/app\/jira\/links\?project=AWPCA$/); await expect(page.getByRole("dialog")).toHaveCount(0); }); diff --git a/web/e2e/layout.spec.ts b/web/e2e/layout.spec.ts index 2b9623a..e0fce73 100644 --- a/web/e2e/layout.spec.ts +++ b/web/e2e/layout.spec.ts @@ -49,7 +49,7 @@ test("C-1: на средних ширинах страница не шире о await expect(page.locator("table.issue-table tbody tr").first()).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1280); await page.setViewportSize({ width: 1000, height: 800 }); - await page.getByRole("link", { name: "Доска связей" }).click(); + await page.getByRole("link", { name: "Связи" }).click(); await expect(page.locator(".board .card[data-key]").first()).toBeVisible(); await page.getByRole("button", { name: "По датам" }).click(); await expect(page.locator(".dcol").first()).toBeVisible(); @@ -71,10 +71,16 @@ test("1440 px: «Задачи» не шире окна, h1 «Вики» — 20px expect(await h1.evaluate((el) => getComputedStyle(el).fontSize)).toBe("20px"); }); -test("/app/board → /app/jira/board с сохранением search", async ({ page }) => { +test("/app/board → /app/jira/links с сохранением search", async ({ page }) => { await login(page); await page.goto("/app/board?project=AWPCA"); - await expect(page).toHaveURL(/\/app\/jira\/board\?project=AWPCA$/); + await expect(page).toHaveURL(/\/app\/jira\/links\?project=AWPCA$/); +}); + +test("/app/jira/board → /app/jira/links с сохранением search", async ({ page }) => { + await login(page); + await page.goto("/app/jira/board?project=AWPCA"); + await expect(page).toHaveURL(/\/app\/jira\/links\?project=AWPCA$/); }); test("390 px: метка проекта в верхней строке, «Меню» модально", async ({ page }) => { diff --git a/web/e2e/login.spec.ts b/web/e2e/login.spec.ts index 9e6399e..c27418e 100644 --- a/web/e2e/login.spec.ts +++ b/web/e2e/login.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"; import { login } from "./hub.ts"; test("неверный токен — пояснение; верный — доска", async ({ page }) => { - await page.goto("/app/jira/board"); + await page.goto("/app/jira/links"); await page.getByLabel("Клиентский токен хаба").fill("wrong-token"); await page.getByRole("button", { name: "Войти" }).click(); await expect(page.getByText(/Токен не подошёл/)).toBeVisible(); @@ -16,7 +16,7 @@ test("прежние адреса ведут на страницы модуле await expect(page).toHaveURL(/\/app\/?\?project=AWPCA$/); await expect(page.getByTestId("page-overview")).toBeVisible(); await page.goto("/app/board?project=AWPCA"); - await expect(page).toHaveURL(/\/app\/jira\/board\?project=AWPCA$/); + await expect(page).toHaveURL(/\/app\/jira\/links\?project=AWPCA$/); await page.goto("/app/issues/AURORA-9001?project=AWPCA"); await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001\?project=AWPCA$/); }); diff --git a/web/e2e/projects.spec.ts b/web/e2e/projects.spec.ts index 3566d85..22270b6 100644 --- a/web/e2e/projects.spec.ts +++ b/web/e2e/projects.spec.ts @@ -5,7 +5,7 @@ import { agentServeRefresh, login } from "./hub.ts"; test.describe.serial("проекты", () => { test("первый вход: проект AWPCA в адресе, на доске задача проекта", async ({ page }) => { await login(page); - await expect(page).toHaveURL(/\/app\/jira\/board\?project=AWPCA$/); + await expect(page).toHaveURL(/\/app\/jira\/links\?project=AWPCA$/); await expect(page.locator('.board .card[data-key="AURORA-9001"]')).toBeVisible(); }); @@ -35,7 +35,7 @@ test.describe.serial("проекты", () => { }); test("переключение проекта и раздела", async ({ page }) => { - await login(page, "/app/jira/board?project=STOCKS_DRRK"); + await login(page, "/app/jira/links?project=STOCKS_DRRK"); await page.getByLabel("Проект", { exact: true }).selectOption({ label: "AWPCA — АРМ КА" }); await expect(page.locator('.board .card[data-key="AURORA-9001"]')).toBeVisible(); await page.getByRole("link", { name: "Задачи", exact: true }).click(); diff --git a/web/e2e/screens.spec.ts b/web/e2e/screens.spec.ts index 7959b5c..b4fdb4b 100644 --- a/web/e2e/screens.spec.ts +++ b/web/e2e/screens.spec.ts @@ -26,7 +26,7 @@ for (const theme of ["light", "dark"] as const) { }; test("login", async ({ page }) => { - await page.goto("/app/jira/board"); + await page.goto("/app/jira/links"); await expect(page.getByLabel("Клиентский токен хаба")).toBeVisible(); await settle(page); await shot(page, "login"); diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 1de4031..792206e 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -20,29 +20,44 @@ describe("оболочка", () => { }); it("переход «Задачи» и обратно", async () => { - const router = at("/app/jira/board"); + const router = at("/app/jira/links"); render(); await userEvent.click(screen.getByRole("link", { name: "Задачи" })); expect(router.state.location.pathname).toBe("/app/jira/issues"); expect(router.state.location.search).toBe("?project=AWPCA"); expect(await screen.findByTestId("page-issues")).toBeInTheDocument(); - await userEvent.click(screen.getByRole("link", { name: "Доска связей" })); - expect(router.state.location.pathname).toBe("/app/jira/board"); + await userEvent.click(screen.getByRole("link", { name: "Связи" })); + expect(router.state.location.pathname).toBe("/app/jira/links"); expect(router.state.location.search).toBe("?project=AWPCA"); expect(await screen.findByTestId("page-board")).toBeInTheDocument(); }); + it("прежний адрес доски связей", async () => { + const router = at("/app/jira/board?project=AWPCA"); + render(); + 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 () => { + const router = at("/app/board?project=AWPCA"); + render(); + await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/links")); + expect(router.state.location.search).toBe("?project=AWPCA"); + }); + it("неизвестный путь — «не найдено» со ссылками", async () => { render(); expect(await screen.findByRole("heading", { name: "Страница не найдена" })).toBeInTheDocument(); const main = within(screen.getByRole("main")); expect(main.getByRole("link", { name: "Обзор" })).toHaveAttribute("href", expect.stringMatching(/^\/app\/?$/)); - expect(main.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/jira/board"); + expect(main.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links"); expect(main.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/jira/issues"); }); it("«Сменить токен» показывает экран входа", async () => { - render(); + render(); await userEvent.click(within(await screen.findByRole("complementary", { name: "Панель приложения" })).getByRole("button", { name: "Ещё" })); // #68 M-2: на доске есть ещё и «Ещё — доска» await userEvent.click(screen.getByRole("button", { name: "Сменить токен" })); expect(getAuth().token).toBe(""); @@ -59,13 +74,13 @@ describe("оболочка", () => { expect(await screen.findByText(/сломалось/)).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Перезагрузить" })).toBeInTheDocument(); expect(screen.getAllByRole("alert")).toHaveLength(1); - expect(screen.getByRole("link", { name: "Доска связей" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Связи" })).toBeInTheDocument(); } finally { PAGE_ROUTES.splice(PAGE_ROUTES.indexOf(boom), 1); } }); it("без токена — только экран входа", () => { clearToken(); - render(); - expect(screen.queryByRole("link", { name: "Доска связей" })).not.toBeInTheDocument(); + render(); + expect(screen.queryByRole("link", { name: "Связи" })).not.toBeInTheDocument(); }); }); diff --git a/web/src/modules/jira/Summary.tsx b/web/src/modules/jira/Summary.tsx index 18a249a..467e255 100644 --- a/web/src/modules/jira/Summary.tsx +++ b/web/src/modules/jira/Summary.tsx @@ -24,7 +24,7 @@ export function Summary({ project }: { project: Project }) { {own.length} задач · {open.length} открытых · {late} просрочено

)} diff --git a/web/src/modules/jira/index.tsx b/web/src/modules/jira/index.tsx index 2fc7d5d..d00e39e 100644 --- a/web/src/modules/jira/index.tsx +++ b/web/src/modules/jira/index.tsx @@ -21,17 +21,18 @@ export const jiraModule: HubModule = { name: "jira", title: "Jira", pages: [ - { path: "/jira/board", element: {(p) => } }, + { path: "/jira/links", element: {(p) => } }, { path: "/jira/issues", element: {(p) => } }, { path: "/jira/feed", element: {(p) => } }, // прежние адреса (закладки): перенаправление с сохранением строки запроса, без новой записи истории - { path: "/board", element: }, + { path: "/jira/board", element: }, + { path: "/board", element: }, { path: "/issues", element: }, { path: "/issues/:key", element: }, { path: "/feed", element: }, ], nav: [ - { to: "/jira/board", label: "Доска связей", icon: }, + { to: "/jira/links", label: "Связи", icon: }, { to: "/jira/issues", label: "Задачи", icon: , padded: true }, { to: "/jira/feed", label: "Лента", icon: , padded: true, keepSearch: true }, ], diff --git a/web/src/modules/jira/issues/IssueModal.test.tsx b/web/src/modules/jira/issues/IssueModal.test.tsx index c58f40e..c646a4a 100644 --- a/web/src/modules/jira/issues/IssueModal.test.tsx +++ b/web/src/modules/jira/issues/IssueModal.test.tsx @@ -33,7 +33,7 @@ describe("карточка окном", () => { it("из списка: окно поверх списка, фон не пересоздаётся, ✕ возвращает со старым фильтром, «назад» не открывает карточку", async () => { sessionStorage.setItem("sdlc-hub-issue-filters", "1"); // панель «Фильтры» раскрыта - const router = at(["/app/jira/board", "/app/jira/issues?sprint=AWPCA_Спринт 2"]); + const router = at(["/app/jira/links", "/app/jira/issues?sprint=AWPCA_Спринт 2"]); render(); const table = await screen.findByRole("table"); expect(screen.getByLabelText("Спринт")).toHaveValue("AWPCA_Спринт 2"); @@ -49,7 +49,7 @@ describe("карточка окном", () => { expect(new URLSearchParams(router.state.location.search).get("sprint")).toBe("AWPCA_Спринт 2"); expect(within(screen.getByRole("main")).getByRole("table")).toBe(table); // таблица команд карточки — в окне, не в main await act(async () => { await router.navigate(-1); }); - expect(router.state.location.pathname).toBe("/app/jira/board"); + expect(router.state.location.pathname).toBe("/app/jira/links"); }); it("переход по связи меняет окно, «назад» возвращает прежнюю карточку, Esc закрывает всё", async () => { @@ -89,22 +89,22 @@ describe("карточка окном", () => { expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9001"); }); - it("с доски: окно поверх доски, раздел «Доска связей» активен, после закрытия доска та же", async () => { - const router = at(["/app/jira/board"]); + it("с доски: окно поверх доски, раздел «Связи» активен, после закрытия доска та же", async () => { + const router = at(["/app/jira/links"]); render(); const ilink = await screen.findByRole("link", { name: "Открыть карточку AURORA-9001" }); const boardEl = screen.getByTestId("page-board"); await userEvent.click(ilink); expect(await within(dialog()).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument(); expect(screen.getByTestId("page-board")).toBe(boardEl); - expect(screen.getByRole("link", { name: "Доска связей" })).toHaveAttribute("aria-current", "page"); + expect(screen.getByRole("link", { name: "Связи" })).toHaveAttribute("aria-current", "page"); await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); - await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/board")); + await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/links")); expect(screen.getByTestId("page-board")).toBe(boardEl); }); it("прямой адрес: под окном список задач, ✕ ведёт в список заменой записи", async () => { - const router = at(["/app/jira/board", "/app/jira/issues/AURORA-9001"]); + const router = at(["/app/jira/links", "/app/jira/issues/AURORA-9001"]); render(); expect(await within(await screen.findByRole("dialog")).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument(); expect(await screen.findByTestId("page-issues")).toBeInTheDocument(); @@ -112,7 +112,7 @@ describe("карточка окном", () => { await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/issues")); await act(async () => { await router.navigate(-1); }); - expect(router.state.location.pathname).toBe("/app/jira/board"); // не карточка + expect(router.state.location.pathname).toBe("/app/jira/links"); // не карточка }); it("прямой адрес и переход по связи: закрытие возвращает в список, а не на первую карточку", async () => { diff --git a/web/src/modules/jira/issues/modal.test.ts b/web/src/modules/jira/issues/modal.test.ts index b7e18db..d832581 100644 --- a/web/src/modules/jira/issues/modal.test.ts +++ b/web/src/modules/jira/issues/modal.test.ts @@ -15,7 +15,7 @@ describe("состояние окна в истории", () => { const s = { background: LIST_BACKGROUND, depth: 0, direct: true }; expect(openCardState({ pathname: "/jira/issues/A-1", search: "", state: s })).toEqual({ background: LIST_BACKGROUND, depth: 1, direct: true }); expect(openCardState({ pathname: "/jira/issues/A-2", search: "", state: BOARD_CARD_STATE })) - .toEqual({ background: { pathname: "/jira/board", search: "" }, depth: 2, direct: false }); + .toEqual({ background: { pathname: "/jira/links", search: "" }, depth: 2, direct: false }); }); it("чужое или битое state — не состояние окна", () => { expect(cardState(null)).toBeNull(); diff --git a/web/src/modules/jira/issues/modal.ts b/web/src/modules/jira/issues/modal.ts index ecd846c..df8beca 100644 --- a/web/src/modules/jira/issues/modal.ts +++ b/web/src/modules/jira/issues/modal.ts @@ -9,8 +9,8 @@ export function listBackground(cardSearch: string): Background { const project = new URLSearchParams(cardSearch).get("project"); return { pathname: ISSUES_PATH, search: project ? "?" + new URLSearchParams({ project }).toString() : "" }; } -// доска не хранит состояние в адресе: «ⓘ» всегда открывает окно поверх /jira/board -export const BOARD_CARD_STATE: CardState = { background: { pathname: "/jira/board", search: "" }, depth: 1, direct: false }; +// доска не хранит состояние в адресе: «ⓘ» всегда открывает окно поверх /jira/links +export const BOARD_CARD_STATE: CardState = { background: { pathname: "/jira/links", search: "" }, depth: 1, direct: false }; export function cardState(s: unknown): CardState | null { const c = s as { background?: { pathname?: unknown; search?: unknown }; depth?: unknown; direct?: unknown } | null; diff --git a/web/src/modules/modules.test.tsx b/web/src/modules/modules.test.tsx index a801327..39b0e5a 100644 --- a/web/src/modules/modules.test.tsx +++ b/web/src/modules/modules.test.tsx @@ -6,12 +6,12 @@ import { MODULES } from "./index.ts"; describe("сборка оболочки из модулей", () => { it("страницы: «Обзор», адреса jira, перенаправления, вики, NotFound последним", () => { - expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/jira/board", "/jira/issues", "/jira/feed", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]); + expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/jira/links", "/jira/issues", "/jira/feed", "/jira/board", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]); }); it("пункты панели: подписи прежние, адреса jira с префиксом, плюс вики", () => { expect(MODULES.flatMap((m) => m.nav).map((n) => [n.label, n.to])).toEqual([ - ["Доска связей", "/jira/board"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"], + ["Связи", "/jira/links"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"], ]); }); diff --git a/web/src/modules/types.ts b/web/src/modules/types.ts index 379b94a..75cd887 100644 --- a/web/src/modules/types.ts +++ b/web/src/modules/types.ts @@ -7,6 +7,7 @@ export type NavItem = { to: string; label: string; icon: ReactNode; title?: string; // название страницы для заголовка вкладки; по умолчанию label padded?: boolean; // страница с полями (не во всю ширину): оболочка не ставит ей класс page--board; для адресов вне панели класс ставится + when?: (p: Project) => boolean; // пункт только для проектов, где условие верно; пока проект не определён (проекты не загружены) — скрыт keepSearch?: boolean; // при смене проекта строка запроса страницы сохраняется (параметры не проектные); иначе сбрасывается }; // окно поверх страницы (карточка задачи): pageLocation — адрес страницы под окном, null — окна нет diff --git a/web/src/projects/projects.test.tsx b/web/src/projects/projects.test.tsx index 406f9c6..84e683e 100644 --- a/web/src/projects/projects.test.tsx +++ b/web/src/projects/projects.test.tsx @@ -47,7 +47,7 @@ describe("проект в приложении", () => { it("первый вход после выкатки: проект подставляется в адрес и запоминается", async () => { const h = hub(); - const router = open("/app/jira/board"); + const router = open("/app/jira/links"); await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA")); expect(router.state.historyAction).toBe("REPLACE"); await waitFor(() => expect(h.get("/board").length).toBeGreaterThan(0)); @@ -57,7 +57,7 @@ describe("проект в приложении", () => { it("переключение проекта: доска, ссылка «Задачи», список и варианты фильтров", async () => { const h = hub(); - const router = open("/app/jira/board?project=AWPCA"); + const router = open("/app/jira/links?project=AWPCA"); await screen.findByTestId("page-board"); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS_DRRK"); expect(router.state.historyAction).toBe("PUSH"); @@ -90,7 +90,7 @@ describe("проект в приложении", () => { it("проектов нет: приглашение создать, без GET /board; щелчок открывает окно", async () => { const h = hub([]); - open("/app/jira/board"); + open("/app/jira/links"); await userEvent.click(await screen.findByRole("button", { name: "+ Новый проект…" })); expect(screen.getByRole("dialog", { name: "Новый проект" })).toHaveAttribute("open"); expect(h.get("/board")).toHaveLength(0); @@ -103,7 +103,7 @@ describe("проект в приложении", () => { "GET /projects": () => json(200, { projects: created ? [A, S] : [A] }), // блок состояния берёт проект из списка "GET /batches/7": () => json(200, { id: 7, author: "viewer", status: "queued", remaining_seconds: 5, counts: {}, commands: [{ type: "refresh", status: "queued" }] }), }); - const router = open("/app/jira/board?project=AWPCA"); + const router = open("/app/jira/links?project=AWPCA"); await screen.findByTestId("page-board"); const listed = h.get("/batches").length; await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); @@ -122,7 +122,7 @@ describe("проект в приложении", () => { it("с номером доски board_id уходит числом", async () => { const h = hub([A], { "POST /projects": () => json(201, { project: S, batch_id: 7, batches: [{ module: "jira", id: 7 }] }) }); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); await screen.findByTestId("page-board"); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); const dlg = screen.getByRole("dialog", { name: "Новый проект" }); @@ -137,7 +137,7 @@ describe("проект в приложении", () => { it("метка занята: окно открыто, текст ошибки хаба рядом с «Создать»", async () => { hub([A], { "POST /projects": () => json(409, { error: "project_exists", detail: "label: проект с такой меткой уже есть: AWPCA" }) }); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); await screen.findByTestId("page-board"); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); const dlg = screen.getByRole("dialog", { name: "Новый проект" }); @@ -155,7 +155,7 @@ describe("проект в приложении", () => { "POST /projects": () => json(201, { project: S, batch_id: 7, batches: [{ module: "jira", id: 7 }] }), "GET /batches/7": () => json(200, { id: 7, author: "viewer", status: "queued", remaining_seconds: 5, counts: {}, commands: [{ type: "refresh", status: "queued" }] }), }); - const router = open("/app/jira/board?project=AWPCA"); + const router = open("/app/jira/links?project=AWPCA"); await screen.findByTestId("page-board"); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); const dlg = screen.getByRole("dialog", { name: "Новый проект" }); @@ -172,7 +172,7 @@ describe("проект в приложении", () => { let release!: () => void; const gate = new Promise((r) => { release = r; }); hub([A], { "POST /projects": async () => { await gate; return json(201, { project: S, batch_id: 7, batches: [{ module: "jira", id: 7 }] }); } }); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); await screen.findByTestId("page-board"); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); const dlg = screen.getByRole("dialog", { name: "Новый проект" }); @@ -226,11 +226,18 @@ describe("адреса модулей и заголовок вкладки", () it("прежние /board, /issues, /feed перенаправляют с сохранением search, replace", async () => { hub(); const router = open("/app/board?project=STOCKS_DRRK&x=1"); - await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/board")); + await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/links")); expect(router.state.location.search).toBe("?project=STOCKS_DRRK&x=1"); expect(router.state.historyAction).toBe("REPLACE"); }); + it("прежний /jira/board перенаправляет на /jira/links с search, replace", async () => { + hub(); + const router = open("/app/jira/board?project=AWPCA"); + await waitFor(() => expect(router.state.location.pathname + router.state.location.search).toBe("/app/jira/links?project=AWPCA")); + expect(router.state.historyAction).toBe("REPLACE"); + }); + it("смена проекта на «Вики» у проекта без вики остаётся на «Вики»", async () => { hub([W, S], { "GET /wiki/status": () => json(409, { error: "no_root", detail: "нет корня" }) }); const router = open("/app/wiki?project=AWPCA"); diff --git a/web/src/shell/NotFound.tsx b/web/src/shell/NotFound.tsx index 6d56a88..ff5c865 100644 --- a/web/src/shell/NotFound.tsx +++ b/web/src/shell/NotFound.tsx @@ -7,7 +7,7 @@ export function NotFound() { return (
Страница не найдена}> - Разделы приложения: {NAV.map((n, i) => {i > 0 && ", "}{n.label})}. + Разделы приложения: {NAV.filter((n) => !n.when).map((n, i) => {i > 0 && ", "}{n.label})}.
); diff --git a/web/src/shell/OverviewPage.test.tsx b/web/src/shell/OverviewPage.test.tsx index 1bf34b7..e3f8900 100644 --- a/web/src/shell/OverviewPage.test.tsx +++ b/web/src/shell/OverviewPage.test.tsx @@ -73,7 +73,7 @@ describe("страница «Обзор»", () => { open(); const jira = within(await card("Jira")); expect(await jira.findByText((_, el) => el?.classList.contains("jira-summary__counts") === true && el.textContent === "4 задач · 3 открытых · 1 просрочено")).toBeInTheDocument(); - expect(jira.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/jira/board?project=AWPCA"); + expect(jira.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links?project=AWPCA"); expect(jira.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/jira/issues?project=AWPCA"); expect(jira.getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/jira/feed?project=AWPCA"); }); diff --git a/web/src/shell/Sidebar.test.tsx b/web/src/shell/Sidebar.test.tsx index d4e07ff..a74b505 100644 --- a/web/src/shell/Sidebar.test.tsx +++ b/web/src/shell/Sidebar.test.tsx @@ -43,13 +43,13 @@ const bar = () => screen.getByRole("complementary", { name: "Панель при describe("боковая панель", () => { beforeEach(() => { setToken("tok"); localStorage.clear(); }); - it("разделы — ссылки «Доска связей» и «Задачи», активная с aria-current", async () => { + it("разделы — ссылки «Связи» и «Задачи», активная с aria-current", async () => { hub(); open("/app/jira/issues?project=AWPCA"); - await screen.findByRole("link", { name: "Доска связей" }); - await waitFor(() => expect(screen.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/jira/board?project=AWPCA")); + await screen.findByRole("link", { name: "Связи" }); + await waitFor(() => expect(screen.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links?project=AWPCA")); expect(screen.getByRole("link", { name: "Задачи" })).toHaveAttribute("aria-current", "page"); - expect(screen.getByRole("link", { name: "Доска связей" })).not.toHaveAttribute("aria-current"); + expect(screen.getByRole("link", { name: "Связи" })).not.toHaveAttribute("aria-current"); }); it("раздел «Лента» ведёт на /feed с проектом и отмечается как текущий", async () => { @@ -67,10 +67,10 @@ describe("боковая панель", () => { open("/app/nowhere?project=AWPCA"); expect(await screen.findByRole("heading", { name: "Страница не найдена" })).toBeInTheDocument(); const nav = within(bar()).getByRole("navigation", { name: "Разделы" }); - expect(within(nav).getByRole("link", { name: "Доска связей" })).not.toHaveAttribute("aria-current"); + expect(within(nav).getByRole("link", { name: "Связи" })).not.toHaveAttribute("aria-current"); expect(within(nav).getByRole("link", { name: "Задачи" })).not.toHaveAttribute("aria-current"); const main = within(screen.getByRole("main")); - expect(main.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/jira/board"); + expect(main.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links"); expect(main.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/jira/issues"); expect(main.getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/jira/feed"); // M-2 expect(main.getByRole("link", { name: "Обзор" })).toHaveAttribute("href", expect.stringMatching(/^\/app\/?$/)); @@ -98,13 +98,13 @@ describe("боковая панель", () => { it("агент офлайн — «агент офлайн»; на связи — «агент на связи»", async () => { hub({ "GET /agents": () => json(200, { agents: [{ module: "jira", online: false, last_seen_at: "2026-10-14T09:30:00+00:00", warning: null, update_hint: null }] }) }); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); expect(await within(bar()).findByText("агент офлайн")).toBeInTheDocument(); }); it("агент на связи", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); expect(await within(bar()).findByText("агент на связи")).toBeInTheDocument(); }); @@ -122,7 +122,7 @@ describe("боковая панель", () => { it("блок состояния не грузит GET /wiki/status; у проекта без корня вики строки «Вики» нет", async () => { const h = hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); await within(bar()).findByRole("button", { name: /^Обновить из Jira/ }); expect(within(bar()).queryByRole("button", { name: /^Обновить вики/ })).toBeNull(); expect(h.count("GET", "/wiki/status")).toBe(0); @@ -130,12 +130,12 @@ describe("боковая панель", () => { it("группы по модулям: «Обзор» первым, затем заголовки «Jira» и «Вики» со своими разделами", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); const nav = within(bar()).getByRole("navigation", { name: "Разделы" }); const links = (await within(nav).findAllByRole("link")).map((l) => l.textContent); - expect(links).toEqual(["Обзор", "Доска связей", "Задачи", "Лента", "Вики"]); + expect(links).toEqual(["Обзор", "Связи", "Задачи", "Лента", "Вики"]); const jira = within(nav).getByRole("group", { name: "Jira" }); - expect(within(jira).getAllByRole("link").map((l) => l.textContent)).toEqual(["Доска связей", "Задачи", "Лента"]); + expect(within(jira).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Задачи", "Лента"]); expect(within(nav).getByRole("group", { name: "Вики" })).toBeInTheDocument(); await waitFor(() => expect(within(nav).getByRole("link", { name: "Обзор" })).toHaveAttribute("href", expect.stringMatching(/^\/app\/?\?project=AWPCA$/))); }); @@ -143,7 +143,7 @@ describe("боковая панель", () => { it("свёрнутая: метка проекта целиком в title (M-4), у групп скрытые названия", async () => { localStorage.setItem("sdlc-hub-sidebar", "collapsed"); hub({ "GET /projects": () => json(200, { projects: [{ ...A, label: "STOCKS_DRRK_LONG" }] }) }); - open("/app/jira/board?project=STOCKS_DRRK_LONG"); + open("/app/jira/links?project=STOCKS_DRRK_LONG"); const tag = await within(bar()).findByRole("button", { name: "Проект STOCKS_DRRK_LONG — развернуть панель" }); expect(tag).toHaveAttribute("title", "Проект STOCKS_DRRK_LONG"); expect(within(bar()).getByRole("group", { name: "Jira" })).toBeInTheDocument(); @@ -151,7 +151,7 @@ describe("боковая панель", () => { it("меню «Ещё» → «Сменить токен» показывает экран входа", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); await userEvent.click(await screen.findByRole("button", { name: "Ещё" })); await userEvent.click(screen.getByRole("button", { name: "Сменить токен" })); expect(getAuth().token).toBe(""); @@ -160,13 +160,13 @@ describe("боковая панель", () => { it("переключатель темы — в панели", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); expect(await within(bar()).findByRole("switch", { name: "Тёмная тема" })).toBeInTheDocument(); }); it("кнопка «Обновить из Jira» в шапке доски — вторичная, рядом со значком панели «— все проекты»", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); await screen.findByTestId("page-board"); await screen.findByText(/задач из/); expect(screen.getAllByRole("button", { name: "Обновить из Jira" })).toHaveLength(1); @@ -175,21 +175,21 @@ describe("боковая панель", () => { it("разделы — в навигации «Разделы» боковой панели", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); const nav = within(bar()).getByRole("navigation", { name: "Разделы" }); - expect(await within(nav).findByRole("link", { name: "Доска связей" })).toHaveAttribute("aria-current", "page"); + expect(await within(nav).findByRole("link", { name: "Связи" })).toHaveAttribute("aria-current", "page"); }); it("«Свернуть панель» сворачивает и запоминает в sdlc-hub-sidebar; после перезапуска свёрнута, разделы доступны", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); const btn = await within(bar()).findByRole("button", { name: "Свернуть панель" }); expect(btn).toHaveAttribute("aria-expanded", "true"); await userEvent.click(btn); expect(localStorage.getItem("sdlc-hub-sidebar")).toBe("collapsed"); expect(bar()).toHaveClass("is-collapsed"); cleanup(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); expect(bar()).toHaveClass("is-collapsed"); expect(within(bar()).getByRole("button", { name: "Развернуть панель" })).toHaveAttribute("aria-expanded", "false"); expect(within(bar()).getByRole("link", { name: "Задачи" })).toHaveAttribute("title", "Задачи"); @@ -213,7 +213,7 @@ describe("боковая панель", () => { it("свёрнутая: метка проекта видна, щелчок по ней разворачивает панель", async () => { localStorage.setItem("sdlc-hub-sidebar", "collapsed"); hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); const tag = await within(bar()).findByRole("button", { name: "Проект AWPCA — развернуть панель" }); await userEvent.click(tag); expect(bar()).not.toHaveClass("is-collapsed"); @@ -229,7 +229,7 @@ describe("боковая панель на узком экране", () => { it("сверху строка с «Меню», меткой проекта и сводной точкой; кнопки обновления и панели нет", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); const top = await screen.findByRole("banner", { name: "Строка приложения" }); expect(within(top).getByRole("button", { name: "Меню" })).toHaveAttribute("aria-expanded", "false"); expect(await within(top).findByText("AWPCA")).toBeInTheDocument(); @@ -240,7 +240,7 @@ describe("боковая панель на узком экране", () => { it("«Меню» открывает панель с фокусом на первом разделе; Escape закрывает и возвращает фокус", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); const menu = await screen.findByRole("button", { name: "Меню" }); await userEvent.click(menu); expect(menu).toHaveAttribute("aria-expanded", "true"); @@ -254,7 +254,7 @@ describe("боковая панель на узком экране", () => { it("выбор раздела закрывает панель и открывает раздел", async () => { hub(); - const router = open("/app/jira/board?project=AWPCA"); + const router = open("/app/jira/links?project=AWPCA"); await userEvent.click(await screen.findByRole("button", { name: "Меню" })); await userEvent.click(within(bar()).getByRole("link", { name: "Задачи" })); expect(router.state.location.pathname).toBe("/app/jira/issues"); @@ -263,7 +263,7 @@ describe("боковая панель на узком экране", () => { it("щелчок по подложке и «Закрыть меню» закрывают панель", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); await userEvent.click(await screen.findByRole("button", { name: "Меню" })); await userEvent.click(screen.getByTestId("sidebar-backdrop")); expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); @@ -274,7 +274,7 @@ describe("боковая панель на узком экране", () => { it("I-1: событие пакета видно строкой под верхней строкой в постоянном живом регионе", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); await screen.findByRole("banner", { name: "Строка приложения" }); const strip = screen.getByTestId("topbar-events"); expect(strip).toHaveAttribute("role", "status"); // T10.1: контейнер на месте до появления текста @@ -287,7 +287,7 @@ describe("боковая панель на узком экране", () => { it("drawer is modal: фокус в панели, страница инертна; Escape во вложенном меню закрывает только меню (M-1)", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); const menu = await screen.findByRole("button", { name: "Меню" }); await userEvent.click(menu); expect(within(bar()).getByRole("link", { name: "Обзор" })).toHaveFocus(); @@ -306,7 +306,7 @@ describe("боковая панель на узком экране", () => { it("#73 M-2: панель — модальное окно «Меню» (role=dialog, aria-modal), прокрутка страницы под ней заблокирована до закрытия", async () => { hub(); - open("/app/jira/board?project=AWPCA"); + open("/app/jira/links?project=AWPCA"); await userEvent.click(await screen.findByRole("button", { name: "Меню" })); const dlg = screen.getByRole("dialog", { name: "Меню" }); expect(dlg).toHaveAttribute("aria-modal", "true"); diff --git a/web/src/shell/Sidebar.tsx b/web/src/shell/Sidebar.tsx index 3e70d57..f8a013d 100644 --- a/web/src/shell/Sidebar.tsx +++ b/web/src/shell/Sidebar.tsx @@ -6,7 +6,8 @@ import type { Ref } from "react"; import { clearToken } from "../auth/token.ts"; import { useActivitySummary } from "./activity/activity.ts"; import { ModuleEventStrip, ModuleStatusBlock } from "./ModuleStatusBlock.tsx"; -import { GROUPS, OVERVIEW } from "./nav.tsx"; +import { GROUPS, OVERVIEW, visibleNav } from "./nav.tsx"; +import { useProjectContext } from "../projects/ProjectContext.ts"; import { withProject } from "../projects/current.ts"; import { ProjectPicker } from "../projects/ProjectPicker.tsx"; import { IconButton, IconClose, IconMenu, IconSidebar, Menu, MenuItem, ThemeSwitch } from "../ui/index.ts"; @@ -75,6 +76,8 @@ function NarrowShell(p: Props) { // модулей, тема, меню «Ещё»; прокручивается середина, низ с меню «Ещё» остаётся на месте (M-5 #73) function Panel({ section, project, drawer }: Props & { drawer?: { close(): void; navigated(): void } }) { const qc = useQueryClient(); + const ctx = useProjectContext().state; + const current = ctx.kind === "ok" ? ctx.project : null; const [stored, setCollapsed] = useState(readSidebar); const collapsed = !drawer && stored; const fold = (c: boolean) => { setCollapsed(c); saveSidebar(c); }; @@ -122,7 +125,7 @@ function Panel({ section, project, drawer }: Props & { drawer?: { close(): void; {GROUPS.map((g) => (
{g.title} - {g.nav.map((n) => link(n))} + {visibleNav(g.nav, current).map((n) => link(n))}
))} diff --git a/web/src/shell/nav.test.ts b/web/src/shell/nav.test.ts new file mode 100644 index 0000000..c654dfa --- /dev/null +++ b/web/src/shell/nav.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import type { Project } from "../api/types.ts"; +import { visibleNav } from "./nav.tsx"; + +const A = { label: "AWPCA", name: "", created_at: "", board_type: "scrum" } as unknown as Project; +const items = [ + { to: "/a", label: "A", icon: null }, + { to: "/b", label: "B", icon: null, when: (p: Project) => p.board_type === "scrum" }, +]; +describe("visibleNav", () => { + 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"]); + }); +}); diff --git a/web/src/shell/nav.tsx b/web/src/shell/nav.tsx index b9fca92..7355641 100644 --- a/web/src/shell/nav.tsx +++ b/web/src/shell/nav.tsx @@ -1,4 +1,5 @@ import { MODULES } from "../modules/index.ts"; +import type { Project } from "../api/types.ts"; import type { NavItem } from "../modules/types.ts"; import { IconHome } from "../ui/index.ts"; @@ -6,3 +7,7 @@ import { IconHome } from "../ui/index.ts"; export const OVERVIEW: NavItem = { to: "/", label: "Обзор", icon: , padded: true }; export const GROUPS = MODULES.filter((m) => m.nav.length > 0).map((m) => ({ name: m.name, title: m.title, nav: m.nav })); export const NAV: NavItem[] = [OVERVIEW, ...GROUPS.flatMap((g) => g.nav)]; + +// пункты, видимые для проекта: безусловные — всегда, условные (`when`) — когда проект известен и условие верно +export const visibleNav = (items: NavItem[], project: Project | null): NavItem[] => + items.filter((n) => !n.when || (project !== null && n.when(project))); -- 2.47.2 From b5f5efef8502339a01b76acf3b876d7b3573e557 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 19:52:24 +0400 Subject: [PATCH 14/21] =?UTF-8?q?=D0=92=D0=B5=D0=B1:=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=20=C2=AB=D0=91=D1=8D=D0=BA=D0=BB?= =?UTF-8?q?=D0=BE=D0=B3=C2=BB=20=D0=B8=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B5=20?= =?UTF-8?q?=D1=87=D0=B0=D1=81=D1=82=D0=B8=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=B4=D0=BE=D1=81=D0=BA=D0=B8=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- web/src/modules/jira/agile/AgileGate.tsx | 37 ++++ .../modules/jira/agile/BacklogPage.test.tsx | 169 ++++++++++++++++++ web/src/modules/jira/agile/BacklogPage.tsx | 75 ++++++++ web/src/modules/jira/agile/IssueRow.tsx | 25 +++ web/src/modules/jira/agile/TypeIcon.tsx | 9 + web/src/modules/jira/agile/agile.css | 38 ++++ web/src/modules/jira/agile/model.test.ts | 38 ++++ web/src/modules/jira/agile/model.ts | 39 ++++ web/src/modules/jira/api/queries.ts | 7 +- web/src/modules/jira/api/types.ts | 19 ++ web/src/modules/jira/index.tsx | 6 +- web/src/modules/jira/projectFields.ts | 6 + web/src/modules/jira/status.ts | 2 +- web/src/modules/modules.test.tsx | 4 +- web/src/ui/icons.tsx | 1 + web/vite.config.ts | 2 +- 16 files changed, 471 insertions(+), 6 deletions(-) create mode 100644 web/src/modules/jira/agile/AgileGate.tsx create mode 100644 web/src/modules/jira/agile/BacklogPage.test.tsx create mode 100644 web/src/modules/jira/agile/BacklogPage.tsx create mode 100644 web/src/modules/jira/agile/IssueRow.tsx create mode 100644 web/src/modules/jira/agile/TypeIcon.tsx create mode 100644 web/src/modules/jira/agile/agile.css create mode 100644 web/src/modules/jira/agile/model.test.ts create mode 100644 web/src/modules/jira/agile/model.ts diff --git a/web/src/modules/jira/agile/AgileGate.tsx b/web/src/modules/jira/agile/AgileGate.tsx new file mode 100644 index 0000000..709d69d --- /dev/null +++ b/web/src/modules/jira/agile/AgileGate.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from "react"; +import { Link } from "react-router"; +import type { Project } from "../../../api/types.ts"; +import { Button, Notice } from "../../../ui/index.ts"; +import { useAgile } from "../api/queries.ts"; +import type { AgileResponse } from "../api/types.ts"; +import { boardType } from "../projectFields.ts"; +import { RefreshButton } from "../RefreshButton.tsx"; +import { boardState } from "./model.ts"; +import "./agile.css"; + +// общая оболочка экранов доски: тип доски проекта, загрузка, ошибка и три состояния данных (spec web-app «Спринты и бэклог») +export function AgileGate({ project, kind, children }: { project: Project; kind: "scrum" | "kanban"; children: (d: AgileResponse) => ReactNode }) { + const right = boardType(project) === kind; + const q = useAgile(project.label, right); + if (!right) { + return ( +
+ Связи}> + {`У проекта нет ${kind}-доски`} + +
+ ); + } + if (q.isPending) return
Загрузка…
; + if (q.isError) { + return ( +
+ void q.refetch()}>Повторить}>{(q.error as Error).message} +
+ ); + } + const state = boardState(q.data); + if (state === "no-data") return
}>Нужен агент v15 и обновление из Jira
; + if (state === "no-columns") return
}>Не удалось прочитать настройки доски из Jira
; + return <>{children(q.data)}; +} diff --git a/web/src/modules/jira/agile/BacklogPage.test.tsx b/web/src/modules/jira/agile/BacklogPage.test.tsx new file mode 100644 index 0000000..a2be107 --- /dev/null +++ b/web/src/modules/jira/agile/BacklogPage.test.tsx @@ -0,0 +1,169 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createMemoryRouter } from "react-router"; +import { beforeEach, describe, expect, it } from "vitest"; +import { App } from "../../../App.tsx"; +import { setToken } from "../../../auth/token.ts"; +import settings from "../board/__fixtures__/settings.json"; +import { routes } from "../../../routes.tsx"; +import { json, mockFetch } from "../../../test/fetchMock.ts"; +import { issue } from "../../../test/issueFixtures.ts"; +import type { AgileIssue, AgileResponse, AgileSprint } from "../api/types.ts"; +import { fmtDay } from "./model.ts"; + +const A = { label: "AWPCA", name: "АРМ КА", board_id: 4216, board_type: "scrum", created_at: "", issues_count: 9, jira_synced_at: null }; +const S = { label: "STOCKS", name: "Запасы", board_id: null, board_type: null, created_at: "", issues_count: 1, jira_synced_at: null }; +const COMPLETE = "2026-10-14T09:00:00.000+0300"; + +const iss = (key: string, over: Partial = {}): AgileIssue => ({ + key, summary: `Тема ${key}`, issue_type: "Задача", status: "В работе", priority: null, assignee: null, + epic_key: null, story_points: null, rank: "0|a", done: false, ...over, +}); +const sprint = (over: Partial): AgileSprint => ({ + id: 1, name: "S", state: "active", start_date: "2026-10-12T09:00:00.000+0300", end_date: "2026-10-26T09:00:00.000+0300", + complete_date: null, goal: null, count: 0, count_done: 0, unestimated: 0, points: 0, points_done: 0, issues: [], ...over, +}); +const many = (n: number, doneN: number) => + Array.from({ length: n }, (_, i) => iss(`AURORA-${2000 + i}`, { done: i < doneN, status: i < doneN ? "Готово" : "В работе" })); +const agile = (over: Partial = {}): AgileResponse => ({ + board: { id: 4216, type: "scrum", columns: [{ name: "К выполнению", statuses: ["Новая"] }, { name: "Готово", statuses: ["Готово"] }], kanban_backlog: null, fetched_at: "2026-10-24T10:00:00+00:00" }, + sprints: [ + sprint({ id: 102, name: "AWPCA_Sprint_3", state: "active", goal: "Закрыть свод", count: 12, count_done: 4, unestimated: 3, points: 25, points_done: 8, + issues: [iss("AURORA-1", { assignee: "Иванов Иван", story_points: 5 }), iss("AURORA-2", { done: true, status: "Готово" })] }), + sprint({ id: 103, name: "AWPCA_Sprint_4", state: "future" }), + sprint({ id: 101, name: "AWPCA_Sprint_2_VGO", state: "closed", complete_date: COMPLETE, count: 73, count_done: 61, points: 25, issues: many(73, 61) }), + ], + backlog: [iss("AURORA-50"), iss("AURORA-51")], + board_issues: null, ...over, +}); + +function hub(opts: { projects?: Response | "hang"; data?: AgileResponse } = {}) { + const agileCalls: URL[] = []; + mockFetch((method, url) => { + const k = `${method} ${url.pathname}`; + if (k === "GET /agile") { agileCalls.push(url); return json(200, opts.data ?? agile()); } + if (k === "GET /projects") return opts.projects === "hang" ? new Promise(() => {}) : opts.projects ?? json(200, { projects: [A, S] }); + if (k === "GET /settings") return json(200, settings); + if (k === "GET /issues") return json(200, { issues: [] }); + if (k === "GET /batches") return json(200, { batches: [] }); + if (k === "GET /agents") return json(200, { agents: [] }); + if (k.startsWith("GET /issues/")) return json(200, issue(decodeURIComponent(url.pathname.split("/").pop()!))); + return json(404, { error: "not_found" }); + }); + return agileCalls; +} +const open = (path: string) => { + const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" }); + render(); + return router; +}; +const page = () => screen.findByTestId("page-backlog"); +// страница с загруженными данными: шапка рисуется сразу, секции — после ответа /agile +const loaded = async () => { const p = await page(); await within(p).findByRole("region", { name: "Бэклог" }); return p; }; +const jiraGroup = () => screen.getByRole("group", { name: "Jira" }); +const links = () => within(jiraGroup()).getAllByRole("link").map((l) => l.textContent); + +describe("«Бэклог»", () => { + beforeEach(() => { setToken("tok"); localStorage.clear(); }); + + it("меню scrum-проекта: «Связи», «Бэклог», «Задачи», «Лента»", async () => { // задача 10 добавит «Спринт» + hub(); + open("/app/jira/links?project=AWPCA"); + await within(jiraGroup()).findByRole("link", { name: "Бэклог" }); + expect(links()).toEqual(["Связи", "Бэклог", "Задачи", "Лента"]); + }); + + it("проект без доски: пунктов нет, «У проекта нет scrum-доски» со ссылкой «Связи», запроса /agile нет", async () => { + const calls = hub(); + open("/app/jira/backlog?project=STOCKS"); + expect(await screen.findByText("У проекта нет scrum-доски")).toBeInTheDocument(); + expect(links()).toEqual(["Связи", "Задачи", "Лента"]); + const inPage = within(screen.getByRole("main")); + expect(inPage.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links?project=STOCKS"); + expect(calls).toHaveLength(0); + }); + + it("пока проекты не загружены, «Бэклог» скрыт", async () => { + hub({ projects: "hang" }); + open("/app/jira/links?project=AWPCA"); + await screen.findByRole("group", { name: "Jira" }); + expect(links()).toEqual(["Связи", "Задачи", "Лента"]); + }); + + it("секции: активный, будущий, «Бэклог», «Прошлые спринты (1)»; заголовок спринта с суммами", async () => { + hub(); + open("/app/jira/backlog?project=AWPCA"); + const p = within(await loaded()); + const secs = p.getAllByRole("region").map((s) => within(s).getAllByRole("heading")[0].textContent); + expect(secs).toEqual(["AWPCA_Sprint_3", "AWPCA_Sprint_4", "Бэклог"]); + const active = p.getByRole("region", { name: "AWPCA_Sprint_3" }); + expect(active).toHaveTextContent("активный"); + expect(active).toHaveTextContent("Закрыть свод"); + expect(active).toHaveTextContent("12 задач"); + expect(active).toHaveTextContent("25 SP (выполнено 8)"); + expect(active).toHaveTextContent("без оценки: 3"); + expect(active).toHaveTextContent(/\d{2}\.\d{2}–\d{2}\.\d{2}/); + const future = p.getByRole("region", { name: "AWPCA_Sprint_4" }); + expect(future).toHaveTextContent("будущий"); + expect(future).toHaveTextContent("Нет задач"); + expect(future).not.toHaveTextContent("без оценки"); + expect(p.getByRole("region", { name: "Бэклог" })).toHaveTextContent("AURORA-50"); + expect(p.getByText("Прошлые спринты (1)")).toBeInTheDocument(); + expect(document.title).toBe("Бэклог · AWPCA · SDLC Hub"); + }); + + it("прошлый спринт: строка и пометки «не выполнено»", async () => { + hub(); + open("/app/jira/backlog?project=AWPCA"); + const p = await loaded(); + const summary = within(p).getByText(`AWPCA_Sprint_2_VGO · закрыт ${fmtDay(COMPLETE)} · выполнено 61 из 73 · 25 SP`); + await userEvent.click(summary); + const det = summary.closest("details")!; + expect(det.querySelectorAll(".agile-row")).toHaveLength(73); + expect(within(det).getAllByText("не выполнено")).toHaveLength(12); + }); + + it("только смотрит: нет draggable, кнопки — только summary и «Обновить из Jira»", async () => { + hub(); + open("/app/jira/backlog?project=AWPCA"); + const p = await loaded(); + expect(document.querySelectorAll('[draggable="true"]').length).toBe(0); + const buttons = within(p).queryAllByRole("button"); + expect(buttons.length).toBeGreaterThan(0); + expect(buttons.every((b) => b.tagName === "SUMMARY" || /Обновить из Jira/.test(b.textContent ?? ""))).toBe(true); + }); + + it("щелчок по строке открывает карточку поверх «Бэклога»", async () => { + hub(); + const router = open("/app/jira/backlog?project=AWPCA"); + const p = await loaded(); + await userEvent.click(within(p).getByText("Тема AURORA-1")); + await screen.findByRole("dialog"); + expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-1"); + await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Закрыть" })); + await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/backlog")); + }); + + it("нет данных доски — «Нужен агент v15 и обновление из Jira» и «Обновить из Jira»", async () => { + hub({ data: { board: null, sprints: [], backlog: null, board_issues: null } }); + open("/app/jira/backlog?project=AWPCA"); + expect(await screen.findByText("Нужен агент v15 и обновление из Jira")).toBeInTheDocument(); + expect(within(screen.getByRole("main")).getAllByRole("button", { name: "Обновить из Jira" })).toHaveLength(2); // шапка и сообщение (R-20) + }); + + it("колонок нет — «Не удалось прочитать настройки доски из Jira»", async () => { + const d = agile(); d.board!.columns = null; + hub({ data: d }); + open("/app/jira/backlog?project=AWPCA"); + expect(await screen.findByText("Не удалось прочитать настройки доски из Jira")).toBeInTheDocument(); + }); + + it("смена проекта на STOCKS оставляет /app/jira/backlog и показывает «У проекта нет scrum-доски»", async () => { // design #21 + hub(); + const router = open("/app/jira/backlog?project=AWPCA"); + await loaded(); + await userEvent.selectOptions(screen.getByLabelText("Проект"), "STOCKS"); + expect(await screen.findByText("У проекта нет scrum-доски")).toBeInTheDocument(); + expect(router.state.location.pathname).toBe("/app/jira/backlog"); + }); +}); diff --git a/web/src/modules/jira/agile/BacklogPage.tsx b/web/src/modules/jira/agile/BacklogPage.tsx new file mode 100644 index 0000000..2df073e --- /dev/null +++ b/web/src/modules/jira/agile/BacklogPage.tsx @@ -0,0 +1,75 @@ +import { useId } from "react"; +import type { Project } from "../../../api/types.ts"; +import { Badge, PageHeader, Panel } from "../../../ui/index.ts"; +import type { AgileIssue, AgileSprint } from "../api/types.ts"; +import { RefreshButton } from "../RefreshButton.tsx"; +import { AgileGate } from "./AgileGate.tsx"; +import { AgileIssueRow } from "./IssueRow.tsx"; +import { fmtDay, fmtRange, fmtSp, tasksWord } from "./model.ts"; +import "./agile.css"; + +function Issues({ issues, markUndone }: { issues: AgileIssue[]; markUndone?: boolean }) { + if (!issues.length) return

Нет задач

; + return
    {issues.map((i) => )}
; +} + +function SprintSection({ s }: { s: AgileSprint }) { + const id = useId(); + return ( + +
+

{s.name}

+ {s.state === "active" ? "активный" : "будущий"} + {fmtRange(s.start_date, s.end_date)} + {s.goal && {s.goal}} + {`${s.count} ${tasksWord(s.count)}`} + {`${fmtSp(s.points)} SP (выполнено ${fmtSp(s.points_done)})`} + {s.unestimated > 0 && {`без оценки: ${s.unestimated}`}} +
+ +
+ ); +} + +function PastSprint({ s }: { s: AgileSprint }) { + return ( +
+ {`${s.name} · закрыт ${fmtDay(s.complete_date)} · выполнено ${s.count_done} из ${s.count} · ${fmtSp(s.points)} SP`} + +
+ ); +} + +// «Бэклог» (spec web-app): активные и будущие спринты, бэклог, прошлые спринты — только просмотр (R-20) +export function BacklogPage({ project }: { project: Project }) { + const backlogId = useId(); + return ( +
+ } /> + + {(d) => { + const live = [...d.sprints.filter((s) => s.state === "active"), ...d.sprints.filter((s) => s.state === "future")]; + const past = d.sprints.filter((s) => s.state === "closed"); + return ( + <> + {live.map((s) => )} + +
+

Бэклог

+ {`${d.backlog?.length ?? 0} ${tasksWord(d.backlog?.length ?? 0)}`} +
+ +
+ {past.length > 0 && ( +
+ {`Прошлые спринты (${past.length})`} + {past.map((s) => )} +
+ )} + + ); + }} +
+
+ ); +} diff --git a/web/src/modules/jira/agile/IssueRow.tsx b/web/src/modules/jira/agile/IssueRow.tsx new file mode 100644 index 0000000..a8e6f7a --- /dev/null +++ b/web/src/modules/jira/agile/IssueRow.tsx @@ -0,0 +1,25 @@ +import type { MouseEvent } from "react"; +import type { AgileIssue } from "../api/types.ts"; +import { IssueLink, useOpenIssue } from "../issues/IssueLink.tsx"; +import { fmtSp } from "./model.ts"; +import { TypeIcon } from "./TypeIcon.tsx"; + +// строка задачи экранов доски; щелчок (не по ссылке и не при выделении текста) открывает карточку поверх страницы +export function AgileIssueRow({ issue, mark }: { issue: AgileIssue; mark?: string }) { + const open = useOpenIssue(); + const click = (e: MouseEvent) => { + if ((e.target as HTMLElement).closest("a")) return; + if (window.getSelection()?.toString()) return; + open(issue.key); + }; + return ( +
  • + + + {issue.summary ?? "—"} + {issue.assignee ?? "—"} + {fmtSp(issue.story_points)} + {mark && {mark}} +
  • + ); +} diff --git a/web/src/modules/jira/agile/TypeIcon.tsx b/web/src/modules/jira/agile/TypeIcon.tsx new file mode 100644 index 0000000..8440969 --- /dev/null +++ b/web/src/modules/jira/agile/TypeIcon.tsx @@ -0,0 +1,9 @@ +import { IconBoard, IconCritical, IconCheck } from "../../../ui/index.ts"; + +// значок типа задачи: эпик, ошибка, прочее — задача; тип — в aria-label и title +export function TypeIcon({ type }: { type: string | null }) { + const label = type ?? "Задача"; + const kind = /^(Эпик|Epic|Фича)$/i.test(label) ? "epic" : /^(Ошибка|Bug)$/i.test(label) ? "bug" : "task"; + const Icon = kind === "epic" ? IconBoard : kind === "bug" ? IconCritical : IconCheck; + return ; +} diff --git a/web/src/modules/jira/agile/agile.css b/web/src/modules/jira/agile/agile.css new file mode 100644 index 0000000..ffff67e --- /dev/null +++ b/web/src/modules/jira/agile/agile.css @@ -0,0 +1,38 @@ +/* экраны доски (бэклог, спринт, Kanban): только токены дизайн-системы */ +.agile{display:flex;flex-direction:column;gap:var(--space-4);padding-top:var(--space-6)} +.agile .ui-page-header{margin-bottom:0} +.agile__gate{padding-top:var(--space-6)} +.agile .agile__gate{padding-top:0} +.agile__link{color:var(--color-action-primary);font-weight:600;text-decoration:none} +.agile__link:hover{text-decoration:underline} +.agile__empty{margin:0;color:var(--color-text-secondary)} +.agile-section{display:flex;flex-direction:column;gap:var(--space-3);min-width:0} +.agile-section__head{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2) var(--space-4)} +.agile-section__title{margin:0;font-size:var(--fs-lg);line-height:var(--lh-lg);font-weight:700} +.agile-section__meta{font-size:var(--fs-body);color:var(--color-text-secondary)} +.agile-section__goal{font-size:var(--fs-body);min-width:0;overflow-wrap:anywhere} +.agile-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column} +.agile-row{display:flex;align-items:center;gap:var(--space-3);min-width:0;padding:var(--space-2) var(--space-1); + border-top:1px solid var(--color-border-default);font-size:var(--fs-body);line-height:var(--lh-body);cursor:pointer} +.agile-row:first-child{border-top:0} +.agile-row:hover{background:var(--color-bg-hover)} +.agile-row.is-done{color:var(--color-text-secondary)} +.agile-row.is-done .agile-row__summary{text-decoration:line-through} +.agile-row__key{flex:0 0 auto;font-family:var(--font-mono);font-size:13px;color:var(--color-action-primary);text-decoration:none} +.agile-row__key:hover{text-decoration:underline} +.agile-row__summary{flex:1 1 auto;min-width:0;overflow-wrap:anywhere} +.agile-row__assignee{flex:0 1 160px;min-width:0;color:var(--color-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.agile-row__sp{flex:0 0 32px;text-align:right;color:var(--color-text-secondary)} +.agile-row__mark{flex:0 0 auto;font-size:var(--fs-caption);color:var(--color-status-warning)} +.agile-type{display:inline-flex;flex:0 0 auto;color:var(--color-text-secondary)} +.agile-type--epic{color:var(--color-cat-1)} +.agile-type--bug{color:var(--color-status-error)} +.agile-type--task{color:var(--color-status-success)} +.agile-past{display:flex;flex-direction:column;gap:var(--space-2)} +.agile-past>summary{cursor:pointer;font-weight:600;padding:var(--space-2) 0} +.agile-past__sprint{background:var(--color-bg-surface);border:1px solid var(--color-border-default);border-radius:var(--radius-lg);padding:var(--space-2) var(--space-4)} +.agile-past__sprint>summary{cursor:pointer;padding:var(--space-1) 0} +@container page (max-width: 640px){ + .agile-row{flex-wrap:wrap} + .agile-row__assignee{flex-basis:100%} +} diff --git a/web/src/modules/jira/agile/model.test.ts b/web/src/modules/jira/agile/model.test.ts new file mode 100644 index 0000000..1dca927 --- /dev/null +++ b/web/src/modules/jira/agile/model.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import type { AgileBoard, AgileResponse } from "../api/types.ts"; +import { boardState, daysLeft, fmtDay, fmtRange, fmtSp, jiraDate, tasksWord } from "./model.ts"; + +const resp = (over: Partial): AgileResponse => ({ + board: { id: 4216, type: "scrum", columns: [{ name: "Готово", statuses: ["Готово"] }], kanban_backlog: null, fetched_at: "2026-10-24T10:00:00+00:00", ...over }, + sprints: [], backlog: [], board_issues: null, +}); + +describe("даты и числа экранов доски", () => { + 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("jiraDate и fmtRange", () => { + expect(jiraDate("2026-10-14T09:00:00.000+0300")?.getTime()).toBe(Date.parse("2026-10-14T09:00:00.000+03:00")); + expect(jiraDate("мусор")).toBeNull(); + expect(fmtRange(null, null)).toBe("—–—"); + expect(fmtRange("2026-10-14T09:00:00.000+0300", "2026-10-28T09:00:00.000+0300")).toMatch(/^\d{2}\.\d{2}–\d{2}\.\d{2}$/); + }); + 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"); + }); +}); diff --git a/web/src/modules/jira/agile/model.ts b/web/src/modules/jira/agile/model.ts new file mode 100644 index 0000000..758b8d9 --- /dev/null +++ b/web/src/modules/jira/agile/model.ts @@ -0,0 +1,39 @@ +import type { AgileResponse } from "../api/types.ts"; + +// время Jira приходит и как «+0300» (поля задачи), и как «+03:00» (Agile REST); Date.parse понимает не всякое — приводим к «+03:00» (R-26) +export function jiraDate(iso: string | null): Date | null { + if (!iso) return null; + const d = new Date(iso.replace(/([+-]\d{2})(\d{2})$/, "$1:$2")); + return isNaN(d.getTime()) ? null : d; +} + +const pad = (n: number) => String(n).padStart(2, "0"); +const day = (d: Date) => `${pad(d.getDate())}.${pad(d.getMonth() + 1)}`; + +// «ДД.ММ» в часовом поясе браузера; нет даты или она негодная — «—» +export const fmtDay = (iso: string | null): string => { const d = jiraDate(iso); return d ? day(d) : "—"; }; +export const fmtRange = (start: string | null, end: string | null): string => `${fmtDay(start)}–${fmtDay(end)}`; + +// сколько календарных дней (в поясе браузера) от «сегодня» до конца спринта; отрицательное — срок прошёл +export function daysLeft(endIso: string | null, now: Date): number | null { + const end = jiraDate(endIso); + if (!end) return null; + const midnight = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + return Math.round((midnight(end) - midnight(now)) / 86_400_000); +} + +export const fmtSp = (n: number | null): string => (n === null ? "—" : String(n)); + +// «1 задача», «2 задачи», «5 задач» (R-24) +export function tasksWord(n: number): string { + const m10 = n % 10, m100 = n % 100; + if (m10 === 1 && m100 !== 11) return "задача"; + if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return "задачи"; + return "задач"; +} + +// три состояния данных доски (R-19, design #23) +export function boardState(d: AgileResponse): "no-data" | "no-columns" | "ok" { + if (!d.board || d.board.fetched_at === null) return "no-data"; + return d.board.columns === null ? "no-columns" : "ok"; +} diff --git a/web/src/modules/jira/api/queries.ts b/web/src/modules/jira/api/queries.ts index 053eecd..31a20b7 100644 --- a/web/src/modules/jira/api/queries.ts +++ b/web/src/modules/jira/api/queries.ts @@ -1,7 +1,7 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "../../../api/client.ts"; import { qk as coreQk } from "../../../api/queries.ts"; -import type { BoardSnapshot, ChangesResponse, IssueDetail, IssueRow, Note, NoteFields } from "./types.ts"; +import type { AgileResponse, BoardSnapshot, ChangesResponse, IssueDetail, IssueRow, Note, NoteFields } from "./types.ts"; // фильтры списка в адресе; hide_closed в адрес не пишется — его выводит список из флажка «скрыть закрытые» (closed=1 — снят) export const ISSUE_FILTER_NAMES = ["sprint", "assignee", "work_kind", "status", "label", "epic", "neighbor", "q"] as const; @@ -12,6 +12,7 @@ export const ISSUE_PAGE = 50; export const qk = { ...coreQk, board: (project: string) => ["board", project] as const, + agile: (project: string) => ["agile", project] as const, boardAll: ["board"] as const, // префикс: все снимки досок сразу (инвалидация) issues: (f: IssueFilters) => ["issues", f] as const, issue: (key: string) => ["issue", key] as const, @@ -62,3 +63,7 @@ export function useSaveNote(key: string) { }, }); } + +// данные экранов доски (бэклог, спринт, Kanban): запрос только для проекта с доской нужного типа (enabled) +export const useAgile = (project: string, enabled = true) => + useQuery({ queryKey: qk.agile(project), enabled, queryFn: () => api.get("/agile", { project }) }); diff --git a/web/src/modules/jira/api/types.ts b/web/src/modules/jira/api/types.ts index e611aa3..ec5951a 100644 --- a/web/src/modules/jira/api/types.ts +++ b/web/src/modules/jira/api/types.ts @@ -71,3 +71,22 @@ export interface IssueDetail extends IssueRow { changes?: ChangeEvent[]; // хаб до журнала изменений (#70) поля не шлёт } + +// ответ GET /agile (доска проекта, спринты, бэклог); поля — как отдаёт хаб +export interface AgileIssue { + key: string; summary: string | null; issue_type: string | null; status: string | null; priority: string | null; + assignee: string | null; epic_key: string | null; story_points: number | null; rank: string | null; done: boolean; +} +export interface BoardColumn { name: string; statuses: string[] } +export interface AgileBoard { + id: number; type: "scrum" | "kanban" | null; columns: BoardColumn[] | null; kanban_backlog: boolean | null; fetched_at: string | null; +} +export type SprintState = "active" | "future" | "closed"; +export interface AgileSprint { + id: number; name: string; state: SprintState; start_date: string | null; end_date: string | null; complete_date: string | null; + goal: string | null; count: number; count_done: number; unestimated: number; points: number; points_done: number; + issues: AgileIssue[]; +} +export interface AgileResponse { + board: AgileBoard | null; sprints: AgileSprint[]; backlog: AgileIssue[] | null; board_issues: AgileIssue[] | null; +} diff --git a/web/src/modules/jira/index.tsx b/web/src/modules/jira/index.tsx index d00e39e..10ded21 100644 --- a/web/src/modules/jira/index.tsx +++ b/web/src/modules/jira/index.tsx @@ -1,11 +1,13 @@ import { Navigate, useLocation, useParams } from "react-router"; import { ProjectGate } from "../../projects/ProjectGate.tsx"; -import { IconBoard, IconHistory, IconList } from "../../ui/index.ts"; +import { IconBacklog, IconBoard, IconHistory, IconList } from "../../ui/index.ts"; import type { HubModule } from "../types.ts"; +import { BacklogPage } from "./agile/BacklogPage.tsx"; import { BoardPage } from "./board/BoardPage.tsx"; import { FeedPage } from "./feed/FeedPage.tsx"; import { useIssueOverlay } from "./issues/overlay.tsx"; import { IssuesPage } from "./issues/IssuesPage.tsx"; +import { boardType } from "./projectFields.ts"; import { jiraStatus } from "./status.ts"; import { Summary } from "./Summary.tsx"; @@ -22,6 +24,7 @@ export const jiraModule: HubModule = { title: "Jira", pages: [ { path: "/jira/links", element: {(p) => } }, + { path: "/jira/backlog", element: {(p) => } }, { path: "/jira/issues", element: {(p) => } }, { path: "/jira/feed", element: {(p) => } }, // прежние адреса (закладки): перенаправление с сохранением строки запроса, без новой записи истории @@ -33,6 +36,7 @@ export const jiraModule: HubModule = { ], nav: [ { to: "/jira/links", label: "Связи", icon: }, + { to: "/jira/backlog", label: "Бэклог", icon: , padded: true, when: (p) => boardType(p) === "scrum" }, { to: "/jira/issues", label: "Задачи", icon: , padded: true }, { to: "/jira/feed", label: "Лента", icon: , padded: true, keepSearch: true }, ], diff --git a/web/src/modules/jira/projectFields.ts b/web/src/modules/jira/projectFields.ts index 42b3a16..335e6a8 100644 --- a/web/src/modules/jira/projectFields.ts +++ b/web/src/modules/jira/projectFields.ts @@ -7,3 +7,9 @@ export type JiraProject = Project & { }; // у проекта в ответе хаба поле модуля есть всегда (null — «ещё не было»); без него (старый ответ, тестовая фикстура) — не проект Jira export const hasJiraSync = (p: Project): p is JiraProject => "jira_synced_at" in p; + +// тип доски проекта по ответу хаба; null — доски нет или тип ещё не известен +export const boardType = (p: Project): "scrum" | "kanban" | null => { + const t = (p as { board_type?: unknown }).board_type; + return t === "scrum" || t === "kanban" ? t : null; +}; diff --git a/web/src/modules/jira/status.ts b/web/src/modules/jira/status.ts index 54c53ec..5ad508c 100644 --- a/web/src/modules/jira/status.ts +++ b/web/src/modules/jira/status.ts @@ -16,6 +16,6 @@ export const jiraStatus: ModuleStatus = { }, // перечитывание активных снимков досок — после своего пакета; показанный файл не заменяется (M-1) invalidate: (qc) => { - for (const key of [qk.boardAll, ["issues"], ["issue"], ["changes"]]) void qc.invalidateQueries({ queryKey: key }); + for (const key of [qk.boardAll, ["issues"], ["issue"], ["changes"], ["agile"]]) void qc.invalidateQueries({ queryKey: key }); }, }; diff --git a/web/src/modules/modules.test.tsx b/web/src/modules/modules.test.tsx index 39b0e5a..5812fc6 100644 --- a/web/src/modules/modules.test.tsx +++ b/web/src/modules/modules.test.tsx @@ -6,12 +6,12 @@ import { MODULES } from "./index.ts"; describe("сборка оболочки из модулей", () => { it("страницы: «Обзор», адреса jira, перенаправления, вики, NotFound последним", () => { - expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/jira/links", "/jira/issues", "/jira/feed", "/jira/board", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]); + expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/jira/links", "/jira/backlog", "/jira/issues", "/jira/feed", "/jira/board", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]); }); it("пункты панели: подписи прежние, адреса jira с префиксом, плюс вики", () => { expect(MODULES.flatMap((m) => m.nav).map((n) => [n.label, n.to])).toEqual([ - ["Связи", "/jira/links"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"], + ["Связи", "/jira/links"], ["Бэклог", "/jira/backlog"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"], ]); }); diff --git a/web/src/ui/icons.tsx b/web/src/ui/icons.tsx index bd60fdd..c4e81c2 100644 --- a/web/src/ui/icons.tsx +++ b/web/src/ui/icons.tsx @@ -44,3 +44,4 @@ export const IconImage = make(<>); export const IconHome = make(<>); export const IconBook = make(<>); +export const IconBacklog = make(<>); diff --git a/web/vite.config.ts b/web/vite.config.ts index 8021a41..f9319ce 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config"; // сервер разработки: приложение под /app/, маршруты API — прокси на хаб (по умолчанию тестовый :8791, R-5) const hub = process.env.SDLC_HUB_URL ?? "http://127.0.0.1:8791"; // порт держать равным HUB_PORT в playwright.config.ts -const API = ["/board", "/issues", "/settings", "/agent", "/refresh", "/batches", "/commands", "/sync-runs"]; +const API = ["/board", "/agile", "/issues", "/settings", "/agent", "/refresh", "/batches", "/commands", "/sync-runs"]; export default defineConfig({ base: "/app/", -- 2.47.2 From a6de46b3f8951e9f45b70dd8df846b56496067cc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 19:54:19 +0400 Subject: [PATCH 15/21] =?UTF-8?q?design.md:=20R-32=20(=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D0=B9=20=D0=B1=D0=BB=D0=BE=D0=BA=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=88=D0=BB=D1=8B=D1=85=20=D1=81=D0=BF=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D1=82=D0=BE=D0=B2)=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- openspec/changes/sprints-backlog/design.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openspec/changes/sprints-backlog/design.md b/openspec/changes/sprints-backlog/design.md index 1284a0d..706f316 100644 --- a/openspec/changes/sprints-backlog/design.md +++ b/openspec/changes/sprints-backlog/design.md @@ -226,3 +226,5 @@ kanban) — свои задачи в статусах остальных кол - 24.10.2026 — R-31 (задача 4, по R-15): пустая строка в `move_open_to` при `state = active` и в `before`/`after` у `ensure_rank` трактуется как «не задано»; `ensure_sprint_meta` без параметров, кроме `sprint`, допустима. Выбрано исполнителем задачи 4. Цена ошибки — ужесточить проверку (422 вместо `failed` у агента). +- 24.10.2026 — R-32 (задача 9, по web-app): блок «Прошлые спринты (N)» на «Бэклоге» не рисуется при N = 0 (спека не + требует пустой блок). Выбрано исполнителем задачи 9. Цена ошибки — рисовать «Прошлые спринты (0)» свёрнутым. -- 2.47.2 From 9212c19dcfa6010bc5ae9868addaa59e5fb3a85b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 20:02:12 +0400 Subject: [PATCH 16/21] =?UTF-8?q?=D0=92=D0=B5=D0=B1:=20=D1=82=D0=BE=D0=BA?= =?UTF-8?q?=D0=B5=D0=BD=20=D1=8D=D0=BF=D0=B8=D0=BA=D0=B0,=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20=D0=BE=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D1=91=D0=BD=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20?= =?UTF-8?q?var(--=E2=80=A6),=20=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B2=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B5=20?= =?UTF-8?q?(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../modules/jira/agile/BacklogPage.test.tsx | 21 +++++++++++++++++++ web/src/modules/jira/agile/IssueRow.tsx | 3 ++- web/src/modules/jira/agile/agile.css | 5 ++--- web/src/modules/jira/api/types.ts | 1 - web/src/styles/tokens.test.ts | 19 +++++++++++++++++ 5 files changed, 44 insertions(+), 5 deletions(-) diff --git a/web/src/modules/jira/agile/BacklogPage.test.tsx b/web/src/modules/jira/agile/BacklogPage.test.tsx index a2be107..f00effe 100644 --- a/web/src/modules/jira/agile/BacklogPage.test.tsx +++ b/web/src/modules/jira/agile/BacklogPage.test.tsx @@ -144,6 +144,27 @@ describe("«Бэклог»", () => { await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/backlog")); }); + it("выделение вне строки не мешает открыть карточку", async () => { + hub(); + const router = open("/app/jira/backlog?project=AWPCA"); + const p = await loaded(); + window.getSelection()!.selectAllChildren(within(p).getByRole("heading", { name: "AWPCA_Sprint_4" })); + await userEvent.click(within(p).getByText("Тема AURORA-1")); + await screen.findByRole("dialog"); + expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-1"); + window.getSelection()!.removeAllRanges(); + }); + + it("щелчок по ссылке ключа открывает карточку один раз", async () => { + hub(); + const router = open("/app/jira/backlog?project=AWPCA"); + const p = await loaded(); + await userEvent.click(within(p).getByRole("link", { name: "AURORA-1" })); + await screen.findByRole("dialog"); + await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Закрыть" })); + await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/backlog")); // одна запись истории, а не две + }); + it("нет данных доски — «Нужен агент v15 и обновление из Jira» и «Обновить из Jira»", async () => { hub({ data: { board: null, sprints: [], backlog: null, board_issues: null } }); open("/app/jira/backlog?project=AWPCA"); diff --git a/web/src/modules/jira/agile/IssueRow.tsx b/web/src/modules/jira/agile/IssueRow.tsx index a8e6f7a..9c38c4f 100644 --- a/web/src/modules/jira/agile/IssueRow.tsx +++ b/web/src/modules/jira/agile/IssueRow.tsx @@ -9,7 +9,8 @@ export function AgileIssueRow({ issue, mark }: { issue: AgileIssue; mark?: strin const open = useOpenIssue(); const click = (e: MouseEvent) => { if ((e.target as HTMLElement).closest("a")) return; - if (window.getSelection()?.toString()) return; + const sel = window.getSelection(); + if (sel && !sel.isCollapsed && sel.containsNode(e.currentTarget, true)) return; // выделен текст этой строки open(issue.key); }; return ( diff --git a/web/src/modules/jira/agile/agile.css b/web/src/modules/jira/agile/agile.css index ffff67e..0f189f9 100644 --- a/web/src/modules/jira/agile/agile.css +++ b/web/src/modules/jira/agile/agile.css @@ -1,8 +1,7 @@ /* экраны доски (бэклог, спринт, Kanban): только токены дизайн-системы */ .agile{display:flex;flex-direction:column;gap:var(--space-4);padding-top:var(--space-6)} .agile .ui-page-header{margin-bottom:0} -.agile__gate{padding-top:var(--space-6)} -.agile .agile__gate{padding-top:0} +.agile__gate{min-width:0} .agile__link{color:var(--color-action-primary);font-weight:600;text-decoration:none} .agile__link:hover{text-decoration:underline} .agile__empty{margin:0;color:var(--color-text-secondary)} @@ -25,7 +24,7 @@ .agile-row__sp{flex:0 0 32px;text-align:right;color:var(--color-text-secondary)} .agile-row__mark{flex:0 0 auto;font-size:var(--fs-caption);color:var(--color-status-warning)} .agile-type{display:inline-flex;flex:0 0 auto;color:var(--color-text-secondary)} -.agile-type--epic{color:var(--color-cat-1)} +.agile-type--epic{color:var(--k-ui)} .agile-type--bug{color:var(--color-status-error)} .agile-type--task{color:var(--color-status-success)} .agile-past{display:flex;flex-direction:column;gap:var(--space-2)} diff --git a/web/src/modules/jira/api/types.ts b/web/src/modules/jira/api/types.ts index ec5951a..f093aea 100644 --- a/web/src/modules/jira/api/types.ts +++ b/web/src/modules/jira/api/types.ts @@ -71,7 +71,6 @@ export interface IssueDetail extends IssueRow { changes?: ChangeEvent[]; // хаб до журнала изменений (#70) поля не шлёт } - // ответ GET /agile (доска проекта, спринты, бэклог); поля — как отдаёт хаб export interface AgileIssue { key: string; summary: string | null; issue_type: string | null; status: string | null; priority: string | null; diff --git a/web/src/styles/tokens.test.ts b/web/src/styles/tokens.test.ts index 50a45d4..afdae9b 100644 --- a/web/src/styles/tokens.test.ts +++ b/web/src/styles/tokens.test.ts @@ -64,6 +64,25 @@ describe("токены AURA", () => { }); }); +describe("использованные токены определены", () => { + const defs = (css: string) => new Set([...stripComments(css).matchAll(/(--[\w-]+)\s*:/g)].map((m) => m[1])); + // переменные, которые выставляет код страницы (setProperty), а не css + const SET_BY_JS = new Set(["--head-h"]); + const tokenDefs = defs(readFileSync(TOKENS, "utf8")); + it("каждый var(--…) в css определён в tokens.css или в том же файле", () => { + const missing: string[] = []; + for (const f of cssFiles(SRC)) { + if (f === TOKENS) continue; + const css = readFileSync(f, "utf8"); + const local = defs(css); + for (const m of stripComments(css).matchAll(/var\(\s*(--[\w-]+)/g)) { + if (!tokenDefs.has(m[1]) && !local.has(m[1]) && !SET_BY_JS.has(m[1])) missing.push(`${relative(SRC, f)}: ${m[1]}`); + } + } + expect([...new Set(missing)]).toEqual([]); + }); +}); + describe("доска без чередования дорожек (#69, design 9)", () => { it("токена --lane-alt нет, правила чётной дорожки нет", () => { expect(readFileSync(TOKENS, "utf8")).not.toContain("--lane-alt"); -- 2.47.2 From caf6ed43ae781f0ac89d872fe22ff5de6ba1aee0 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 20:11:52 +0400 Subject: [PATCH 17/21] =?UTF-8?q?=D0=92=D0=B5=D0=B1:=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=20=C2=AB=D0=A1=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D1=82=C2=BB=20=D1=81=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD?= =?UTF-8?q?=D0=BA=D0=B0=D0=BC=D0=B8=20=D0=B4=D0=BE=D1=81=D0=BA=D0=B8=20(#1?= =?UTF-8?q?08)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- web/src/modules/jira/agile/AgileCard.tsx | 27 +++ .../modules/jira/agile/BacklogPage.test.tsx | 4 +- web/src/modules/jira/agile/BoardColumns.tsx | 21 +++ .../modules/jira/agile/SprintPage.test.tsx | 156 ++++++++++++++++++ web/src/modules/jira/agile/SprintPage.tsx | 61 +++++++ web/src/modules/jira/agile/agile.css | 20 +++ web/src/modules/jira/agile/columns.test.ts | 24 +++ web/src/modules/jira/agile/columns.ts | 21 +++ web/src/modules/jira/index.tsx | 5 +- web/src/modules/modules.test.tsx | 4 +- web/src/ui/icons.tsx | 1 + 11 files changed, 339 insertions(+), 5 deletions(-) create mode 100644 web/src/modules/jira/agile/AgileCard.tsx create mode 100644 web/src/modules/jira/agile/BoardColumns.tsx create mode 100644 web/src/modules/jira/agile/SprintPage.test.tsx create mode 100644 web/src/modules/jira/agile/SprintPage.tsx create mode 100644 web/src/modules/jira/agile/columns.test.ts create mode 100644 web/src/modules/jira/agile/columns.ts diff --git a/web/src/modules/jira/agile/AgileCard.tsx b/web/src/modules/jira/agile/AgileCard.tsx new file mode 100644 index 0000000..99249e5 --- /dev/null +++ b/web/src/modules/jira/agile/AgileCard.tsx @@ -0,0 +1,27 @@ +import type { MouseEvent } from "react"; +import type { AgileIssue } from "../api/types.ts"; +import { IssueLink, useOpenIssue } from "../issues/IssueLink.tsx"; +import { fmtSp } from "./model.ts"; +import { TypeIcon } from "./TypeIcon.tsx"; + +// карточка задачи в колонке доски; щелчок (не по ссылке и не при выделении текста) открывает карточку задачи поверх страницы +export function AgileCard({ issue }: { issue: AgileIssue }) { + const open = useOpenIssue(); + const click = (e: MouseEvent) => { + if ((e.target as HTMLElement).closest("a")) return; + const sel = window.getSelection(); + if (sel && !sel.isCollapsed && sel.containsNode(e.currentTarget, true)) return; + open(issue.key); + }; + return ( +
  • + {issue.summary ?? "—"} + + + + {issue.assignee ?? "—"} + {fmtSp(issue.story_points)} + +
  • + ); +} diff --git a/web/src/modules/jira/agile/BacklogPage.test.tsx b/web/src/modules/jira/agile/BacklogPage.test.tsx index f00effe..d11c922 100644 --- a/web/src/modules/jira/agile/BacklogPage.test.tsx +++ b/web/src/modules/jira/agile/BacklogPage.test.tsx @@ -66,11 +66,11 @@ const links = () => within(jiraGroup()).getAllByRole("link").map((l) => l.textCo describe("«Бэклог»", () => { beforeEach(() => { setToken("tok"); localStorage.clear(); }); - it("меню scrum-проекта: «Связи», «Бэклог», «Задачи», «Лента»", async () => { // задача 10 добавит «Спринт» + it("меню scrum-проекта: «Связи», «Бэклог», «Спринт», «Задачи», «Лента»", async () => { hub(); open("/app/jira/links?project=AWPCA"); await within(jiraGroup()).findByRole("link", { name: "Бэклог" }); - expect(links()).toEqual(["Связи", "Бэклог", "Задачи", "Лента"]); + expect(links()).toEqual(["Связи", "Бэклог", "Спринт", "Задачи", "Лента"]); }); it("проект без доски: пунктов нет, «У проекта нет scrum-доски» со ссылкой «Связи», запроса /agile нет", async () => { diff --git a/web/src/modules/jira/agile/BoardColumns.tsx b/web/src/modules/jira/agile/BoardColumns.tsx new file mode 100644 index 0000000..152edd3 --- /dev/null +++ b/web/src/modules/jira/agile/BoardColumns.tsx @@ -0,0 +1,21 @@ +import type { ColumnView } from "./columns.ts"; +import { AgileCard } from "./AgileCard.tsx"; + +// колонки доски; контейнер прокручивается по горизонтали внутри себя, страница не растягивается +export function BoardColumns({ columns, lastCaption }: { columns: ColumnView[]; lastCaption?: string }) { + const lastIdx = columns.reduce((acc, c, i) => (c.extra ? acc : i), -1); + return ( +
    + {columns.map((c, i) => ( +
    +
    +

    {c.name}

    + {c.issues.length} +
    + {lastCaption && i === lastIdx &&

    {lastCaption}

    } +
      {c.issues.map((x) => )}
    +
    + ))} +
    + ); +} diff --git a/web/src/modules/jira/agile/SprintPage.test.tsx b/web/src/modules/jira/agile/SprintPage.test.tsx new file mode 100644 index 0000000..0e24302 --- /dev/null +++ b/web/src/modules/jira/agile/SprintPage.test.tsx @@ -0,0 +1,156 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createMemoryRouter } from "react-router"; +import { beforeEach, describe, expect, it } from "vitest"; +import { App } from "../../../App.tsx"; +import { setToken } from "../../../auth/token.ts"; +import settings from "../board/__fixtures__/settings.json"; +import { routes } from "../../../routes.tsx"; +import { json, mockFetch } from "../../../test/fetchMock.ts"; +import { issue } from "../../../test/issueFixtures.ts"; +import type { AgileIssue, AgileResponse, AgileSprint } from "../api/types.ts"; + +const A = { label: "AWPCA", name: "АРМ КА", board_id: 4216, board_type: "scrum", created_at: "", issues_count: 9, jira_synced_at: null }; + +const iss = (key: string, over: Partial = {}): AgileIssue => ({ + key, summary: `Тема ${key}`, issue_type: "Задача", status: "Новая", priority: null, assignee: null, + epic_key: null, story_points: null, rank: "0|a", done: false, ...over, +}); +const iso = (offsetDays: number) => { + const d = new Date(Date.now() + offsetDays * 86_400_000); + const p = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T12:00:00.000+0300`; +}; +const sprint = (over: Partial): AgileSprint => ({ + id: 1, name: "S", state: "active", start_date: "2026-10-12T09:00:00.000+0300", end_date: iso(3), + complete_date: null, goal: null, count: 0, count_done: 0, unestimated: 0, points: 0, points_done: 0, issues: [], ...over, +}); +const s102 = (over: Partial = {}) => sprint({ + id: 102, name: "AWPCA_Sprint_3", goal: "Закрыть свод", count: 3, count_done: 1, points: 25, points_done: 8, + issues: [iss("AURORA-1", { story_points: 5 }), iss("AURORA-2", { done: true, status: "Готово" }), iss("AURORA-3", { status: "Странный" })], ...over, +}); +const agile = (sprints: AgileSprint[] = [s102()], over: Partial = {}): AgileResponse => ({ + board: { id: 4216, type: "scrum", columns: [{ name: "К выполнению", statuses: ["Новая"] }, { name: "Готово", statuses: ["Готово"] }], kanban_backlog: null, fetched_at: "2026-10-24T10:00:00+00:00" }, + sprints, backlog: [], board_issues: null, ...over, +}); + +function hub(data: AgileResponse) { + mockFetch((method, url) => { + const k = `${method} ${url.pathname}`; + if (k === "GET /agile") return json(200, data); + if (k === "GET /projects") return json(200, { projects: [A] }); + if (k === "GET /settings") return json(200, settings); + if (k === "GET /issues") return json(200, { issues: [] }); + if (k === "GET /batches") return json(200, { batches: [] }); + if (k === "GET /agents") return json(200, { agents: [] }); + if (k.startsWith("GET /issues/")) return json(200, issue(decodeURIComponent(url.pathname.split("/").pop()!))); + return json(404, { error: "not_found" }); + }); +} +const open = (path: string) => { + const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" }); + render(); + return router; +}; +const page = () => screen.findByTestId("page-sprint"); +const jiraGroup = () => screen.getByRole("group", { name: "Jira" }); +const colNames = (p: HTMLElement) => Array.from(p.querySelectorAll(".agile-col__name")).map((e) => e.textContent); + +describe("«Спринт»", () => { + beforeEach(() => { setToken("tok"); localStorage.clear(); }); + + it("меню scrum-проекта: «Связи», «Бэклог», «Спринт», «Задачи», «Лента»; «Kanban» нет", async () => { + hub(agile()); + open("/app/jira/links?project=AWPCA"); + await within(jiraGroup()).findByRole("link", { name: "Спринт" }); + expect(within(jiraGroup()).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Бэклог", "Спринт", "Задачи", "Лента"]); + }); + + it("колонки доски с числом задач и «Без колонки»", async () => { + hub(agile()); + open("/app/jira/sprint?project=AWPCA"); + const p = await page(); + await within(p).findByText("Тема AURORA-1"); + expect(colNames(p)).toEqual(["К выполнению", "Готово", "Без колонки"]); + const cols = Array.from(p.querySelectorAll(".agile-col")); + expect(cols.map((c) => c.querySelector(".agile-col__count")!.textContent)).toEqual(["1", "1", "1"]); + expect(within(cols[2] as HTMLElement).getByText("Тема AURORA-3")).toBeInTheDocument(); + expect(p.querySelector(".agile-columns")).not.toBeNull(); + const h = within(p).getByRole("heading", { name: "AWPCA_Sprint_3" }); + expect(h.parentElement).toHaveTextContent("Закрыть свод"); + expect(h.parentElement).toHaveTextContent("1 из 3 задач"); + expect(h.parentElement).toHaveTextContent("8 из 25 SP"); + }); + + it("«осталось 3 дн.» и «срок прошёл»", async () => { + hub(agile([s102({ end_date: iso(3) })])); + open("/app/jira/sprint?project=AWPCA"); + expect(await within(await page()).findByText("осталось 3 дн.")).toBeInTheDocument(); + }); + + it("срок прошёл", async () => { + hub(agile([s102({ end_date: iso(-1) })])); + open("/app/jira/sprint?project=AWPCA"); + expect(await within(await page()).findByText("срок прошёл")).toBeInTheDocument(); + }); + + it("два активных: переключатель «Спринты», выбор — ?sprint= и его задачи", async () => { + hub(agile([s102(), sprint({ id: 104, name: "AWPCA_Sprint_5", issues: [iss("AURORA-77")], count: 1 })])); + const router = open("/app/jira/sprint?project=AWPCA"); + const p = within(await page()); + await p.findByText("Тема AURORA-1"); + const seg = p.getByRole("navigation", { name: "Спринты" }); + await userEvent.click(within(seg).getByRole("link", { name: "AWPCA_Sprint_5" })); + expect(await p.findByText("Тема AURORA-77")).toBeInTheDocument(); + expect(p.queryByText("Тема AURORA-1")).toBeNull(); + expect(router.state.location.search).toContain("sprint=104"); + expect(router.state.location.search).toContain("project=AWPCA"); + }); + + it("один активный: переключателя нет", async () => { + hub(agile()); + open("/app/jira/sprint?project=AWPCA"); + const p = within(await page()); + await p.findByText("Тема AURORA-1"); + expect(p.queryByRole("navigation", { name: "Спринты" })).toBeNull(); + }); + + it("устаревший ?sprint= — первый активный", async () => { + hub(agile([s102(), sprint({ id: 101, name: "Закрытый", state: "closed", issues: [iss("AURORA-99")] })])); + open("/app/jira/sprint?project=AWPCA&sprint=101"); + expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument(); + expect(screen.queryByText("Тема AURORA-99")).toBeNull(); + }); + + it("мусорный ?sprint=abc — первый активный", async () => { + hub(agile()); + open("/app/jira/sprint?project=AWPCA&sprint=abc"); + expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument(); + }); + + it("нет активного — «Нет активного спринта» и ссылка «Бэклог»", async () => { + hub(agile([sprint({ id: 103, name: "F", state: "future" })])); + open("/app/jira/sprint?project=AWPCA"); + const p = within(await page()); + expect(await p.findByText("Нет активного спринта")).toBeInTheDocument(); + expect(p.getByRole("link", { name: "Бэклог" })).toHaveAttribute("href", "/app/jira/backlog?project=AWPCA"); + }); + + it("агент без досок — «Нужен агент v15 и обновление из Jira» и «Обновить из Jira»", async () => { + hub({ board: null, sprints: [], backlog: null, board_issues: null }); + open("/app/jira/sprint?project=AWPCA"); + expect(await screen.findByText("Нужен агент v15 и обновление из Jira")).toBeInTheDocument(); + expect(within(screen.getByRole("main")).getAllByRole("button", { name: "Обновить из Jira" }).length).toBeGreaterThan(0); + }); + + it("щелчок по карточке открывает окно задачи поверх «Спринта»", async () => { + hub(agile()); + const router = open("/app/jira/sprint?project=AWPCA"); + const p = within(await page()); + await userEvent.click(await p.findByText("Тема AURORA-1")); + await screen.findByRole("dialog"); + expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-1"); + await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Закрыть" })); + await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/sprint")); + }); +}); diff --git a/web/src/modules/jira/agile/SprintPage.tsx b/web/src/modules/jira/agile/SprintPage.tsx new file mode 100644 index 0000000..35508f4 --- /dev/null +++ b/web/src/modules/jira/agile/SprintPage.tsx @@ -0,0 +1,61 @@ +import { Link, useSearchParams } from "react-router"; +import type { Project } from "../../../api/types.ts"; +import { PageHeader, Panel, Segmented } from "../../../ui/index.ts"; +import type { AgileSprint } from "../api/types.ts"; +import { RefreshButton } from "../RefreshButton.tsx"; +import { AgileGate } from "./AgileGate.tsx"; +import { BoardColumns } from "./BoardColumns.tsx"; +import { columnsOf } from "./columns.ts"; +import { daysLeft, fmtRange, fmtSp, tasksWord } from "./model.ts"; +import "./agile.css"; + +function SprintHead({ s }: { s: AgileSprint }) { + const left = daysLeft(s.end_date, new Date()); + return ( +
    +

    {s.name}

    + {fmtRange(s.start_date, s.end_date)} + {left !== null && {left >= 0 ? `осталось ${left} дн.` : "срок прошёл"}} + {s.goal && {s.goal}} + {`${s.count_done} из ${s.count} ${tasksWord(s.count)}`} + {`${fmtSp(s.points_done)} из ${fmtSp(s.points)} SP`} +
    + ); +} + +// «Спринт» (spec web-app): колонки доски активного спринта; только просмотр +export function SprintPage({ project }: { project: Project }) { + const [params] = useSearchParams(); + const wanted = params.get("sprint"); + return ( +
    + } /> + + {(d) => { + const active = d.sprints.filter((s) => s.state === "active"); + // устаревший или мусорный ?sprint= — первый активный (Review Focus 2) + const sprint = active.find((s) => String(s.id) === wanted) ?? active[0]; + if (!sprint) { + return ( + +

    Нет активного спринта

    + Бэклог +
    + ); + } + const proj = encodeURIComponent(project.label); + return ( + <> + {active.length > 1 && ( + ({ value: String(s.id), label: s.name, href: `/jira/sprint?project=${proj}&sprint=${s.id}` }))} /> + )} + + + + ); + }} +
    +
    + ); +} diff --git a/web/src/modules/jira/agile/agile.css b/web/src/modules/jira/agile/agile.css index 0f189f9..90f981c 100644 --- a/web/src/modules/jira/agile/agile.css +++ b/web/src/modules/jira/agile/agile.css @@ -35,3 +35,23 @@ .agile-row{flex-wrap:wrap} .agile-row__assignee{flex-basis:100%} } +/* «Спринт»: страница во всю ширину, колонки прокручиваются внутри контейнера */ +.agile--wide{padding-inline:var(--space-6);min-width:0} +.agile-columns{display:flex;align-items:flex-start;gap:var(--space-3);overflow-x:auto;padding-bottom:var(--space-2);min-width:0} +.agile-col{flex:0 0 280px;min-width:0;display:flex;flex-direction:column;gap:var(--space-2); + background:var(--color-bg-subtle);border:1px solid var(--color-border-default);border-radius:var(--radius-xl);padding:var(--space-3)} +.agile-col.is-extra{border-style:dashed} +.agile-col__head{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2)} +.agile-col__name{margin:0;font-size:var(--fs-body);line-height:var(--lh-body);font-weight:700;overflow-wrap:anywhere} +.agile-col__count{font-size:var(--fs-caption);color:var(--color-text-secondary)} +.agile-col__caption{margin:0;font-size:var(--fs-caption);color:var(--color-text-secondary)} +.agile-col__list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--space-2)} +.agile-card{display:flex;flex-direction:column;gap:var(--space-2);min-width:0;padding:var(--space-3);cursor:pointer; + background:var(--color-bg-surface);border:1px solid var(--color-border-default);border-radius:var(--radius-lg); + font-size:var(--fs-body);line-height:var(--lh-body)} +.agile-card:hover{background:var(--color-bg-hover)} +.agile-card.is-done{color:var(--color-text-secondary)} +.agile-card.is-done .agile-card__summary{text-decoration:line-through} +.agile-card__summary{overflow-wrap:anywhere} +.agile-card__foot{display:flex;align-items:center;gap:var(--space-2);min-width:0} +.agile-card__assignee{flex:1 1 auto;min-width:0;color:var(--color-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} diff --git a/web/src/modules/jira/agile/columns.test.ts b/web/src/modules/jira/agile/columns.test.ts new file mode 100644 index 0000000..3319984 --- /dev/null +++ b/web/src/modules/jira/agile/columns.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import type { AgileIssue } from "../api/types.ts"; +import { columnsOf } from "./columns.ts"; + +const iss = (key: string, status: string): AgileIssue => ({ + key, summary: key, issue_type: "Задача", status, priority: null, assignee: null, epic_key: null, story_points: null, rank: null, done: false, +}); +const cols = [{ name: "К выполнению", statuses: ["Backlog"] }, { name: "В работе", statuses: ["В работе"] }, { name: "Готово", statuses: ["Готово"] }]; + +describe("columnsOf", () => { + 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(v.map((c) => c.extra)).toEqual([false, false, false, true]); + expect(columnsOf(cols, [iss("A", "Готово")]).map((c) => c.name)).toEqual(["К выполнению", "В работе", "Готово"]); + }); + + it("columns = null — одна «Без колонки»", () => { + const v = columnsOf(null, [iss("A", "Готово")]); + expect(v).toHaveLength(1); + expect(v[0]).toMatchObject({ name: "Без колонки", extra: true }); + expect(v[0].issues.map((i) => i.key)).toEqual(["A"]); + }); +}); diff --git a/web/src/modules/jira/agile/columns.ts b/web/src/modules/jira/agile/columns.ts new file mode 100644 index 0000000..177e264 --- /dev/null +++ b/web/src/modules/jira/agile/columns.ts @@ -0,0 +1,21 @@ +import type { AgileIssue, BoardColumn } from "../api/types.ts"; + +export type ColumnView = { name: string; issues: AgileIssue[]; extra: boolean }; + +const NO_COLUMN = "Без колонки"; + +// задачи по колонкам доски: порядок колонок — доски, порядок задач — как во входе (ранг); +// статусы вне колонок — последняя «Без колонки», только если в ней есть задачи; нет колонок — одна «Без колонки» +export function columnsOf(columns: BoardColumn[] | null, issues: AgileIssue[]): ColumnView[] { + if (columns === null) return [{ name: NO_COLUMN, issues: [...issues], extra: true }]; + const byStatus = new Map(); + columns.forEach((c, i) => c.statuses.forEach((s) => { if (!byStatus.has(s)) byStatus.set(s, i); })); + const out: ColumnView[] = columns.map((c) => ({ name: c.name, issues: [], extra: false })); + const rest: AgileIssue[] = []; + for (const i of issues) { + const idx = i.status === null ? undefined : byStatus.get(i.status); + if (idx === undefined) rest.push(i); else out[idx].issues.push(i); + } + if (rest.length) out.push({ name: NO_COLUMN, issues: rest, extra: true }); + return out; +} diff --git a/web/src/modules/jira/index.tsx b/web/src/modules/jira/index.tsx index 10ded21..28e72f9 100644 --- a/web/src/modules/jira/index.tsx +++ b/web/src/modules/jira/index.tsx @@ -1,8 +1,9 @@ import { Navigate, useLocation, useParams } from "react-router"; import { ProjectGate } from "../../projects/ProjectGate.tsx"; -import { IconBacklog, IconBoard, IconHistory, IconList } from "../../ui/index.ts"; +import { IconBacklog, IconBoard, IconHistory, IconList, IconSprint } from "../../ui/index.ts"; import type { HubModule } from "../types.ts"; import { BacklogPage } from "./agile/BacklogPage.tsx"; +import { SprintPage } from "./agile/SprintPage.tsx"; import { BoardPage } from "./board/BoardPage.tsx"; import { FeedPage } from "./feed/FeedPage.tsx"; import { useIssueOverlay } from "./issues/overlay.tsx"; @@ -25,6 +26,7 @@ export const jiraModule: HubModule = { pages: [ { path: "/jira/links", element: {(p) => } }, { path: "/jira/backlog", element: {(p) => } }, + { path: "/jira/sprint", element: {(p) => } }, { path: "/jira/issues", element: {(p) => } }, { path: "/jira/feed", element: {(p) => } }, // прежние адреса (закладки): перенаправление с сохранением строки запроса, без новой записи истории @@ -37,6 +39,7 @@ export const jiraModule: HubModule = { nav: [ { to: "/jira/links", label: "Связи", icon: }, { to: "/jira/backlog", label: "Бэклог", icon: , padded: true, when: (p) => boardType(p) === "scrum" }, + { to: "/jira/sprint", label: "Спринт", icon: , when: (p) => boardType(p) === "scrum" }, { to: "/jira/issues", label: "Задачи", icon: , padded: true }, { to: "/jira/feed", label: "Лента", icon: , padded: true, keepSearch: true }, ], diff --git a/web/src/modules/modules.test.tsx b/web/src/modules/modules.test.tsx index 5812fc6..c9a0a99 100644 --- a/web/src/modules/modules.test.tsx +++ b/web/src/modules/modules.test.tsx @@ -6,12 +6,12 @@ import { MODULES } from "./index.ts"; describe("сборка оболочки из модулей", () => { it("страницы: «Обзор», адреса jira, перенаправления, вики, NotFound последним", () => { - expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/jira/links", "/jira/backlog", "/jira/issues", "/jira/feed", "/jira/board", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]); + expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/jira/links", "/jira/backlog", "/jira/sprint", "/jira/issues", "/jira/feed", "/jira/board", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]); }); it("пункты панели: подписи прежние, адреса jira с префиксом, плюс вики", () => { expect(MODULES.flatMap((m) => m.nav).map((n) => [n.label, n.to])).toEqual([ - ["Связи", "/jira/links"], ["Бэклог", "/jira/backlog"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"], + ["Связи", "/jira/links"], ["Бэклог", "/jira/backlog"], ["Спринт", "/jira/sprint"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"], ]); }); diff --git a/web/src/ui/icons.tsx b/web/src/ui/icons.tsx index c4e81c2..814e3bf 100644 --- a/web/src/ui/icons.tsx +++ b/web/src/ui/icons.tsx @@ -44,4 +44,5 @@ export const IconImage = make(<>); export const IconHome = make(<>); export const IconBook = make(<>); +export const IconSprint = make(<>); export const IconBacklog = make(<>); -- 2.47.2 From 611ed830c4ea90b48785cff1650d03901e1762ea Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 20:21:04 +0400 Subject: [PATCH 18/21] =?UTF-8?q?=D0=92=D0=B5=D0=B1:=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=20=C2=ABKanban=C2=BB=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../modules/jira/agile/KanbanPage.test.tsx | 111 ++++++++++++++++++ web/src/modules/jira/agile/KanbanPage.tsx | 38 ++++++ web/src/modules/jira/index.tsx | 5 +- web/src/modules/modules.test.tsx | 4 +- web/src/ui/icons.tsx | 1 + 5 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 web/src/modules/jira/agile/KanbanPage.test.tsx create mode 100644 web/src/modules/jira/agile/KanbanPage.tsx diff --git a/web/src/modules/jira/agile/KanbanPage.test.tsx b/web/src/modules/jira/agile/KanbanPage.test.tsx new file mode 100644 index 0000000..31294ff --- /dev/null +++ b/web/src/modules/jira/agile/KanbanPage.test.tsx @@ -0,0 +1,111 @@ +import { render, screen, within } from "@testing-library/react"; +import { createMemoryRouter } from "react-router"; +import { beforeEach, describe, expect, it } from "vitest"; +import { App } from "../../../App.tsx"; +import { setToken } from "../../../auth/token.ts"; +import settings from "../board/__fixtures__/settings.json"; +import { routes } from "../../../routes.tsx"; +import { json, mockFetch } from "../../../test/fetchMock.ts"; +import type { AgileIssue, AgileResponse } from "../api/types.ts"; + +const K = { label: "KAN", name: "Канбан", board_id: 5120, board_type: "kanban", created_at: "", issues_count: 4, jira_synced_at: null }; +const S = { label: "AWPCA", name: "АРМ КА", board_id: 4216, board_type: "scrum", created_at: "", issues_count: 9, jira_synced_at: null }; + +const iss = (key: string, over: Partial = {}): AgileIssue => ({ + key, summary: `Тема ${key}`, issue_type: "Задача", status: "Новая", priority: null, assignee: null, + epic_key: null, story_points: null, rank: "0|a", done: false, ...over, +}); +const cols = [ + { name: "Бэклог", statuses: ["Новая"] }, { name: "В работе", statuses: ["В работе"] }, { name: "Готово", statuses: ["Готово"] }, +]; +const agile = (backlog: boolean | null): AgileResponse => ({ + board: { id: 5120, type: "kanban", columns: cols, kanban_backlog: backlog, fetched_at: "2026-10-24T10:00:00+00:00" }, + sprints: [], + backlog: backlog ? [iss("AURORA-1"), iss("AURORA-2")] : null, + board_issues: [iss("AURORA-1"), iss("AURORA-2"), iss("AURORA-3", { status: "В работе" }), iss("AURORA-4", { status: "Готово", done: true }), iss("AURORA-5", { status: "Странный" })], +}); + +function hub(data: AgileResponse, projects = [K]) { + const urls: string[] = []; + mockFetch((method, url) => { + const k = `${method} ${url.pathname}`; + urls.push(k); + if (k === "GET /agile") return json(200, data); + if (k === "GET /projects") return json(200, { projects }); + if (k === "GET /settings") return json(200, settings); + if (k === "GET /issues") return json(200, { issues: [] }); + if (k === "GET /batches") return json(200, { batches: [] }); + if (k === "GET /agents") return json(200, { agents: [] }); + return json(404, { error: "not_found" }); + }); + return urls; +} +const open = (path: string) => { + const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" }); + render(); + return router; +}; +const page = () => screen.findByTestId("page-kanban"); +const colNames = (p: HTMLElement) => Array.from(p.querySelectorAll(".agile-col__name")).map((e) => e.textContent); + +describe("«Kanban»", () => { + beforeEach(() => { setToken("tok"); localStorage.clear(); }); + + it("меню kanban-проекта: «Связи», «Kanban», «Задачи», «Лента»", async () => { + hub(agile(false)); + open("/app/jira/links?project=KAN"); + const g = () => screen.getByRole("group", { name: "Jira" }); + await within(g()).findByRole("link", { name: "Kanban" }); + expect(within(g()).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Kanban", "Задачи", "Лента"]); + }); + + it("колонки из board_issues, у последней — «за 14 дней», «Без колонки» при статусе вне доски", async () => { + hub(agile(false)); + open("/app/jira/kanban?project=KAN"); + const p = await page(); + await within(p).findByText("Тема AURORA-3"); + expect(colNames(p)).toEqual(["Бэклог", "В работе", "Готово", "Без колонки"]); + const done = p.querySelector('[aria-label="Готово"]') as HTMLElement; + expect(within(done).getByText("за 14 дней")).toBeInTheDocument(); + expect(within(done).getByText("Тема AURORA-4")).toBeInTheDocument(); + expect(p.querySelectorAll(".agile-col__caption")).toHaveLength(1); + const rest = p.querySelector('[aria-label="Без колонки"]') as HTMLElement; + expect(within(rest).getByText("Тема AURORA-5")).toBeInTheDocument(); + }); + + it("бэклог включён: «Бэклог (2)» над колонками, первая колонка не рисуется", async () => { + hub(agile(true)); + open("/app/jira/kanban?project=KAN"); + const p = await page(); + const sum = await within(p).findByText("Бэклог (2)"); + const det = sum.closest("details")!; + expect(within(det).getByText("Тема AURORA-1")).toBeInTheDocument(); + expect(within(det).getByText("Тема AURORA-2")).toBeInTheDocument(); + expect(colNames(p)).toEqual(["В работе", "Готово", "Без колонки"]); + expect(p.querySelector(".agile-columns")!.textContent).not.toContain("Тема AURORA-1"); + expect(det.compareDocumentPosition(p.querySelector(".agile-columns")!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it("бэклог выключен или неизвестен: раздела «Бэклог» нет, первая колонка на доске", async () => { + for (const flag of [false, null]) { + hub(agile(flag)); + const { unmount } = render(); + const p = await page(); + await within(p).findByText("Тема AURORA-3"); + expect(p.querySelector("details")).toBeNull(); + expect(screen.queryByText(/Бэклог \(/)).toBeNull(); + expect(colNames(p)[0]).toBe("Бэклог"); + expect(within(p.querySelector('[aria-label="Бэклог"]') as HTMLElement).getByText("Тема AURORA-1")).toBeInTheDocument(); + unmount(); + } + }); + + it("scrum-проект на /app/jira/kanban — «У проекта нет kanban-доски» со ссылкой «Связи»", async () => { + const urls = hub(agile(false), [S]); + open("/app/jira/kanban?project=AWPCA"); + const p = within(await page()); + expect(await p.findByText("У проекта нет kanban-доски")).toBeInTheDocument(); + expect(p.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links?project=AWPCA"); + expect(urls).not.toContain("GET /agile"); + }); +}); diff --git a/web/src/modules/jira/agile/KanbanPage.tsx b/web/src/modules/jira/agile/KanbanPage.tsx new file mode 100644 index 0000000..16c48d9 --- /dev/null +++ b/web/src/modules/jira/agile/KanbanPage.tsx @@ -0,0 +1,38 @@ +import type { Project } from "../../../api/types.ts"; +import { PageHeader } from "../../../ui/index.ts"; +import { RefreshButton } from "../RefreshButton.tsx"; +import { AgileGate } from "./AgileGate.tsx"; +import { BoardColumns } from "./BoardColumns.tsx"; +import { columnsOf } from "./columns.ts"; +import { AgileIssueRow } from "./IssueRow.tsx"; +import "./agile.css"; + +// «Kanban» (spec web-app): колонки доски; при включённом бэклоге Jira первая колонка не рисуется, её задачи — в «Бэклог (N)» (R-18) +export function KanbanPage({ project }: { project: Project }) { + return ( +
    + } /> + + {(d) => { + const withBacklog = d.board?.kanban_backlog === true; + const cols = d.board?.columns ?? null; + // задачи первой колонки уходят в «Бэклог», а не в «Без колонки» + const first = new Set(withBacklog && cols ? cols[0]?.statuses ?? [] : []); + const issues = (d.board_issues ?? []).filter((i) => i.status === null || !first.has(i.status)); + const backlog = d.backlog ?? []; + return ( + <> + {withBacklog && ( +
    + {`Бэклог (${backlog.length})`} +
      {backlog.map((i) => )}
    +
    + )} + + + ); + }} +
    +
    + ); +} diff --git a/web/src/modules/jira/index.tsx b/web/src/modules/jira/index.tsx index 28e72f9..8d8191c 100644 --- a/web/src/modules/jira/index.tsx +++ b/web/src/modules/jira/index.tsx @@ -1,8 +1,9 @@ import { Navigate, useLocation, useParams } from "react-router"; import { ProjectGate } from "../../projects/ProjectGate.tsx"; -import { IconBacklog, IconBoard, IconHistory, IconList, IconSprint } from "../../ui/index.ts"; +import { IconBacklog, IconBoard, IconHistory, IconKanban, IconList, IconSprint } from "../../ui/index.ts"; import type { HubModule } from "../types.ts"; import { BacklogPage } from "./agile/BacklogPage.tsx"; +import { KanbanPage } from "./agile/KanbanPage.tsx"; import { SprintPage } from "./agile/SprintPage.tsx"; import { BoardPage } from "./board/BoardPage.tsx"; import { FeedPage } from "./feed/FeedPage.tsx"; @@ -27,6 +28,7 @@ export const jiraModule: HubModule = { { path: "/jira/links", element: {(p) => } }, { path: "/jira/backlog", element: {(p) => } }, { path: "/jira/sprint", element: {(p) => } }, + { path: "/jira/kanban", element: {(p) => } }, { path: "/jira/issues", element: {(p) => } }, { path: "/jira/feed", element: {(p) => } }, // прежние адреса (закладки): перенаправление с сохранением строки запроса, без новой записи истории @@ -40,6 +42,7 @@ export const jiraModule: HubModule = { { to: "/jira/links", label: "Связи", icon: }, { to: "/jira/backlog", label: "Бэклог", icon: , padded: true, when: (p) => boardType(p) === "scrum" }, { to: "/jira/sprint", label: "Спринт", icon: , when: (p) => boardType(p) === "scrum" }, + { to: "/jira/kanban", label: "Kanban", icon: , when: (p) => boardType(p) === "kanban" }, { to: "/jira/issues", label: "Задачи", icon: , padded: true }, { to: "/jira/feed", label: "Лента", icon: , padded: true, keepSearch: true }, ], diff --git a/web/src/modules/modules.test.tsx b/web/src/modules/modules.test.tsx index c9a0a99..3328300 100644 --- a/web/src/modules/modules.test.tsx +++ b/web/src/modules/modules.test.tsx @@ -6,12 +6,12 @@ import { MODULES } from "./index.ts"; describe("сборка оболочки из модулей", () => { it("страницы: «Обзор», адреса jira, перенаправления, вики, NotFound последним", () => { - expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/jira/links", "/jira/backlog", "/jira/sprint", "/jira/issues", "/jira/feed", "/jira/board", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]); + expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/jira/links", "/jira/backlog", "/jira/sprint", "/jira/kanban", "/jira/issues", "/jira/feed", "/jira/board", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]); }); it("пункты панели: подписи прежние, адреса jira с префиксом, плюс вики", () => { expect(MODULES.flatMap((m) => m.nav).map((n) => [n.label, n.to])).toEqual([ - ["Связи", "/jira/links"], ["Бэклог", "/jira/backlog"], ["Спринт", "/jira/sprint"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"], + ["Связи", "/jira/links"], ["Бэклог", "/jira/backlog"], ["Спринт", "/jira/sprint"], ["Kanban", "/jira/kanban"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"], ]); }); diff --git a/web/src/ui/icons.tsx b/web/src/ui/icons.tsx index 814e3bf..a154a2d 100644 --- a/web/src/ui/icons.tsx +++ b/web/src/ui/icons.tsx @@ -46,3 +46,4 @@ export const IconHome = make(<>); export const IconSprint = make(<>); export const IconBacklog = make(<>); +export const IconKanban = make(<>); -- 2.47.2 From fe1d5926a5cf78d08c0844b5f06e5a4dd8b1a631 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 20:28:48 +0400 Subject: [PATCH 19/21] =?UTF-8?q?e2e=20=D1=81=D0=BF=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20=D0=B8=20=D0=B1=D1=8D=D0=BA=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=B0,=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=85=D0=B0=D0=B1=20=D1=81=20=D0=B4=D0=BE=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=D0=BC=D0=B8,=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- ARCHITECTURE.md | 5 ++ CLAUDE.md | 2 +- README.md | 14 +++++ web/e2e/agile.spec.ts | 81 ++++++++++++++++++++++++++++ web/scripts/seed-hub.py | 33 +++++++++++- web/src/modules/jira/agile/agile.css | 2 +- 6 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 web/e2e/agile.spec.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f4c788d..eacff13 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -101,6 +101,11 @@ страницы модуля — `/app/<модуль>/…` (`/app/jira/links`, `/app/jira/issues[/{key}]`, `/app/jira/feed`, `/app/wiki`); прежние `/app/board`, `/app/jira/board`, `/app/issues`, `/app/issues/{key}`, `/app/feed` перенаправляются с сохранением `search` и `replace`. Пункты меню могут зависеть от проекта (`NavItem.when`, `visibleNav` в `shell/nav.tsx`): пока проект не определён, условный пункт скрыт; «не найдено» перечисляет только безусловные. +Экраны доски модуля jira — `web/src/modules/jira/agile/`: `model` (даты Jira, «осталось N дн.», склонение, состояние данных +`boardState`), `columns` (раскладка задач по колонкам доски, «Без колонки»), `AgileGate` (тип доски проекта, загрузка, ошибка, три +состояния данных), `BoardColumns` и страницы `BacklogPage` (`/jira/backlog`), `SprintPage` (`/jira/sprint`), `KanbanPage` +(`/jira/kanban`). Данные — один запрос `GET /agile?project=<метка>` (`modules/jira/agile.py`); страницы только смотрят. + Модуль `wiki` (`web/src/modules/wiki/index.tsx`): страница «Вики» (`/app/wiki`): дерево (`Tree`) с поиском (`Search`), страница (`WikiPage`, `PageView` — `react-markdown` + `remark-gfm`, без `rehype-raw`; ссылки `page:` ведут на страницу вики, `attachment:` скачивают файл через `download.ts`, `urlTransform.ts` пропускает только эти схемы и http(s)), форма корня без привязки (`RootForm`), diff --git a/CLAUDE.md b/CLAUDE.md index 244cdce..91af62d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ SDLC-хаб с модулями jira и wiki. Мост Jira для АРМ КА: - `uv sync` — установка окружения. - `uv run sdlc-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов. - `uv run sdlc-hub serve [--host] [--port]` — локальный запуск хаба. -- Веб-приложение — `http://127.0.0.1:8790/app/` («Обзор» — `/app/?project=<метка>`, «Связи» (доска связей) — `/app/jira/links?project=<метка>`, задачи — `/app/jira/issues?project=<метка>`, лента изменений — `/app/jira/feed?project=<метка>`, вики — `/app/wiki?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на «Обзор», прежние `/app/board`, `/app/jira/board`, `/app/issues`, `/app/feed` — на страницы модуля Jira; пункты меню могут зависеть от проекта (`NavItem.when`). +- Веб-приложение — `http://127.0.0.1:8790/app/` («Обзор» — `/app/?project=<метка>`, «Связи» (доска связей) — `/app/jira/links?project=<метка>`, задачи — `/app/jira/issues?project=<метка>`, «Бэклог» — `/app/jira/backlog?project=<метка>`, «Спринт» — `/app/jira/sprint?project=<метка>`, «Kanban» — `/app/jira/kanban?project=<метка>`, лента изменений — `/app/jira/feed?project=<метка>`, вики — `/app/wiki?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на «Обзор», прежние `/app/board`, `/app/jira/board`, `/app/issues`, `/app/feed` — на страницы модуля Jira; пункты меню могут зависеть от проекта (`NavItem.when`). - `make web` — сборка приложения (после `git pull` на Mac mini, до перезапуска хаба); `make check` — pytest + lint и тесты фронта; `cd web && npm run dev` — разработка с прокси на тестовый хаб `bash web/scripts/test-hub.sh` (:8791); `cd web && npm run e2e` — Playwright; `cd web && npm run golden` — перегенерировать эталоны доски из `links.html@3931a88` (только при смене фикстур). - `uv run sdlc-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run sdlc-hub mcp <модуль> [--project <метка>]` — MCP модуля по stdio (модуль обязателен: `jira` — сервер `sdlc-jira`, `wiki` — сервер `sdlc-wiki`; с привязкой сессии к проекту); `uv run sdlc-hub tokens` — показать токены (клиентский и по агенту на модуль). - `uv run sdlc-hub agent publish [--module jira|wiki] PATH` — опубликовать версию агента модуля для самообновления (только вверх; по умолчанию `jira`; публикация лежит в `release/<модуль>/`). diff --git a/README.md b/README.md index a5c71b6..8cf93c5 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,15 @@ make web переводят её в новую схему, а хаб на прежнем коде с такой базой не работает. Старый фронт с новым API совместим, сборка может подождать. Откат на копию v6 из `backups/` — вместе с возвратом `release/jira/*` в `release/` (прежняя раскладка), иначе старый код не найдёт публикацию агента. +Экраны доски (только просмотр: ни перетаскивания, ни кнопок спринтов, ни смены статуса или ранга): «Бэклог» +`/app/jira/backlog?project=<метка>` (активные и будущие спринты, бэклог, «Прошлые спринты (N)»), «Спринт» +`/app/jira/sprint?project=<метка>[&sprint=]` (колонки доски активного спринта; устаревший `sprint` — первый активный), +«Kanban» `/app/jira/kanban?project=<метка>` (колонки доски, «Бэклог (N)» при включённом бэклоге Jira, выполненное за 14 дней). +Пункты меню зависят от типа доски проекта: у scrum-проекта «Бэклог» и «Спринт», у kanban-проекта «Kanban»; пока тип не известен, +пунктов нет. Адрес страницы чужого типа показывает «У проекта нет scrum-доски» / «У проекта нет kanban-доски» со ссылкой +«Связи». Три состояния данных: «Нужен агент v15 и обновление из Jira» (доска ещё не прочитана), «Не удалось прочитать настройки +доски из Jira» (колонок нет), сами данные. Карточка задачи открывается окном поверх экрана. + Проект выбирается выпадающим списком «Проект» в верхней панели (метка и название); пункт «+ Новый проект…» открывает окно (метка, название, необязательно номер доски Jira): после создания приложение переходит в новый проект и показывает ход пакета обновления. Текущий проект — параметр `project` в адресе; без него приложение подставляет последний выбранный @@ -349,6 +358,11 @@ make web ## Эксплуатация +- Выкатка спринтов и бэклога (порядок обязателен): `git pull` → `launchctl kickstart -k gui/$(id -u)/ru.aepif.sdlc-hub` + (владелец; миграция схемы v9) → `make web` → `/mcp` reconnect → `agent publish` агента v15 (`uv run sdlc-hub agent publish + ../jira-agent/src/agent.py`) → проверка полей Jira банка (ранг, оценка, спринты) → `field_ids.story_points` в настройках, + если агент поле оценки не нашёл. `min_agent_version` поднимает владелец после выкатки v15 и после того, как `agent_status` + покажет новую версию. - Резервные копии: перед первой записывающей командой пакета хаб копирует базу в `backups/<дата-время>-.sqlite` и хранит последние `backup_keep` копий (по умолчанию 30). - Файлы вложений: `files/`, одна копия на содержимое, в резервные копии базы не входят. Раз в сутки (первый diff --git a/web/e2e/agile.spec.ts b/web/e2e/agile.spec.ts new file mode 100644 index 0000000..8ea5ce4 --- /dev/null +++ b/web/e2e/agile.spec.ts @@ -0,0 +1,81 @@ +import { expect, test } from "@playwright/test"; +import { login } from "./hub.ts"; + +// экраны доски: «Бэклог», «Спринт», «Kanban» (spec web-app «Спринты и бэклог»); данные — сид тестового хаба (R-27) +const side = (page: import("@playwright/test").Page) => page.getByRole("complementary", { name: "Панель приложения" }); + +test("«Бэклог» AWPCA: секции, «Прошлые спринты (1)», щелчок открывает карточку", async ({ page }) => { + await login(page, "/app/jira/backlog?project=AWPCA"); + await expect(page.getByRole("heading", { name: "AWPCA_Спринт 2" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "AWPCA_Спринт 3" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Бэклог", exact: true, level: 2 })).toBeVisible(); + const past = page.getByText("Прошлые спринты (1)"); + await expect(past).toBeVisible(); + await past.click(); + await page.getByText("AWPCA_Спринт 1").first().click(); + const row = page.locator(".agile-row").filter({ hasText: "AURORA-9001" }).last(); + await expect(row).toBeVisible(); + await row.click(); + await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001/); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page).toHaveURL(/\/app\/jira\/backlog/); + await expect(page.getByRole("dialog")).toHaveCount(0); +}); + +test("«Спринт» AWPCA: колонки доски, «осталось 7 дн.», карточка окном", async ({ page }) => { + await login(page, "/app/jira/sprint?project=AWPCA"); + await expect(page.getByRole("heading", { name: "AWPCA_Спринт 2" })).toBeVisible(); + await expect(page.getByText("осталось 7 дн.")).toBeVisible(); + for (const name of ["К выполнению", "В работе", "Готово"]) { + await expect(page.locator(".agile-col__name", { hasText: name })).toBeVisible(); + } + await page.locator(".agile-card").filter({ hasText: "AURORA-9001" }).click(); + await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001/); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page).toHaveURL(/\/app\/jira\/sprint/); +}); + +test("«Kanban»: «Бэклог (1)», колонки «В работе» и «Готово», подпись «за 14 дней»", async ({ page }) => { + await login(page, "/app/jira/kanban?project=KANBAN_E2E"); + await expect(page.getByText("Бэклог (1)")).toBeVisible(); + await expect(page.locator(".agile-col__name", { hasText: "В работе" })).toBeVisible(); + await expect(page.locator(".agile-col__name", { hasText: "Готово" })).toBeVisible(); + await expect(page.locator(".agile-col__name", { hasText: "Бэклог" })).toHaveCount(0); + await expect(page.getByText("за 14 дней")).toBeVisible(); + await expect(page.locator(".agile-card").filter({ hasText: "AURORA-9102" })).toBeVisible(); + await expect(page.locator(".agile-card").filter({ hasText: "AURORA-9103" })).toBeVisible(); +}); + +test("без scrum-доски: /app/jira/backlog?project=KANBAN_E2E — «У проекта нет scrum-доски», ссылка «Связи»", async ({ page }) => { + await login(page, "/app/jira/backlog?project=KANBAN_E2E"); + await expect(page.getByText("У проекта нет scrum-доски")).toBeVisible(); + await page.getByRole("link", { name: "Связи", exact: true }).last().click(); + await expect(page).toHaveURL(/\/app\/jira\/links\?project=KANBAN_E2E/); +}); + +test("меню по проекту: у AWPCA «Бэклог» и «Спринт», у KANBAN_E2E — «Kanban»", async ({ page }) => { + await login(page, "/app/jira/links?project=AWPCA"); + await expect(side(page).getByRole("link", { name: "Бэклог" })).toBeVisible(); + await expect(side(page).getByRole("link", { name: "Спринт" })).toBeVisible(); + await expect(side(page).getByRole("link", { name: "Kanban" })).toHaveCount(0); + await page.goto("/app/jira/links?project=KANBAN_E2E"); + await expect(side(page).getByRole("link", { name: "Kanban" })).toBeVisible(); + await expect(side(page).getByRole("link", { name: "Бэклог" })).toHaveCount(0); + await expect(side(page).getByRole("link", { name: "Спринт" })).toHaveCount(0); +}); + +test("900 px: колонки «Спринта» прокручиваются внутри доски, страница без горизонтальной прокрутки", async ({ page }) => { + await page.setViewportSize({ width: 900, height: 900 }); + await login(page, "/app/jira/sprint?project=AWPCA"); + await expect(page.locator(".agile-col").first()).toBeVisible(); + const m = await page.evaluate(() => { + const d = document.documentElement, c = document.querySelector(".agile-columns")!; + return { doc: [d.scrollWidth, d.clientWidth], cols: [c.scrollWidth, c.clientWidth], + gutter: c.getBoundingClientRect().left - document.querySelector(".agile")!.getBoundingClientRect().left }; + }); + expect(m.gutter, "колонки не получают второй отступ внутри страницы (T10.1)").toBe(0); + expect(m.doc[0]).toBeLessThanOrEqual(m.doc[1]); + expect(m.cols[0]).toBeGreaterThan(m.cols[1]); +}); diff --git a/web/scripts/seed-hub.py b/web/scripts/seed-hub.py index 56450e2..e38ae95 100644 --- a/web/scripts/seed-hub.py +++ b/web/scripts/seed-hub.py @@ -3,10 +3,11 @@ Дополнения только для тестового хаба (описание, комментарии, команды) — из seed-extras.json рядом со скриптом.""" import json import sys +from datetime import date, timedelta from pathlib import Path from sdlc_hub.core import db, paths, settings -from sdlc_hub.modules.jira import mirror +from sdlc_hub.modules.jira import mirror, projects as jira_projects from sdlc_hub.modules.wiki import paths as wiki_paths, search as wiki_search, sync as wiki_sync snap = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) @@ -25,7 +26,35 @@ def own_labels(i): issues = [{k: v for k, v in i.items() if k not in ("work_kind", "neighbor")} | {"id": i["key"]} | {"labels": own_labels(i)} | extra_issues.get(i["key"], {}) for i in snap["issues"]] links = [{"source_key": l["source"], "target_key": l["target"], "link_type": l["link_type"]} for l in snap["links"]] -mirror.apply_state(conn, issues, links, neighbor_keys=[i["key"] for i in snap["issues"] if i["neighbor"]]) +# доски для экранов «Бэклог», «Спринт», «Kanban» (R-27): AWPCA — scrum 4216, KANBAN_E2E — kanban 5120 +SPRINTS = {"AWPCA_Спринт 1": 101, "AWPCA_Спринт 2": 102, "AWPCA_Спринт 3": 103} +for n, i in enumerate(issues, 1): + i["rank"] = f"0|i{n:04d}:" + sid = SPRINTS.get(i.get("sprint")) + i["sprint_ids"] = [] if sid is None else [101, 102] if i["key"] == "AURORA-9001" else [sid] + if n % 2: + i["story_points"] = float(n % 5 + 1) +today = date.today() +iso = lambda d, hhmm: f"{d.isoformat()}T{hhmm}:00.000+03:00" +now_iso = iso(today, "12:00") +issues += [{"key": k, "id": k, "summary": f"Задача канбана {k[-4:]}", "issue_type": "Задача", "status": st, "priority": "Средний", + "labels": ["KANBAN_E2E"], "rank": f"0|k{n:04d}:", "sprint_ids": [], **({"jira_updated": now_iso} if st == "Готово" else {})} + for n, (k, st) in enumerate([("AURORA-9101", "Backlog"), ("AURORA-9102", "В работе"), ("AURORA-9103", "Готово")], 1)] +jira_projects.create(conn, "KANBAN_E2E", "Канбан e2e", 5120) +boards = [ + {"id": 4216, "type": "scrum", "kanban_backlog": None, + "columns": [{"name": "К выполнению", "statuses": ["Backlog", "К выполнению", "Ожидание", "Ожидание разработки"]}, + {"name": "В работе", "statuses": ["В работе"]}, {"name": "Готово", "statuses": ["Готово"]}], + "sprints": [{"id": 101, "name": "AWPCA_Спринт 1", "state": "closed", "start_date": iso(today - timedelta(days=21), "09:00"), + "end_date": iso(today - timedelta(days=8), "09:00"), "complete_date": iso(today - timedelta(days=8), "18:00"), "goal": None}, + {"id": 102, "name": "AWPCA_Спринт 2", "state": "active", "start_date": iso(today - timedelta(days=7), "09:00"), + "end_date": iso(today + timedelta(days=7), "12:00"), "complete_date": None, "goal": "Тестовая цель спринта"}, + {"id": 103, "name": "AWPCA_Спринт 3", "state": "future", "start_date": None, "end_date": None, "complete_date": None, "goal": None}]}, + {"id": 5120, "type": "kanban", "kanban_backlog": True, + "columns": [{"name": "Бэклог", "statuses": ["Backlog"]}, {"name": "В работе", "statuses": ["В работе"]}, + {"name": "Готово", "statuses": ["Готово"]}]}, +] +mirror.apply_state(conn, issues, links, neighbor_keys=[i["key"] for i in snap["issues"] if i["neighbor"]], boards=boards) for i in snap["issues"]: note = {k: i[k] for k in ("work_kind",) if i.get(k)} if note: diff --git a/web/src/modules/jira/agile/agile.css b/web/src/modules/jira/agile/agile.css index 90f981c..a984c47 100644 --- a/web/src/modules/jira/agile/agile.css +++ b/web/src/modules/jira/agile/agile.css @@ -36,7 +36,7 @@ .agile-row__assignee{flex-basis:100%} } /* «Спринт»: страница во всю ширину, колонки прокручиваются внутри контейнера */ -.agile--wide{padding-inline:var(--space-6);min-width:0} +.agile--wide{min-width:0} .agile-columns{display:flex;align-items:flex-start;gap:var(--space-3);overflow-x:auto;padding-bottom:var(--space-2);min-width:0} .agile-col{flex:0 0 280px;min-width:0;display:flex;flex-direction:column;gap:var(--space-2); background:var(--color-bg-subtle);border:1px solid var(--color-border-default);border-radius:var(--radius-xl);padding:var(--space-3)} -- 2.47.2 From f5be3759906a9bc567119148c07e60035316a487 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 20:40:08 +0400 Subject: [PATCH 20/21] =?UTF-8?q?design.md:=20R-33=20(=D0=BE=D1=86=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=B0=20=D0=B4=D0=BE=20=D0=B2=D1=8B=D0=BA=D0=B0?= =?UTF-8?q?=D1=82=D0=BA=D0=B8=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0=20v15?= =?UTF-8?q?)=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- openspec/changes/sprints-backlog/design.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openspec/changes/sprints-backlog/design.md b/openspec/changes/sprints-backlog/design.md index 706f316..ea19afd 100644 --- a/openspec/changes/sprints-backlog/design.md +++ b/openspec/changes/sprints-backlog/design.md @@ -228,3 +228,8 @@ kanban) — свои задачи в статусах остальных кол исполнителем задачи 4. Цена ошибки — ужесточить проверку (422 вместо `failed` у агента). - 24.10.2026 — R-32 (задача 9, по web-app): блок «Прошлые спринты (N)» на «Бэклоге» не рисуется при N = 0 (спека не требует пустой блок). Выбрано исполнителем задачи 9. Цена ошибки — рисовать «Прошлые спринты (0)» свёрнутым. +- 24.10.2026 — R-33 (финальное ревью M-3): до выкатки агента v15 команда `ensure_fields` со `story_points`, поставленная + хабу, будет выполнена агентом v14 как `done` с пустым `changed` (неизвестное поле v14 пропускает), хаб при постановке + версию агента не проверяет. Принято как ограничение окна выкатки: README требует публиковать v15 сразу после + перезапуска хаба. Цена ошибки — ложное «done» по оценке в окне между перезапуском и публикацией v15; исправление — + 422 при постановке, если `agent_version` модуля jira < 15. -- 2.47.2 From 26a48787829d77a9d846a5fb020b43a907dc4946 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 20:51:20 +0400 Subject: [PATCH 21/21] =?UTF-8?q?=D0=9F=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE?= =?UTF-8?q?=D0=BC=D1=83=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E:=20=D0=B4=D0=B8?= =?UTF-8?q?=D0=B0=D0=BF=D0=B0=D0=B7=D0=BE=D0=BD=D1=8B=20=D1=87=D0=B8=D1=81?= =?UTF-8?q?=D0=B5=D0=BB,=20strip,=20=D1=83=D1=81=D1=82=D0=BE=D0=B9=D1=87?= =?UTF-8?q?=D0=B8=D0=B2=D1=8B=D0=B5=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20(#1?= =?UTF-8?q?08)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- ARCHITECTURE.md | 2 +- README.md | 9 ++++--- src/sdlc_hub/modules/jira/commands.py | 26 +++++++++++++------ src/sdlc_hub/modules/jira/mirror.py | 21 ++++++++++++--- tests/test_api.py | 7 ++++- tests/test_commands.py | 11 ++++++++ tests/test_mcp.py | 5 ++-- web/e2e/agile.spec.ts | 2 +- .../modules/jira/agile/KanbanPage.test.tsx | 4 ++- web/src/modules/jira/agile/KanbanPage.tsx | 4 +-- .../modules/jira/agile/SprintPage.test.tsx | 4 ++- 11 files changed, 70 insertions(+), 25 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index eacff13..d8908ec 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -47,7 +47,7 @@ | `refresh` | журнал синхронизаций `sync_run`, полный прогон, автообновление | `core.db`, `core.queue`, `core.settings`, `projects` | | `projects` | базовая JQL из меток (`base_jql`), доски (`boards`), миграция v3 → v4 (`migrate_v3`), проект по умолчанию | `core.projects`, `core.settings` | | `hooks` | `CommandHooks` для очереди ядра: проверки постановки (`bug` без настройки, файл вложения, проект пакета, `board_id` у `ensure_sprint` (по проекту задачи) и у `ensure_sprint_meta`/`ensure_sprint_state` (по проекту пакета)), паузы, `deliver_params` (`size`, `boards`, `history`), отчёты (`attachment_file`, `sync_run`) | `core.queue`, `core.files`, `core.projects`, `core.settings`, `settings`, `projects`, `commands`, `mirror`, `history`, `refresh`, `attachments`, `fetch` | -| `agile` | чтение для `GET /agile`: доска, спринты, бэклог, выполнено по последней колонке | `core.db`, `history` | +| `agile` | чтение для `GET /agile`: доска, спринты, бэклог, выполнено по последней колонке | `history` | | `api` | маршруты модуля `MODULE.routes`: `/issues`, `/board`, `/agile`, `/changes`, `/attachments`, `/refresh`, `/sync-runs` и др. | `core.db`, `core.files`, `core.queue`, `core.settings`, `core.http`, `core.errors`, `attachments`, `projects`, `mirror`, `history`, `fetch`, `refresh` | | `mcp` | инструменты MCP: `issues` (колонка SP), `issue`, `sprints`, `sprint`, `backlog` (читают `GET /agile`), `batch_create`, `batch_*`, `comment`, `attachment`, `changes`, `refresh`, `settings_*` и др.; общие `projects`/`project_create` подключает `register_core_tools` | `core.mcp`, `core.client`, `commands`, `attachments` | | `importer`, `cli` | разовый импорт CSV-пакета в заметки (`sdlc-hub import FOLDER`) | `mirror`, `core.db`, `core.settings` | diff --git a/README.md b/README.md index 8cf93c5..d3dbb30 100644 --- a/README.md +++ b/README.md @@ -341,9 +341,9 @@ make web `ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint`, `ensure_attachment`, `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank` 30 с, `refresh` 2 с (настройка `pacing`). -Команды спринта (`ensure_sprint_meta`, `ensure_sprint_state`) и `ensure_rank` ставятся только в пакете проекта (поле `project`): -`board_id` дописывает хаб по проекту пакета; пакет без проекта — 422 «команды спринта — только в пакете проекта»; -доски нет, kanban или тип не известен — 422, как у `ensure_sprint`. `ensure_rank` в пакете проекта — `key` своей задачи. +Команды спринта (`ensure_sprint_meta`, `ensure_sprint_state`) ставятся только в пакете проекта (поле `project`); `ensure_rank` — в любом пакете. +У команд спринта `board_id` дописывает хаб по проекту пакета; пакет без проекта — 422 «команды спринта — только в пакете проекта»; +доски нет, kanban или тип не известен — 422, как у `ensure_sprint`. В пакете проекта у `ensure_rank` `key` должен быть своей задачей. Даты проверяются хабом: настоящая дата, начало не позже конца. `ensure_attachment` ставится, только если файл уже загружен (`POST /files`), иначе 422 «команда N: файл не @@ -362,7 +362,8 @@ make web (владелец; миграция схемы v9) → `make web` → `/mcp` reconnect → `agent publish` агента v15 (`uv run sdlc-hub agent publish ../jira-agent/src/agent.py`) → проверка полей Jira банка (ранг, оценка, спринты) → `field_ids.story_points` в настройках, если агент поле оценки не нашёл. `min_agent_version` поднимает владелец после выкатки v15 и после того, как `agent_status` - покажет новую версию. + покажет новую версию. Ограничение (R-33): до публикации агента v15 `story_points` в `ensure_fields` агент v14 выполнит как + `done` с пустым `changed`; v15 публикуется сразу после перезапуска хаба. - Резервные копии: перед первой записывающей командой пакета хаб копирует базу в `backups/<дата-время>-.sqlite` и хранит последние `backup_keep` копий (по умолчанию 30). - Файлы вложений: `files/`, одна копия на содержимое, в резервные копии базы не входят. Раз в сутки (первый diff --git a/src/sdlc_hub/modules/jira/commands.py b/src/sdlc_hub/modules/jira/commands.py index d8c5e7b..a5317f6 100644 --- a/src/sdlc_hub/modules/jira/commands.py +++ b/src/sdlc_hub/modules/jira/commands.py @@ -81,6 +81,13 @@ def temp_keys_used(cmd) -> list[str]: return [k for k in referenced_keys(cmd) if is_temp(k) and k != own] +def _finite(value) -> bool: + try: + return math.isfinite(value) + except OverflowError: # целое 10**400 не приводится к float + return False + + def _type_error(ctype, name, value) -> str | None: """Проверка типа значения параметра (M-3 #12): иначе .strip() и подстановка ключей падают с 500. null допустим везде — обязательность проверяется отдельно.""" @@ -91,7 +98,7 @@ def _type_error(ctype, name, value) -> str | None: if name in BOOL_PARAMS: return None if isinstance(value, bool) else f"{name} — true или false" if name in NUMBER_PARAMS: # конечное число ≥ 0; bool, NaN и Infinity — не число (R-15) - ok = isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) and value >= 0 + ok = isinstance(value, (int, float)) and not isinstance(value, bool) and _finite(value) and value >= 0 return None if ok else f"{name} — число ≥ 0 или null" if ctype == "ensure_status" and name == "target": return None if isinstance(value, str) or _is_str_list(value) else f"{name} — строка или список строк" @@ -122,23 +129,26 @@ def _rule_error(ctype, p) -> str | None: if start and end and start > end: return "start_date позже end_date" if ctype == "ensure_sprint_state": + if not p["sprint"].strip(): + return "sprint — непустое имя" if p["state"] not in ("active", "closed"): return "state — active или closed" - move = p.get("move_open_to") + move = (p.get("move_open_to") or "").strip() if p["state"] == "closed": - if move in (None, ""): + if not move: return "move_open_to обязателен при state = closed" - if move == p["sprint"]: + if move == p["sprint"].strip(): return "move_open_to — другой спринт" - elif move not in (None, ""): + elif move: return "move_open_to — только при state = closed" if ctype == "ensure_rank": - before, after = p.get("before") or None, p.get("after") or None + before = (p.get("before") or "").strip() or None # пустая и пробельная строка — «не задано» (R-31) + after = (p.get("after") or "").strip() or None if bool(before) == bool(after): return "ровно одно из before, after" - if before == p["key"]: + if before == p["key"].strip(): return "before — другая задача" - if after == p["key"]: + if after == p["key"].strip(): return "after — другая задача" return None diff --git a/src/sdlc_hub/modules/jira/mirror.py b/src/sdlc_hub/modules/jira/mirror.py index eb1ec25..90da1a3 100644 --- a/src/sdlc_hub/modules/jira/mirror.py +++ b/src/sdlc_hub/modules/jira/mirror.py @@ -55,6 +55,21 @@ def check_attachments(issues) -> None: raise ValueError(f"{where}.{f} — строка или null") +_INT_MAX = 2**63 - 1 # диапазон SQLite INTEGER + + +def _in_range(v) -> bool: + return -2**63 <= v <= _INT_MAX + + +def _finite(v) -> bool: + """Конечное число; огромные целые (10**400) не падают с OverflowError, а считаются неподходящими.""" + try: + return math.isfinite(v) + except OverflowError: + return False + + def check_agile_fields(issues) -> None: """Форма полей rank, story_points, sprint_ids у задач состояния (агент v15+); поля необязательны (не присланное не проверяется). Нарушение — ValueError с ключом и полем.""" @@ -64,16 +79,16 @@ def check_agile_fields(issues) -> None: raise ValueError(f"{key}: rank — строка или null") if "story_points" in it and it["story_points"] is not None: sp = it["story_points"] - if isinstance(sp, bool) or not isinstance(sp, (int, float)) or not math.isfinite(sp): + if isinstance(sp, bool) or not isinstance(sp, (int, float)) or not _finite(sp) or (isinstance(sp, int) and not _in_range(sp)): raise ValueError(f"{key}: story_points — число или null") if "sprint_ids" in it: ids = it["sprint_ids"] - if not isinstance(ids, list) or any(isinstance(i, bool) or not isinstance(i, int) for i in ids): + if not isinstance(ids, list) or any(isinstance(i, bool) or not isinstance(i, int) or not _in_range(i) for i in ids): raise ValueError(f"{key}: sprint_ids — список целых") def _is_id(v) -> bool: - return isinstance(v, int) and not isinstance(v, bool) and v > 0 + return isinstance(v, int) and not isinstance(v, bool) and 0 < v <= _INT_MAX def check_boards(boards) -> None: diff --git a/tests/test_api.py b/tests/test_api.py index 94300bb..efc2a39 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1228,6 +1228,8 @@ def test_sweep_files_downloads(hub): ({"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"), + ({"story_points": 10**400}, "A-1: story_points"), ({"story_points": 2**63}, "A-1: story_points"), + ({"sprint_ids": [2**63]}, "A-1: sprint_ids"), ({"sprint_ids": [-2**63 - 1]}, "A-1: sprint_ids"), ]) def test_agent_state_bad_agile_fields_422(hub, over, detail): with pytest.raises(HubError) as e: @@ -1242,16 +1244,19 @@ def test_agent_state_bad_agile_fields_422(hub, over, detail): "доска 4216: sprints[0].state — active, future или closed"), ([{"id": 4216, "type": "sprint"}], "доска 4216: type"), ([{"id": 4216, "type": "kanban", "kanban_backlog": "да"}], "доска 4216: kanban_backlog"), + ([{"id": 2**63}], "boards[0].id"), + ([{"id": 4216, "sprints": [{"id": 2**63, "name": "S", "state": "active"}]}], "sprints[0].id"), ]) def test_agent_state_bad_boards_422(hub, boards, detail): with pytest.raises(HubError) as e: - hub["agent"].post("/agent/state", {"issues": [], "links": [], "boards": boards}) + hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": []}], "links": [], "boards": boards}) assert e.value.status == 422 and e.value.error == "bad_state" and detail in e.value.detail c = hub["hub"].conn() try: assert c.execute("SELECT COUNT(*) FROM jira_board").fetchone()[0] == 0 finally: c.close() + assert hub["client"].get("/issues")["issues"] == [] # пачка не применена def test_agent_state_empty_batch_with_boards(hub): diff --git a/tests/test_commands.py b/tests/test_commands.py index 3254a7e..a55c623 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -298,11 +298,22 @@ def test_new_types_validate_ok(): ({"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"), + ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": 10**400}}, "story_points — число ≥ 0 или null"), # OverflowError + ({"type": "ensure_sprint_state", "params": {"sprint": " ", "state": "active"}}, "sprint — непустое имя"), + (state(state="closed", move_open_to=" "), "move_open_to обязателен при state = closed"), + (state(state="closed", move_open_to=" AWPCA_Sprint_3_Corrections "), "move_open_to — другой спринт"), + (state(state="active", move_open_to="x"), "move_open_to — только при state = closed"), + (rank(before=" ", after=" "), "ровно одно из before, after"), + (rank(before=" AURORA-1612 "), "before — другая задача"), ]) def test_new_types_errors(cmd, msg): with pytest.raises(CommandError, match=re.escape(msg)): commands.validate([cmd]) +def test_blank_state_move_open_to_is_unset_when_active(): # R-31: пробельная строка — «не задано» + assert commands.validate([state(state="active", move_open_to=" ")]) + assert commands.validate([rank(before="A-2", after=" ")]) + 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"] diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 9e2c76b..59781ad 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -926,8 +926,9 @@ def test_fmt_backlog_limit(): # сценарий « 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("2026-10-14T09:00:00.000Z") != "—" + want = datetime(2026, 10, 14, 12, tzinfo=timezone.utc).astimezone().strftime("%d.%m") # один момент во всех записях + for raw in ("2026-10-14T12:00:00.000Z", "2026-10-14T15:00:00.000+0300", "2026-10-14T15:00:00.000+03:00"): + assert jmcp.fmt_day(raw) == want assert jmcp.fmt_day(None) == "—" and jmcp.fmt_day("мусор") == "—" diff --git a/web/e2e/agile.spec.ts b/web/e2e/agile.spec.ts index 8ea5ce4..5c03720 100644 --- a/web/e2e/agile.spec.ts +++ b/web/e2e/agile.spec.ts @@ -26,7 +26,7 @@ test("«Бэклог» AWPCA: секции, «Прошлые спринты (1) test("«Спринт» AWPCA: колонки доски, «осталось 7 дн.», карточка окном", async ({ page }) => { await login(page, "/app/jira/sprint?project=AWPCA"); await expect(page.getByRole("heading", { name: "AWPCA_Спринт 2" })).toBeVisible(); - await expect(page.getByText("осталось 7 дн.")).toBeVisible(); + await expect(page.getByText(/осталось \d+ дн\./)).toBeVisible(); for (const name of ["К выполнению", "В работе", "Готово"]) { await expect(page.locator(".agile-col__name", { hasText: name })).toBeVisible(); } diff --git a/web/src/modules/jira/agile/KanbanPage.test.tsx b/web/src/modules/jira/agile/KanbanPage.test.tsx index 31294ff..85eafb4 100644 --- a/web/src/modules/jira/agile/KanbanPage.test.tsx +++ b/web/src/modules/jira/agile/KanbanPage.test.tsx @@ -22,7 +22,9 @@ const agile = (backlog: boolean | null): AgileResponse => ({ board: { id: 5120, type: "kanban", columns: cols, kanban_backlog: backlog, fetched_at: "2026-10-24T10:00:00+00:00" }, sprints: [], backlog: backlog ? [iss("AURORA-1"), iss("AURORA-2")] : null, - board_issues: [iss("AURORA-1"), iss("AURORA-2"), iss("AURORA-3", { status: "В работе" }), iss("AURORA-4", { status: "Готово", done: true }), iss("AURORA-5", { status: "Странный" })], + // как отвечает сервер: при kanban_backlog = true задачи первой колонки есть только в backlog, не в board_issues + board_issues: [...(backlog ? [] : [iss("AURORA-1"), iss("AURORA-2")]), iss("AURORA-3", { status: "В работе" }), + iss("AURORA-4", { status: "Готово", done: true }), iss("AURORA-5", { status: "Странный" })], }); function hub(data: AgileResponse, projects = [K]) { diff --git a/web/src/modules/jira/agile/KanbanPage.tsx b/web/src/modules/jira/agile/KanbanPage.tsx index 16c48d9..8dc910a 100644 --- a/web/src/modules/jira/agile/KanbanPage.tsx +++ b/web/src/modules/jira/agile/KanbanPage.tsx @@ -16,9 +16,7 @@ export function KanbanPage({ project }: { project: Project }) { {(d) => { const withBacklog = d.board?.kanban_backlog === true; const cols = d.board?.columns ?? null; - // задачи первой колонки уходят в «Бэклог», а не в «Без колонки» - const first = new Set(withBacklog && cols ? cols[0]?.statuses ?? [] : []); - const issues = (d.board_issues ?? []).filter((i) => i.status === null || !first.has(i.status)); + const issues = d.board_issues ?? []; // сервер уже убрал задачи первой колонки, если бэклог включён const backlog = d.backlog ?? []; return ( <> diff --git a/web/src/modules/jira/agile/SprintPage.test.tsx b/web/src/modules/jira/agile/SprintPage.test.tsx index 0e24302..55cef31 100644 --- a/web/src/modules/jira/agile/SprintPage.test.tsx +++ b/web/src/modules/jira/agile/SprintPage.test.tsx @@ -19,7 +19,9 @@ const iss = (key: string, over: Partial = {}): AgileIssue => ({ const iso = (offsetDays: number) => { const d = new Date(Date.now() + offsetDays * 86_400_000); const p = (n: number) => String(n).padStart(2, "0"); - return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T12:00:00.000+0300`; + d.setHours(12, 0, 0, 0); // полдень местного времени и местное смещение: результат не зависит от пояса и часа суток + const off = -d.getTimezoneOffset(), a = Math.abs(off); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T12:00:00.000${off < 0 ? "-" : "+"}${p(Math.floor(a / 60))}${p(a % 60)}`; }; const sprint = (over: Partial): AgileSprint => ({ id: 1, name: "S", state: "active", start_date: "2026-10-12T09:00:00.000+0300", end_date: iso(3), -- 2.47.2