From 590e15e0e07ad758ef651d7b8bf97a8dd92e0bc9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 12:18:35 +0400 Subject: [PATCH 01/15] =?UTF-8?q?docs(openspec):=20change=20issue-card-mod?= =?UTF-8?q?al=20=E2=80=94=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=20=D0=BE=D0=BA?= =?UTF-8?q?=D0=BD=D0=BE=D0=BC,=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D1=80=D0=B8=D0=B8=20=D0=B8=D0=B7=20Jira=20(#37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../changes/issue-card-modal/.openspec.yaml | 2 + openspec/changes/issue-card-modal/design.md | 156 +++++++++++++++++ openspec/changes/issue-card-modal/proposal.md | 60 +++++++ .../issue-card-modal/specs/agent/spec.md | 36 ++++ .../issue-card-modal/specs/data-model/spec.md | 66 +++++++ .../issue-card-modal/specs/hub-api/spec.md | 125 ++++++++++++++ .../issue-card-modal/specs/mcp/spec.md | 53 ++++++ .../issue-card-modal/specs/web-app/spec.md | 162 ++++++++++++++++++ 8 files changed, 660 insertions(+) create mode 100644 openspec/changes/issue-card-modal/.openspec.yaml create mode 100644 openspec/changes/issue-card-modal/design.md create mode 100644 openspec/changes/issue-card-modal/proposal.md create mode 100644 openspec/changes/issue-card-modal/specs/agent/spec.md create mode 100644 openspec/changes/issue-card-modal/specs/data-model/spec.md create mode 100644 openspec/changes/issue-card-modal/specs/hub-api/spec.md create mode 100644 openspec/changes/issue-card-modal/specs/mcp/spec.md create mode 100644 openspec/changes/issue-card-modal/specs/web-app/spec.md diff --git a/openspec/changes/issue-card-modal/.openspec.yaml b/openspec/changes/issue-card-modal/.openspec.yaml new file mode 100644 index 0000000..a216797 --- /dev/null +++ b/openspec/changes/issue-card-modal/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-15 diff --git a/openspec/changes/issue-card-modal/design.md b/openspec/changes/issue-card-modal/design.md new file mode 100644 index 0000000..961beeb --- /dev/null +++ b/openspec/changes/issue-card-modal/design.md @@ -0,0 +1,156 @@ +## Context + +`main` 13e6c16. Устройство, на которое опираются правки: + +- **Агент** (`jira-agent/src/agent.py`, v9): `refresh` читает задачи `search` с `fields = STATE_FIELDS` (+ плановые даты и + поле эпика), `to_state` собирает словарь задачи, пачки по 100 уходят в `POST /agent/state`. Комментарии агент читает + только в `ensure_comment` (`GET /issue/{key}/comment`, постранично по 100). +- **Хаб**: `mirror.apply_state` пишет в `issue` только столбцы `ISSUE_COLUMNS` — лишние поля задачи (в том числе будущее + `comments`) молча пропускаются, поэтому агент v10 совместим с нынешним хабом. `mirror.get_issue` отдаёт задачу, связи + (`target_key`/`source_key`, `link_type`), заметку, 10 последних команд. Схема базы — v2 (`db.SCHEMA_VERSION`), + миграция с копией базы — `db.migrate`. Тело запроса ограничено 16 МБ (`api.MAX_BODY`). +- **Веб-приложение**: `createBrowserRouter(routes, {basename: "/app"})`; `Layout` с ``; маршруты `board`, + `issues`, `issues/:key` (страница `IssuePage`). Ссылки на карточку — ключ в `IssuesPage` и «ⓘ» в `board/components/ + Card.tsx`. `NoteForm` — форма заметок с `useBlocker` (смена `pathname` при правках требует подтверждения) и + `beforeunload`. `useSaveNote` после успеха обновляет кэш задачи и инвалидирует `["issues"]` и `board`. + Прокрутка доски при возврате на `/app/board` восстанавливается (M-3 #29). +- **Справочники**: `link_types` настроек — логическое имя → тип Jira (`блокирует начало` → `ASUPR_Блокер_Начала`, + `блокирует завершение` → `ASUPR_Блокер_Завершения`, `связанные` → `ASUPR_Связанные`); `status_labels` — статус по факту → + класс (`fact-done`, `fact-cancelled`, …). + +## Goals / Non-Goals + +**Goals:** карточка открывается окном поверх списка и доски без потери их состояния; по шапке видно состояние задачи, +по связям — что держит задачу и что ждёт её, с темой и статусом связанных; комментарии задачи из Jira в карточке со +скрытием; агент v10 и хаб совместимы в любом порядке выкатки. + +**Non-Goals:** см. proposal.md. + +## Decisions + +### Агент v10 (решения агентской части — `jira-agent/openspec/changes/agent-v10-comments/design.md`) + +1. **Поле `comment` в том же `search`.** `STATE_FIELDS` получает `comment`; новых вызовов Jira на обычную задачу нет. + Jira Server отдаёт `fields.comment = {comments: [...], total, maxResults, startAt}`. +2. **Дочитывание.** Если `len(comments) < total`, агент читает комментарии этой задачи `GET /issue/{key}/comment` + (`startAt`, `maxResults = 100`) до конца и берёт их вместо частичного списка. Вызовы учитываются в темпе и 429 как + любые вызовы Jira. +3. **Форма.** `comments: [{id, author, created, updated, body}]` в порядке Jira (старые первыми): `id` — строка, + `author` — `author.displayName`, иначе `author.name`, иначе `null`; `created`/`updated` — строки Jira как есть; + `body` — текст как есть (`""`, если в Jira пусто). Поле `comments` есть у каждой задачи, прочитанной агентом v10 + (пустой список — комментариев нет). +4. **Версия и выкатка.** `AGENT_VERSION = 10`; после merge PR агента — `jira-hub agent publish src/agent.py` (пишет в + каталог живого хаба: выполняет владелец или агент по его отдельной команде). + +### Хаб + +5. **Таблица `issue_comment`** `(key, comment_id, author, created, updated, body, PRIMARY KEY (key, comment_id))`, индекс + по `key`. Схема v3; миграция v2 → v3 создаёт таблицу (копия базы `schema-v2` — существующим механизмом). +6. **Запись.** `apply_state`: для задачи с полем `comments` (список) комментарии этой задачи удаляются и вставляются + присланные — в одной транзакции с задачей; задача без поля `comments` своих комментариев не теряет (агент до v10). +7. **Проверка формы** в `h_agent_state` до записи: `comments` — список объектов; `id` — непустая строка; `body` — строка; + `author`, `created`, `updated` — строка или `null`. Нарушение — 422 `bad_state` с ключом задачи и полем, пачка не + применяется целиком (агент получает ошибку хаба в отчёте `refresh`). +8. **`GET /issues/{key}`**: добавляются `comments` (новые сверху: по `created` убыв., затем `comment_id` убыв.), + `epic_summary` (тема задачи-эпика из зеркала или `null`), у каждой связи — `summary` и `status` связанной задачи + (`LEFT JOIN issue`, `null`, если её нет в зеркале). Прежние поля и их форма не меняются. +9. **MCP `issue`**: после заметок — «комментарии (N):» и 5 последних (автор, время, текст до 300 символов с «…»), затем + «ещё M», если их больше 5; нет комментариев — «комментариев нет». + +### Веб-приложение: окно + +10. **Фоновая страница.** Ссылки на карточку (ключ в списке, «ⓘ» на доске, связи в карточке) передают в + `location.state` `{background, depth}`: `background` — адрес страницы под окном (`pathname + search`), `depth` — + сколько карточек открыто поверх неё (1 при открытии со страницы, +1 при переходе по связи). `Layout` отрисовывает + маршрут `background` (если он есть), а при адресе `/issues/:key` — окно поверх него. Страница под окном не + пересоздаётся: её состояние (фильтры, загруженные порции, прокрутка, раскладка доски, опрос пакета) сохраняется. +11. **Прямой адрес** `/app/issues/{key}` без `state` (новая вкладка, закладка, перезагрузка): под окном — список задач без + фильтров, `depth = 0`. +12. **Закрытие** (✕, Esc, щелчок по фону): при `depth ≥ 1` — `navigate(-depth)`, браузер возвращается к фоновой + странице, и «назад» после закрытия ведёт на шаг раньше неё; при `depth = 0` — `navigate("/issues", {replace: true})`. +13. **Элемент ``** с `showModal()`: фокус удерживается в окне, фон недоступен, Esc вызывает `cancel` (обрабатываем + сами — закрытие по п. 12 с проверкой заметок). Щелчок по фону — щелчок по самому `` вне области содержимого. + Прокрутка страницы под окном на время открытия блокируется (`overflow: hidden` у `body`), содержимое окна + прокручивается внутри, шапка окна закреплена. Ширина до 1000 px, высота до 90vh; на экранах уже 700 px — на всю + ширину. +14. **Несохранённые заметки.** Подтверждение «Есть несохранённые правки заметки. Уйти без сохранения?» при закрытии окна, + переходе по связи и уходе в раздел; условие блокировки — смена `pathname` при правках (как сейчас), закрытие по + п. 12 проходит через тот же блокировщик. Отказ — окно остаётся с введённым текстом. +15. **Доска под окном.** Сохранение заметки уже инвалидирует `board` и `["issues"]` (`useSaveNote`): фоновая страница + смонтирована, поэтому перечитывается и показывает новые «вид работы» и «статус по факту». Отдельного кода нет — + поведение закрепляется тестом. + +### Веб-приложение: раскладка карточки + +16. **Шапка**: ключ; статус Jira (плашка); статус по факту (плашка цветом класса из `status_labels`, если есть); + «Открыть в Jira ↗»; ✕; тема; строка «исполнитель · срок · спринт · эпик: <тема эпика>» — пустые части пропускаются, + эпик без темы в зеркале — ключом. Срок — `planned_end`, иначе `duedate`; «просрочено» (красным) — срок раньше + сегодняшней даты и статус по факту не относится к классам `fact-done`, `fact-cancelled`. +17. **Связи по смыслу.** По `link_types` настроек: входящие `блокирует начало` / `блокирует завершение` — группа «Зависит + от», исходящие — «Блокирует»; у строки метка «начало» / «завершение». `связанные` в обе стороны — «Связана с». + Тип, которого нет в `link_types`, — «Прочие» с именем типа Jira и направлением («→» исходящая, «←» входящая). + Строка: ключ (ссылка — открывает связанную задачу в окне), тема, статус Jira (плашка); связанной задачи нет в + зеркале — ключ и «нет в зеркале». Пустая группа не показывается; связей нет — «связей нет». Порядок групп: «Зависит + от», «Блокирует», «Связана с», «Прочие»; внутри — по ключу. +18. **Описание** — `pre-wrap` без разбора разметки; выше 8 строк сворачивается с кнопкой «развернуть» / «свернуть» + (кнопка только если текст не помещается). Нет описания — раздела нет. +19. **Комментарии** — «Комментарии (N)» с кнопкой «Скрыть» / «Показать»; новые сверху; автор, время (`fmtStamp`), текст + `pre-wrap`. Выбор «скрыто» — один на все карточки, в `localStorage` `jira-hub-comments-hidden` (`"1"` — скрыты); + недоступный `localStorage` — показаны. Нет комментариев — «комментариев нет» (кнопки нет). +20. **Заметки** — по умолчанию режим чтения: «Вид работы», «Статус по факту», «Сделано», «Осталось», «Заметка владельца» + (пустые — «—»), строка «изменено <время>, <автор>»; кнопка «Править» показывает нынешнюю форму `NoteForm` + (поля, «Сохранить», «Отмена»); после успешного сохранения — снова режим чтения; «Отмена» без правок — сразу, с + правками — подтверждение «Отменить правки заметки?». Заметки ещё нет — режим чтения с «—» и «Править». +21. **Прочие поля**: тип, приоритет, автор, метки, плановое начало, обновлена в Jira, «сосед по связям» — непустые. +22. **Команды моста (N)** — свёрнуты (`
`), внутри нынешняя таблица; команд нет — «команд по задаче не было» и + раздел не сворачивается. +23. **Колонки**: под шапкой слева — «Связи», «Описание», «Комментарии»; справа (≈320 px) «Заметки», «Прочие + поля», «Команды моста». Уже 900 px — одна колонка: заметки, связи, описание, комментарии, прочие поля, команды. +24. **Состояния окна**: загрузка — «загрузка …»; 404 — «Задачи нет в зеркале хаба.» и «Закрыть»; хаб не + отвечает — сообщение и «Повторить», ранее загруженные данные остаются; поверх ошибки при данных — предупреждение + в шапке окна. + +### Проверки + +25. **Тестовый хаб**: фикстура `hub-basic` дополняется у одной задачи описанием длиннее 8 строк, тремя комментариями и + двумя командами; у второй — входящими и исходящими связями всех групп, включая тип вне `link_types` и связь на + задачу вне зеркала. Эталоны доски от этого не меняются (комментарии и описание в модель доски не входят) — проверяется + golden-тестом. +26. **e2e** (Playwright на :8791): открытие из списка (фильтр и прокрутка списка после закрытия на месте), по «ⓘ» с доски + (прокрутка и раскладка доски на месте), переход по связи и «назад», Esc / ✕ / фон, «назад» после закрытия не + открывает карточку, прямой адрес и перезагрузка с открытым окном, подтверждение при несохранённой заметке, ширина + 600 px, «Скрыть» комментарии переживает перезагрузку. + +## Risks / Trade-offs + +- **Фоновая страница в `Layout`** — нестандартный для data router приём (две отрисовки маршрутов). Риск: `useBlocker` + только один на страницу — блокировщик заметок остаётся в окне, фоновая страница блокировщиков не имеет. Проверяется + тестами п. 14 и e2e. +- **`navigate(-depth)`** полагается на то, что все `depth` шагов истории — наши переходы. Ручное изменение адреса внутри + окна сбрасывает `state` → `depth = 0`, закрытие ведёт в список задач (`replace`). Принято. +- **Объём состояния**: комментарии увеличивают пачку `POST /agent/state`; при 100 задачах и типичных комментариях это + сотни килобайт при пределе 16 МБ. Принято без разбиения. +- **Просрочка по статусу по факту** (п. 16): задача, закрытая в Jira без заметки, покажется просроченной. Цена — лишняя + красная пометка; списка «закрытых» статусов Jira в настройках нет. + +## Migration Plan + +1. PR jira-agent (v10) → merge → `jira-hub agent publish` → `agent_status` «обновление: ok v9 → v10». +2. PR jira-hub (#37) → merge → `git pull --ff-only`, `make web` в основном checkout → перезапуск хаба владельцем + (`launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub`): миграция v2 → v3 с копией базы → `/mcp` reconnect. +3. Ближайший `refresh` (час или «Обновить из Jira») заполняет комментарии. +Откат хаба: копия `backups/<…>-schema-v2.sqlite` и предыдущий коммит; агент v10 со старым хабом работает (п. Context). + +## Open Questions + +Нет. + +## Decisions made without discussion + +| Маркер | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| R-1 | 2026-10-15 | Требования к агенту v10 — дельтой `specs/agent` в этом change jira-hub; в jira-agent — change `agent-v10-comments` только с решениями агентской части (design.md) | Заведённый порядок: спека агента живёт в `jira-hub/openspec/specs/agent/`, так сделаны v9 (hub#30 + agent#24) и предыдущие; «один change на репозиторий» соблюдается — у агента свой change и свой PR | Низкая: перенос дельты в другой change — правка документов | +| R-2 | 2026-10-15 | Просрочка — срок раньше сегодня и статус по факту не `fact-done`/`fact-cancelled`; статус Jira не учитывается | Списка «закрытых» статусов Jira в настройках нет, а свой словарь в приложении запрещён требованием «Справочники из настроек хаба» | Лишняя красная пометка у закрытой в Jira задачи без заметки | +| R-3 | 2026-10-15 | В карточке показываются все комментарии задачи, без постраничности; в MCP — 5 последних | В задачах проекта комментариев единицы; окно прокручивается внутри | При десятках длинных комментариев — длинная прокрутка окна; лечится «Скрыть» | +| R-4 | 2026-10-15 | «Команды моста» свёрнуты всегда при открытии, выбор не запоминается; «Скрыть» комментарии запоминается | Команды нужны редко и по конкретному поводу; комментарии владелец просил скрывать — это предпочтение | Лишний щелчок, если команды смотрят часто | +| R-5 | 2026-10-15 | Отмена правки заметки — кнопка «Отмена» с подтверждением при правках (в разделе 1 дизайна был только «Править»/«Сохранить») | Из режима правки нужен выход без сохранения, не закрывая окно | Лишний вопрос при отмене | diff --git a/openspec/changes/issue-card-modal/proposal.md b/openspec/changes/issue-card-modal/proposal.md new file mode 100644 index 0000000..b891327 --- /dev/null +++ b/openspec/changes/issue-card-modal/proposal.md @@ -0,0 +1,60 @@ +## Why + +Карточка задачи (`/app/issues/{key}`, change `web-app` #21) — отдельная страница: уход со списка или доски теряет +контекст, а сама карточка плохо читается — поля одного веса, связи без темы и статуса связанной задачи, входящие связи +записаны «X → тип → эта задача», техническая таблица команд на виду, форма заметок занимает пол-экрана. Комментариев +задачи из Jira в карточке нет: хаб их не хранит, агент их не читает. + +Решения владельца 15.10.2026 (brainstorming, jira-hub#37): +- карточку открывают, чтобы быстро понять состояние задачи (статус, кто, срок, что осталось) и разобраться в связях + (что держит задачу, что ждёт её); в карточке нужны комментарии задачи из Jira с возможностью их скрыть; +- карточка открывается модальным окном поверх того места, откуда её открыли (список «Задачи» или доска), у окна свой + адрес `/app/issues/{key}`; переходы по связям — внутри окна, «назад» браузера возвращает к прежней карточке; +- раскладка карточки — по разделу 1 дизайна (шапка состояния, связи по смыслу, заметки в режиме чтения, комментарии, + второстепенное внизу); визуальные макеты не нужны, раскладку предлагает агент; +- комментарии — два change по репозиториям, сначала агент: jira-agent v10 (aep/jira-agent#27) передаёт комментарии в + состоянии задачи, jira-hub (#37) хранит, отдаёт и показывает их. Требования к агенту по заведённому порядку живут в + этом change (`specs/agent`), решения агентской части — в `jira-agent/openspec/changes/agent-v10-comments/design.md`. + +## What Changes + +- **Агент v10** (`AGENT_VERSION = 10`): `refresh` запрашивает в том же `search` поле `comment`; по каждой задаче + передаёт `comments: [{id, author, created, updated, body}]` (автор — отображаемое имя, иначе логин; текст как есть); + если Jira отдала не все комментарии задачи — дочитывание через `GET /issue/{key}/comment`. Выкатка самообновлением. +- **Хаб, данные**: таблица `issue_comment`, схема базы v2 → v3 (копия базы перед миграцией — как сейчас). Присланные + комментарии задачи заменяют её прежние; задача без поля `comments` (агент до v10) свои комментарии не теряет. + Неверная форма комментария — 422 на всю пачку `POST /agent/state`. +- **Хаб, API**: `GET /issues/{key}` дополнительно отдаёт `comments` (новые сверху), у связей — `summary` и `status` + связанной задачи (`null`, если её нет в зеркале), `epic_summary`. Прежние поля не меняются. +- **MCP**: инструмент `issue` показывает 5 последних комментариев и «ещё N». +- **Веб-приложение**: + - карточка задачи — модальное окно (``) поверх списка «Задачи» или доски; адрес `/app/issues/{key}`; прямой + адрес открывает окно поверх списка задач без фильтров; закрытие (✕, Esc, щелчок по фону) возвращает туда, откуда + открыли, и «назад» после закрытия карточку не открывает; + - новая раскладка: шапка (ключ, статус Jira, статус по факту, тема, исполнитель · срок · спринт · эпик по имени, + «Открыть в Jira», ✕); связи по группам «Зависит от», «Блокирует», «Связана с», «Прочие» с темой и статусом; + описание свёрнуто до 8 строк; комментарии со «Скрыть/Показать», выбор запоминается в браузере; заметки в режиме + чтения с «Править»; «Прочие поля»; «Команды моста» свёрнуты; + - после сохранения заметки доска под окном показывает новые «вид работы» и «статус по факту». +- Спеки: `agent` — MODIFIED «Чтение состояния»; `data-model` — MODIFIED «Схема базы хаба», «Зеркало задачи»; `hub-api` — + MODIFIED «Маршруты агента», «Маршруты интерфейсов»; `mcp` — MODIFIED «Инструменты»; `web-app` — MODIFIED «Оболочка и + адреса», «Доска связей», «Список задач», «Карточка задачи», «Служебные заметки». + +## Non-Goals + +- Запись в Jira из карточки (правка полей, статуса, добавление комментариев) — следующий change `viewer-edit`. +- Разбор разметки Jira (wiki) в описании и комментариях: текст показывается как есть, с переносами строк. +- Комментарии в списке задач, на доске и в `GET /board`. +- Изменения поведения доски, кроме открытия карточки окном и обновления заметки под окном. +- Хранение истории правок комментариев: в зеркале — последнее состояние из Jira. + +## Impact + +- jira-agent: `src/agent.py` (`STATE_FIELDS`, `to_state`, дочитывание комментариев), тесты `tests/test_refresh.py`, + фейк `tests/fake_jira.py`; `AGENT_VERSION = 10`; публикация `jira-hub agent publish` после merge. +- jira-hub: `db.py` (v3, `issue_comment`), `mirror.py` (`apply_state`, `get_issue`), `api.py` (`h_agent_state` + валидация), `mcp_server.py` (`issue`), тесты; `web/src/routes.tsx`, `shell/Layout.tsx`, `issues/*` (окно, раскладка, + заметки), `board/components/Card.tsx` (ссылка «ⓘ»), `api/types.ts`, `api/queries.ts`, e2e; фикстура тестового хаба + с описанием, комментариями и командами. +- Порядок выкатки: агент v10 (любой хаб принимает его состояние — лишнее поле `comments` нынешний хаб пропускает), затем + хаб; комментарии появляются после первого `refresh` агента v10. diff --git a/openspec/changes/issue-card-modal/specs/agent/spec.md b/openspec/changes/issue-card-modal/specs/agent/spec.md new file mode 100644 index 0000000..01aaa37 --- /dev/null +++ b/openspec/changes/issue-card-modal/specs/agent/spec.md @@ -0,0 +1,36 @@ +## MODIFIED Requirements + +### Requirement: Чтение состояния +`refresh` SHALL выполняться запросом `search` (`jql`, `fields` = поля зеркала включая `issuelinks`, `comment` и спринт, +`maxResults = 100`, постранично) и отправляться хабу пачками по 100 задач со связями; ключи из `issuelinks`, которых нет +в выборке и в зеркале хаба (хаб сообщает список известных), агент SHALL дочитать одним `search` по `key in (...)` +и отправить с признаком `neighbor`. Ошибка хаба при запросе известных ключей или отправке пачки SHALL попадать в +`error` с префиксом «хаб: »; недоступность хаба и его 5xx повторяются, как обрыв связи, с источником «хаб». +Каждая задача в состоянии (с v10) SHALL нести `comments` — все комментарии задачи в порядке Jira, каждый +`{id, author, created, updated, body}`: `id` — строка; `author` — отображаемое имя автора, иначе его логин, иначе `null`; +`created`, `updated` — время Jira как есть; `body` — текст как есть (пустой — `""`). Если `search` отдал у задачи меньше +комментариев, чем `comment.total`, агент SHALL дочитать комментарии этой задачи через `GET /issue/{key}/comment` +постранично (`maxResults = 100`) и передать полный список. + +#### Scenario: Полная синхронизация +- **WHEN** `refresh {jql: "project = AURORA AND labels = AWPCA"}` при 180 задачах +- **THEN** 2 вызова `search` + 1 на соседей; `sync_run.issues_count = 180 + соседи` + +#### Scenario: Хаб не ответил при отправке состояния +- **WHEN** `refresh` через папку, реле не ответило на отправку пачки ни в одной из 4 попыток +- **THEN** отчёт `failed` с ошибкой «результат неизвестен (обрыв связи с хабом), повтор безопасен: хаб: не ответил через + папку …»; в логе повторов — «обрыв связи с хабом» + +#### Scenario: Комментарии в состоянии +- **WHEN** у AURORA-1620 в Jira два комментария, автор первого — `{name: "petrov", displayName: "Петров Пётр"}`, у автора + второго нет `displayName`, его `name` — `ivanov` +- **THEN** в состоянии AURORA-1620 `comments` из двух элементов в порядке Jira, `author` — «Петров Пётр» и «ivanov»; число + вызовов `search` не изменилось + +#### Scenario: Неполный список комментариев +- **WHEN** `search` вернул у AURORA-1620 20 комментариев при `comment.total = 130` +- **THEN** агент дочитал комментарии двумя вызовами `GET /issue/AURORA-1620/comment`; в состоянии 130 комментариев + +#### Scenario: Задача без комментариев +- **WHEN** у задачи в Jira нет комментариев +- **THEN** в её состоянии `comments = []` diff --git a/openspec/changes/issue-card-modal/specs/data-model/spec.md b/openspec/changes/issue-card-modal/specs/data-model/spec.md new file mode 100644 index 0000000..279bc7c --- /dev/null +++ b/openspec/changes/issue-card-modal/specs/data-model/spec.md @@ -0,0 +1,66 @@ +## MODIFIED Requirements + +### Requirement: Схема базы хаба +База хаба SHALL быть одним файлом SQLite (WAL) с таблицами `issue`, `issue_link`, `issue_comment`, `issue_note`, `batch`, +`command`, `sync_run`, `agent_status`, `setting`. Схема SHALL создаваться и мигрироваться самим хабом при старте по номеру +версии в `setting.schema_version` (текущая — 3). Миграция существующей базы SHALL выполняться в одной транзакции с +блокировкой записи; в той же транзакции, после повторной проверки версии, хаб SHALL скопировать файл базы в +`backups/<дата-время>-schema-v<старая>.sqlite` рядом с базой — одна копия на миграцию, сколько бы соединений ни +открывалось одновременно. Каталог данных хаба и `backups/` SHALL иметь права `0700` (в базе токены): хаб выставляет их +при создании и при каждом старте; если выставить не удалось — предупреждение в лог, работа продолжается. Денежных и +дробных полей нет; даты — ISO-8601 строки в UTC, кроме `duedate` и плановых дат (даты без времени) и времени +комментариев (строки Jira как есть). + +#### 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 = 3` + +#### Scenario: Миграция базы v2 +- **WHEN** хаб стартует с базой `schema_version = 2`, в которой есть задачи, связи и заметки +- **THEN** в `backups/` копия базы v2; добавлена пустая `issue_comment`; данные на месте; `schema_version = 3` + +#### 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`, `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`, `issue_link` и +`issue_comment` SHALL только загрузка состояния от агента; HTTP API интерфейсов и MCP MUST NOT писать в них. Присланные +комментарии задачи SHALL заменять все её прежние комментарии; задача, присланная без поля `comments`, свои комментарии +SHALL сохранять. Полный прогон (`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 обновлены, три комментария на месте diff --git a/openspec/changes/issue-card-modal/specs/hub-api/spec.md b/openspec/changes/issue-card-modal/specs/hub-api/spec.md new file mode 100644 index 0000000..56601aa --- /dev/null +++ b/openspec/changes/issue-card-modal/specs/hub-api/spec.md @@ -0,0 +1,125 @@ +## MODIFIED Requirements + +### Requirement: Маршруты агента +Хаб SHALL предоставлять агенту следующие маршруты (все под агентским токеном): +- `POST /agent/claim` `{agent_version, settings_version, last_jira_call_at, update_state, launcher_version}` — долгий + запрос до 30 с; ответ `{command: {id, type, params, pause_seconds}, settings_version, agent_release}` или `{command: + null, settings_version, agent_release}`, где `agent_release` — `{version, sha256}` опубликованной версии агента или + `null`; обновляет `agent_status` (`last_jira_call_at = null` не затирает прежнее значение). `update_state` (объект или + `null`, `error` хранится до 500 символов) и `launcher_version` (целое или `null`) необязательны; хаб SHALL хранить + последние значения в памяти процесса, значения неверного типа игнорировать, не отвергая `claim`. Если есть команда в + `claimed`, `claim` SHALL выдать её снова (повторная выдача, см. `commands`), а не следующую из очереди. +- `GET /agent/settings` — настройки для агента (без токенов). +- `GET /agent/commands/{id}` — команда по id (для восстановления из журнала), включая `pause_seconds`. +- `POST /agent/commands/{id}/result` `{status, result, error, jira_calls, rate_limited}` — идемпотентно: повторный + отчёт по команде в терминальном статусе отвечает 200 без изменений, кроме позднего отчёта `done` с `result.key` по + `ensure_issue` в `cancelled` или `failed` без результата (результат записывается, статус не меняется, заметка `new:N` + переносится на ключ). Отчёт по команде в `queued` SHALL отвергаться 409 `not_claimed`. `error` хаб SHALL обрезать до + 1500 символов. +- `POST /agent/state` `{command_id, issues: [...], links: [...], neighbor_keys: [...], page, last_page}` — загрузка состояния + пачками; `neighbor_keys` — ключи вне базовой JQL, дочитанные по связям; хаб обновляет зеркало и `sync_run`. Каждая + пачка применяется независимо; `page` и `last_page` необязательны и хабом не используются. Поле задачи `comments` + необязательно; если оно есть, хаб SHALL до записи проверить его форму: список объектов, `id` — непустая строка, `body` — + строка, `author`, `created`, `updated` — строка или `null`; нарушение — 422 `bad_state` с ключом задачи и именем поля, + пачка не применяется. +- `GET /agent/known-keys` — ключи зеркала (для определения соседей). +- `GET /agent/release` — опубликованная версия агента `{version, sha256, content}` (`content` — текст `agent.py`); + нет публикации или файл не сходится с манифестом по sha256 — 404 `no_release`. + +#### Scenario: Выдача с подстановкой ключа +- **WHEN** следующая команда ссылается на `new:60`, а `ensure_issue` для `new:60` завершилась с `AURORA-1590` +- **THEN** `claim` отдаёт команду с `AURORA-1590` в параметрах + +#### Scenario: Повторный отчёт +- **WHEN** агент дважды прислал `result` для команды 15 +- **THEN** второй ответ 200, статус и результат не изменились + +#### Scenario: Отчёт по невыданной команде +- **WHEN** `POST /agent/commands/16/result` с `status = done`, команда 16 в `queued` +- **THEN** 409 `not_claimed`, команда осталась `queued` + +#### Scenario: Длинная ошибка +- **WHEN** агент прислал `failed` с `error` длиной 5000 символов +- **THEN** в `command.error` первые 1500 символов + +#### Scenario: Поздний отчёт после предела выдач +- **WHEN** `ensure_issue {temp_key: new:8, …}` переведена хабом в `failed` «агент не отчитался после 3 выдач», затем агент + прислал `done` с `result.key = AURORA-1630` +- **THEN** команда `failed`, в `result` — `AURORA-1630`; заметка `new:8` перенесена на `AURORA-1630` + +#### Scenario: Публикация в ответе claim +- **WHEN** опубликована v6, агент v5 отправил `claim`, команд нет +- **THEN** ответ `{command: null, settings_version, agent_release: {version: 6, sha256: …}}` + +#### Scenario: Состояние обновления от агента +- **WHEN** агент прислал в `claim` `update_state = {state: rolled_back, from_version: 6, to_version: 7, error: "код 1 через 3 с"}` + и `launcher_version = 1` +- **THEN** `GET /agent` отдаёт это `update_state`, `launcher_version = 1` и `update_hint` «откат с v7 на v6: код 1 через 3 с» + +#### Scenario: Скачивание версии +- **WHEN** агентским токеном `GET /agent/release`, опубликована v6 +- **THEN** 200 `{version: 6, sha256, content}`, sha256 от `content` в UTF-8 равен `sha256`; клиентским токеном — 401; + без публикации — 404 `no_release` + +#### Scenario: Неверный комментарий в пачке +- **WHEN** `POST /agent/state` с задачами X и Y, у Y комментарий `{id: "7", body: 42}` +- **THEN** 422 `bad_state` с ключом Y и полем `body`; ни X, ни Y в зеркале не изменились + +### Requirement: Маршруты интерфейсов +Хаб SHALL предоставлять интерфейсам (MCP, позже просмотрщик) следующие маршруты под клиентским токеном: +- `GET /issues` с фильтрами `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, + `q` (подстрока темы), `limit`/`offset` — строки зеркала (все поля `issue`) с `fact_status` и `work_kind` из заметок; + сокращение до карточки — дело интерфейса. +- `GET /issues/{key}` — задача целиком: поля, `epic_summary` (тема эпика из зеркала или `null`), связи в обе стороны + (`links_out: [{target_key, link_type, summary, status}]`, `links_in: [{source_key, link_type, summary, status}]`, где + `summary` и `status` — связанной задачи из зеркала или `null`, если её там нет), заметки, последние 10 команд по ней, + `comments: [{comment_id, author, created, updated, body}]` — все комментарии, новые сверху (по `created`, затем + `comment_id`, по убыванию). +- `PUT /issues/{key}/note` — заметки (частичное обновление, `updated_by` из тела). +- `POST /batches` `{title, author, commands: [...]}` → `{id, estimate_seconds}`; 422 при неизвестном типе, неверных + параметрах (в том числе неверного типа значения), временном ключе без `ensure_issue`, двух и более `ensure_issue` с + одинаковой `summary` (сравнение после обрезки пробелов по краям; в ответе перечислены повторяющиеся темы). +- `GET /batches?limit=`, `GET /batches/{id}` (с командами), `POST /batches/{id}/cancel` — отменяет `queued` команды + пакета; ответ — вид пакета с полем `still_claimed`: список `{id, type}` команд пакета в `claimed` (они будут выполнены + агентом; остановить — отмена команды с `force`). +- `POST /commands/{id}/retry` — новая команда с теми же параметрами в новом пакете «Повтор команды N». +- `POST /commands/{id}/cancel` — только для `queued`; для `claimed` — 409, если не передан `force: true`. +- `POST /refresh` `{keys | jql | full: true}` → пакет из одного `refresh`. +- `GET /agent`, `GET /sync-runs?limit=`, `GET /settings`, `PUT /settings` (частично; поднимает `settings_version`; + ответ — все настройки после записи, как у `GET /settings`, включая `settings_version`). + +#### Scenario: Фильтр по спринту +- **WHEN** `GET /issues?sprint=Спринт 2` +- **THEN** только задачи с таким `sprint`, отсортированы по сроку, затем по ключу + +#### Scenario: Отмена взятой команды +- **WHEN** `POST /commands/15/cancel` без `force`, команда `claimed` +- **THEN** 409 с `claimed_at`; с `force: true` — `cancelled`, следующий отчёт агента по ней принимается, статус не меняется + +#### Scenario: Поздний отчёт по отменённому созданию +- **WHEN** `ensure_issue {temp_key: new:7, …}` отменена с `force: true`, затем агент прислал `done` с `result.key = AURORA-1620` +- **THEN** команда `cancelled`, в `result` — `AURORA-1620`; заметка `new:7` перенесена на `AURORA-1620` + +#### Scenario: Дубль темы в пакете +- **WHEN** `POST /batches` с двумя `ensure_issue`, у обеих `summary = "Шахматка: комментарии"` +- **THEN** 422 с темой «Шахматка: комментарии», пакет не создан + +#### Scenario: Тема не строка +- **WHEN** `POST /batches` с `ensure_issue {temp_key: new:1, summary: 42, …}` +- **THEN** 422 с указанием параметра `summary`, пакет не создан + +#### Scenario: Отмена пакета с командой у агента +- **WHEN** в пакете 9 команда 30 `claimed`, команды 31 и 32 `queued`; `POST /batches/9/cancel` +- **THEN** 31 и 32 `cancelled`; в ответе `still_claimed = [{id: 30, type: …}]`; 30 остаётся `claimed` + +#### Scenario: Ответ на запись настроек +- **WHEN** `PUT /settings {relay_dir: "/Volumes/externalSSD/utm/rshb/jira-bridge"}` +- **THEN** 200; ответ содержит `relay_dir` с записанным значением, новую `settings_version` и остальные настройки; + токенов в ответе нет + +#### Scenario: Карточка с комментариями и связями +- **WHEN** у AURORA-9001 два комментария (14.10 и 15.10), исходящая связь на AURORA-9002 («API свода», «Новая») и на + AURORA-1, которой нет в зеркале; эпик AURORA-9100 «Тестовый эпик» +- **THEN** `GET /issues/AURORA-9001` отдаёт `comments` с комментарием от 15.10 первым, у связи на AURORA-9002 — + `summary = "API свода"`, `status = "Новая"`, у связи на AURORA-1 — `summary = null`, `status = null`; + `epic_summary = "Тестовый эпик"` diff --git a/openspec/changes/issue-card-modal/specs/mcp/spec.md b/openspec/changes/issue-card-modal/specs/mcp/spec.md new file mode 100644 index 0000000..3c3186e --- /dev/null +++ b/openspec/changes/issue-card-modal/specs/mcp/spec.md @@ -0,0 +1,53 @@ +## MODIFIED Requirements + +### Requirement: Инструменты +| Инструмент | Параметры | Результат | +|---|---|---| +| `issues` | фильтры как у `GET /issues`, `limit` (по умолчанию 50) | таблица: ключ, тема (до 80 символов), статус, срок, исполнитель, спринт, статус по факту | +| `issue` | `key` | поля, связи (обе стороны, с типом и направлением), заметки, последние команды, комментарии: «комментарии (N):» и 5 последних (автор, время, текст до 300 символов с «…»), «ещё M», если их больше 5; без комментариев — «комментариев нет» | +| `notes_set` | `key`, подмножество полей заметок | обновлённые заметки | +| `batch_create` | `title`, `commands` (список объектов по контракту `commands`) | `id`, `estimate_seconds`, число команд; 422 — текст ошибки валидации | +| `batch_status` | `id` | статусы команд, ошибки, оценка остатка | +| `batch_wait` | `id`, `timeout_minutes` (≤ 60) | как `batch_status`, возвращается по завершении или по таймауту | +| `batches` | `limit` | последние пакеты со статусом | +| `command_retry` | `id` | новый пакет | +| `batch_cancel` | `id` | вид пакета; для команд пакета, уже взятых агентом, — строка «команда #N уже у агента и будет выполнена; остановить — `POST /commands/N/cancel` с `force: true`» | +| `refresh` | `keys` или `full`, `wait` (bool) | без `wait` — строка с id поставленного пакета; с `wait` — итог пакета, как у `batch_wait` | +| `agent_status` | — | онлайн/офлайн, версия, текущая команда, довыданные команды (id, тип, число выдач), 429 за сутки, версия настроек; публикация агента («публикация: v от <дата>» или «публикации нет»), состояние обновления («обновление: v → v»), запускатель («запускатель: v», «без запускателя» или, пока `agent_reported = false`, «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)»), `update_hint`, если он есть | +| `sync_log` | `limit` | последние `sync_run` | +| `settings_get`, `settings_set` | ключ, значение | текущее значение / новая `settings_version` | + +Описание инструмента `batch_create` SHALL предупреждать, что `ensure_issue` должна нести `labels` с базовой меткой +проекта (`AWPCA`): хаб и агент метки сами не добавляют, и задача без неё выпадает из базовой JQL и из зеркала. + +Автор команд из MCP SHALL быть `claude`; из API без автора — `api`. + +#### Scenario: Пакет из чата +- **WHEN** `batch_create {title: "1538 в спринт 2", commands: [ensure_fields{…}, ensure_link{…}, ensure_sprint{…}]}` +- **THEN** пакет создан с четырьмя командами (три + `refresh`), ответ содержит оценку в минутах + +#### Scenario: Ожидание результата +- **WHEN** `batch_wait {id, timeout_minutes: 10}` и пакет завершился через 6 минут +- **THEN** ответ через 6 минут со статусами всех команд + +#### Scenario: Статус агента с довыдачей +- **WHEN** агент на связи, команда 40 выдана дважды и ещё без отчёта +- **THEN** ответ `agent_status` содержит «агент онлайн» и строку о команде 40 с числом выдач 2 + +#### Scenario: Отмена пакета с командой у агента +- **WHEN** `batch_cancel {id: 9}`, команда 30 пакета в `claimed` +- **THEN** ответ содержит вид пакета и строку «команда #30 уже у агента и будет выполнена; остановить — …» + +#### Scenario: Статус агента после отката +- **WHEN** опубликована v7, агент v6 под запускателем v1 прислал `update_state = rolled_back` для v7 +- **THEN** ответ `agent_status` содержит «публикация: v7 от …», «обновление: rolled_back v6 → v7», «запускатель: v1» и + «откат с v7 на v6: …» + +#### Scenario: Статус агента сразу после перезапуска хаба +- **WHEN** хаб перезапущен, агент ещё не прислал `claim` +- **THEN** ответ `agent_status` содержит «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска + хаба)» и не содержит «без запускателя» и «агент запущен без run.py» + +#### Scenario: Задача с семью комментариями +- **WHEN** `issue {key: "AURORA-9001"}`, у задачи 7 комментариев, у самого нового текст 500 символов +- **THEN** ответ содержит «комментарии (7):», 5 последних комментариев (самый новый — первым, обрезан до 300 символов с «…») и «ещё 2» diff --git a/openspec/changes/issue-card-modal/specs/web-app/spec.md b/openspec/changes/issue-card-modal/specs/web-app/spec.md new file mode 100644 index 0000000..d669499 --- /dev/null +++ b/openspec/changes/issue-card-modal/specs/web-app/spec.md @@ -0,0 +1,162 @@ +## MODIFIED Requirements + +### Requirement: Оболочка и адреса +Приложение SHALL открываться под префиксом `/app/` и иметь страницы `/app/board` (доска связей), `/app/issues` (список +задач) и `/app/issues/{key}` (карточка задачи — окно поверх страницы, требование «Карточка задачи»); `/app` и `/app/` SHALL вести на `/app/board`, неизвестный путь — +на страницу «не найдено» со ссылками на разделы. Верхняя панель SHALL содержать переходы «Доска», «Задачи» и действие +«Сменить токен». Неожиданная ошибка отрисовки страницы SHALL показываться сообщением с кнопкой «Перезагрузить», не +пустым экраном. + +#### Scenario: Переход между разделами +- **WHEN** на доске выбран раздел «Задачи» +- **THEN** адрес — `/app/issues`, показан список задач; «назад» возвращает на доску + +### Requirement: Доска связей +Страница `/app/board` SHALL воспроизводить поведение доски связей хаба (`links.html` ревизии 3931a88) без изменений, +кроме перечисленных в этом требовании и в требовании «Источник данных доски»: построение модели из `GET /board`, CSV +фильтра Jira и JSON доски (`arm-ka-links/1`); дорожки по эпикам со свёрткой и перестановкой, служебные дорожки внизу; +карточки с перетаскиванием, растяжкой, размером по содержимому и привязкой к сетке; стрелки связей с выравниванием; +фильтры по спринтам и исполнителям; легенда со скрытием видов работ и типов связей; «+ соседи по связям», «⇄ передачи», +«скрыть без связей», «⚑ критический путь», «▥ по датам» с колонками дат и их растяжкой; ящик «Проблемы»; таблица +связей; экспорт и импорт JSON; тема; «Сбросить раскладку». Раскладка, дорожки, фильтры и тема SHALL храниться в +`localStorage` под теми же ключами `graph-<имя>…`, что у `links.html`, с той же миграцией старых ключей. +Результат построения модели, видимой выборки, критического пути и проблем SHALL совпадать с результатом `links.html` +на одних и тех же данных и фильтрах. У карточки задачи (не у заглушки) SHALL быть значок «Открыть карточку», +открывающий `/app/issues/{key}` окном поверх доски; ключ карточки по-прежнему открывает задачу в Jira. Пока окно открыто +и после его закрытия доска SHALL сохранять прокрутку, раскладку, фильтры и строку состояния пакета. Действия «Сменить токен» в меню +доски нет — оно в верхней панели приложения. Ещё два отличия от `links.html` (R-10, R-18): двойной щелчок по уголку +карточки меряет высоту по содержимому при новой ширине, а не при прежней; указатель при перетаскивании и растяжке +захватывается на нажатом элементе, а не на карточке, поэтому двойной щелчок по уголку сбрасывает размер. Загрузка файла CSV/JSON доступна только после входа, с экрана входа её нет. +При возврате на `/app/board` с другой страницы приложения в той же вкладке доска SHALL восстанавливать прежнюю +прокрутку; после перезагрузки страницы доска открывается сверху. + +#### Scenario: Раскладка переживает переход +- **WHEN** раскладка карточек сохранена страницей `links.html`, открыта `/app/board` с теми же данными +- **THEN** карточки стоят на тех же местах, дорожки в том же порядке и свёртке, фильтры те же + +#### Scenario: Совпадение со старой доской +- **WHEN** на одной фикстуре `GET /board` и одном наборе фильтров построены модель, видимая выборка, критический путь + и проблемы старой страницей и приложением +- **THEN** результаты совпадают + +#### Scenario: Открыть карточку +- **WHEN** на карточке AURORA-1620 нажат значок «Открыть карточку» +- **THEN** адрес `/app/issues/AURORA-1620`, карточка задачи открыта окном, под ним доска + +#### Scenario: Возврат с карточки +- **WHEN** доска прокручена вниз, нажат значок «Открыть карточку», затем окно закрыто +- **THEN** адрес `/app/board`, доска показана с той же прокруткой и раскладкой, без повторной загрузки `GET /board` + +#### Scenario: Возврат из другого раздела +- **WHEN** доска прокручена вниз, выбран раздел «Задачи», затем раздел «Доска» +- **THEN** доска показана с той же прокруткой + +### Requirement: Список задач +Страница `/app/issues` SHALL показывать задачи зеркала из `GET /issues` таблицей: ключ (открывает карточку задачи окном поверх списка), тема, +тип, статус Jira, исполнитель, спринт, срок (`planned_end`, иначе `duedate`), эпик (имя эпика, иначе ключ), вид работы, +статус по факту, признак «сосед». Фильтры SHALL соответствовать параметрам `GET /issues` (спринт, исполнитель, вид +работы, статус по факту, статус Jira, метка, эпик, только соседи, поиск по ключу или теме), храниться в query-строке +адреса и применяться сервером; варианты выпадающих списков SHALL браться из снимка `GET /board`; значение фильтра из адреса, которого нет среди +вариантов, SHALL показываться в списке с пометкой «(нет на доске)». Задачи SHALL +загружаться порциями по 50 строк; «Показать ещё» SHALL быть доступна, пока последняя порция полная. + +#### Scenario: Фильтр в адресе +- **WHEN** выбран спринт «AWPCA_Спринт 2» и исполнитель +- **THEN** адрес содержит `sprint=` и `assignee=`, таблица — результат `GET /issues` с этими параметрами; открытие этого + адреса в новой вкладке показывает ту же выборку + +#### Scenario: Конец списка +- **WHEN** вторая порция вернула 17 задач +- **THEN** в таблице 67 задач, «Показать ещё» не показана + +#### Scenario: Фильтр по закрытому спринту +- **WHEN** открыт адрес `/app/issues?sprint=AWPCA_Спринт 0`, а в снимке доски такого спринта нет +- **THEN** выпадающий список «Спринт» показывает «AWPCA_Спринт 0 (нет на доске)» выбранным, таблица — по этому фильтру + +#### Scenario: Карточка поверх списка +- **WHEN** список отфильтрован по спринту, прокручен и догружен «Показать ещё», открыта карточка задачи и окно закрыто +- **THEN** адрес списка с тем же фильтром, в таблице те же строки, прокрутка та же + +### Requirement: Карточка задачи +Адрес `/app/issues/{key}` SHALL открывать карточку задачи из `GET /issues/{key}` модальным окном поверх страницы, с +которой её открыли (список «Задачи» или доска); страница под окном SHALL оставаться смонтированной и сохранять фильтры, +загруженные строки, прокрутку и раскладку. Открытый напрямую адрес (новая вкладка, закладка, перезагрузка) SHALL +показывать окно поверх списка задач без фильтров. Окно SHALL закрываться кнопкой ✕, клавишей Esc и щелчком по фону и +возвращать на страницу, с которой его открыли, даже после переходов по связям внутри окна; «назад» браузера после +закрытия MUST NOT открывать карточку снова. Переход по связанной задаче SHALL менять содержимое окна и адрес, «назад» +браузера — возвращать к прежней карточке. Пока окно открыто, фокус SHALL оставаться в окне, страница под ним не +прокручивается. + +Содержимое окна: +- шапка: ключ, статус Jira, статус по факту (цветом класса из `status_labels`), «Открыть в Jira», ✕, тема; строка + «исполнитель · срок · спринт · эпик» без пустых частей, срок — `planned_end`, иначе `duedate`, с пометкой «просрочено», + если он раньше сегодняшней даты и класс статуса по факту не `fact-done` и не `fact-cancelled`; эпик — темой + (`epic_summary`), иначе ключом; +- «Связи» по группам в порядке: «Зависит от» (входящие связи логических типов `блокирует начало` и `блокирует + завершение` из `link_types`, с меткой «начало» / «завершение»), «Блокирует» (исходящие тех же типов, с теми же метками), + «Связана с» (тип `связанные` в обе стороны), «Прочие» (тип Jira вне `link_types`: имя типа и направление «→» / «←»); + в строке — ключ (открывает связанную задачу в окне), тема и статус Jira, а если задачи нет в зеркале — ключ и «нет в + зеркале»; пустые группы не показываются, без связей — «связей нет»; +- «Описание» — текст с сохранёнными переносами строк, без разбора разметки Jira; длиннее 8 строк — свёрнуто с кнопкой + «развернуть» / «свернуть»; без описания раздела нет; +- «Комментарии (N)» — новые сверху, автор, время, текст с сохранёнными переносами без разбора разметки; кнопка + «Скрыть» / «Показать» сворачивает раздел, выбор SHALL запоминаться в браузере для всех карточек; без комментариев — + «комментариев нет»; +- «Служебные заметки» (требование «Служебные заметки»); +- «Прочие поля» — непустые из: тип, приоритет, автор, метки, плановое начало, обновлена в Jira, сосед по связям; +- «Команды моста (N)» — 10 последних команд по задаче (id, пакет, тип, статус, время завершения, ошибка), раздел свёрнут + при открытии; если команд не было — текст «команд по задаче не было». +На ширине от 900 px «Связи», «Описание», «Комментарии» — в левой колонке, заметки, прочие поля и команды — в правой; +уже — одна колонка: заметки, связи, описание, комментарии, прочие поля, команды. Если задачи нет в зеркале (404), окно +SHALL сообщать об этом с кнопкой «Закрыть»; хаб не отвечает — сообщение и «Повторить», ранее загруженные данные +остаются. + +#### Scenario: Связанная задача +- **WHEN** у AURORA-1620 исходящая связь `ASUPR_Блокер_Начала` на AURORA-1621 «Расчёт свода» в статусе «В работе» +- **THEN** в карточке в группе «Блокирует» строка «начало · AURORA-1621 · Расчёт свода · В работе»; щелчок по ключу + меняет содержимое окна на AURORA-1621, адрес — `/app/issues/AURORA-1621`; «назад» браузера возвращает AURORA-1620 + +#### Scenario: Входящий блокер +- **WHEN** у AURORA-1620 входящая связь `ASUPR_Блокер_Завершения` от AURORA-1500 +- **THEN** строка с AURORA-1500 и меткой «завершение» — в группе «Зависит от» + +#### Scenario: Задача без команд +- **WHEN** по AURORA-1620 не было команд +- **THEN** в разделе «Команды моста» — текст «команд по задаче не было», таблицы нет + +#### Scenario: Закрытие после переходов +- **WHEN** со списка «Задачи» открыта AURORA-1620, в окне — переход по связи на AURORA-1621, нажата Esc +- **THEN** окно закрыто, адрес — адрес списка с прежними фильтрами; «назад» браузера не открывает карточку + +#### Scenario: Прямой адрес +- **WHEN** в новой вкладке открыт `/app/issues/AURORA-1620`, затем нажат ✕ +- **THEN** под окном был список задач без фильтров; после закрытия адрес `/app/issues`, «назад» не открывает карточку + +#### Scenario: Скрытые комментарии +- **WHEN** в карточке нажато «Скрыть» у комментариев, затем страница перезагружена и открыта другая карточка +- **THEN** комментарии в ней скрыты, видна кнопка «Показать» и число комментариев + +### Requirement: Служебные заметки +Карточка задачи SHALL показывать служебные заметки в режиме чтения: «Вид работы», «Статус по факту», «Сделано», +«Осталось», «Заметка владельца» (пустые — «—») и время и автор последнего изменения; кнопка «Править» SHALL открывать +форму: «вид работы» — выбор из значений `kind_labels` настроек (и текущего значения, если его там нет, и пустого), +«статус по факту» — текст с подсказками из ключей `status_labels`, «сделано», «осталось», «заметка владельца» — +многострочный текст; «Отмена» SHALL возвращать режим чтения, при несохранённых правках — после подтверждения. +Сохранение SHALL отправлять `PUT /issues/{key}/note` только с изменёнными полями и `updated_by: "web"`; очищенное поле +отправляется как `null`. После успеха SHALL показываться режим чтения с сохранённой заметкой; страница под окном (список, +доска) SHALL показывать новые «вид работы» и «статус по факту» без «Перечитать»; при 422 — текст ошибки у формы без +потери введённого. Закрытие окна, переход по связи и уход в другой раздел с несохранёнными правками SHALL требовать +подтверждения. Заметки пишутся только в хаб, в Jira приложение ничего не отправляет. + +#### Scenario: Только изменённые поля +- **WHEN** в заметке нажато «Править», изменено только «осталось» и нажато «Сохранить» +- **THEN** тело `PUT /issues/{key}/note` — `{rest_text: "…", updated_by: "web"}`; карточка снова в режиме чтения с новым + «Осталось» + +#### Scenario: Уход с несохранёнными правками +- **WHEN** в форме заметки есть несохранённая правка и нажата Esc +- **THEN** приложение спрашивает подтверждение; при отказе окно открыто, форма с введённым текстом + +#### Scenario: Заметка видна на доске +- **WHEN** карточка AURORA-9002 открыта с доски, вид работы изменён на «тестирование» и сохранён, окно закрыто +- **THEN** карточка AURORA-9002 на доске показывает вид работы «тестирование» -- 2.47.2 From 9484d10d0b1f4912ad2556e65cd28c234d51eb32 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 12:52:09 +0400 Subject: [PATCH 02/15] =?UTF-8?q?docs:=20=D0=BF=D0=BB=D0=B0=D0=BD=20issue-?= =?UTF-8?q?card-modal=20(#37);=20=D1=83=D1=82=D0=BE=D1=87=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20design=205,=2010=E2=80=9313,=2025?= 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-15-issue-card-modal.md | 1949 +++++++++++++++++ openspec/changes/issue-card-modal/design.md | 42 +- 2 files changed, 1976 insertions(+), 15 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-15-issue-card-modal.md diff --git a/docs/superpowers/plans/2026-10-15-issue-card-modal.md b/docs/superpowers/plans/2026-10-15-issue-card-modal.md new file mode 100644 index 0000000..4fcebbd --- /dev/null +++ b/docs/superpowers/plans/2026-10-15-issue-card-modal.md @@ -0,0 +1,1949 @@ +# issue-card-modal — карточка задачи окном, комментарии из Jira — план хаба + +> **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:** карточка задачи веб-приложения открывается модальным окном поверх списка «Задачи» и доски, с понятной +раскладкой (состояние задачи в шапке, связи по смыслу, заметки в режиме чтения, комментарии из Jira со скрытием); +хаб хранит комментарии, присланные агентом v10, и отдаёт их вместе с темами и статусами связанных задач. + +**Architecture:** хаб — таблица `issue_comment` (схема v3), запись в `mirror.apply_state`, проверка формы в +`h_agent_state`, расширенный `mirror.get_issue`, комментарии в MCP `issue`. Веб — маршрут верхнего уровня +`{path: "*", element: }`; `Layout` рисует страницы через `useRoutes(PAGE_ROUTES, pageLocation)` с явным адресом +страницы под окном (`location.state.background`) и окно `` (`IssueModal` → `IssueCard`) при адресе +`/issues/:key`. Логика без React — в `issues/modal.ts` (состояние истории) и `issues/card.ts` (группы связей, +просрочка). + +**Tech Stack:** Python 3.13 (uv), stdlib `sqlite3`/`http.server`, pytest; React 19 + TypeScript, React Router 8.4, +TanStack Query 5, Vitest 5 + Testing Library (jsdom 29), Playwright. + +**Spec:** `openspec/changes/issue-card-modal/` — `proposal.md`, `design.md` (решения 5–26, R-1…R-5), `specs/` +(`data-model`, `hub-api`, `mcp`, `web-app`; `agent` — для jira-agent, план +`../jira-agent/.wt/agent-v10/docs/superpowers/plans/2026-10-15-agent-v10-comments.md`, исполняется отдельно и раньше). + +**Worktree:** `/Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/issue-modal`, ветка `feat/37-issue-card-modal`. +Все команды — из корня worktree (фронт — из `web/`). Живой хаб :8790 и `~/.local/share/jira-hub` не трогать; ручные +проверки — только тестовый хаб `bash web/scripts/test-hub.sh` (:8791). + +## Global Constraints + +- Схема базы — v3 (`db.SCHEMA_VERSION = 3`); таблица `issue_comment (key, comment_id, author, created, updated, body)`, + `PRIMARY KEY (key, comment_id)`. +- Комментарии задачи заменяются присланными, только если у задачи в состоянии есть поле `comments` (список); без поля — + не трогаются. +- Неверная форма `comments` в `POST /agent/state` — 422 `bad_state`, текст называет ключ задачи и поле; пачка не + применяется целиком. +- `GET /issues/{key}`: `comments` новые сверху (`created` убыв., затем `comment_id` как число убыв.), `epic_summary`, + у связей `summary` и `status` связанной задачи или `null`. Прежние поля не меняются. +- MCP `issue`: «комментарии (N):», 5 последних, текст до 300 символов с «…», «ещё M»; без комментариев — «комментариев нет». +- Тексты интерфейса — дословно как в спеке: «Зависит от», «Блокирует», «Связана с», «Прочие», «начало», «завершение», + «нет в зеркале», «связей нет», «развернуть»/«свернуть», «Комментарии (N)», «Скрыть»/«Показать», «комментариев нет», + «Править», «Отмена», «Отменить правки заметки?», «Прочие поля», «Команды моста (N)», «команд по задаче не было», + «просрочено», «Открыть в Jira ↗», «Закрыть», «Задачи нет в зеркале хаба.», «Есть несохранённые правки заметки. + Уйти без сохранения?». +- Описание сворачивается свыше 8 строк; выбор «скрыто» у комментариев — `localStorage["jira-hub-comments-hidden"] = "1"`. +- Окно: до 1000 px шириной, до 90vh высотой; уже 700 px — на всю ширину; уже 900 px — одна колонка (заметки, связи, + описание, комментарии, прочие поля, команды). +- Новых npm-зависимостей нет. +- Тесты: `uv run pytest` и `cd web && npm test` — зелёными считается только весь набор; перед коммитом каждой задачи + фронта — `cd web && npm run lint && npx tsc -b`. +- Коммиты на русском, трейлер `Co-Authored-By: Claude Opus 5.5 `. + +## Review Focus + +1. Фоновая страница не пересоздаётся при открытии и закрытии окна (фильтры, догруженные порции, прокрутка) — Task 4, тест «фон не пересоздаётся». +2. Перезагрузка страницы с открытым окном: под окном та же страница, закрытие возвращает на неё — Task 6, e2e «прямой адрес» и первый тест `issues.spec.ts` (reload). +3. Esc при несохранённой заметке не закрывает окно молча — Task 4, тест «Esc с несохранённой заметкой спрашивает». +4. Комментарий с пустым текстом или без автора не ломает карточку — Task 5, тест «комментарий без автора и текста». +5. Связь на задачу вне зеркала и тип связи вне `link_types` — Task 3 (`groupLinks`) и Task 5 (отрисовка «нет в зеркале»). + +--- + +### Task 1: Комментарии в зеркале и расширенная карточка в хабе + +**Модель исполнителя:** haiku (готовый код). + +**Files:** +- Modify: `src/jira_hub/db.py` (`SCHEMA_VERSION`, `SCHEMA`, `MIGRATIONS`) +- Modify: `src/jira_hub/mirror.py` (`apply_state`, `get_issue`, новая `check_comments`) +- Test: `tests/test_db.py`, `tests/test_mirror.py`, `tests/test_e2e_hub.py:78` + +**Interfaces:** +- Produces: `mirror.check_comments(issues: list[dict]) -> None` (бросает `ValueError("<ключ>: comments[].<поле> — …")`); + `mirror.get_issue` → дополнительно `epic_summary: str | None`, `comments: list[{comment_id, author, created, updated, + body}]`, у `links_out`/`links_in` — `summary`, `status`. + +- [ ] **Step 1: Падающие тесты схемы** + +В `tests/test_db.py` в `test_connect_creates_schema` заменить множество таблиц и версию: + +```python + assert {"setting", "issue", "issue_link", "issue_comment", "issue_note", "batch", "command", "sync_run", + "agent_status"} <= names + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3" +``` + +Во всём файле `tests/test_db.py` строки `... .fetchone()["value"] == "2"` (миграция v1 и два соединения) заменить на +`== "3"`. После `test_migrates_v1_with_backup` добавить: + +```python +def test_migrates_v2_with_backup(tmp_path): + """База v2 (без issue_comment) с задачей и заметкой → v3: копия schema-v2, таблица комментариев пустая, данные на месте.""" + path = tmp_path / "hub.sqlite" + conn = db.connect(path) + conn.execute("INSERT INTO issue(key, summary, fetched_at) VALUES ('A-1', 'Т', '2026-10-15T00:00:00+00:00')") + conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')") + conn.execute("DROP TABLE issue_comment") + conn.execute("UPDATE setting SET value = '2' WHERE key = 'schema_version'") + conn.close() + conn = db.connect(path) + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3" + assert conn.execute("SELECT COUNT(*) AS n FROM issue_comment").fetchone()["n"] == 0 + assert conn.execute("SELECT summary FROM issue WHERE key = 'A-1'").fetchone()["summary"] == "Т" + assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе" + copies = list((tmp_path / "backups").glob("*-schema-v2.sqlite")) + assert len(copies) == 1 + conn.close() +``` + +- [ ] **Step 2: Падающие тесты зеркала** + +В `tests/test_mirror.py` заменить ожидания связей (строки 30, 31, 38): + +```python + assert got["links_out"] == [{"target_key": "A-2", "link_type": "ASUPR_Связанные", "summary": "Тема A-2", + "status": "Ожидание разработки"}] + assert mirror.get_issue(conn, "A-2")["links_in"] == [{"source_key": "A-1", "link_type": "ASUPR_Связанные", + "summary": "Новая тема", "status": "Ожидание разработки"}] +``` + +и в `test_links_from_other_source_survive` (строка 38): + +```python + assert mirror.get_issue(conn, "A-1")["links_in"] == [{"source_key": "A-2", "link_type": "ASUPR_Связанные", + "summary": "Тема A-2", "status": "Ожидание разработки"}] +``` + +В конец файла добавить: + +```python +def comment(cid, created="2026-10-14T12:00:00.000+0300", **over): + base = {"id": cid, "author": "Петров Пётр", "created": created, "updated": created, "body": f"текст {cid}"} + base.update(over) + return base + + +def test_comments_replaced_and_kept_without_field(conn): + mirror.apply_state(conn, [issue("A-1", comments=[comment("101"), comment("102")])], []) + mirror.apply_state(conn, [issue("A-1", comments=[comment("102", body="новый текст")])], []) + got = mirror.get_issue(conn, "A-1")["comments"] + assert [(c["comment_id"], c["body"]) for c in got] == [("102", "новый текст")] + mirror.apply_state(conn, [issue("A-1", summary="Без поля comments")], []) # агент до v10 + assert [c["comment_id"] for c in mirror.get_issue(conn, "A-1")["comments"]] == ["102"] + mirror.apply_state(conn, [issue("A-1", comments=[])], []) + assert mirror.get_issue(conn, "A-1")["comments"] == [] + + +def test_comments_newest_first_and_shape(conn): + mirror.apply_state(conn, [issue("A-1", comments=[ + comment("9", created="2026-10-14T10:00:00.000+0300"), + comment("10", created="2026-10-15T10:00:00.000+0300", author=None, body=None), + comment("11", created="2026-10-15T10:00:00.000+0300")])], []) + got = mirror.get_issue(conn, "A-1")["comments"] + assert [c["comment_id"] for c in got] == ["11", "10", "9"] # то же время — по номеру, как число + assert got[1] == {"comment_id": "10", "author": None, "created": "2026-10-15T10:00:00.000+0300", + "updated": "2026-10-15T10:00:00.000+0300", "body": ""} + + +def test_issue_epic_summary_and_absent_link(conn): + mirror.apply_state(conn, [issue("A-1", epic_key="E-1"), issue("E-1", summary="Эпик свода", epic_key=None)], + [{"source_key": "A-1", "target_key": "Z-9", "link_type": "ASUPR_Связанные"}]) + got = mirror.get_issue(conn, "A-1") + assert got["epic_summary"] == "Эпик свода" + assert got["links_out"] == [{"target_key": "Z-9", "link_type": "ASUPR_Связанные", "summary": None, "status": None}] + assert mirror.get_issue(conn, "E-1")["epic_summary"] is None + + +@pytest.mark.parametrize("bad, field", [ + ("x", "comments"), (["x"], "comments[0]"), ([{"id": "", "body": "т"}], "comments[0].id"), + ([{"id": 5, "body": "т"}], "comments[0].id"), ([{"id": "5", "body": 42}], "comments[0].body"), + ([{"id": "5", "body": "т", "author": 7}], "comments[0].author"), + ([{"id": "5", "body": "т", "created": 1}], "comments[0].created"), + ([{"id": "5", "body": "т", "updated": []}], "comments[0].updated"), +]) +def test_check_comments_rejects(bad, field): + with pytest.raises(ValueError) as e: + mirror.check_comments([issue("A-1"), issue("A-2", comments=bad)]) + assert str(e.value).startswith(f"A-2: {field}") + + +def test_check_comments_accepts_missing_and_valid(): + mirror.check_comments([issue("A-1"), issue("A-2", comments=[comment("1"), comment("2", author=None, body="")])]) +``` + +В `tests/test_e2e_hub.py` строку 78 заменить на: + +```python + links_in = client.get("/issues/A-1")["links_in"] + assert [(l["source_key"], l["link_type"]) for l in links_in] == [("A-90", "ASUPR_Связанные")] +``` + +- [ ] **Step 3: Тесты падают** + +Run: `uv run pytest tests/test_db.py tests/test_mirror.py -q` +Expected: FAIL — `issue_comment` нет в таблицах, версия `"2"`, `KeyError: 'comments'`, `AttributeError: module +'jira_hub.mirror' has no attribute 'check_comments'`. + +- [ ] **Step 4: Схема v3** + +В `src/jira_hub/db.py`: `SCHEMA_VERSION = 3`. В `SCHEMA` после индекса `ix_issue_link_target` добавить: + +```sql +CREATE TABLE IF NOT EXISTS issue_comment ( + key TEXT NOT NULL, + comment_id TEXT NOT NULL, + author TEXT, + created TEXT, + updated TEXT, + body TEXT NOT NULL DEFAULT '', + PRIMARY KEY (key, comment_id) +); +``` + +В `MIGRATIONS` добавить шаг (таблицу создаёт `SCHEMA` через `CREATE TABLE IF NOT EXISTS` до миграции; шаг нужен, чтобы +цикл миграции дошёл до версии 3): + +```python + 2: [], # v2 → v3: issue_comment создаётся SCHEMA (CREATE TABLE IF NOT EXISTS), данных переносить не нужно +``` + +- [ ] **Step 5: Запись, проверка и чтение комментариев** + +В `src/jira_hub/mirror.py` заменить строку документации модуля на: + +```python +"""Зеркало задач Jira (issue, issue_link, issue_comment) и служебные заметки (issue_note). +Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает.""" +``` + +После `NOTE_FIELDS` добавить: + +```python +def check_comments(issues) -> None: + """Форма поля comments у задач состояния (агент v10+); поле необязательно. Нарушение — ValueError с ключом и полем.""" + for it in issues: + if "comments" not in it: + continue + key, lst = it.get("key"), it["comments"] + if not isinstance(lst, list): + raise ValueError(f"{key}: comments — список комментариев") + for n, c in enumerate(lst): + where = f"{key}: comments[{n}]" + if not isinstance(c, dict): + raise ValueError(f"{where} — объект комментария") + if not isinstance(c.get("id"), str) or not c["id"]: + raise ValueError(f"{where}.id — непустая строка") + if not isinstance(c.get("body"), str) and c.get("body") is not None: + raise ValueError(f"{where}.body — строка") + for f in ("author", "created", "updated"): + if c.get(f) is not None and not isinstance(c[f], str): + raise ValueError(f"{where}.{f} — строка или null") +``` + +В `apply_state`, внутри цикла `for it in issues:` после `keys.append(it["key"])` добавить: + +```python + if isinstance(it.get("comments"), list): # без поля (агент до v10) комментарии не трогаем + conn.execute("DELETE FROM issue_comment WHERE key = ?", (it["key"],)) + conn.executemany( + "INSERT OR REPLACE INTO issue_comment(key, comment_id, author, created, updated, body) " + "VALUES (?, ?, ?, ?, ?, ?)", + [(it["key"], c["id"], c.get("author"), c.get("created"), c.get("updated"), c.get("body") or "") + for c in it["comments"]]) +``` + +В `get_issue` заменить блок от `d["links_out"] = ...` до `d["note"] = get_note(conn, key)` на: + +```python + epic = conn.execute("SELECT summary FROM issue WHERE key = ?", (d["epic_key"],)).fetchone() if d.get("epic_key") else None + d["epic_summary"] = epic["summary"] if epic else None + d["links_out"] = [dict(r) for r in conn.execute( + "SELECT l.target_key, l.link_type, i.summary, i.status FROM issue_link l LEFT JOIN issue i ON i.key = l.target_key " + "WHERE l.source_key = ? ORDER BY l.link_type, l.target_key", (key,))] + d["links_in"] = [dict(r) for r in conn.execute( + "SELECT l.source_key, l.link_type, i.summary, i.status FROM issue_link l LEFT JOIN issue i ON i.key = l.source_key " + "WHERE l.target_key = ? ORDER BY l.link_type, l.source_key", (key,))] + d["comments"] = [dict(r) for r in conn.execute( + "SELECT comment_id, author, created, updated, body FROM issue_comment WHERE key = ? " + "ORDER BY created DESC, CAST(comment_id AS INTEGER) DESC, comment_id DESC", (key,))] + d["note"] = get_note(conn, key) +``` + +- [ ] **Step 6: Тесты проходят, весь набор зелёный** + +Run: `uv run pytest tests/test_db.py tests/test_mirror.py tests/test_e2e_hub.py -q` +Expected: PASS. + +Run: `uv run pytest -q` +Expected: все тесты зелёные, кроме `tests/test_api.py` строки 75 (ожидание `links_out` без `summary`) — его правит +Task 2. Если упало что-то ещё — чинить в этой задаче. + +- [ ] **Step 7: Commit** + +```bash +git add src/jira_hub/db.py src/jira_hub/mirror.py tests/test_db.py tests/test_mirror.py tests/test_e2e_hub.py +git commit -m "feat(hub): комментарии задач в зеркале, схема v3; темы и статусы связанных в карточке (#37) + +Co-Authored-By: Claude Opus 5.5 " +``` + +### Task 2: Проверка состояния от агента и комментарии в MCP + +**Модель исполнителя:** haiku (готовый код). + +**Files:** +- Modify: `src/jira_hub/api.py` (`h_agent_state`) +- Modify: `src/jira_hub/mcp_server.py` (`fmt_issue`) +- Test: `tests/test_api.py`, `tests/test_mcp.py` + +**Interfaces:** +- Consumes: Task 1 — `mirror.check_comments`, `comments`/`epic_summary`/`summary`/`status` в `get_issue`. +- Produces: 422 `bad_state` у `POST /agent/state`; строки комментариев в `fmt_issue`. + +- [ ] **Step 1: Падающие тесты API** + +В `tests/test_api.py` строку 75 заменить на: + +```python + assert issue["links_out"] == [{"target_key": "B-1", "link_type": "ASUPR_Связанные", "summary": "Сосед", "status": None}] +``` + +В конец файла добавить: + +```python +def test_agent_state_comments_validated_whole_batch(hub): + a, c = hub["agent"], hub["client"] + a.post("/agent/state", {"issues": [{"key": "X-1", "summary": "Икс", "labels": []}], "links": []}) + with pytest.raises(HubError) as e: + a.post("/agent/state", {"issues": [{"key": "X-1", "summary": "Икс новый", "labels": []}, + {"key": "Y-1", "summary": "Игрек", "labels": [], + "comments": [{"id": "7", "body": 42}]}], "links": []}) + assert e.value.status == 422 and e.value.error == "bad_state" + assert "Y-1" in e.value.detail and "body" in e.value.detail + assert c.get("/issues/X-1")["summary"] == "Икс" # пачка не применена целиком + with pytest.raises(HubError) as e: + c.get("/issues/Y-1") + assert e.value.status == 404 + + +def test_issue_detail_with_comments(hub): + hub["agent"].post("/agent/state", {"issues": [ + {"key": "A-1", "summary": "Схема", "labels": [], "epic_key": "E-1", "comments": [ + {"id": "1", "author": "Петров Пётр", "created": "2026-10-14T12:00:00.000+0300", "updated": None, "body": "старый"}, + {"id": "2", "author": None, "created": "2026-10-15T12:00:00.000+0300", "updated": None, "body": "новый"}]}, + {"key": "E-1", "summary": "Эпик", "labels": []}], "links": []}) + got = hub["client"].get("/issues/A-1") + assert [x["body"] for x in got["comments"]] == ["новый", "старый"] + assert got["epic_summary"] == "Эпик" +``` + +- [ ] **Step 2: Падающий тест MCP** + +В `tests/test_mcp.py` в конец файла добавить: + +```python +def _detail(**over): + d = {"key": "A-1", "summary": "Т", "labels": [], "links_out": [], "links_in": [], "note": None, "commands": [], + "comments": [], "description": None} + d.update(over) + return d + + +def test_fmt_issue_comments(): + assert "комментариев нет" in mcp_server.fmt_issue(_detail()) + comments = [{"comment_id": str(n), "author": f"Автор {n}", "created": f"2026-10-1{n}T12:00:00.000+0300", + "updated": None, "body": ("д" * 500 if n == 7 else f"текст {n}\nстрока")} for n in range(7, 0, -1)] + text = mcp_server.fmt_issue(_detail(comments=comments)) + assert "комментарии (7):" in text + assert "д" * 299 + "…" in text and "д" * 300 not in text # самый новый — первым, обрезан до 300 + assert "Автор 3" in text and "Автор 2" not in text # 5 последних: 7…3 + assert "ещё 2" in text + assert text.index("заметки:") < text.index("комментарии (7):") < text.index("команды:") + no_author = mcp_server.fmt_issue(_detail(comments=[{"comment_id": "1", "author": None, "created": None, + "updated": None, "body": ""}])) + assert "комментарии (1):" in no_author and "ещё" not in no_author +``` + +- [ ] **Step 3: Тесты падают** + +Run: `uv run pytest tests/test_api.py tests/test_mcp.py -q` +Expected: FAIL — 422 не возвращается (пачка применяется), в `fmt_issue` нет «комментариев нет». + +- [ ] **Step 4: Реализация** + +В `src/jira_hub/api.py` заменить `h_agent_state` целиком: + +```python +def h_agent_state(hub, conn, m, q, body): + issues = body.get("issues") or [] + try: + mirror.check_comments(issues) # до записи: неверная пачка не применяется целиком + 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=queue.is_full_refresh(conn, body.get("command_id"))) + if body.get("command_id"): + queue.record_state(conn, body["command_id"], out["issues"], out["links"]) + return 200, out +``` + +В `src/jira_hub/mcp_server.py` в `fmt_issue` заменить строку `return "\n".join(head + ...` (две строки до конца +функции) на: + +```python + comments = d.get("comments") or [] + if comments: + shown = [f" {_v(c.get('author'))} {_v(c.get('created'))}: {_cut(c.get('body'), 300)}" for c in comments[:5]] + cmts = [f"комментарии ({len(comments)}):"] + shown + ([f" ещё {len(comments) - 5}"] if len(comments) > 5 else []) + else: + cmts = ["комментариев нет"] + return "\n".join(head + ["связи:"] + (links or [" —"]) + ["заметки:"] + (notes or [" —"]) + cmts + + ["команды:"] + (cmds or [" —"]) + ["описание:", d.get("description") or "—"]) +``` + +- [ ] **Step 5: Весь набор зелёный** + +Run: `uv run pytest -q` +Expected: все тесты зелёные. + +- [ ] **Step 6: Commit** + +```bash +git add src/jira_hub/api.py src/jira_hub/mcp_server.py tests/test_api.py tests/test_mcp.py +git commit -m "feat(hub): проверка комментариев в POST /agent/state (422 bad_state), комментарии в MCP issue (#37) + +Co-Authored-By: Claude Opus 5.5 " +``` + +### Task 3: Данные карточки во фронте: типы, группы связей, просрочка, состояние окна + +**Модель исполнителя:** haiku (готовый код). + +**Files:** +- Modify: `web/src/api/types.ts` (`IssueDetail`, новые `IssueLinkOut`, `IssueLinkIn`, `IssueComment`) +- Create: `web/src/issues/card.ts`, `web/src/issues/card.test.ts` +- Create: `web/src/issues/modal.ts`, `web/src/issues/modal.test.ts` +- Create: `web/src/issues/commentsPref.ts` +- Create: `web/src/test/issueFixtures.ts` +- Modify: `web/src/test/setup.ts` (замена `showModal`/`close` для jsdom) +- Modify: `web/src/issues/IssuePage.test.tsx` (фабрика из `issueFixtures.ts`) + +**Interfaces:** +- Produces (`card.ts`): `type LinkGroupId = "depends" | "blocks" | "related" | "other"`; + `interface CardLink { key: string; summary: string | null; status: string | null; tag: string | null }`; + `groupLinks(d: Pick, linkTypes: Record): [LinkGroupId, string, CardLink[]][]`; + `factClass(fact: string | null | undefined, statusLabels: Record | undefined): string | null`; + `isOverdue(due: string, fact: string | null | undefined, statusLabels: Record | undefined, today: string): boolean`; + `todayISO(d?: Date): string`. +- Produces (`modal.ts`): `interface Background { pathname: string; search: string }`; + `interface CardState { background: Background; depth: number; direct: boolean }`; `LIST_BACKGROUND`; + `BOARD_CARD_STATE: CardState`; `cardState(s: unknown): CardState | null`; + `openCardState(loc: { pathname: string; search: string; state: unknown }): CardState`. +- Produces (`commentsPref.ts`): `useCommentsHidden(): [boolean, (hidden: boolean) => void]`, ключ `COMMENTS_HIDDEN_KEY`. +- Produces (`test/issueFixtures.ts`): `note(over?)`, `issue(key, over?)` — `IssueDetail` для тестов. + +- [ ] **Step 1: Типы** + +В `web/src/api/types.ts` заменить `IssueDetail` на: + +```ts +export interface IssueLinkOut { target_key: string; link_type: string; summary: string | null; status: string | null } +export interface IssueLinkIn { source_key: string; link_type: string; summary: string | null; status: string | null } +// комментарий задачи из Jira (зеркало хаба, агент v10+): текст и время — как в Jira +export interface IssueComment { comment_id: string; author: string | null; created: string | null; updated: string | null; body: string } +export interface IssueDetail extends IssueRow { + epic_summary: string | null; + links_out: IssueLinkOut[]; + links_in: IssueLinkIn[]; + note: Note | null; + commands: IssueCommand[]; + comments: IssueComment[]; +} +``` + +- [ ] **Step 2: Общие тестовые данные и `` в jsdom** + +Создать `web/src/test/issueFixtures.ts`: + +```ts +import type { IssueDetail, Note } from "../api/types.ts"; + +export const note = (over: Partial = {}): Note => ({ + key: "AURORA-9001", fact_status: "В работе", work_kind: "разработка", done_text: "схема", rest_text: "API", + owner_note: null, updated_at: "2026-10-14T09:00:00+00:00", updated_by: "chat", ...over, +}); + +export const issue = (key: string, over: Partial = {}): IssueDetail => ({ + key, id: "1", issue_type: "Задача", summary: `Тема ${key}`, description: "строка 1\nстрока 2", status: "В работе", + priority: "Высокий", assignee: "Иванов Иван Иванович", reporter: "me", duedate: null, planned_start: null, + planned_end: "2026-10-20", epic_key: "AURORA-9100", epic_summary: "Тестовый эпик", labels: ["kind-dev"], + sprint: "AWPCA_Спринт 2", created: null, jira_updated: null, fetched_at: "", neighbor: false, + links_out: [{ target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала", summary: "API свода", status: "Новая" }], + links_in: [{ source_key: "AURORA-9003", link_type: "Duplicate", summary: "Макет экрана", status: "Готово" }], + note: note({ key }), + commands: [{ id: 5, batch_id: 2, type: "ensure_fields", status: "done", finished_at: "2026-10-14T09:00:00+00:00", error: null }], + comments: [], + ...over, +}); +``` + +В `web/src/issues/IssuePage.test.tsx` удалить локальные `const note = …` и `const issue = …` (строки 13–25) и добавить +импорт `import { issue, note } from "../test/issueFixtures.ts";`. + +В `web/src/test/setup.ts` после импортов добавить: + +```ts +// jsdom 29 не умеет showModal/close у : открытость — атрибут open, как в браузере +if (typeof HTMLDialogElement !== "undefined" && !HTMLDialogElement.prototype.showModal) { + HTMLDialogElement.prototype.showModal = function showModal(this: HTMLDialogElement) { this.setAttribute("open", ""); }; + HTMLDialogElement.prototype.close = function close(this: HTMLDialogElement) { this.removeAttribute("open"); }; +} +``` + +- [ ] **Step 3: Падающие тесты логики** + +Создать `web/src/issues/card.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import settings from "../board/__fixtures__/settings.json"; +import { factClass, groupLinks, isOverdue, todayISO } from "./card.ts"; + +const types = settings.link_types as Record; +const labels = { ...(settings.status_labels as Record), "Снято": "fact-cancelled" }; +const out = (k: string, t: string, s: string | null = `Тема ${k}`, st: string | null = "В работе") => ({ target_key: k, link_type: t, summary: s, status: st }); +const inn = (k: string, t: string, s: string | null = `Тема ${k}`, st: string | null = "В работе") => ({ source_key: k, link_type: t, summary: s, status: st }); + +describe("groupLinks", () => { + it("блокеры по направлению, связанные без повторов, прочие с направлением; порядок групп и ключей", () => { + const g = groupLinks({ + links_out: [out("A-10", "ASUPR_Блокер_Завершения"), out("A-2", "ASUPR_Блокер_Начала"), out("A-5", "ASUPR_Связанные"), + out("A-7", "Duplicate")], + links_in: [inn("A-3", "ASUPR_Блокер_Начала"), inn("A-5", "ASUPR_Связанные"), inn("A-8", "Duplicate", null, null)], + }, types); + expect(g.map(([id, title]) => [id, title])).toEqual([["depends", "Зависит от"], ["blocks", "Блокирует"], + ["related", "Связана с"], ["other", "Прочие"]]); + expect(g[0][2]).toEqual([{ key: "A-3", summary: "Тема A-3", status: "В работе", tag: "начало" }]); + expect(g[1][2].map((l) => [l.key, l.tag])).toEqual([["A-2", "начало"], ["A-10", "завершение"]]); // ключи как числа + expect(g[2][2].map((l) => l.key)).toEqual(["A-5"]); + expect(g[3][2]).toEqual([{ key: "A-7", summary: "Тема A-7", status: "В работе", tag: "→ Duplicate" }, + { key: "A-8", summary: null, status: null, tag: "← Duplicate" }]); + }); + + it("пустые группы не возвращаются; без link_types всё — «Прочие» с именем Jira", () => { + expect(groupLinks({ links_out: [], links_in: [] }, types)).toEqual([]); + const g = groupLinks({ links_out: [out("A-2", "ASUPR_Блокер_Начала")], links_in: [] }, {}); + expect(g).toEqual([["other", "Прочие", [{ key: "A-2", summary: "Тема A-2", status: "В работе", tag: "→ ASUPR_Блокер_Начала" }]]]); + }); + + it("логический тип из настроек вне трёх известных — «Прочие» с логическим именем", () => { + const g = groupLinks({ links_out: [out("A-2", "X_Дубль")], links_in: [] }, { ...types, "дублирует": "X_Дубль" }); + expect(g[0][2][0].tag).toBe("→ дублирует"); + }); +}); + +describe("просрочка и класс статуса по факту", () => { + it("класс из status_labels", () => { + expect(factClass("Готово — закрыть", labels)).toBe("fact-done"); + expect(factClass("свой статус", labels)).toBeNull(); + expect(factClass(null, labels)).toBeNull(); + }); + it("срок раньше сегодня и не закрыта по факту — просрочено (R-2)", () => { + expect(isOverdue("2026-10-14", "В работе", labels, "2026-10-15")).toBe(true); + expect(isOverdue("2026-10-14", null, labels, "2026-10-15")).toBe(true); + expect(isOverdue("2026-10-15", "В работе", labels, "2026-10-15")).toBe(false); + expect(isOverdue("2026-10-14", "Готово — закрыть", labels, "2026-10-15")).toBe(false); + expect(isOverdue("2026-10-14", "Снято", labels, "2026-10-15")).toBe(false); + expect(isOverdue("", "В работе", labels, "2026-10-15")).toBe(false); + }); + it("todayISO — местная дата YYYY-MM-DD", () => { + expect(todayISO(new Date(2026, 0, 5, 23, 30))).toBe("2026-01-05"); + }); +}); +``` + +Создать `web/src/issues/modal.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { BOARD_CARD_STATE, cardState, LIST_BACKGROUND, openCardState } from "./modal.ts"; + +describe("состояние окна в истории", () => { + it("со страницы — фон = адрес страницы, глубина 1", () => { + expect(openCardState({ pathname: "/issues", search: "?sprint=S", state: null })) + .toEqual({ background: { pathname: "/issues", search: "?sprint=S" }, depth: 1, direct: false }); + }); + it("из окна — тот же фон, глубина + 1, признак прямого адреса сохраняется", () => { + const s = { background: LIST_BACKGROUND, depth: 0, direct: true }; + expect(openCardState({ pathname: "/issues/A-1", search: "", state: s })).toEqual({ background: LIST_BACKGROUND, depth: 1, direct: true }); + expect(openCardState({ pathname: "/issues/A-2", search: "", state: BOARD_CARD_STATE })) + .toEqual({ background: { pathname: "/board", search: "" }, depth: 2, direct: false }); + }); + it("чужое или битое state — не состояние окна", () => { + expect(cardState(null)).toBeNull(); + expect(cardState({ background: "/issues", depth: 1 })).toBeNull(); + expect(cardState({ background: { pathname: "/issues", search: "" }, depth: "1" })).toBeNull(); + expect(cardState({ background: { pathname: "/issues", search: "" }, depth: 1 })) + .toEqual({ background: { pathname: "/issues", search: "" }, depth: 1, direct: false }); + }); +}); +``` + +Run: `cd web && npx vitest run src/issues/card.test.ts src/issues/modal.test.ts` +Expected: FAIL — `Failed to resolve import "./card.ts"` и `"./modal.ts"`. + +- [ ] **Step 4: Реализация логики** + +Создать `web/src/issues/card.ts`: + +```ts +import type { IssueDetail } from "../api/types.ts"; + +export type LinkGroupId = "depends" | "blocks" | "related" | "other"; +export interface CardLink { key: string; summary: string | null; status: string | null; tag: string | null } + +const GROUPS: [LinkGroupId, string][] = [["depends", "Зависит от"], ["blocks", "Блокирует"], ["related", "Связана с"], ["other", "Прочие"]]; +const BLOCK_TAGS: Record = { "блокирует начало": "начало", "блокирует завершение": "завершение" }; +const byKey = (a: CardLink, b: CardLink) => + a.key.localeCompare(b.key, "ru", { numeric: true }) || (a.tag ?? "").localeCompare(b.tag ?? "", "ru"); + +// связи карточки по смыслу (design 17): логическое имя типа — из link_types настроек (имя типа Jira → логическое) +export function groupLinks(d: Pick, linkTypes: Record): [LinkGroupId, string, CardLink[]][] { + const logical = new Map(Object.entries(linkTypes).map(([name, jira]) => [jira, name])); + const groups: Record = { depends: [], blocks: [], related: [], other: [] }; + const add = (dir: "out" | "in", key: string, jiraType: string, summary: string | null, status: string | null) => { + const name = logical.get(jiraType); + if (name && BLOCK_TAGS[name]) groups[dir === "in" ? "depends" : "blocks"].push({ key, summary, status, tag: BLOCK_TAGS[name] }); + else if (name === "связанные") { if (!groups.related.some((l) => l.key === key)) groups.related.push({ key, summary, status, tag: null }); } + else groups.other.push({ key, summary, status, tag: `${dir === "out" ? "→" : "←"} ${name ?? jiraType}` }); + }; + for (const l of d.links_out) add("out", l.target_key, l.link_type, l.summary, l.status); + for (const l of d.links_in) add("in", l.source_key, l.link_type, l.summary, l.status); + return GROUPS.filter(([id]) => groups[id].length).map(([id, title]) => [id, title, groups[id].sort(byKey)]); +} + +const CLOSED_FACT = new Set(["fact-done", "fact-cancelled"]); +export const factClass = (fact: string | null | undefined, statusLabels: Record | undefined): string | null => + (fact && statusLabels?.[fact]) || null; + +// R-2: просрочено — срок раньше сегодня, а статус по факту не «закрыт» (классы fact-done, fact-cancelled) +export function isOverdue(due: string, fact: string | null | undefined, statusLabels: Record | undefined, today: string): boolean { + if (!due || due >= today) return false; + const cls = factClass(fact, statusLabels); + return !(cls && CLOSED_FACT.has(cls)); +} + +export const todayISO = (d = new Date()): string => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +``` + +Создать `web/src/issues/modal.ts`: + +```ts +// состояние окна карточки в записи истории (design 10–12): страница под окном, сколько записей заняли карточки +// поверх неё, открыто ли окно прямым адресом +export interface Background { pathname: string; search: string } +export interface CardState { background: Background; depth: number; direct: boolean } + +export const LIST_BACKGROUND: Background = { pathname: "/issues", search: "" }; +// доска не хранит состояние в адресе: «ⓘ» всегда открывает окно поверх /board +export const BOARD_CARD_STATE: CardState = { background: { pathname: "/board", 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; + if (!c || typeof c !== "object" || !c.background || typeof c.background !== "object") return null; + const { pathname, search } = c.background; + if (typeof pathname !== "string" || typeof search !== "string" || typeof c.depth !== "number") return null; + return { background: { pathname, search }, depth: c.depth, direct: c.direct === true }; +} + +// state ссылки на карточку: со страницы — фон = её адрес, глубина 1; из окна — тот же фон, глубина + 1 +export function openCardState(loc: { pathname: string; search: string; state: unknown }): CardState { + const cur = cardState(loc.state); + return cur ? { ...cur, depth: cur.depth + 1 } : { background: { pathname: loc.pathname, search: loc.search }, depth: 1, direct: false }; +} +``` + +Создать `web/src/issues/commentsPref.ts`: + +```ts +import { useState } from "react"; + +// выбор «комментарии скрыты» — один на все карточки, в браузере (design 19); без localStorage — показаны +export const COMMENTS_HIDDEN_KEY = "jira-hub-comments-hidden"; +const read = (): boolean => { try { return localStorage.getItem(COMMENTS_HIDDEN_KEY) === "1"; } catch { return false; } }; + +export function useCommentsHidden(): [boolean, (hidden: boolean) => void] { + const [hidden, setHidden] = useState(read); + const set = (v: boolean) => { + setHidden(v); + try { if (v) localStorage.setItem(COMMENTS_HIDDEN_KEY, "1"); else localStorage.removeItem(COMMENTS_HIDDEN_KEY); } + catch { /* хранилище недоступно: выбор живёт до закрытия карточки */ } + }; + return [hidden, set]; +} +``` + +- [ ] **Step 5: Тесты проходят, весь набор зелёный** + +Run: `cd web && npx vitest run src/issues/card.test.ts src/issues/modal.test.ts` +Expected: PASS. + +Run: `cd web && npm test && npm run lint && npx tsc -b` +Expected: все тесты зелёные (в том числе `IssuePage.test.tsx` с фабрикой из `issueFixtures.ts`), lint и tsc без ошибок. + +- [ ] **Step 6: Commit** + +```bash +git add web/src/api/types.ts web/src/issues/card.ts web/src/issues/card.test.ts web/src/issues/modal.ts \ + web/src/issues/modal.test.ts web/src/issues/commentsPref.ts web/src/test/issueFixtures.ts web/src/test/setup.ts \ + web/src/issues/IssuePage.test.tsx +git commit -m "feat(web): данные карточки — комментарии, группы связей, просрочка, состояние окна (#37) + +Co-Authored-By: Claude Opus 5.5 " +``` + +### Task 4: Карточка окном поверх страницы + +**Модель исполнителя:** sonnet (интеграция с маршрутизатором). + +**Files:** +- Create: `web/src/shell/pages.tsx` (`PAGE_ROUTES`) +- Modify: `web/src/routes.tsx` +- Modify: `web/src/shell/Layout.tsx` +- Create: `web/src/issues/IssueModal.tsx`, `web/src/issues/IssueLink.tsx` +- Rename: `web/src/issues/IssuePage.tsx` → `web/src/issues/IssueCard.tsx`; `web/src/issues/IssuePage.test.tsx` → `web/src/issues/IssueCard.test.tsx` +- Modify: `web/src/issues/IssuesPage.tsx:66` (ключ — `IssueLink`), `web/src/board/components/Card.tsx:34-35` (ⓘ — `BOARD_CARD_STATE`) +- Modify: `web/src/issues/issues.css` (окно) +- Test: `web/src/issues/IssueModal.test.tsx`, `web/src/App.test.tsx`, `web/src/board/components/BoardCanvas.test.tsx:53` (если проверяет только `href` — без изменений) + +**Interfaces:** +- Consumes: Task 3 — `cardState`, `openCardState`, `LIST_BACKGROUND`, `BOARD_CARD_STATE`, `issue()`/`note()`. +- Produces: `IssueCard({ issueKey, onClose }: { issueKey: string; onClose: () => void })` (Task 5 переписывает его + содержимое, сигнатура та же); `IssueModal({ issueKey, onClose })`; `IssueLink({ issueKey, className?, children? })`; + `PAGE_ROUTES: RouteObject[]`; заголовок карточки — `

`. + +- [ ] **Step 1: Перенос страницы карточки в компонент окна** + +```bash +git mv web/src/issues/IssuePage.tsx web/src/issues/IssueCard.tsx +git mv web/src/issues/IssuePage.test.tsx web/src/issues/IssueCard.test.tsx +``` + +В `web/src/issues/IssueCard.tsx`: +- заменить `import { Link, useParams } from "react-router";` на `import { IssueLink } from "./IssueLink.tsx";`; +- заменить `export function IssuePage() {` и строку `const { key = "" } = useParams();` на + `export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClose: () => void }) {`; +- `data-testid="page-issue"` везде заменить на `data-testid="issue-card"`; +- ветку 404 заменить на + `return

Задачи {key} нет в зеркале хаба.

;`; +- строку `

← задачи

` заменить на + `

`; +- `

{i.key} · {i.summary}

` заменить на `

{i.key} · {i.summary}

`; +- в связях `{l.target_key}` → ``, + `{l.source_key}` → ``. + +В `web/src/issues/NoteForm.tsx` у блока подтверждения ухода `
` заменить +`role="dialog"` на `role="alertdialog"`: внутри окна `` второй `role="dialog"` делает поиск окна +(`getByRole("dialog")`) неоднозначным. + +Создать `web/src/issues/IssueLink.tsx`: + +```tsx +import type { ReactNode } from "react"; +import { Link, useLocation } from "react-router"; +import { openCardState } from "./modal.ts"; + +// ссылка на карточку задачи: открывает окно поверх текущей страницы (или меняет содержимое открытого окна) +export function IssueLink({ issueKey, className, children }: { issueKey: string; className?: string; children?: ReactNode }) { + const loc = useLocation(); + return {children ?? issueKey}; +} +``` + +Создать `web/src/issues/IssueModal.tsx`: + +```tsx +import { useEffect, useRef } from "react"; +import { ErrorBoundary } from "../shell/ErrorBoundary.tsx"; +import { IssueCard } from "./IssueCard.tsx"; + +// окно карточки (design 13): держит фокус и делает фон недоступным; Esc и щелчок по фону — onClose. +// Сами окно не закрываем: оно исчезает вместе с адресом карточки. Если браузер закрыл его сам (Esc без активации +// пользователя — cancel не отменяемый), открываем снова и просим закрытия по общему пути (блокировщик заметок). +export function IssueModal({ issueKey, onClose }: { issueKey: string; onClose: () => void }) { + const ref = useRef(null); + useEffect(() => { + const d = ref.current; + if (d && !d.open) d.showModal(); + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; // страница под окном не прокручивается + return () => { document.body.style.overflow = prev; }; + }, []); + return ( + { if (e.cancelable) { e.preventDefault(); onClose(); } }} + onClose={() => { const d = ref.current; if (d?.isConnected) { d.showModal(); onClose(); } }} + onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}> + + + ); +} +``` + +- [ ] **Step 2: Маршруты и оболочка** + +Создать `web/src/shell/pages.tsx`: + +```tsx +import { Navigate, type RouteObject } from "react-router"; +import { BoardPage } from "../board/BoardPage.tsx"; +import { IssuesPage } from "../issues/IssuesPage.tsx"; +import { NotFound } from "./NotFound.tsx"; + +// страницы приложения; Layout рисует их по адресу страницы под окном карточки (design 10) +export const PAGE_ROUTES: RouteObject[] = [ + { path: "/", element: }, + { path: "/board", element: }, + { path: "/issues", element: }, + { path: "*", element: }, +]; +``` + +Заменить `web/src/routes.tsx` целиком: + +```tsx +import type { RouteObject } from "react-router"; +import { Layout } from "./shell/Layout.tsx"; + +// адреса относительно basename "/app": страницы и окно карточки рисует Layout (shell/pages.tsx, design 10) +export const routes: RouteObject[] = [{ path: "*", element: }]; +``` + +Заменить `web/src/shell/Layout.tsx` целиком: + +```tsx +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { Link, matchPath, useLocation, useNavigate, useRoutes } from "react-router"; +import { clearToken } from "../auth/token.ts"; +import { IssueModal } from "../issues/IssueModal.tsx"; +import { cardState, LIST_BACKGROUND } from "../issues/modal.ts"; +import { ErrorBoundary } from "./ErrorBoundary.tsx"; +import { PAGE_ROUTES } from "./pages.tsx"; +import "./shell.css"; + +export function Layout() { + const qc = useQueryClient(); + const location = useLocation(); + const navigate = useNavigate(); + const issueKey = matchPath("/issues/:key", location.pathname)?.params.key ?? null; + const card = issueKey ? cardState(location.state) : null; + const bgPath = issueKey ? (card?.background ?? LIST_BACKGROUND).pathname : null; + const bgSearch = issueKey ? (card?.background ?? LIST_BACKGROUND).search : ""; + // адрес страницы под окном; передаётся в useRoutes всегда (и без окна), чтобы дерево страницы не менялось при + // открытии окна и useSearchParams внутри неё видел её собственный адрес (design 10) + const bgLocation = useMemo(() => bgPath === null ? null : { pathname: bgPath, search: bgSearch, hash: "", state: null, key: "card-background" }, + [bgPath, bgSearch]); + const pageLocation = bgLocation ?? location; + const page = useRoutes(PAGE_ROUTES, pageLocation); + + // прямой адрес карточки: запись истории получает state окна поверх списка задач (design 11) + useEffect(() => { + if (issueKey && !card) { + void navigate(location.pathname + location.search, { replace: true, state: { background: LIST_BACKGROUND, depth: 0, direct: true } }); + } + }, [issueKey, card, location.pathname, location.search, navigate]); + + // закрытие (design 12): назад на depth записей; запись прямого адреса, на которую вернулись, заменяется списком + const closingDirect = useRef(false); + useEffect(() => { + if (closingDirect.current && card?.direct && card.depth === 0) { + closingDirect.current = false; + void navigate("/issues", { replace: true }); + } + }, [card, navigate]); + const close = useCallback(() => { + if (!card || card.depth === 0) { void navigate("/issues", { replace: true }); return; } + closingDirect.current = card.direct; + void navigate(-card.depth); + }, [card, navigate]); + + const at = pageLocation.pathname; + const tab = (to: string, label: string) => { + const active = at === to || at.startsWith(to + "/"); + return {label}; + }; + return ( + <> +
+ Хаб Jira + + + +
+
+ {page} +
+ {issueKey && } + + ); +} +``` + +В `web/src/issues/IssuesPage.tsx`: импорт `Link` из `react-router` убрать (оставить `useSearchParams`), добавить +`import { IssueLink } from "./IssueLink.tsx";`, в строке таблицы `{i.key}` +заменить на ``. + +В `web/src/board/components/Card.tsx` добавить импорт `import { BOARD_CARD_STATE } from "../../issues/modal.ts";` и у +ссылки «ⓘ» добавить атрибут `state={BOARD_CARD_STATE}` (после `to={`/issues/${n.key}`}`). + +В `web/src/issues/issues.css` добавить в конец: + +```css +/* окно карточки задачи (design 13) */ +.issue-modal{width:min(1000px,calc(100vw - 32px));max-width:none;max-height:90vh;padding:0;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--fg);overflow:auto} +.issue-modal::backdrop{background:rgb(0 0 0 / .45)} +.issue-modal > .issue{padding:0 20px 20px} +@media (max-width:700px){.issue-modal{width:100vw;max-width:100vw;height:100vh;max-height:100vh;margin:0;border-radius:0}} +``` + +- [ ] **Step 3: Падающие тесты окна** + +Создать `web/src/issues/IssueModal.test.tsx`: + +```tsx +import { act, fireEvent, 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 board from "../board/__fixtures__/hub-basic.board.json"; +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"; + +const rows = board.issues.map((i) => ({ ...i, id: i.key, description: null, reporter: null, created: null, jira_updated: null, fetched_at: "" })); + +function hub() { + return mockFetch((_m, url) => { + const p = url.pathname; + if (p === "/settings") return json(200, settings); + if (p === "/board") return json(200, board); + if (p === "/issues") return json(200, { issues: rows }); + if (p.startsWith("/issues/")) return json(200, issue(decodeURIComponent(p.slice("/issues/".length)))); + if (p === "/batches") return json(200, { batches: [] }); + return json(200, {}); + }); +} +const at = (entries: string[], index = entries.length - 1) => + createMemoryRouter(routes, { initialEntries: entries, initialIndex: index, basename: "/app" }); +const dialog = () => screen.getByRole("dialog"); + +describe("карточка окном", () => { + beforeEach(() => { setToken("tok"); hub(); }); + + it("из списка: окно поверх списка, фон не пересоздаётся, ✕ возвращает со старым фильтром, «назад» не открывает карточку", async () => { + const router = at(["/app/board", "/app/issues?sprint=AWPCA_Спринт 2"]); + render(); + const table = await screen.findByRole("table"); + expect(screen.getByLabelText("Спринт")).toHaveValue("AWPCA_Спринт 2"); + await userEvent.click(await within(table).findByRole("link", { name: "AURORA-9002" })); + expect(router.state.location.pathname).toBe("/app/issues/AURORA-9002"); + expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); + expect(screen.getByRole("table")).toBe(table); // фон не пересоздаётся + expect(screen.getByLabelText("Спринт")).toHaveValue("AWPCA_Спринт 2"); // фильтр фона — из его адреса + expect(screen.getByRole("link", { name: "Задачи", exact: true })).toHaveClass("active"); + await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(router.state.location.pathname).toBe("/app/issues"); + expect(new URLSearchParams(router.state.location.search).get("sprint")).toBe("AWPCA_Спринт 2"); + expect(screen.getByRole("table")).toBe(table); + await act(async () => { await router.navigate(-1); }); + expect(router.state.location.pathname).toBe("/app/board"); + }); + + it("переход по связи меняет окно, «назад» возвращает прежнюю карточку, Esc закрывает всё", async () => { + const router = at(["/app/issues"]); + render(); + await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" })); + await userEvent.click(await within(dialog()).findByRole("link", { name: "AURORA-9002" })); + expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); + await act(async () => { await router.navigate(-1); }); + expect(await within(dialog()).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument(); + await userEvent.click(within(dialog()).getByRole("link", { name: "AURORA-9002" })); + await within(dialog()).findByRole("heading", { name: /AURORA-9002/ }); + fireEvent(dialog(), new Event("cancel", { cancelable: true })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(router.state.location.pathname).toBe("/app/issues"); + }); + + it("щелчок по фону окна закрывает его", async () => { + const router = at(["/app/issues"]); + render(); + await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" })); + await within(dialog()).findByRole("heading", { name: /AURORA-9001/ }); + await userEvent.click(dialog()); // цель — сам , не содержимое + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(router.state.location.pathname).toBe("/app/issues"); + }); + + it("с доски: окно поверх доски, вкладка «Доска» активна, после закрытия доска та же", async () => { + const router = at(["/app/board"]); + 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: "Доска", exact: true })).toHaveClass("active"); + await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); + await waitFor(() => expect(router.state.location.pathname).toBe("/app/board")); + expect(screen.getByTestId("page-board")).toBe(boardEl); + }); + + it("прямой адрес: под окном список задач, ✕ ведёт в список заменой записи", async () => { + const router = at(["/app/board", "/app/issues/AURORA-9001"]); + render(); + expect(await within(await screen.findByRole("dialog")).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument(); + expect(await screen.findByTestId("page-issues")).toBeInTheDocument(); + await waitFor(() => expect(router.state.location.state).toMatchObject({ depth: 0, direct: true })); + await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); + await waitFor(() => expect(router.state.location.pathname).toBe("/app/issues")); + await act(async () => { await router.navigate(-1); }); + expect(router.state.location.pathname).toBe("/app/board"); // не карточка + }); + + it("прямой адрес и переход по связи: закрытие возвращает в список, а не на первую карточку", async () => { + const router = at(["/app/issues/AURORA-9001"]); + render(); + await userEvent.click(await within(await screen.findByRole("dialog")).findByRole("link", { name: "AURORA-9002" })); + await within(dialog()).findByRole("heading", { name: /AURORA-9002/ }); + fireEvent(dialog(), new Event("cancel", { cancelable: true })); + await waitFor(() => expect(router.state.location.pathname).toBe("/app/issues")); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + }); + + it("Esc с несохранённой заметкой спрашивает; «Остаться» оставляет окно с текстом", async () => { + const router = at(["/app/issues"]); + render(); + await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" })); + const box = await within(dialog()).findByLabelText("Заметка владельца"); + await userEvent.type(box, "черновик"); + fireEvent(dialog(), new Event("cancel", { cancelable: true })); + expect(await within(dialog()).findByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeInTheDocument(); + await userEvent.click(within(dialog()).getByRole("button", { name: "Остаться" })); + expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001"); + expect(within(dialog()).getByLabelText("Заметка владельца")).toHaveValue("черновик"); + }); +}); +``` + +В `web/src/issues/IssueCard.test.tsx`: +- импорт `import { IssuePage } from "./IssuePage.tsx";` заменить на `import { IssueCard } from "./IssueCard.tsx";` и + добавить `useNavigate, useParams` в импорт из `react-router`; +- в `open()` маршрут `{ path: "/issues/:key", element: }` заменить на + `{ path: "/issues/:key", element: }` и перед `open()` добавить: + +```tsx +function CardAt() { + const { key = "" } = useParams(); + const navigate = useNavigate(); + return void navigate("/board")} />; +} +``` + +- тест «заголовок страницы — h1 (бренд в оболочке — span)» заменить на: + +```tsx + it("заголовок карточки — h2 с id для aria-labelledby окна", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); + open("/issues/AURORA-9001"); + expect(await screen.findByRole("heading", { level: 2, name: /AURORA-9001/ })).toHaveAttribute("id", "issue-modal-title"); + }); +``` + +- тест «задачи нет в зеркале — сообщение и ссылка на список» заменить на: + +```tsx + it("задачи нет в зеркале — сообщение и «Закрыть»", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(404, { error: "not_found", detail: "issue" })); + const router = open("/issues/AURORA-1"); + expect(await screen.findByText("Задачи AURORA-1 нет в зеркале хаба.")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Закрыть" })); + expect(router.state.location.pathname).toBe("/board"); + }); +``` + +- тест «O-10: ссылка в ветке «задачи нет в зеркале» стоит внутри .issue (цвет темы)» удалить (ссылки в ветке 404 больше + нет; цвет ссылок `.issue a` проверяет Task 5). + +В `web/src/App.test.tsx` в тесте «неизвестный путь — «не найдено» со ссылками» ожидания не меняются (NotFound — в +`PAGE_ROUTES`); если тест обращается к `Layout` напрямую — оставить импорт (`Layout` экспортируется по-прежнему). + +Run: `cd web && npx vitest run src/issues/IssueModal.test.tsx src/issues/IssueCard.test.tsx src/App.test.tsx` +Expected до Step 2 — FAIL (нет `IssueModal`, `routes` без окна); после Step 1–2 — проверить следующим шагом. + +- [ ] **Step 4: Тесты проходят, весь набор зелёный** + +Run: `cd web && npx vitest run src/issues/IssueModal.test.tsx src/issues/IssueCard.test.tsx src/App.test.tsx` +Expected: PASS. + +Run: `cd web && npm test && npm run lint && npx tsc -b` +Expected: всё зелёное. `BoardCanvas.test.tsx` проверяет `href="/issues/AURORA-9001"` у «ⓘ» — `state` на `href` не влияет. + +Если `useRoutes` с явным адресом пересоздаёт страницу при открытии окна (тест «фон не пересоздаётся» падает на +`toBe(table)`), это нарушение design 10: не обходить ключами `key`, а остановиться и описать наблюдение в отчёте. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/routes.tsx web/src/shell/Layout.tsx web/src/shell/pages.tsx web/src/issues web/src/board/components/Card.tsx \ + web/src/App.test.tsx +git commit -m "feat(web): карточка задачи окном поверх списка и доски (#37) + +Co-Authored-By: Claude Opus 5.5 " +``` + +### Task 5: Раскладка карточки и заметки в режиме чтения + +**Модель исполнителя:** sonnet (вёрстка и перенос тестов). + +**Files:** +- Modify: `web/src/issues/IssueCard.tsx` (полностью), `web/src/issues/NoteForm.tsx` (`onDone`, «Отмена») +- Create: `web/src/issues/Notes.tsx` +- Modify: `web/src/issues/issues.css` (раскладка карточки) +- Test: `web/src/issues/IssueCard.test.tsx`, `web/src/issues/IssueModal.test.tsx` + +**Interfaces:** +- Consumes: Task 3 — `groupLinks`, `factClass`, `isOverdue`, `todayISO`, `useCommentsHidden`, `IssueComment`; Task 4 — + `IssueCard({ issueKey, onClose })`, `IssueLink`, `

`. +- Produces: `Notes({ issue }: { issue: IssueDetail })`; `NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () => void })`. + +- [ ] **Step 1: Падающие тесты раскладки** + +В `web/src/issues/IssueCard.test.tsx`: + +1) Тест «поля, описание с переносами, связи с подписями, команды» заменить на: + +```tsx + it("шапка: ключ, статусы, тема, исполнитель · срок · спринт · эпик по имени; ссылка в Jira", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { planned_end: "2020-01-10" }))); + open("/issues/AURORA-9001"); + expect(await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9001" })).toBeInTheDocument(); + const head = screen.getByTestId("issue-head"); + expect(within(head).getByText("AURORA-9001")).toBeInTheDocument(); + expect(within(head).getByText("В работе", { selector: ".badge.status" })).toBeInTheDocument(); + expect(within(head).getByText("В работе", { selector: ".badge.fact" })).toHaveClass("fact-in-progress"); + expect(within(head).getByTestId("issue-facts")).toHaveTextContent("Иванов Иван Иванович · срок 10.01 просрочено · AWPCA_Спринт 2 · эпик: Тестовый эпик"); + expect(within(head).getByText("просрочено")).toHaveClass("overdue"); + expect(screen.getByRole("link", { name: "Открыть в Jira ↗" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001"); + }); + + it("связи по группам с темой и статусом; задача вне зеркала; прочий тип с направлением", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { + links_out: [{ target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала", summary: "API свода", status: "Новая" }, + { target_key: "lower-1", link_type: "ASUPR_Связанные", summary: null, status: null }], + links_in: [{ source_key: "AURORA-9003", link_type: "ASUPR_Блокер_Завершения", summary: "Макет экрана", status: "Готово" }, + { source_key: "AURORA-9008", link_type: "Duplicate", summary: "Актуализация ТЗ", status: "В работе" }], + }))); + open("/issues/AURORA-9001"); + const depends = await screen.findByRole("group", { name: "Зависит от" }); + expect(within(depends).getByRole("listitem")).toHaveTextContent("завершение AURORA-9003 Макет экрана Готово"); + const blocks = screen.getByRole("group", { name: "Блокирует" }); + expect(within(blocks).getByRole("link", { name: "AURORA-9002" })).toHaveAttribute("href", "/issues/AURORA-9002"); + expect(within(blocks).getByRole("listitem")).toHaveTextContent("начало AURORA-9002 API свода Новая"); + expect(within(screen.getByRole("group", { name: "Связана с" })).getByRole("listitem")).toHaveTextContent("lower-1 нет в зеркале"); + expect(within(screen.getByRole("group", { name: "Прочие" })).getByRole("listitem")).toHaveTextContent("← Duplicate AURORA-9008"); + expect(screen.getByRole("link", { name: "AURORA-9002" }).closest(".issue")).not.toBeNull(); // O-10: цвет темы + }); + + it("без связей — «связей нет»", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { links_out: [], links_in: [] }))); + open("/issues/AURORA-9001"); + expect(await screen.findByText("связей нет")).toBeInTheDocument(); + }); + + it("описание свыше 8 строк свёрнуто, «развернуть»/«свернуть»; короткое — без кнопки", async () => { + const long = Array.from({ length: 10 }, (_, n) => `строка ${n + 1}`).join("\n"); + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: long }))); + open("/issues/AURORA-9001"); + const pre = await screen.findByText(long, { normalizer: (s) => s }); + expect(pre).toHaveClass("description", "clamped"); + await userEvent.click(screen.getByRole("button", { name: "развернуть" })); + expect(pre).not.toHaveClass("clamped"); + await userEvent.click(screen.getByRole("button", { name: "свернуть" })); + expect(pre).toHaveClass("clamped"); + }); + + it("короткое описание — без кнопки; нет описания — нет раздела", async () => { + let d: string | null = "строка 1\nстрока 2"; + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: d }))); + open("/issues/AURORA-9001"); + expect(await screen.findByText("строка 1\nстрока 2", { normalizer: (s) => s })).toHaveClass("description"); + expect(screen.queryByRole("button", { name: "развернуть" })).toBeNull(); + d = null; + cleanup(); + open("/issues/AURORA-9001"); + await screen.findByRole("heading", { level: 2 }); + expect(screen.queryByRole("heading", { name: "Описание" })).toBeNull(); + }); + + it("комментарии новые сверху; «Скрыть» запоминается для следующей карточки", async () => { + const comments = [ + { comment_id: "2", author: "Петров Пётр", created: "2026-10-15T12:00:00.000+0300", updated: null, body: "новый\nвторая строка" }, + { comment_id: "1", author: "Иванов Иван", created: "2026-10-14T12:00:00.000+0300", updated: null, body: "старый" }]; + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { comments }))); + open("/issues/AURORA-9001"); + expect(await screen.findByRole("heading", { name: "Комментарии (2)" })).toBeInTheDocument(); + const items = within(screen.getByTestId("comments")).getAllByRole("listitem"); + expect(items[0]).toHaveTextContent("Петров Пётр"); + expect(within(items[0]).getByText("новый\nвторая строка", { normalizer: (s) => s })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Скрыть" })); + expect(screen.queryByText("старый")).toBeNull(); + expect(localStorage.getItem("jira-hub-comments-hidden")).toBe("1"); + cleanup(); + open("/issues/AURORA-9001"); + expect(await screen.findByRole("button", { name: "Показать" })).toBeInTheDocument(); + expect(screen.queryByText("старый")).toBeNull(); + }); + + it("комментарий без автора и текста не ломает карточку; без комментариев — «комментариев нет»", async () => { + let comments = [{ comment_id: "1", author: null, created: null, updated: null, body: "" }]; + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { comments }))); + open("/issues/AURORA-9001"); + expect(await screen.findByRole("heading", { name: "Комментарии (1)" })).toBeInTheDocument(); + expect(within(screen.getByTestId("comments")).getByRole("listitem")).toHaveTextContent("—"); + comments = []; + cleanup(); + open("/issues/AURORA-9001"); + expect(await screen.findByText("комментариев нет")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Скрыть" })).toBeNull(); + }); + + it("прочие поля и команды моста свёрнуты", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); + open("/issues/AURORA-9001"); + const other = await screen.findByRole("region", { name: "Прочие поля" }); + expect(other).toHaveTextContent("Приоритет"); + expect(other).toHaveTextContent("Высокий"); + const details = screen.getByText("Команды моста (1)").closest("details")!; + expect(details).not.toHaveAttribute("open"); + expect(within(details).getByRole("cell", { name: "ensure_fields" })).toBeInTheDocument(); + }); +``` + +(в импорт из `@testing-library/react` добавить `cleanup` и `within`). + +2) Тест «O-11: задача без команд — текст вместо пустой таблицы» оставить, в нём ничего не менять. + +3) Тесты заметок — перед первым обращением к полю формы добавить щелчок «Править»: +`await userEvent.click(await screen.findByRole("button", { name: "Править" }));` — в тестах «сохраняются только +изменённые поля…», «422 — текст ошибки…», «уход с несохранёнными правками…», «ответ «Сохранить» после ухода…», +«T14.1…», «T14.3…». Дальше `findByLabelText(...)` остаётся. Ожидания после успешного сохранения в тесте «сохраняются +только изменённые поля» заменить на: + +```tsx + await waitFor(() => expect(put).toEqual({ rest_text: "тесты", done_text: null, updated_by: "web" })); + expect(await screen.findByText(/изменено .*web/)).toBeInTheDocument(); // снова режим чтения + expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Сохранить" })).toBeNull(); +``` + +В тесте «ответ «Сохранить» после ухода в другую карточку…» после перехода на AURORA-9002 последние три ожидания заменить +на: + +```tsx + expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument(); // новая карточка — в режиме чтения + expect(screen.queryByText("черновик")).toBeNull(); +``` + +В тесте «T14.1…» после `slowPut.resolve(...)` ожидания заменить на: + +```tsx + expect(await screen.findByText("раз")).toBeInTheDocument(); // режим чтения с сохранённым +``` + +4) В `web/src/issues/IssueModal.test.tsx`, тест «Esc с несохранённой заметкой спрашивает…», перед строкой +`const box = await within(dialog()).findByLabelText("Заметка владельца");` добавить +`await userEvent.click(await within(dialog()).findByRole("button", { name: "Править" }));`. + +5) Добавить тесты режима чтения и «Отмены»: + +```tsx + it("заметки в режиме чтения: пустые — «—», время и автор; «Отмена» без правок — сразу", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); + open("/issues/AURORA-9001"); + const view = await screen.findByRole("region", { name: "Служебные заметки" }); + expect(view).toHaveTextContent("Сделано"); + expect(view).toHaveTextContent("схема"); + expect(within(view).getByText("Заметка владельца").nextElementSibling).toHaveTextContent("—"); + expect(view).toHaveTextContent(/изменено .*chat/); + await userEvent.click(within(view).getByRole("button", { name: "Править" })); + await userEvent.click(screen.getByRole("button", { name: "Отмена" })); + expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument(); + }); + + it("«Отмена» с правками спрашивает; «Отменить правки» возвращает чтение без изменений", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); + open("/issues/AURORA-9001"); + await userEvent.click(await screen.findByRole("button", { name: "Править" })); + await userEvent.type(screen.getByLabelText("Заметка владельца"), "черновик"); + await userEvent.click(screen.getByRole("button", { name: "Отмена" })); + expect(screen.getByText("Отменить правки заметки?")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Продолжить правку" })); + expect(screen.getByLabelText("Заметка владельца")).toHaveValue("черновик"); + await userEvent.click(screen.getByRole("button", { name: "Отмена" })); + await userEvent.click(screen.getByRole("button", { name: "Отменить правки" })); + expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument(); + expect(screen.queryByText("черновик")).toBeNull(); + }); + + it("заметки ещё нет — режим чтения с «—» и «Править»", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { note: null }))); + open("/issues/AURORA-9001"); + const view = await screen.findByRole("region", { name: "Служебные заметки" }); + expect(within(view).getAllByText("—")).toHaveLength(5); + expect(within(view).getByRole("button", { name: "Править" })).toBeInTheDocument(); + }); +``` + +Run: `cd web && npx vitest run src/issues/IssueCard.test.tsx` +Expected: FAIL — нет `issue-head`, групп связей, раздела «Комментарии», кнопки «Править». + +- [ ] **Step 2: Заметки: чтение и правка** + +Создать `web/src/issues/Notes.tsx`: + +```tsx +import { useState } from "react"; +import type { IssueDetail, NoteField } from "../api/types.ts"; +import { fmtStamp } from "../board/model/util.ts"; +import { NoteForm } from "./NoteForm.tsx"; + +const ROWS: [NoteField, string][] = [["work_kind", "Вид работы"], ["fact_status", "Статус по факту"], ["done_text", "Сделано"], + ["rest_text", "Осталось"], ["owner_note", "Заметка владельца"]]; + +// служебные заметки (design 20): по умолчанию чтение, «Править» — форма; после сохранения или отмены — снова чтение +export function Notes({ issue }: { issue: IssueDetail }) { + const [editing, setEditing] = useState(false); + if (editing) return setEditing(false)} />; + const n = issue.note; + return ( +
+
+

Служебные заметки

+ +
+
+ {ROWS.map(([f, label]) =>
{label}
{n?.[f] || "—"}
)} +
+ {n &&

изменено {fmtStamp(n.updated_at)}, {n.updated_by}

} +
+ ); +} +``` + +В `web/src/issues/NoteForm.tsx`: +- в импорт из `react` добавить `useRef`; +- сигнатуру заменить на `export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () => void }) {`; +- после `const save = useSaveNote(issue.key);` добавить + `const [askCancel, setAskCancel] = useState(false);` и `const confirmRef = useRef(null);`; +- после эффекта `beforeunload` добавить: + +```tsx + // подтверждение ухода может оказаться ниже видимой части окна — прокрутить к нему + useEffect(() => { if (blocker.state === "blocked") confirmRef.current?.scrollIntoView?.({ block: "nearest" }); }, [blocker.state]); +``` + +- `submit` заменить на: + +```tsx + const submit = () => save.mutate(changed, { + onSuccess: (note) => { const v = valuesOf(note); setInitial(v); setValues(v); onDone(); }, + }); +``` + +- строку `const saved = save.data ?? issue.note;` удалить; +- `
` заменить на ``, а + `

Служебные заметки

` — на `

Служебные заметки

`; +- блок `
…
` (кнопка «Сохранить» и строка «сохранено …») заменить на: + +```tsx +
+ + +
+ {askCancel && ( +
+ Отменить правки заметки? + + +
+ )} +``` + +- у блока подтверждения ухода `
` (роль поменял Task 4) добавить + `ref={confirmRef}`. + +- [ ] **Step 3: Карточка** + +Заменить `web/src/issues/IssueCard.tsx` целиком: + +```tsx +import { useLayoutEffect, useRef, useState } from "react"; +import { ApiError } from "../api/client.ts"; +import { useIssue, useSettings } from "../api/queries.ts"; +import type { IssueComment, IssueDetail } from "../api/types.ts"; +import { JIRA_URL } from "../board/constants.ts"; +import { fmtDate, fmtStamp } from "../board/model/util.ts"; +import { factClass, groupLinks, isOverdue, todayISO } from "./card.ts"; +import { useCommentsHidden } from "./commentsPref.ts"; +import { IssueLink } from "./IssueLink.tsx"; +import { Notes } from "./Notes.tsx"; +import { dueOf } from "./options.ts"; +import "./issues.css"; + +const DESC_LINES = 8; + +export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClose: () => void }) { + const issue = useIssue(key); + const settings = useSettings(); + const close = ; + if (issue.isPending) return

загрузка {key}…

{close}
; + if (issue.error instanceof ApiError && issue.error.status === 404) { + return

Задачи {key} нет в зеркале хаба.

; + } + if (issue.isError && !issue.data) { + return ( +
+

{issue.error.message}{" "} +

+ +
+ ); + } + const i = issue.data!; + const labels = settings.data?.status_labels as Record | undefined; + const fact = i.note?.fact_status ?? null; + const cls = factClass(fact, labels); + const due = dueOf(i); + const facts = [ + i.assignee && {i.assignee}, + due && срок {fmtDate(due)}{isOverdue(due, fact, labels, todayISO()) && <> просрочено}, + i.sprint && {i.sprint}, + i.epic_key && эпик: {i.epic_summary ?? i.epic_key}, + ].filter(Boolean); + return ( +
+
+
+ {i.key} + {i.status && {i.status}} + {fact && {fact}} + + Открыть в Jira ↗ + {close} +
+

{i.summary || i.key}

+ {facts.length > 0 &&

{facts.flatMap((f, n) => (n ? [" · ", f] : [f]))}

} + {issue.isError && ( +

{issue.error.message}{" "} +

+ )} +
+
+
+ } /> + {i.description && } + +
+
+ + + +
+
+
+ ); +} + +function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record }) { + const groups = groupLinks(issue, linkTypes); + return ( +
+

Связи

+ {!groups.length &&

связей нет

} + {groups.map(([id, title, links]) => ( +
+ +
    + {links.map((l) => ( +
  • + {l.tag && <>{l.tag}{" "}} + {" "} + {l.summary === null && l.status === null + ? нет в зеркале + : <>{l.summary}{l.status && <>{" "}{l.status}}} +
  • + ))} +
+
+ ))} +
+ ); +} + +function Description({ text }: { text: string }) { + const ref = useRef(null); + const [open, setOpen] = useState(false); + const [overflow, setOverflow] = useState(false); + useLayoutEffect(() => { + const el = ref.current; + if (el && !open) setOverflow(el.scrollHeight > el.clientHeight + 1); + }, [text, open]); + const long = text.split("\n").length > DESC_LINES || overflow; + return ( +
+

Описание

+
{text}
+ {(long || open) && } +
+ ); +} + +function Comments({ items }: { items: IssueComment[] }) { + const [hidden, setHidden] = useCommentsHidden(); + return ( +
+
+

Комментарии ({items.length})

+ {items.length > 0 && } +
+ {!items.length &&

комментариев нет

} + {items.length > 0 && !hidden && ( +
    + {items.map((c) => ( +
  • +
    {c.author ?? "—"}{c.created ? `, ${fmtStamp(c.created)}` : ""}
    +
    {c.body}
    +
  • + ))} +
+ )} +
+ ); +} + +function OtherFields({ issue: i }: { issue: IssueDetail }) { + const rows: [string, string | null][] = [ + ["Тип", i.issue_type], ["Приоритет", i.priority], ["Автор", i.reporter], ["Метки", i.labels.join(", ") || null], + ["Плановое начало", i.planned_start], ["Обновлена в Jira", i.jira_updated ? fmtStamp(i.jira_updated) : null], + ["Сосед по связям", i.neighbor ? "да" : null], + ]; + return ( +
+

Прочие поля

+
{rows.filter(([, v]) => v).map(([k, v]) =>
{k}
{v}
)}
+
+ ); +} + +function Commands({ issue: i }: { issue: IssueDetail }) { + if (!i.commands.length) { + return

Команды моста

команд по задаче не было

; + } + return ( +
+
+ Команды моста ({i.commands.length}) + + + {i.commands.map((c) => ( + + + ))} +
КомандаПакетТипСтатусЗавершенаОшибка
#{c.id}#{c.batch_id}{c.type}{c.status}{c.finished_at ? fmtStamp(c.finished_at) : ""}{c.error}
+
+
+ ); +} +``` + +Тест O-11 ищет `screen.queryByRole("columnheader", { name: "Команда" })` = `null` — при пустых командах таблицы нет. + +- [ ] **Step 4: Стили карточки** + +В `web/src/issues/issues.css` заменить строки от `.issue a{color:var(--accent)}` до +`.issue .links{padding-left:18px}` включительно на: + +```css +.issue a{color:var(--accent)} +.issue-head{position:sticky;top:0;z-index:1;background:var(--bg);padding:14px 0 10px;border-bottom:1px solid var(--line);margin-bottom:14px} +.issue-head .row1{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.issue-head .key{font-family:var(--font-mono);font-size:13px;color:var(--muted)} +.issue-head .spacer{flex:1 1 auto} +.issue-head h2{margin:6px 0 4px;font-size:20px;line-height:1.3} +.issue-head .facts{margin:0;color:var(--muted);font-size:14px} +.issue .overdue{color:var(--p-crit);font-weight:600} +.issue .close{font-size:18px;line-height:1;background:none;border:0;color:var(--muted);cursor:pointer;padding:4px 6px} +.badge{display:inline-block;font-size:12px;line-height:18px;padding:0 8px;border-radius:9px;background:var(--soft);color:var(--fg);white-space:nowrap} +.badge.fact{background:transparent;border:1px solid var(--accent);color:var(--accent)} +.badge.fact.fact-done{border-color:var(--k-test);color:var(--k-test)} +.badge.fact.fact-cancelled{border-color:var(--muted);color:var(--muted);text-decoration:line-through} +.issue .cols{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:24px;align-items:start} +.issue .side{display:flex;flex-direction:column;gap:16px;min-width:0} +.issue .main{min-width:0} +@media (max-width:900px){ + .issue .cols{display:flex;flex-direction:column;gap:16px} + .issue .side{display:contents} + .issue .side .note{order:-1} + .issue .side .other{order:1} + .issue .side .commands{order:2} +} +.issue section{margin-bottom:18px} +.issue h3{font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin:0 0 8px} +.issue .sec-head{display:flex;align-items:center;justify-content:space-between;gap:8px} +.issue .group{margin:0 0 10px} +.issue .group h4{font-size:13px;font-weight:600;margin:0 0 4px} +.issue .links{list-style:none;margin:0;padding:0} +.issue .links li{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px;padding:3px 0} +.issue .links .tag{font-size:12px;color:var(--muted);min-width:76px} +.issue .links .sum{flex:1 1 200px;min-width:0} +.issue .links .absent{color:var(--muted);font-style:italic} +.issue .description{white-space:pre-wrap;font:inherit;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:10px;margin:0 0 4px} +.issue .description.clamped{display:-webkit-box;-webkit-line-clamp:8;-webkit-box-orient:vertical;overflow:hidden} +.issue .comments ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px} +.issue .comments .who{font-size:12px;color:var(--muted);margin-bottom:2px} +.issue .comments .body{white-space:pre-wrap;font:inherit;margin:0} +.issue .fields{display:grid;grid-template-columns:1fr;gap:6px;margin:0} +.issue .fields dt{font-size:12px;color:var(--muted)} +.issue .fields dd{margin:0;white-space:pre-wrap} +.issue .commands summary{cursor:pointer;font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em} +.issue .commands table{margin-top:8px;font-size:13px} +``` + +- [ ] **Step 5: Тесты проходят, весь набор зелёный** + +Run: `cd web && npx vitest run src/issues` +Expected: PASS (`IssueCard.test.tsx`, `IssueModal.test.tsx`, `card.test.ts`, `modal.test.ts`, `IssuesPage.test.tsx`). + +Run: `cd web && npm test && npm run lint && npx tsc -b` +Expected: всё зелёное. + +- [ ] **Step 6: Commit** + +```bash +git add web/src/issues +git commit -m "feat(web): раскладка карточки — шапка состояния, связи по смыслу, комментарии, заметки в режиме чтения (#37) + +Co-Authored-By: Claude Opus 5.5 " +``` + +### Task 6: Тестовый хаб, e2e и документация + +**Модель исполнителя:** sonnet (e2e и проверка в браузере). + +**Files:** +- Create: `web/scripts/seed-extras.json` +- Modify: `web/scripts/seed-hub.py` +- Modify: `web/e2e/issues.spec.ts`, `web/e2e/board.spec.ts:56-81` +- Modify: `README.md` (раздел веб-приложения, строка 161), `ARCHITECTURE.md` (если описывает страницы `web/`) + +**Interfaces:** +- Consumes: Tasks 1–5 (комментарии в зеркале, окно, раскладка). + +- [ ] **Step 1: Дополнения тестового хаба** + +Создать `web/scripts/seed-extras.json`: + +```json +{ + "issues": { + "AURORA-9001": { + "description": "Цель: схема свода для КФО.\nКонтекст: ТЗ 6.4.\nШаг 1 — таблицы.\nШаг 2 — связи.\nШаг 3 — индексы.\nШаг 4 — миграции.\nШаг 5 — проверки.\nШаг 6 — документация.\nШаг 7 — ревью.\nШаг 8 — выкатка.\nШаг 9 — приёмка.\nКонец описания.", + "comments": [ + {"id": "501", "author": "Петров Пётр Петрович", "created": "2026-10-13T10:00:00.000+0300", "updated": "2026-10-13T10:00:00.000+0300", "body": "Первый комментарий e2e\nвторая строка"}, + {"id": "502", "author": "Иванов Иван Иванович", "created": "2026-10-14T11:30:00.000+0300", "updated": "2026-10-14T12:00:00.000+0300", "body": "Схему согласовали с аналитиком."}, + {"id": "503", "author": null, "created": "2026-10-15T09:15:00.000+0300", "updated": "2026-10-15T09:15:00.000+0300", "body": "[мост] служебный комментарий"} + ] + } + }, + "commands": [ + {"type": "ensure_fields", "params": {"key": "AURORA-9001", "fields": {"duedate": "2026-10-20"}}, "status": "done"}, + {"type": "ensure_status", "params": {"key": "AURORA-9001", "status": "В работе"}, "status": "failed", "error": "нет перехода в «В работе»"} + ] +} +``` + +В `web/scripts/seed-hub.py`: +- в строку документации добавить предложение «Дополнения только для тестового хаба (описание, комментарии, команды) — из + seed-extras.json рядом со скриптом.»; +- после `snap = json.loads(...)` добавить: + +```python +extras_path = Path(__file__).with_name("seed-extras.json") +extras = json.loads(extras_path.read_text(encoding="utf-8")) if extras_path.exists() else {} +extra_issues = extras.get("issues", {}) +``` + +- строку `issues = [...]` заменить на: + +```python +issues = [{k: v for k, v in i.items() if k not in ("fact_status", "work_kind", "neighbor")} | {"id": i["key"]} + | extra_issues.get(i["key"], {}) for i in snap["issues"]] +``` + +- перед строкой `(paths.home() / "tokens.json").write_text(...)` добавить: + +```python +if extras.get("commands"): # завершённые команды по задачам — для раздела «Команды моста» карточки + with db.transaction(conn): + batch = conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('Тестовые команды', 'seed', ?)", + (db.now(),)).lastrowid + for seq, c in enumerate(extras["commands"], 1): + conn.execute("INSERT INTO command(batch_id, seq, type, params, status, finished_at, error) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (batch, seq, c["type"], json.dumps(c["params"], ensure_ascii=False), c["status"], db.now(), + c.get("error"))) +``` + +Проверка: `JIRA_HUB_HOME=web/.e2e-hub bash web/scripts/test-hub.sh` в фоне, затем +`curl -s -H "Authorization: Bearer $(python3 -c "import json;print(json.load(open('web/.e2e-hub/tokens.json'))['client'])")" http://127.0.0.1:8791/issues/AURORA-9001 | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d['comments']), len(d['commands']), d['description'].count(chr(10)))"` +Expected: `3 2 11`. Остановить тестовый хаб. + +- [ ] **Step 2: e2e списка задач** + +Заменить `web/e2e/issues.spec.ts` целиком: + +```ts +import { expect, test } from "@playwright/test"; +import { login } from "./hub.ts"; + +test("фильтр → карточка окном → заметка сохраняется и переживает перезагрузку", async ({ page }) => { + await login(page, "/app/issues"); + await page.getByLabel("Исполнитель").selectOption("Петров Пётр Петрович"); + await expect(page).toHaveURL(/assignee=/); + await page.getByRole("link", { name: "AURORA-9002", exact: true }).click(); + const d = page.getByRole("dialog"); + await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible(); + await d.getByRole("button", { name: "Править" }).click(); + await d.getByLabel("Осталось").fill("e2e: дописать тесты"); + await d.getByRole("button", { name: "Сохранить" }).click(); + await expect(d.getByText("e2e: дописать тесты")).toBeVisible(); + await page.reload(); + await expect(page.getByRole("dialog").getByText("e2e: дописать тесты")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page).toHaveURL(/\/app\/issues\?assignee=/); + await expect(page.getByLabel("Исполнитель")).toHaveValue("Петров Пётр Петрович"); +}); + +test("окно поверх списка: прокрутка списка на месте при открытии и после закрытия", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 320 }); + await login(page, "/app/issues"); + await expect(page.locator("table.grid tbody tr")).toHaveCount(10); + const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; }); + expect(y).toBeGreaterThan(50); + await page.locator("table.grid tbody tr").last().getByRole("link").click(); + await expect(page.getByRole("dialog")).toBeVisible(); + expect(await page.evaluate(() => window.scrollY)).toBe(y); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toHaveCount(0); + expect(await page.evaluate(() => window.scrollY)).toBe(y); +}); + +test("переход по связи и «назад»; ✕ и щелчок по фону; после закрытия «назад» не открывает карточку", async ({ page }) => { + await login(page); // доска + await page.getByRole("link", { name: "Задачи", exact: true }).click(); + await page.getByRole("link", { name: "AURORA-9001", exact: true }).click(); + const d = page.getByRole("dialog"); + await d.getByRole("link", { name: "AURORA-9002", exact: true }).click(); + await expect(page).toHaveURL(/\/app\/issues\/AURORA-9002$/); + await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible(); + await page.goBack(); + await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible(); + await d.getByRole("link", { name: "AURORA-9002", exact: true }).click(); + await d.getByRole("button", { name: "Закрыть" }).click(); + await expect(page).toHaveURL(/\/app\/issues$/); + await expect(page.getByRole("dialog")).toHaveCount(0); + await page.getByRole("link", { name: "AURORA-9003", exact: true }).click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.mouse.click(5, 300); // фон слева от окна + await expect(page.getByRole("dialog")).toHaveCount(0); + await page.goBack(); + await expect(page).toHaveURL(/\/app\/board$/); + await expect(page.getByRole("dialog")).toHaveCount(0); +}); + +test("прямой адрес и перезагрузка: окно поверх списка, ✕ ведёт в список", async ({ page }) => { + await login(page, "/app/issues/AURORA-9001"); + const d = page.getByRole("dialog"); + await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible(); + await expect(page.getByTestId("page-issues")).toBeAttached(); + await page.reload(); + await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible(); + await page.getByRole("dialog").getByRole("button", { name: "Закрыть" }).click(); + await expect(page).toHaveURL(/\/app\/issues$/); + await expect(page.getByRole("dialog")).toHaveCount(0); +}); + +test("карточка: описание свёрнуто, комментарии новые сверху и скрываются с запоминанием, команды свёрнуты", async ({ page }) => { + await login(page, "/app/issues/AURORA-9001"); + const d = page.getByRole("dialog"); + await expect(d.getByRole("heading", { name: "Комментарии (3)" })).toBeVisible(); + await expect(d.getByTestId("comments").getByRole("listitem").first()).toContainText("[мост] служебный комментарий"); + await expect(d.getByRole("button", { name: "развернуть" })).toBeVisible(); + await expect(d.getByText("Конец описания.")).toBeHidden(); + await d.getByRole("button", { name: "развернуть" }).click(); + await expect(d.getByText("Конец описания.")).toBeVisible(); + await expect(d.getByRole("cell", { name: "ensure_fields" })).toBeHidden(); + await d.getByText("Команды моста (2)").click(); + await expect(d.getByRole("cell", { name: "ensure_fields" })).toBeVisible(); + await d.getByRole("button", { name: "Скрыть" }).click(); + await expect(d.getByText("Первый комментарий e2e")).toHaveCount(0); + await page.reload(); + await expect(page.getByRole("dialog").getByRole("button", { name: "Показать" })).toBeVisible(); + await expect(page.getByRole("dialog").getByText("Первый комментарий e2e")).toHaveCount(0); +}); + +test("несохранённая заметка: Esc спрашивает, «Остаться» оставляет окно, «Уйти» закрывает", async ({ page }) => { + await login(page, "/app/issues"); + await page.getByRole("link", { name: "AURORA-9003", exact: true }).click(); + const d = page.getByRole("dialog"); + await d.getByRole("button", { name: "Править" }).click(); + await d.getByLabel("Сделано").fill("черновик"); + await page.keyboard.press("Escape"); + await expect(d.getByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeVisible(); + await d.getByRole("button", { name: "Остаться" }).click(); + await expect(d.getByLabel("Сделано")).toHaveValue("черновик"); + await page.keyboard.press("Escape"); + await d.getByRole("button", { name: "Уйти" }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page).toHaveURL(/\/app\/issues$/); +}); + +test("узкий экран: окно на всю ширину, заметки выше связей, без горизонтальной прокрутки", async ({ page }) => { + await page.setViewportSize({ width: 600, height: 800 }); + await login(page, "/app/issues/AURORA-9001"); + const d = page.getByRole("dialog"); + await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible(); + expect((await d.boundingBox())!.width).toBeGreaterThan(590); + const notesY = (await d.getByRole("heading", { name: "Служебные заметки" }).boundingBox())!.y; + const linksY = (await d.getByRole("heading", { name: "Связи", exact: true }).boundingBox())!.y; + expect(notesY).toBeLessThan(linksY); + expect(await d.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true); +}); +``` + +- [ ] **Step 3: e2e доски** + +В `web/e2e/board.spec.ts` заменить тесты «ⓘ открывает карточку задачи в приложении» и «M-3: прокрутка доски после +возврата из карточки задачи» на: + +```ts +test("ⓘ открывает карточку окном поверх доски", async ({ page }) => { + await page.locator('.board .card[data-key="AURORA-9001"]').getByRole("link", { name: "Открыть карточку AURORA-9001" }).click(); + await expect(page).toHaveURL(/\/app\/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 })).toHaveClass(/active/); +}); + +test("M-3: прокрутка и данные доски после окна карточки и после другого раздела", async ({ page }) => { + await page.setViewportSize({ width: 1600, height: 600 }); + await expect(page.locator(".board .card[data-key]").first()).toBeVisible(); + const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; }); + expect(y).toBeGreaterThan(100); + // реальный щелчок по ⓘ карточки, видимой в окне после прокрутки (иначе Playwright сам сдвинет страницу) + const key = await page.evaluate(() => { + const vh = window.innerHeight; + const a = [...document.querySelectorAll(".board .card[data-key] a.open-card")] + .find((e) => { const r = e.getBoundingClientRect(); return r.top > 200 && r.bottom < vh - 20; }); + return a?.closest(".card")?.dataset.key ?? null; + }); + expect(key).not.toBeNull(); + let boardLoads = 0; + page.on("request", (r) => { if (new URL(r.url()).pathname === "/board") boardLoads++; }); + 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\/board$/); + expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2); + expect(boardLoads).toBe(0); // доска не перезагружалась + 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); +}); + +test("заметка из окна видна на доске под ним", async ({ page }) => { + await page.locator('.board .card[data-key="AURORA-9002"] a.open-card').click(); + const d = page.getByRole("dialog"); + await d.getByRole("button", { name: "Править" }).click(); + await d.getByLabel("Вид работы").selectOption("тестирование"); + await d.getByRole("button", { name: "Сохранить" }).click(); + await expect(d.getByRole("button", { name: "Править" })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.locator('.board .card[data-key="AURORA-9002"] .kind')).toHaveText("тестирование"); +}); +``` + +- [ ] **Step 4: Прогон e2e** + +Run: `cd web && npm run e2e` +Expected: все тесты зелёные. Затем три прогона подряд: `cd web && for n in 1 2 3; do npm run e2e || break; done` +Expected: три зелёных прогона (устойчивость). Падение — чинить причину, не добавлять ожидания «на всякий случай». + +- [ ] **Step 5: Документация** + +В `README.md` в строке со списком адресов (`Адреса: … карточка \`/app/issues/{key}\` …`) заменить фрагмент +«карточка `/app/issues/{key}`» на «карточка `/app/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, +щелчок по фону; прямой адрес открывает окно поверх списка задач)». В том же разделе, после абзаца про «Задачи», добавить +абзац: + +```markdown +Карточка задачи: в шапке — статус Jira, статус по факту, исполнитель, срок (просроченный — красным), спринт и эпик; +связи сгруппированы «Зависит от» / «Блокирует» / «Связана с» / «Прочие» с темой и статусом связанной задачи; +комментарии из Jira (агент v10+) новые сверху, «Скрыть» запоминается в браузере; заметки — режим чтения, «Править» +открывает форму. Комментарии появляются после первого обновления из Jira агентом v10. +``` + +В `ARCHITECTURE.md`: +- строку таблицы `| \`mirror\` | зеркало задач и связей, \`apply_state\`, заметки, фильтры, снимок доски \`board\` | \`db\` |` + заменить на `| \`mirror\` | зеркало задач, связей и комментариев, \`apply_state\` (с проверкой \`check_comments\`), заметки, фильтры, снимок доски \`board\` | \`db\` |`; +- в абзаце про `web/` фрагмент «и «Задачи» (`/app/issues`, `/app/issues/{key}`).» заменить на «и «Задачи» + (`/app/issues`); карточка `/app/issues/{key}` — окно поверх страницы, с которой её открыли: `shell/Layout.tsx` рисует + страницу по адресу из `location.state.background` (`useRoutes` с явным адресом) и `issues/IssueModal.tsx` поверх неё.». + +- [ ] **Step 6: Полная проверка** + +Run: `make check` +Expected: pytest и vitest зелёные, lint чистый. + +- [ ] **Step 7: Commit** + +```bash +git add web/scripts/seed-extras.json web/scripts/seed-hub.py web/e2e README.md ARCHITECTURE.md +git commit -m "test(web): e2e окна карточки, тестовый хаб с комментариями и командами; README (#37) + +Co-Authored-By: Claude Opus 5.5 " +``` + +## После плана + +- Финальное ревью ветки (opus); ручной проход сценариев в браузере на тестовом хабе :8791 со скриншотами (делает + сессия, не контроллер). +- PR в aep/jira-hub со ссылкой на #37 и на PR агента; merge — по команде владельца, после merge и публикации агента v10. diff --git a/openspec/changes/issue-card-modal/design.md b/openspec/changes/issue-card-modal/design.md index 961beeb..47aea88 100644 --- a/openspec/changes/issue-card-modal/design.md +++ b/openspec/changes/issue-card-modal/design.md @@ -44,8 +44,8 @@ ### Хаб -5. **Таблица `issue_comment`** `(key, comment_id, author, created, updated, body, PRIMARY KEY (key, comment_id))`, индекс - по `key`. Схема v3; миграция v2 → v3 создаёт таблицу (копия базы `schema-v2` — существующим механизмом). +5. **Таблица `issue_comment`** `(key, comment_id, author, created, updated, body, PRIMARY KEY (key, comment_id))`; выборка + по `key` идёт по первичному ключу, отдельный индекс не нужен. Схема v3; миграция v2 → v3 создаёт таблицу (копия базы `schema-v2` — существующим механизмом). 6. **Запись.** `apply_state`: для задачи с полем `comments` (список) комментарии этой задачи удаляются и вставляются присланные — в одной транзакции с задачей; задача без поля `comments` своих комментариев не теряет (агент до v10). 7. **Проверка формы** в `h_agent_state` до записи: `comments` — список объектов; `id` — непустая строка; `body` — строка; @@ -60,16 +60,27 @@ ### Веб-приложение: окно 10. **Фоновая страница.** Ссылки на карточку (ключ в списке, «ⓘ» на доске, связи в карточке) передают в - `location.state` `{background, depth}`: `background` — адрес страницы под окном (`pathname + search`), `depth` — - сколько карточек открыто поверх неё (1 при открытии со страницы, +1 при переходе по связи). `Layout` отрисовывает - маршрут `background` (если он есть), а при адресе `/issues/:key` — окно поверх него. Страница под окном не - пересоздаётся: её состояние (фильтры, загруженные порции, прокрутка, раскладка доски, опрос пакета) сохраняется. -11. **Прямой адрес** `/app/issues/{key}` без `state` (новая вкладка, закладка, перезагрузка): под окном — список задач без - фильтров, `depth = 0`. -12. **Закрытие** (✕, Esc, щелчок по фону): при `depth ≥ 1` — `navigate(-depth)`, браузер возвращается к фоновой - странице, и «назад» после закрытия ведёт на шаг раньше неё; при `depth = 0` — `navigate("/issues", {replace: true})`. + `location.state` `{background, depth, direct}`: `background` — адрес страницы под окном (`pathname + search`), + `depth` — сколько записей истории занято карточками поверх неё (1 при открытии со страницы, +1 при переходе по + связи), `direct` — окно открыто прямым адресом (переносится в переходы по связям). Маршрут верхнего уровня — + `{path: "*", element: }`; `Layout` всегда рисует страницы через `useRoutes(PAGE_ROUTES, pageLocation)` с + явным адресом: `background` при открытом окне, иначе текущий адрес. Явный адрес нужен всегда: так дерево + компонентов страницы не меняется при открытии окна (страница не пересоздаётся), а `useSearchParams`/`useLocation` + внутри фоновой страницы видят её собственный адрес, и фильтры списка не сбрасываются. Состояние страницы (фильтры, + загруженные порции, прокрутка, раскладка доски, опрос пакета) сохраняется. `ErrorBoundary` страницы получает ключ по + адресу страницы, у окна — свой. +11. **Прямой адрес** `/app/issues/{key}` без `state` (новая вкладка, закладка): `Layout` заменяет запись истории тем же + адресом со `state = {background: /issues, depth: 0, direct: true}`; под окном — список задач без фильтров. + Перезагрузка страницы с открытым окном сохраняет `state` (браузер хранит его в записи истории). +12. **Закрытие** (✕, Esc, щелчок по фону): `depth = 0` — `navigate("/issues", {replace: true})`; `depth ≥ 1` — + `navigate(-depth)`, браузер возвращается к записи страницы под окном, и «назад» после закрытия ведёт на шаг раньше + неё; если окно было открыто прямым адресом (`direct`), запись прямого адреса, на которую вернулись, заменяется на + `/issues` (`replace`). Закрытие проходит через блокировщик заметок (п. 14). Вкладки «Доска»/«Задачи» в шапке + подсвечиваются по адресу страницы под окном. 13. **Элемент ``** с `showModal()`: фокус удерживается в окне, фон недоступен, Esc вызывает `cancel` (обрабатываем - сами — закрытие по п. 12 с проверкой заметок). Щелчок по фону — щелчок по самому `` вне области содержимого. + сами — закрытие по п. 12 с проверкой заметок; если `cancel` не отменяемый — браузер закрыл окно сам без активации + пользователя, — по событию `close` окно открывается снова и закрытие идёт тем же путём). Щелчок по фону — щелчок по + самому `` вне области содержимого. Прокрутка страницы под окном на время открытия блокируется (`overflow: hidden` у `body`), содержимое окна прокручивается внутри, шапка окна закреплена. Ширина до 1000 px, высота до 90vh; на экранах уже 700 px — на всю ширину. @@ -112,10 +123,11 @@ ### Проверки -25. **Тестовый хаб**: фикстура `hub-basic` дополняется у одной задачи описанием длиннее 8 строк, тремя комментариями и - двумя командами; у второй — входящими и исходящими связями всех групп, включая тип вне `link_types` и связь на - задачу вне зеркала. Эталоны доски от этого не меняются (комментарии и описание в модель доски не входят) — проверяется - golden-тестом. +25. **Тестовый хаб**: `web/scripts/seed-extras.json` — дополнения к фикстуре доски только для тестового хаба: у AURORA-9001 + описание длиннее 8 строк, три комментария и две команды (пакет автора `seed`, команды завершены). `seed-hub.py` + применяет их после фикстуры. Связи не добавляются (они меняют доску и её e2e); группа «Прочие» и связь на задачу вне + зеркала проверяются в vitest, а связь на задачу вне зеркала (`lower-1`) уже есть в фикстуре. Фикстура доски и её + golden-эталоны не меняются. 26. **e2e** (Playwright на :8791): открытие из списка (фильтр и прокрутка списка после закрытия на месте), по «ⓘ» с доски (прокрутка и раскладка доски на месте), переход по связи и «назад», Esc / ✕ / фон, «назад» после закрытия не открывает карточку, прямой адрес и перезагрузка с открытым окном, подтверждение при несохранённой заметке, ширина -- 2.47.2 From 88a6f72b13df4e97ed7703c6fe750bf8de1aa818 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 13:00:28 +0400 Subject: [PATCH 03/15] =?UTF-8?q?feat(hub):=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=20=D0=B2=20=D0=B7=D0=B5=D1=80=D0=BA=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5,=20=D1=81=D1=85=D0=B5=D0=BC=D0=B0=20v3;=20=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=D1=8B=20=D0=B8=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81?= =?UTF-8?q?=D1=8B=20=D1=81=D0=B2=D1=8F=D0=B7=D0=B0=D0=BD=D0=BD=D1=8B=D1=85?= =?UTF-8?q?=20=D0=B2=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B5=20?= =?UTF-8?q?(#37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/db.py | 12 +++++++- src/jira_hub/mirror.py | 40 +++++++++++++++++++++++++-- tests/test_db.py | 28 ++++++++++++++++--- tests/test_e2e_hub.py | 3 +- tests/test_mirror.py | 63 ++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 134 insertions(+), 12 deletions(-) diff --git a/src/jira_hub/db.py b/src/jira_hub/db.py index 58f3b71..0e091b6 100644 --- a/src/jira_hub/db.py +++ b/src/jira_hub/db.py @@ -8,7 +8,7 @@ from pathlib import Path from . import backup, paths -SCHEMA_VERSION = 2 +SCHEMA_VERSION = 3 SCHEMA = """ CREATE TABLE IF NOT EXISTS setting ( @@ -43,6 +43,15 @@ CREATE TABLE IF NOT EXISTS issue_link ( PRIMARY KEY (source_key, target_key, link_type) ); CREATE INDEX IF NOT EXISTS ix_issue_link_target ON issue_link(target_key); +CREATE TABLE IF NOT EXISTS issue_comment ( + key TEXT NOT NULL, + comment_id TEXT NOT NULL, + author TEXT, + created TEXT, + updated TEXT, + body TEXT NOT NULL DEFAULT '', + PRIMARY KEY (key, comment_id) +); CREATE TABLE IF NOT EXISTS issue_note ( key TEXT PRIMARY KEY, fact_status TEXT, @@ -105,6 +114,7 @@ CREATE TABLE IF NOT EXISTS agent_status ( MIGRATIONS = { 1: ["ALTER TABLE command ADD COLUMN deliveries INTEGER NOT NULL DEFAULT 0", "UPDATE command SET deliveries = 1 WHERE status = 'claimed'"], # уже выданные — одна выдача + 2: [], # v2 → v3: issue_comment создаётся SCHEMA (CREATE TABLE IF NOT EXISTS), данных переносить не нужно } diff --git a/src/jira_hub/mirror.py b/src/jira_hub/mirror.py index 1405e7c..923b670 100644 --- a/src/jira_hub/mirror.py +++ b/src/jira_hub/mirror.py @@ -1,4 +1,4 @@ -"""Зеркало задач Jira (issue, issue_link) и служебные заметки (issue_note). +"""Зеркало задач Jira (issue, issue_link, issue_comment) и служебные заметки (issue_note). Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает.""" import json @@ -8,6 +8,26 @@ ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"] NOTE_FIELDS = ["fact_status", "work_kind", "done_text", "rest_text", "owner_note"] +def check_comments(issues) -> None: + """Форма поля comments у задач состояния (агент v10+); поле необязательно. Нарушение — ValueError с ключом и полем.""" + for it in issues: + if "comments" not in it: + continue + key, lst = it.get("key"), it["comments"] + if not isinstance(lst, list): + raise ValueError(f"{key}: comments — список комментариев") + for n, c in enumerate(lst): + where = f"{key}: comments[{n}]" + if not isinstance(c, dict): + raise ValueError(f"{where} — объект комментария") + if not isinstance(c.get("id"), str) or not c["id"]: + raise ValueError(f"{where}.id — непустая строка") + if not isinstance(c.get("body"), str) and c.get("body") is not None: + raise ValueError(f"{where}.body — строка") + for f in ("author", "created", "updated"): + if c.get(f) is not None and not isinstance(c[f], str): + raise ValueError(f"{where}.{f} — строка или null") + _FILTERS = {"sprint": "i.sprint = ?", "assignee": "i.assignee = ?", "status": "i.status = ?", "epic": "i.epic_key = ?", "work_kind": "n.work_kind = ?", "fact_status": "n.fact_status = ?"} @@ -35,6 +55,13 @@ 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("comments"), list): # без поля (агент до v10) комментарии не трогаем + conn.execute("DELETE FROM issue_comment WHERE key = ?", (it["key"],)) + conn.executemany( + "INSERT OR REPLACE INTO issue_comment(key, comment_id, author, created, updated, body) " + "VALUES (?, ?, ?, ?, ?, ?)", + [(it["key"], c["id"], c.get("author"), c.get("created"), c.get("updated"), c.get("body") or "") + for c in it["comments"]]) conn.executemany("DELETE FROM issue_link WHERE source_key = ?", [(k,) for k in keys]) for link in links: conn.execute("INSERT OR IGNORE INTO issue_link(source_key, target_key, link_type) VALUES (?, ?, ?)", @@ -80,10 +107,17 @@ def get_issue(conn, key) -> dict | None: if row is None: return None d = _issue_dict(row) + epic = conn.execute("SELECT summary FROM issue WHERE key = ?", (d["epic_key"],)).fetchone() if d.get("epic_key") else None + d["epic_summary"] = epic["summary"] if epic else None d["links_out"] = [dict(r) for r in conn.execute( - "SELECT target_key, link_type FROM issue_link WHERE source_key = ? ORDER BY link_type, target_key", (key,))] + "SELECT l.target_key, l.link_type, i.summary, i.status FROM issue_link l LEFT JOIN issue i ON i.key = l.target_key " + "WHERE l.source_key = ? ORDER BY l.link_type, l.target_key", (key,))] d["links_in"] = [dict(r) for r in conn.execute( - "SELECT source_key, link_type FROM issue_link WHERE target_key = ? ORDER BY link_type, source_key", (key,))] + "SELECT l.source_key, l.link_type, i.summary, i.status FROM issue_link l LEFT JOIN issue i ON i.key = l.source_key " + "WHERE l.target_key = ? ORDER BY l.link_type, l.source_key", (key,))] + d["comments"] = [dict(r) for r in conn.execute( + "SELECT comment_id, author, created, updated, body FROM issue_comment WHERE key = ? " + "ORDER BY created DESC, CAST(comment_id AS INTEGER) DESC, comment_id DESC", (key,))] d["note"] = get_note(conn, key) d["commands"] = [dict(r) for r in conn.execute( "SELECT id, batch_id, type, status, finished_at, error FROM command WHERE params LIKE ? ORDER BY id DESC LIMIT 10", diff --git a/tests/test_db.py b/tests/test_db.py index 25d788e..b0ba510 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -10,8 +10,9 @@ from jira_hub import backup, db, paths, queue, settings def test_connect_creates_schema(open_db): conn = open_db() names = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")} - assert {"setting", "issue", "issue_link", "issue_note", "batch", "command", "sync_run", "agent_status"} <= names - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2" + assert {"setting", "issue", "issue_link", "issue_comment", "issue_note", "batch", "command", "sync_run", + "agent_status"} <= names + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3" assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 cols = {r["name"] for r in conn.execute("PRAGMA table_info(command)")} assert "deliveries" in cols @@ -34,7 +35,7 @@ def test_migrates_v1_with_backup(tmp_path): path = tmp_path / "hub.sqlite" _v1_database(path) conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3" rows = conn.execute("SELECT status, deliveries FROM command ORDER BY id").fetchall() assert [(r["status"], r["deliveries"]) for r in rows] == [("claimed", 1), ("done", 0), ("queued", 0)] assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе" @@ -181,7 +182,26 @@ def test_parallel_connect_makes_one_backup(tmp_path): assert errors == [] assert len(list((tmp_path / "backups").glob("*-schema-v1.sqlite"))) == 1 conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3" + conn.close() + + +def test_migrates_v2_with_backup(tmp_path): + """База v2 (без issue_comment) с задачей и заметкой → v3: копия schema-v2, таблица комментариев пустая, данные на месте.""" + path = tmp_path / "hub.sqlite" + conn = db.connect(path) + conn.execute("INSERT INTO issue(key, summary, fetched_at) VALUES ('A-1', 'Т', '2026-10-15T00:00:00+00:00')") + conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')") + conn.execute("DROP TABLE issue_comment") + conn.execute("UPDATE setting SET value = '2' WHERE key = 'schema_version'") + conn.close() + conn = db.connect(path) + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3" + assert conn.execute("SELECT COUNT(*) AS n FROM issue_comment").fetchone()["n"] == 0 + assert conn.execute("SELECT summary FROM issue WHERE key = 'A-1'").fetchone()["summary"] == "Т" + assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе" + copies = list((tmp_path / "backups").glob("*-schema-v2.sqlite")) + assert len(copies) == 1 conn.close() diff --git a/tests/test_e2e_hub.py b/tests/test_e2e_hub.py index f6b03aa..60c17b8 100644 --- a/tests/test_e2e_hub.py +++ b/tests/test_e2e_hub.py @@ -75,5 +75,6 @@ def test_create_link_sprint_flow(live): assert issue["note"]["work_kind"] == "разработка" assert len(issue["links_out"]) == 1 assert issue["links_out"][0]["target_key"] == "A-1" and issue["links_out"][0]["link_type"] == "ASUPR_Связанные" - assert client.get("/issues/A-1")["links_in"] == [{"source_key": "A-90", "link_type": "ASUPR_Связанные"}] + links_in = client.get("/issues/A-1")["links_in"] + assert [(l["source_key"], l["link_type"]) for l in links_in] == [("A-90", "ASUPR_Связанные")] assert client.get("/sync-runs")["runs"][0]["issues_count"] == 2 diff --git a/tests/test_mirror.py b/tests/test_mirror.py index 434be71..5215c94 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -27,15 +27,18 @@ def test_apply_state_upserts_and_replaces_links(conn): [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}]) got = mirror.get_issue(conn, "A-1") assert got["summary"] == "Новая тема" and got["labels"] == ["AWPCA"] - assert got["links_out"] == [{"target_key": "A-2", "link_type": "ASUPR_Связанные"}] - assert mirror.get_issue(conn, "A-2")["links_in"] == [{"source_key": "A-1", "link_type": "ASUPR_Связанные"}] + assert got["links_out"] == [{"target_key": "A-2", "link_type": "ASUPR_Связанные", "summary": "Тема A-2", + "status": "Ожидание разработки"}] + assert mirror.get_issue(conn, "A-2")["links_in"] == [{"source_key": "A-1", "link_type": "ASUPR_Связанные", + "summary": "Новая тема", "status": "Ожидание разработки"}] def test_links_from_other_source_survive(conn): mirror.apply_state(conn, [issue("A-1"), issue("A-2")], [{"source_key": "A-2", "target_key": "A-1", "link_type": "ASUPR_Связанные"}]) mirror.apply_state(conn, [issue("A-1")], []) # пришла только A-1 без связей - assert mirror.get_issue(conn, "A-1")["links_in"] == [{"source_key": "A-2", "link_type": "ASUPR_Связанные"}] + assert mirror.get_issue(conn, "A-1")["links_in"] == [{"source_key": "A-2", "link_type": "ASUPR_Связанные", + "summary": "Тема A-2", "status": "Ожидание разработки"}] def test_apply_state_marks_neighbors(conn): @@ -183,3 +186,57 @@ def test_board_inside_open_transaction_keeps_it_open(conn): assert conn.in_transaction assert not conn.in_transaction assert [i["key"] for i in snap["issues"]] == ["AURORA-1620"] + + +def comment(cid, created="2026-10-14T12:00:00.000+0300", **over): + base = {"id": cid, "author": "Петров Пётр", "created": created, "updated": created, "body": f"текст {cid}"} + base.update(over) + return base + + +def test_comments_replaced_and_kept_without_field(conn): + mirror.apply_state(conn, [issue("A-1", comments=[comment("101"), comment("102")])], []) + mirror.apply_state(conn, [issue("A-1", comments=[comment("102", body="новый текст")])], []) + got = mirror.get_issue(conn, "A-1")["comments"] + assert [(c["comment_id"], c["body"]) for c in got] == [("102", "новый текст")] + mirror.apply_state(conn, [issue("A-1", summary="Без поля comments")], []) # агент до v10 + assert [c["comment_id"] for c in mirror.get_issue(conn, "A-1")["comments"]] == ["102"] + mirror.apply_state(conn, [issue("A-1", comments=[])], []) + assert mirror.get_issue(conn, "A-1")["comments"] == [] + + +def test_comments_newest_first_and_shape(conn): + mirror.apply_state(conn, [issue("A-1", comments=[ + comment("9", created="2026-10-14T10:00:00.000+0300"), + comment("10", created="2026-10-15T10:00:00.000+0300", author=None, body=None), + comment("11", created="2026-10-15T10:00:00.000+0300")])], []) + got = mirror.get_issue(conn, "A-1")["comments"] + assert [c["comment_id"] for c in got] == ["11", "10", "9"] # то же время — по номеру, как число + assert got[1] == {"comment_id": "10", "author": None, "created": "2026-10-15T10:00:00.000+0300", + "updated": "2026-10-15T10:00:00.000+0300", "body": ""} + + +def test_issue_epic_summary_and_absent_link(conn): + mirror.apply_state(conn, [issue("A-1", epic_key="E-1"), issue("E-1", summary="Эпик свода", epic_key=None)], + [{"source_key": "A-1", "target_key": "Z-9", "link_type": "ASUPR_Связанные"}]) + got = mirror.get_issue(conn, "A-1") + assert got["epic_summary"] == "Эпик свода" + assert got["links_out"] == [{"target_key": "Z-9", "link_type": "ASUPR_Связанные", "summary": None, "status": None}] + assert mirror.get_issue(conn, "E-1")["epic_summary"] is None + + +@pytest.mark.parametrize("bad, field", [ + ("x", "comments"), (["x"], "comments[0]"), ([{"id": "", "body": "т"}], "comments[0].id"), + ([{"id": 5, "body": "т"}], "comments[0].id"), ([{"id": "5", "body": 42}], "comments[0].body"), + ([{"id": "5", "body": "т", "author": 7}], "comments[0].author"), + ([{"id": "5", "body": "т", "created": 1}], "comments[0].created"), + ([{"id": "5", "body": "т", "updated": []}], "comments[0].updated"), +]) +def test_check_comments_rejects(bad, field): + with pytest.raises(ValueError) as e: + mirror.check_comments([issue("A-1"), issue("A-2", comments=bad)]) + assert str(e.value).startswith(f"A-2: {field}") + + +def test_check_comments_accepts_missing_and_valid(): + mirror.check_comments([issue("A-1"), issue("A-2", comments=[comment("1"), comment("2", author=None, body="")])]) -- 2.47.2 From a03550f079e6740f8a6f4a085b0a3237acf99af7 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 13:04:55 +0400 Subject: [PATCH 04/15] =?UTF-8?q?feat(hub):=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BA=D0=B0=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D1=80=D0=B8=D0=B5=D0=B2=20=D0=B2=20POST=20/agent/s?= =?UTF-8?q?tate=20(422=20bad=5Fstate),=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8=20=D0=B2=20MCP=20issue=20(?= =?UTF-8?q?#37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/api.py | 7 ++++++- src/jira_hub/mcp_server.py | 10 ++++++++-- tests/test_api.py | 28 +++++++++++++++++++++++++++- tests/test_mcp.py | 22 ++++++++++++++++++++++ 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 9c892e9..1d5efce 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -163,8 +163,13 @@ def h_agent_result(hub, conn, m, q, body): def h_agent_state(hub, conn, m, q, body): + issues = body.get("issues") or [] + try: + mirror.check_comments(issues) # до записи: неверная пачка не применяется целиком + except ValueError as e: + raise ApiError(422, "bad_state", str(e)) with db.transaction(conn): - out = mirror.apply_state(conn, body.get("issues") or [], body.get("links") or [], body.get("neighbor_keys") or [], + out = mirror.apply_state(conn, issues, body.get("links") or [], body.get("neighbor_keys") or [], authoritative=queue.is_full_refresh(conn, body.get("command_id"))) if body.get("command_id"): queue.record_state(conn, body["command_id"], out["issues"], out["links"]) diff --git a/src/jira_hub/mcp_server.py b/src/jira_hub/mcp_server.py index e538b1a..2fdcbd8 100644 --- a/src/jira_hub/mcp_server.py +++ b/src/jira_hub/mcp_server.py @@ -80,8 +80,14 @@ def fmt_issue(d) -> str: note = d.get("note") or {} notes = [f" {k}: {note[k]}" for k in ("fact_status", "work_kind", "done_text", "rest_text", "owner_note") if note.get(k)] cmds = [f" #{c['id']} {c['type']} {c['status']} {c.get('finished_at') or ''} {c.get('error') or ''}".rstrip() for c in d.get("commands", [])] - return "\n".join(head + ["связи:"] + (links or [" —"]) + ["заметки:"] + (notes or [" —"]) + ["команды:"] + (cmds or [" —"]) - + ["описание:", d.get("description") or "—"]) + comments = d.get("comments") or [] + if comments: + shown = [f" {_v(c.get('author'))} {_v(c.get('created'))}: {_cut(c.get('body'), 300)}" for c in comments[:5]] + cmts = [f"комментарии ({len(comments)}):"] + shown + ([f" ещё {len(comments) - 5}"] if len(comments) > 5 else []) + else: + cmts = ["комментариев нет"] + return "\n".join(head + ["связи:"] + (links or [" —"]) + ["заметки:"] + (notes or [" —"]) + cmts + + ["команды:"] + (cmds or [" —"]) + ["описание:", d.get("description") or "—"]) def fmt_batch(b) -> str: diff --git a/tests/test_api.py b/tests/test_api.py index e42bacd..e11d7c0 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -72,7 +72,7 @@ def test_agent_flow(hub): a.post(f"/agent/commands/{tail['id']}/result", {"status": "done", "jira_calls": 1}) assert c.get("/batches/%d" % b["id"])["status"] == "done" issue = c.get("/issues/A-1") - assert issue["links_out"] == [{"target_key": "B-1", "link_type": "ASUPR_Связанные"}] + assert issue["links_out"] == [{"target_key": "B-1", "link_type": "ASUPR_Связанные", "summary": "Сосед", "status": None}] assert c.get("/issues/B-1")["neighbor"] is True runs = c.get("/sync-runs", limit=5)["runs"] assert runs[0]["issues_count"] == 2 and runs[0]["finished_at"] @@ -572,3 +572,29 @@ def test_tick_creates_auto_refresh_once(hub): assert [b["title"] for b in batches] == ["Автообновление"] and batches[0]["author"] == "hub" hub["hub"].tick() assert len(hub["client"].get("/batches", limit=5)["batches"]) == 1 + + +def test_agent_state_comments_validated_whole_batch(hub): + a, c = hub["agent"], hub["client"] + a.post("/agent/state", {"issues": [{"key": "X-1", "summary": "Икс", "labels": []}], "links": []}) + with pytest.raises(HubError) as e: + a.post("/agent/state", {"issues": [{"key": "X-1", "summary": "Икс новый", "labels": []}, + {"key": "Y-1", "summary": "Игрек", "labels": [], + "comments": [{"id": "7", "body": 42}]}], "links": []}) + assert e.value.status == 422 and e.value.error == "bad_state" + assert "Y-1" in e.value.detail and "body" in e.value.detail + assert c.get("/issues/X-1")["summary"] == "Икс" # пачка не применена целиком + with pytest.raises(HubError) as e: + c.get("/issues/Y-1") + assert e.value.status == 404 + + +def test_issue_detail_with_comments(hub): + hub["agent"].post("/agent/state", {"issues": [ + {"key": "A-1", "summary": "Схема", "labels": [], "epic_key": "E-1", "comments": [ + {"id": "1", "author": "Петров Пётр", "created": "2026-10-14T12:00:00.000+0300", "updated": None, "body": "старый"}, + {"id": "2", "author": None, "created": "2026-10-15T12:00:00.000+0300", "updated": None, "body": "новый"}]}, + {"key": "E-1", "summary": "Эпик", "labels": []}], "links": []}) + got = hub["client"].get("/issues/A-1") + assert [x["body"] for x in got["comments"]] == ["новый", "старый"] + assert got["epic_summary"] == "Эпик" diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 029be47..429929e 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -236,3 +236,25 @@ def test_agent_status_right_after_hub_start(live): live["agent"].post("/agent/claim", {"agent_version": 9, "launcher_version": 2}) text = mcp_server.agent_status() assert "запускатель: v2" in text and "неизвестно" not in text + + +def _detail(**over): + d = {"key": "A-1", "summary": "Т", "labels": [], "links_out": [], "links_in": [], "note": None, "commands": [], + "comments": [], "description": None} + d.update(over) + return d + + +def test_fmt_issue_comments(): + assert "комментариев нет" in mcp_server.fmt_issue(_detail()) + comments = [{"comment_id": str(n), "author": f"Автор {n}", "created": f"2026-10-1{n}T12:00:00.000+0300", + "updated": None, "body": ("д" * 500 if n == 7 else f"текст {n}\nстрока")} for n in range(7, 0, -1)] + text = mcp_server.fmt_issue(_detail(comments=comments)) + assert "комментарии (7):" in text + assert "д" * 299 + "…" in text and "д" * 300 not in text # самый новый — первым, обрезан до 300 + assert "Автор 3" in text and "Автор 2" not in text # 5 последних: 7…3 + assert "ещё 2" in text + assert text.index("заметки:") < text.index("комментарии (7):") < text.index("команды:") + no_author = mcp_server.fmt_issue(_detail(comments=[{"comment_id": "1", "author": None, "created": None, + "updated": None, "body": ""}])) + assert "комментарии (1):" in no_author and "ещё" not in no_author -- 2.47.2 From 092e7b32ed818ba0632ed80796ca6e0384567a85 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 13:06:49 +0400 Subject: [PATCH 05/15] =?UTF-8?q?feat(web):=20=D0=B4=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8?= =?UTF-8?q?=20=E2=80=94=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D0=B8,=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=8B=20?= =?UTF-8?q?=D1=81=D0=B2=D1=8F=D0=B7=D0=B5=D0=B9,=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=81=D1=80=D0=BE=D1=87=D0=BA=D0=B0,=20=D1=81=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D0=B5=20=D0=BE=D0=BA=D0=BD=D0=B0?= =?UTF-8?q?=20(#37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/api/types.ts | 10 ++++-- web/src/issues/IssuePage.test.tsx | 13 +------- web/src/issues/card.test.ts | 55 +++++++++++++++++++++++++++++++ web/src/issues/card.ts | 38 +++++++++++++++++++++ web/src/issues/commentsPref.ts | 15 +++++++++ web/src/issues/modal.test.ts | 22 +++++++++++++ web/src/issues/modal.ts | 22 +++++++++++++ web/src/test/issueFixtures.ts | 19 +++++++++++ web/src/test/setup.ts | 6 ++++ 9 files changed, 186 insertions(+), 14 deletions(-) create mode 100644 web/src/issues/card.test.ts create mode 100644 web/src/issues/card.ts create mode 100644 web/src/issues/commentsPref.ts create mode 100644 web/src/issues/modal.test.ts create mode 100644 web/src/issues/modal.ts create mode 100644 web/src/test/issueFixtures.ts diff --git a/web/src/api/types.ts b/web/src/api/types.ts index c8ca895..da86f18 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -35,11 +35,17 @@ export type Note = Record & { key: string; updated_at: export interface IssueCommand { id: number; batch_id: number; type: string; status: string; finished_at: string | null; error: string | null; } +export interface IssueLinkOut { target_key: string; link_type: string; summary: string | null; status: string | null } +export interface IssueLinkIn { source_key: string; link_type: string; summary: string | null; status: string | null } +// комментарий задачи из Jira (зеркало хаба, агент v10+): текст и время — как в Jira +export interface IssueComment { comment_id: string; author: string | null; created: string | null; updated: string | null; body: string } export interface IssueDetail extends IssueRow { - links_out: { target_key: string; link_type: string }[]; - links_in: { source_key: string; link_type: string }[]; + epic_summary: string | null; + links_out: IssueLinkOut[]; + links_in: IssueLinkIn[]; note: Note | null; commands: IssueCommand[]; + comments: IssueComment[]; } export type BatchStatus = "queued" | "running" | "done" | "cancelled"; diff --git a/web/src/issues/IssuePage.test.tsx b/web/src/issues/IssuePage.test.tsx index 801f37e..9cda5fe 100644 --- a/web/src/issues/IssuePage.test.tsx +++ b/web/src/issues/IssuePage.test.tsx @@ -8,20 +8,9 @@ import { makeQueryClient } from "../api/queryClient.ts"; import { setToken } from "../auth/token.ts"; import settings from "../board/__fixtures__/settings.json"; import { json, mockFetch } from "../test/fetchMock.ts"; +import { issue, note } from "../test/issueFixtures.ts"; import { IssuePage } from "./IssuePage.tsx"; -const note = (over = {}) => ({ key: "AURORA-9001", fact_status: "В работе", work_kind: "разработка", done_text: "схема", - rest_text: "API", owner_note: null, updated_at: "2026-10-14T09:00:00+00:00", updated_by: "chat", ...over }); -const issue = (key: string, over = {}) => ({ - key, id: "1", issue_type: "Задача", summary: `Тема ${key}`, description: "строка 1\nстрока 2", status: "В работе", - priority: "Высокий", assignee: "Иванов Иван Иванович", reporter: "me", duedate: null, planned_start: null, - planned_end: "2026-10-20", epic_key: "AURORA-9100", labels: ["kind-dev"], sprint: "AWPCA_Спринт 2", created: null, - jira_updated: null, fetched_at: "", neighbor: false, - links_out: [{ target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала" }], - links_in: [{ source_key: "AURORA-9003", link_type: "Duplicate" }], - note: note({ key }), commands: [{ id: 5, batch_id: 2, type: "ensure_fields", status: "done", finished_at: "2026-10-14T09:00:00+00:00", error: null }], - ...over, -}); const deferred = () => { let resolve!: (v: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }; function open(path: string, client = makeQueryClient()) { diff --git a/web/src/issues/card.test.ts b/web/src/issues/card.test.ts new file mode 100644 index 0000000..9f3bd9b --- /dev/null +++ b/web/src/issues/card.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import settings from "../board/__fixtures__/settings.json"; +import { factClass, groupLinks, isOverdue, todayISO } from "./card.ts"; + +const types = settings.link_types as Record; +const labels = { ...(settings.status_labels as Record), "Снято": "fact-cancelled" }; +const out = (k: string, t: string, s: string | null = `Тема ${k}`, st: string | null = "В работе") => ({ target_key: k, link_type: t, summary: s, status: st }); +const inn = (k: string, t: string, s: string | null = `Тема ${k}`, st: string | null = "В работе") => ({ source_key: k, link_type: t, summary: s, status: st }); + +describe("groupLinks", () => { + it("блокеры по направлению, связанные без повторов, прочие с направлением; порядок групп и ключей", () => { + const g = groupLinks({ + links_out: [out("A-10", "ASUPR_Блокер_Завершения"), out("A-2", "ASUPR_Блокер_Начала"), out("A-5", "ASUPR_Связанные"), + out("A-7", "Duplicate")], + links_in: [inn("A-3", "ASUPR_Блокер_Начала"), inn("A-5", "ASUPR_Связанные"), inn("A-8", "Duplicate", null, null)], + }, types); + expect(g.map(([id, title]) => [id, title])).toEqual([["depends", "Зависит от"], ["blocks", "Блокирует"], + ["related", "Связана с"], ["other", "Прочие"]]); + expect(g[0][2]).toEqual([{ key: "A-3", summary: "Тема A-3", status: "В работе", tag: "начало" }]); + expect(g[1][2].map((l) => [l.key, l.tag])).toEqual([["A-2", "начало"], ["A-10", "завершение"]]); // ключи как числа + expect(g[2][2].map((l) => l.key)).toEqual(["A-5"]); + expect(g[3][2]).toEqual([{ key: "A-7", summary: "Тема A-7", status: "В работе", tag: "→ Duplicate" }, + { key: "A-8", summary: null, status: null, tag: "← Duplicate" }]); + }); + + it("пустые группы не возвращаются; без link_types всё — «Прочие» с именем Jira", () => { + expect(groupLinks({ links_out: [], links_in: [] }, types)).toEqual([]); + const g = groupLinks({ links_out: [out("A-2", "ASUPR_Блокер_Начала")], links_in: [] }, {}); + expect(g).toEqual([["other", "Прочие", [{ key: "A-2", summary: "Тема A-2", status: "В работе", tag: "→ ASUPR_Блокер_Начала" }]]]); + }); + + it("логический тип из настроек вне трёх известных — «Прочие» с логическим именем", () => { + const g = groupLinks({ links_out: [out("A-2", "X_Дубль")], links_in: [] }, { ...types, "дублирует": "X_Дубль" }); + expect(g[0][2][0].tag).toBe("→ дублирует"); + }); +}); + +describe("просрочка и класс статуса по факту", () => { + it("класс из status_labels", () => { + expect(factClass("Готово — закрыть", labels)).toBe("fact-done"); + expect(factClass("свой статус", labels)).toBeNull(); + expect(factClass(null, labels)).toBeNull(); + }); + it("срок раньше сегодня и не закрыта по факту — просрочено (R-2)", () => { + expect(isOverdue("2026-10-14", "В работе", labels, "2026-10-15")).toBe(true); + expect(isOverdue("2026-10-14", null, labels, "2026-10-15")).toBe(true); + expect(isOverdue("2026-10-15", "В работе", labels, "2026-10-15")).toBe(false); + expect(isOverdue("2026-10-14", "Готово — закрыть", labels, "2026-10-15")).toBe(false); + expect(isOverdue("2026-10-14", "Снято", labels, "2026-10-15")).toBe(false); + expect(isOverdue("", "В работе", labels, "2026-10-15")).toBe(false); + }); + it("todayISO — местная дата YYYY-MM-DD", () => { + expect(todayISO(new Date(2026, 0, 5, 23, 30))).toBe("2026-01-05"); + }); +}); diff --git a/web/src/issues/card.ts b/web/src/issues/card.ts new file mode 100644 index 0000000..9dbab91 --- /dev/null +++ b/web/src/issues/card.ts @@ -0,0 +1,38 @@ +import type { IssueDetail } from "../api/types.ts"; + +export type LinkGroupId = "depends" | "blocks" | "related" | "other"; +export interface CardLink { key: string; summary: string | null; status: string | null; tag: string | null } + +const GROUPS: [LinkGroupId, string][] = [["depends", "Зависит от"], ["blocks", "Блокирует"], ["related", "Связана с"], ["other", "Прочие"]]; +const BLOCK_TAGS: Record = { "блокирует начало": "начало", "блокирует завершение": "завершение" }; +const byKey = (a: CardLink, b: CardLink) => + a.key.localeCompare(b.key, "ru", { numeric: true }) || (a.tag ?? "").localeCompare(b.tag ?? "", "ru"); + +// связи карточки по смыслу (design 17): логическое имя типа — из link_types настроек (имя типа Jira → логическое) +export function groupLinks(d: Pick, linkTypes: Record): [LinkGroupId, string, CardLink[]][] { + const logical = new Map(Object.entries(linkTypes).map(([name, jira]) => [jira, name])); + const groups: Record = { depends: [], blocks: [], related: [], other: [] }; + const add = (dir: "out" | "in", key: string, jiraType: string, summary: string | null, status: string | null) => { + const name = logical.get(jiraType); + if (name && BLOCK_TAGS[name]) groups[dir === "in" ? "depends" : "blocks"].push({ key, summary, status, tag: BLOCK_TAGS[name] }); + else if (name === "связанные") { if (!groups.related.some((l) => l.key === key)) groups.related.push({ key, summary, status, tag: null }); } + else groups.other.push({ key, summary, status, tag: `${dir === "out" ? "→" : "←"} ${name ?? jiraType}` }); + }; + for (const l of d.links_out) add("out", l.target_key, l.link_type, l.summary, l.status); + for (const l of d.links_in) add("in", l.source_key, l.link_type, l.summary, l.status); + return GROUPS.filter(([id]) => groups[id].length).map(([id, title]) => [id, title, groups[id].sort(byKey)]); +} + +const CLOSED_FACT = new Set(["fact-done", "fact-cancelled"]); +export const factClass = (fact: string | null | undefined, statusLabels: Record | undefined): string | null => + (fact && statusLabels?.[fact]) || null; + +// R-2: просрочено — срок раньше сегодня, а статус по факту не «закрыт» (классы fact-done, fact-cancelled) +export function isOverdue(due: string, fact: string | null | undefined, statusLabels: Record | undefined, today: string): boolean { + if (!due || due >= today) return false; + const cls = factClass(fact, statusLabels); + return !(cls && CLOSED_FACT.has(cls)); +} + +export const todayISO = (d = new Date()): string => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; diff --git a/web/src/issues/commentsPref.ts b/web/src/issues/commentsPref.ts new file mode 100644 index 0000000..16c0647 --- /dev/null +++ b/web/src/issues/commentsPref.ts @@ -0,0 +1,15 @@ +import { useState } from "react"; + +// выбор «комментарии скрыты» — один на все карточки, в браузере (design 19); без localStorage — показаны +export const COMMENTS_HIDDEN_KEY = "jira-hub-comments-hidden"; +const read = (): boolean => { try { return localStorage.getItem(COMMENTS_HIDDEN_KEY) === "1"; } catch { return false; } }; + +export function useCommentsHidden(): [boolean, (hidden: boolean) => void] { + const [hidden, setHidden] = useState(read); + const set = (v: boolean) => { + setHidden(v); + try { if (v) localStorage.setItem(COMMENTS_HIDDEN_KEY, "1"); else localStorage.removeItem(COMMENTS_HIDDEN_KEY); } + catch { /* хранилище недоступно: выбор живёт до закрытия карточки */ } + }; + return [hidden, set]; +} diff --git a/web/src/issues/modal.test.ts b/web/src/issues/modal.test.ts new file mode 100644 index 0000000..7aa61d8 --- /dev/null +++ b/web/src/issues/modal.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { BOARD_CARD_STATE, cardState, LIST_BACKGROUND, openCardState } from "./modal.ts"; + +describe("состояние окна в истории", () => { + it("со страницы — фон = адрес страницы, глубина 1", () => { + expect(openCardState({ pathname: "/issues", search: "?sprint=S", state: null })) + .toEqual({ background: { pathname: "/issues", search: "?sprint=S" }, depth: 1, direct: false }); + }); + it("из окна — тот же фон, глубина + 1, признак прямого адреса сохраняется", () => { + const s = { background: LIST_BACKGROUND, depth: 0, direct: true }; + expect(openCardState({ pathname: "/issues/A-1", search: "", state: s })).toEqual({ background: LIST_BACKGROUND, depth: 1, direct: true }); + expect(openCardState({ pathname: "/issues/A-2", search: "", state: BOARD_CARD_STATE })) + .toEqual({ background: { pathname: "/board", search: "" }, depth: 2, direct: false }); + }); + it("чужое или битое state — не состояние окна", () => { + expect(cardState(null)).toBeNull(); + expect(cardState({ background: "/issues", depth: 1 })).toBeNull(); + expect(cardState({ background: { pathname: "/issues", search: "" }, depth: "1" })).toBeNull(); + expect(cardState({ background: { pathname: "/issues", search: "" }, depth: 1 })) + .toEqual({ background: { pathname: "/issues", search: "" }, depth: 1, direct: false }); + }); +}); diff --git a/web/src/issues/modal.ts b/web/src/issues/modal.ts new file mode 100644 index 0000000..3a418b5 --- /dev/null +++ b/web/src/issues/modal.ts @@ -0,0 +1,22 @@ +// состояние окна карточки в записи истории (design 10–12): страница под окном, сколько записей заняли карточки +// поверх неё, открыто ли окно прямым адресом +export interface Background { pathname: string; search: string } +export interface CardState { background: Background; depth: number; direct: boolean } + +export const LIST_BACKGROUND: Background = { pathname: "/issues", search: "" }; +// доска не хранит состояние в адресе: «ⓘ» всегда открывает окно поверх /board +export const BOARD_CARD_STATE: CardState = { background: { pathname: "/board", 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; + if (!c || typeof c !== "object" || !c.background || typeof c.background !== "object") return null; + const { pathname, search } = c.background; + if (typeof pathname !== "string" || typeof search !== "string" || typeof c.depth !== "number") return null; + return { background: { pathname, search }, depth: c.depth, direct: c.direct === true }; +} + +// state ссылки на карточку: со страницы — фон = её адрес, глубина 1; из окна — тот же фон, глубина + 1 +export function openCardState(loc: { pathname: string; search: string; state: unknown }): CardState { + const cur = cardState(loc.state); + return cur ? { ...cur, depth: cur.depth + 1 } : { background: { pathname: loc.pathname, search: loc.search }, depth: 1, direct: false }; +} diff --git a/web/src/test/issueFixtures.ts b/web/src/test/issueFixtures.ts new file mode 100644 index 0000000..5e13c9d --- /dev/null +++ b/web/src/test/issueFixtures.ts @@ -0,0 +1,19 @@ +import type { IssueDetail, Note } from "../api/types.ts"; + +export const note = (over: Partial = {}): Note => ({ + key: "AURORA-9001", fact_status: "В работе", work_kind: "разработка", done_text: "схема", rest_text: "API", + owner_note: null, updated_at: "2026-10-14T09:00:00+00:00", updated_by: "chat", ...over, +}); + +export const issue = (key: string, over: Partial = {}): IssueDetail => ({ + key, id: "1", issue_type: "Задача", summary: `Тема ${key}`, description: "строка 1\nстрока 2", status: "В работе", + priority: "Высокий", assignee: "Иванов Иван Иванович", reporter: "me", duedate: null, planned_start: null, + planned_end: "2026-10-20", epic_key: "AURORA-9100", epic_summary: "Тестовый эпик", labels: ["kind-dev"], + sprint: "AWPCA_Спринт 2", created: null, jira_updated: null, fetched_at: "", neighbor: false, + links_out: [{ target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала", summary: "API свода", status: "Новая" }], + links_in: [{ source_key: "AURORA-9003", link_type: "Duplicate", summary: "Макет экрана", status: "Готово" }], + note: note({ key }), + commands: [{ id: 5, batch_id: 2, type: "ensure_fields", status: "done", finished_at: "2026-10-14T09:00:00+00:00", error: null }], + comments: [], + ...over, +}); diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index 04f5acd..e2181ae 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -3,6 +3,12 @@ import { cleanup } from "@testing-library/react"; import { afterEach } from "vitest"; import { saveBoardScroll } from "../board/scrollMemory.ts"; +// jsdom 29 не умеет showModal/close у : открытость — атрибут open, как в браузере +if (typeof HTMLDialogElement !== "undefined" && !HTMLDialogElement.prototype.showModal) { + HTMLDialogElement.prototype.showModal = function showModal(this: HTMLDialogElement) { this.setAttribute("open", ""); }; + HTMLDialogElement.prototype.close = function close(this: HTMLDialogElement) { this.removeAttribute("open"); }; +} + afterEach(() => { cleanup(); saveBoardScroll(null); -- 2.47.2 From 10285371b2d359a0077d30a983be5cd6295dc237 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 13:09:20 +0400 Subject: [PATCH 06/15] =?UTF-8?q?feat(web):=20=D0=BA=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8?= =?UTF-8?q?=20=D0=BE=D0=BA=D0=BD=D0=BE=D0=BC=20=D0=BF=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=85=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0=20=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BE=D1=81=D0=BA=D0=B8=20(#37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/App.test.tsx | 17 ++- web/src/board/components/Card.tsx | 3 +- ...{IssuePage.test.tsx => IssueCard.test.tsx} | 32 ++--- .../issues/{IssuePage.tsx => IssueCard.tsx} | 21 ++- web/src/issues/IssueLink.tsx | 9 ++ web/src/issues/IssueModal.test.tsx | 126 ++++++++++++++++++ web/src/issues/IssueModal.tsx | 25 ++++ web/src/issues/IssuesPage.tsx | 5 +- web/src/issues/NoteForm.tsx | 2 +- web/src/issues/issues.css | 6 + web/src/routes.tsx | 20 +-- web/src/shell/Layout.tsx | 54 +++++++- web/src/shell/pages.tsx | 12 ++ 13 files changed, 270 insertions(+), 62 deletions(-) rename web/src/issues/{IssuePage.test.tsx => IssueCard.test.tsx} (90%) rename web/src/issues/{IssuePage.tsx => IssueCard.tsx} (76%) create mode 100644 web/src/issues/IssueLink.tsx create mode 100644 web/src/issues/IssueModal.test.tsx create mode 100644 web/src/issues/IssueModal.tsx create mode 100644 web/src/shell/pages.tsx diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index fde4622..1cce5c8 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { App } from "./App.tsx"; import { clearToken, getAuth, setToken } from "./auth/token.ts"; import { routes } from "./routes.tsx"; -import { Layout } from "./shell/Layout.tsx"; +import { PAGE_ROUTES } from "./shell/pages.tsx"; import { json, mockFetch } from "./test/fetchMock.ts"; const at = (path: string) => createMemoryRouter(routes, { initialEntries: [path], basename: "/app" }); @@ -45,12 +45,15 @@ describe("оболочка", () => { it("исключение в странице — сообщение и «Перезагрузить», панель на месте", async () => { const Boom = () => { throw new Error("сломалось"); }; - const router = createMemoryRouter([{ path: "/", element: , children: [{ path: "x", element: }] }], - { initialEntries: ["/x"] }); - render(); - expect(await screen.findByText(/сломалось/)).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Перезагрузить" })).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Доска" })).toBeInTheDocument(); + const boom = { path: "/x", element: }; + PAGE_ROUTES.unshift(boom); + try { + const router = createMemoryRouter(routes, { initialEntries: ["/app/x"], basename: "/app" }); + render(); + expect(await screen.findByText(/сломалось/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Перезагрузить" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Доска" })).toBeInTheDocument(); + } finally { PAGE_ROUTES.splice(PAGE_ROUTES.indexOf(boom), 1); } }); it("без токена — только экран входа", () => { diff --git a/web/src/board/components/Card.tsx b/web/src/board/components/Card.tsx index 9d05362..0623e40 100644 --- a/web/src/board/components/Card.tsx +++ b/web/src/board/components/Card.tsx @@ -1,5 +1,6 @@ import { memo, type PointerEvent } from "react"; import { Link } from "react-router"; +import { BOARD_CARD_STATE } from "../../issues/modal.ts"; import { CW, JIRA_URL, KIND, PRIO } from "../constants.ts"; import { lineClamp, type Rect, type RelBox } from "../layout/layout.ts"; import { fmtDate, shortName, unassigned } from "../model/util.ts"; @@ -31,7 +32,7 @@ export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintS onPointerDown={onPointerDown ? (e) => onPointerDown(e, n.key) : undefined}>
{n.key} - {!n.stub && ⓘ} {showTag && (n.sprint ? {n.sprint.replace(/^AWPCA_/, "")} diff --git a/web/src/issues/IssuePage.test.tsx b/web/src/issues/IssueCard.test.tsx similarity index 90% rename from web/src/issues/IssuePage.test.tsx rename to web/src/issues/IssueCard.test.tsx index 9cda5fe..452c79d 100644 --- a/web/src/issues/IssuePage.test.tsx +++ b/web/src/issues/IssueCard.test.tsx @@ -1,7 +1,7 @@ import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClientProvider } from "@tanstack/react-query"; -import { createMemoryRouter, RouterProvider } from "react-router"; +import { createMemoryRouter, RouterProvider, useNavigate, useParams } from "react-router"; import { beforeEach, describe, expect, it } from "vitest"; import { qk } from "../api/queries.ts"; import { makeQueryClient } from "../api/queryClient.ts"; @@ -9,13 +9,19 @@ import { setToken } from "../auth/token.ts"; import settings from "../board/__fixtures__/settings.json"; import { json, mockFetch } from "../test/fetchMock.ts"; import { issue, note } from "../test/issueFixtures.ts"; -import { IssuePage } from "./IssuePage.tsx"; +import { IssueCard } from "./IssueCard.tsx"; const deferred = () => { let resolve!: (v: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }; +function CardAt() { + const { key = "" } = useParams(); + const navigate = useNavigate(); + return void navigate("/board")} />; +} + function open(path: string, client = makeQueryClient()) { const router = createMemoryRouter([ - { path: "/issues/:key", element: }, + { path: "/issues/:key", element: }, { path: "/board", element:

доска

}, ], { initialEntries: [path] }); render(); @@ -37,17 +43,18 @@ describe("карточка задачи", () => { expect(screen.getByRole("link", { name: "Открыть в Jira" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001"); }); - it("заголовок страницы — h1 (бренд в оболочке — span)", async () => { + it("заголовок карточки — h2 с id для aria-labelledby окна", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); open("/issues/AURORA-9001"); - expect(await screen.findByRole("heading", { level: 1, name: /AURORA-9001/ })).toBeInTheDocument(); + expect(await screen.findByRole("heading", { level: 2, name: /AURORA-9001/ })).toHaveAttribute("id", "issue-modal-title"); }); - it("задачи нет в зеркале — сообщение и ссылка на список", async () => { + it("задачи нет в зеркале — сообщение и «Закрыть»", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(404, { error: "not_found", detail: "issue" })); - open("/issues/AURORA-1"); - expect(await screen.findByText(/Задачи AURORA-1 нет в зеркале/)).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "к списку задач" })).toHaveAttribute("href", "/issues"); + const router = open("/issues/AURORA-1"); + expect(await screen.findByText("Задачи AURORA-1 нет в зеркале хаба.")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Закрыть" })); + expect(router.state.location.pathname).toBe("/board"); }); it("сохраняются только изменённые поля, очищенное — null", async () => { @@ -169,13 +176,6 @@ describe("карточка задачи", () => { await waitFor(() => expect(screen.queryByRole("alert")).toBeNull()); }); - it("O-10: ссылка в ветке «задачи нет в зеркале» стоит внутри .issue (цвет темы)", async () => { - mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(404, { error: "not_found", detail: "issue" })); - open("/issues/AURORA-1"); - const link = await screen.findByRole("link", { name: "к списку задач" }); - expect(link.closest(".issue")).not.toBeNull(); - }); - it("O-11: задача без команд — текст вместо пустой таблицы", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { commands: [] }))); open("/issues/AURORA-9001"); diff --git a/web/src/issues/IssuePage.tsx b/web/src/issues/IssueCard.tsx similarity index 76% rename from web/src/issues/IssuePage.tsx rename to web/src/issues/IssueCard.tsx index 9a8b93c..222e4da 100644 --- a/web/src/issues/IssuePage.tsx +++ b/web/src/issues/IssueCard.tsx @@ -1,4 +1,4 @@ -import { Link, useParams } from "react-router"; +import { IssueLink } from "./IssueLink.tsx"; import { ApiError } from "../api/client.ts"; import { useIssue, useSettings } from "../api/queries.ts"; import { JIRA_URL } from "../board/constants.ts"; @@ -7,21 +7,20 @@ import { NoteForm } from "./NoteForm.tsx"; import { dueOf } from "./options.ts"; import "./issues.css"; -export function IssuePage() { - const { key = "" } = useParams(); +export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClose: () => void }) { const issue = useIssue(key); const settings = useSettings(); // подпись типа связи: логический тип из link_types настроек, иначе имя типа Jira const typeName = (jira: string) => Object.entries(settings.data?.link_types ?? {}).find(([, j]) => j === jira)?.[0] ?? jira; - if (issue.isPending) return

загрузка {key}…

; + if (issue.isPending) return

загрузка {key}…

; if (issue.error instanceof ApiError && issue.error.status === 404) { - return

Задачи {key} нет в зеркале хаба. к списку задач

; + return

Задачи {key} нет в зеркале хаба.

; } if (issue.isError && !issue.data) { return ( -

{issue.error.message}{" "} +

{issue.error.message}{" "}

); } @@ -33,13 +32,13 @@ export function IssuePage() { ["Обновлена в Jira", i.jira_updated ? fmtStamp(i.jira_updated) : null], ]; return ( -
+
{issue.isError && (

{issue.error.message}{" "}

)} -

← задачи

-

{i.key} · {i.summary}

+

+

{i.key} · {i.summary}

Открыть в Jira

@@ -47,8 +46,8 @@ export function IssuePage() { {i.description &&
{i.description}
}

Связи

    - {i.links_out.map((l) =>
  • {typeName(l.link_type)} → {l.target_key}
  • )} - {i.links_in.map((l) =>
  • {l.source_key} → {typeName(l.link_type)} → эта задача
  • )} + {i.links_out.map((l) =>
  • {typeName(l.link_type)} →
  • )} + {i.links_in.map((l) =>
  • → {typeName(l.link_type)} → эта задача
  • )} {!i.links_out.length && !i.links_in.length &&
  • связей нет
  • }

Последние команды

diff --git a/web/src/issues/IssueLink.tsx b/web/src/issues/IssueLink.tsx new file mode 100644 index 0000000..dff0d22 --- /dev/null +++ b/web/src/issues/IssueLink.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from "react"; +import { Link, useLocation } from "react-router"; +import { openCardState } from "./modal.ts"; + +// ссылка на карточку задачи: открывает окно поверх текущей страницы (или меняет содержимое открытого окна) +export function IssueLink({ issueKey, className, children }: { issueKey: string; className?: string; children?: ReactNode }) { + const loc = useLocation(); + return {children ?? issueKey}; +} diff --git a/web/src/issues/IssueModal.test.tsx b/web/src/issues/IssueModal.test.tsx new file mode 100644 index 0000000..1e9f6d8 --- /dev/null +++ b/web/src/issues/IssueModal.test.tsx @@ -0,0 +1,126 @@ +import { act, fireEvent, 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 board from "../board/__fixtures__/hub-basic.board.json"; +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"; + +const rows = board.issues.map((i) => ({ ...i, id: i.key, description: null, reporter: null, created: null, jira_updated: null, fetched_at: "" })); + +function hub() { + return mockFetch((_m, url) => { + const p = url.pathname; + if (p === "/settings") return json(200, settings); + if (p === "/board") return json(200, board); + if (p === "/issues") return json(200, { issues: rows }); + if (p.startsWith("/issues/")) return json(200, issue(decodeURIComponent(p.slice("/issues/".length)))); + if (p === "/batches") return json(200, { batches: [] }); + return json(200, {}); + }); +} +const at = (entries: string[], index = entries.length - 1) => + createMemoryRouter(routes, { initialEntries: entries, initialIndex: index, basename: "/app" }); +const dialog = () => screen.getByRole("dialog"); + +describe("карточка окном", () => { + beforeEach(() => { setToken("tok"); hub(); }); + + it("из списка: окно поверх списка, фон не пересоздаётся, ✕ возвращает со старым фильтром, «назад» не открывает карточку", async () => { + const router = at(["/app/board", "/app/issues?sprint=AWPCA_Спринт 2"]); + render(); + const table = await screen.findByRole("table"); + expect(screen.getByLabelText("Спринт")).toHaveValue("AWPCA_Спринт 2"); + await userEvent.click(await within(table).findByRole("link", { name: "AURORA-9002" })); + expect(router.state.location.pathname).toBe("/app/issues/AURORA-9002"); + expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); + expect(within(screen.getByRole("main")).getByRole("table")).toBe(table); // таблица команд карточки — в окне, не в main // фон не пересоздаётся + expect(screen.getByLabelText("Спринт")).toHaveValue("AWPCA_Спринт 2"); // фильтр фона — из его адреса + expect(screen.getByRole("link", { name: "Задачи" })).toHaveClass("active"); + await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(router.state.location.pathname).toBe("/app/issues"); + 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/board"); + }); + + it("переход по связи меняет окно, «назад» возвращает прежнюю карточку, Esc закрывает всё", async () => { + const router = at(["/app/issues"]); + render(); + await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" })); + await userEvent.click(await within(dialog()).findByRole("link", { name: "AURORA-9002" })); + expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); + await act(async () => { await router.navigate(-1); }); + expect(await within(dialog()).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument(); + await userEvent.click(within(dialog()).getByRole("link", { name: "AURORA-9002" })); + await within(dialog()).findByRole("heading", { name: /AURORA-9002/ }); + fireEvent(dialog(), new Event("cancel", { cancelable: true })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(router.state.location.pathname).toBe("/app/issues"); + }); + + it("щелчок по фону окна закрывает его", async () => { + const router = at(["/app/issues"]); + render(); + await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" })); + await within(dialog()).findByRole("heading", { name: /AURORA-9001/ }); + await userEvent.click(dialog()); // цель — сам , не содержимое + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(router.state.location.pathname).toBe("/app/issues"); + }); + + it("с доски: окно поверх доски, вкладка «Доска» активна, после закрытия доска та же", async () => { + const router = at(["/app/board"]); + 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: "Доска" })).toHaveClass("active"); + await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); + await waitFor(() => expect(router.state.location.pathname).toBe("/app/board")); + expect(screen.getByTestId("page-board")).toBe(boardEl); + }); + + it("прямой адрес: под окном список задач, ✕ ведёт в список заменой записи", async () => { + const router = at(["/app/board", "/app/issues/AURORA-9001"]); + render(); + expect(await within(await screen.findByRole("dialog")).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument(); + expect(await screen.findByTestId("page-issues")).toBeInTheDocument(); + await waitFor(() => expect(router.state.location.state).toMatchObject({ depth: 0, direct: true })); + await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); + await waitFor(() => expect(router.state.location.pathname).toBe("/app/issues")); + await act(async () => { await router.navigate(-1); }); + expect(router.state.location.pathname).toBe("/app/board"); // не карточка + }); + + it("прямой адрес и переход по связи: закрытие возвращает в список, а не на первую карточку", async () => { + const router = at(["/app/issues/AURORA-9001"]); + render(); + await userEvent.click(await within(await screen.findByRole("dialog")).findByRole("link", { name: "AURORA-9002" })); + await within(dialog()).findByRole("heading", { name: /AURORA-9002/ }); + fireEvent(dialog(), new Event("cancel", { cancelable: true })); + await waitFor(() => expect(router.state.location.pathname).toBe("/app/issues")); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + }); + + it("Esc с несохранённой заметкой спрашивает; «Остаться» оставляет окно с текстом", async () => { + const router = at(["/app/issues"]); + render(); + await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" })); + const box = await within(dialog()).findByLabelText("Заметка владельца"); + await userEvent.type(box, "черновик"); + fireEvent(dialog(), new Event("cancel", { cancelable: true })); + expect(await within(dialog()).findByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeInTheDocument(); + await userEvent.click(within(dialog()).getByRole("button", { name: "Остаться" })); + expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001"); + expect(within(dialog()).getByLabelText("Заметка владельца")).toHaveValue("черновик"); + }); +}); diff --git a/web/src/issues/IssueModal.tsx b/web/src/issues/IssueModal.tsx new file mode 100644 index 0000000..ee30cbb --- /dev/null +++ b/web/src/issues/IssueModal.tsx @@ -0,0 +1,25 @@ +import { useEffect, useRef } from "react"; +import { ErrorBoundary } from "../shell/ErrorBoundary.tsx"; +import { IssueCard } from "./IssueCard.tsx"; + +// окно карточки (design 13): держит фокус и делает фон недоступным; Esc и щелчок по фону — onClose. +// Сами окно не закрываем: оно исчезает вместе с адресом карточки. Если браузер закрыл его сам (Esc без активации +// пользователя — cancel не отменяемый), открываем снова и просим закрытия по общему пути (блокировщик заметок). +export function IssueModal({ issueKey, onClose }: { issueKey: string; onClose: () => void }) { + const ref = useRef(null); + useEffect(() => { + const d = ref.current; + if (d && !d.open) d.showModal(); + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; // страница под окном не прокручивается + return () => { document.body.style.overflow = prev; }; + }, []); + return ( + { if (e.cancelable) { e.preventDefault(); onClose(); } }} + onClose={() => { const d = ref.current; if (d?.isConnected) { d.showModal(); onClose(); } }} + onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}> + + + ); +} diff --git a/web/src/issues/IssuesPage.tsx b/web/src/issues/IssuesPage.tsx index 2015915..44da18b 100644 --- a/web/src/issues/IssuesPage.tsx +++ b/web/src/issues/IssuesPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { Link, useSearchParams } from "react-router"; +import { useSearchParams } from "react-router"; import { ISSUE_FILTER_NAMES, useBoard, useIssues, useSettings, type IssueFilters } from "../api/queries.ts"; +import { IssueLink } from "./IssueLink.tsx"; import { dueOf, epicNames, filterOptions, withCurrent } from "./options.ts"; import "./issues.css"; @@ -63,7 +64,7 @@ export function IssuesPage() { {rows.map((i) => ( - {i.key}{i.summary}{i.issue_type}{i.status} + {i.summary}{i.issue_type}{i.status} {i.assignee}{i.sprint}{dueOf(i)}{i.epic_key ? names[i.epic_key] ?? i.epic_key : ""} {i.work_kind}{i.fact_status}{i.neighbor ? "да" : ""} diff --git a/web/src/issues/NoteForm.tsx b/web/src/issues/NoteForm.tsx index 833deb4..e4ea965 100644 --- a/web/src/issues/NoteForm.tsx +++ b/web/src/issues/NoteForm.tsx @@ -70,7 +70,7 @@ export function NoteForm({ issue }: { issue: IssueDetail }) { {saved && сохранено {fmtStamp(saved.updated_at)}, {saved.updated_by}}
{blocker.state === "blocked" && ( -
+
Есть несохранённые правки заметки. Уйти без сохранения? diff --git a/web/src/issues/issues.css b/web/src/issues/issues.css index a2053ac..574b4a4 100644 --- a/web/src/issues/issues.css +++ b/web/src/issues/issues.css @@ -20,3 +20,9 @@ .note input,.note select,.note textarea{font:inherit;font-size:14px;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:6px 8px} .note .err{color:var(--p-crit)} .note .confirm{display:flex;gap:8px;align-items:center;flex-wrap:wrap} + +/* окно карточки задачи (design 13) */ +.issue-modal{width:min(1000px,calc(100vw - 32px));max-width:none;max-height:90vh;padding:0;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--fg);overflow:auto} +.issue-modal::backdrop{background:rgb(0 0 0 / .45)} +.issue-modal > .issue{padding:0 20px 20px} +@media (max-width:700px){.issue-modal{width:100vw;max-width:100vw;height:100vh;max-height:100vh;margin:0;border-radius:0}} diff --git a/web/src/routes.tsx b/web/src/routes.tsx index c120fcf..afa157a 100644 --- a/web/src/routes.tsx +++ b/web/src/routes.tsx @@ -1,19 +1,5 @@ -import { Navigate, type RouteObject } from "react-router"; -import { BoardPage } from "./board/BoardPage.tsx"; -import { IssuePage } from "./issues/IssuePage.tsx"; -import { IssuesPage } from "./issues/IssuesPage.tsx"; +import type { RouteObject } from "react-router"; import { Layout } from "./shell/Layout.tsx"; -import { NotFound } from "./shell/NotFound.tsx"; -// адреса относительно basename "/app": /app → /app/board -export const routes: RouteObject[] = [{ - path: "/", - element: , - children: [ - { index: true, element: }, - { path: "board", element: }, - { path: "issues", element: }, - { path: "issues/:key", element: }, - { path: "*", element: }, - ], -}]; +// адреса относительно basename "/app": страницы и окно карточки рисует Layout (shell/pages.tsx, design 10) +export const routes: RouteObject[] = [{ path: "*", element: }]; diff --git a/web/src/shell/Layout.tsx b/web/src/shell/Layout.tsx index f08a4b3..3f82a47 100644 --- a/web/src/shell/Layout.tsx +++ b/web/src/shell/Layout.tsx @@ -1,26 +1,66 @@ import { useQueryClient } from "@tanstack/react-query"; -import { NavLink, Outlet, useLocation } from "react-router"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { Link, matchPath, useLocation, useNavigate, useRoutes } from "react-router"; import { clearToken } from "../auth/token.ts"; +import { IssueModal } from "../issues/IssueModal.tsx"; +import { cardState, LIST_BACKGROUND } from "../issues/modal.ts"; import { ErrorBoundary } from "./ErrorBoundary.tsx"; +import { PAGE_ROUTES } from "./pages.tsx"; import "./shell.css"; export function Layout() { const qc = useQueryClient(); - const { pathname } = useLocation(); + const location = useLocation(); + const navigate = useNavigate(); + const issueKey = matchPath("/issues/:key", location.pathname)?.params.key ?? null; + const card = issueKey ? cardState(location.state) : null; + const bgPath = issueKey ? (card?.background ?? LIST_BACKGROUND).pathname : null; + const bgSearch = issueKey ? (card?.background ?? LIST_BACKGROUND).search : ""; + // адрес страницы под окном; передаётся в useRoutes всегда (и без окна), чтобы дерево страницы не менялось при + // открытии окна и useSearchParams внутри неё видел её собственный адрес (design 10) + const bgLocation = useMemo(() => bgPath === null ? null : { pathname: bgPath, search: bgSearch, hash: "", state: null, key: "card-background" }, + [bgPath, bgSearch]); + const pageLocation = bgLocation ?? location; + const page = useRoutes(PAGE_ROUTES, pageLocation); + + // прямой адрес карточки: запись истории получает state окна поверх списка задач (design 11) + useEffect(() => { + if (issueKey && !card) { + void navigate(location.pathname + location.search, { replace: true, state: { background: LIST_BACKGROUND, depth: 0, direct: true } }); + } + }, [issueKey, card, location.pathname, location.search, navigate]); + + // закрытие (design 12): назад на depth записей; запись прямого адреса, на которую вернулись, заменяется списком + const closingDirect = useRef(false); + useEffect(() => { + if (closingDirect.current && card?.direct && card.depth === 0) { + closingDirect.current = false; + void navigate("/issues", { replace: true }); + } + }, [card, navigate]); + const close = useCallback(() => { + if (!card || card.depth === 0) { void navigate("/issues", { replace: true }); return; } + closingDirect.current = card.direct; + void navigate(-card.depth); + }, [card, navigate]); + + const at = pageLocation.pathname; + const tab = (to: string, label: string) => { + const active = at === to || at.startsWith(to + "/"); + return {label}; + }; return ( <>
Хаб Jira - +
- + {page}
+ {issueKey && } ); } diff --git a/web/src/shell/pages.tsx b/web/src/shell/pages.tsx new file mode 100644 index 0000000..5111b01 --- /dev/null +++ b/web/src/shell/pages.tsx @@ -0,0 +1,12 @@ +import { Navigate, type RouteObject } from "react-router"; +import { BoardPage } from "../board/BoardPage.tsx"; +import { IssuesPage } from "../issues/IssuesPage.tsx"; +import { NotFound } from "./NotFound.tsx"; + +// страницы приложения; Layout рисует их по адресу страницы под окном карточки (design 10) +export const PAGE_ROUTES: RouteObject[] = [ + { path: "/", element: }, + { path: "/board", element: }, + { path: "/issues", element: }, + { path: "*", element: }, +]; -- 2.47.2 From 87db9720f9b3ec73639f5c543cb422f3b45ae425 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 13:11:11 +0400 Subject: [PATCH 07/15] =?UTF-8?q?docs(openspec):=20=D1=80=D1=83=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=20R-6=20=D0=BF=D0=BE=20=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D0=BF=D1=82=D0=B0=D1=86=D0=B8=D0=B8=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=20Task=204=20(#37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/issue-card-modal/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/issue-card-modal/design.md b/openspec/changes/issue-card-modal/design.md index 47aea88..34eae56 100644 --- a/openspec/changes/issue-card-modal/design.md +++ b/openspec/changes/issue-card-modal/design.md @@ -166,3 +166,4 @@ | R-3 | 2026-10-15 | В карточке показываются все комментарии задачи, без постраничности; в MCP — 5 последних | В задачах проекта комментариев единицы; окно прокручивается внутри | При десятках длинных комментариев — длинная прокрутка окна; лечится «Скрыть» | | R-4 | 2026-10-15 | «Команды моста» свёрнуты всегда при открытии, выбор не запоминается; «Скрыть» комментарии запоминается | Команды нужны редко и по конкретному поводу; комментарии владелец просил скрывать — это предпочтение | Лишний щелчок, если команды смотрят часто | | R-5 | 2026-10-15 | Отмена правки заметки — кнопка «Отмена» с подтверждением при правках (в разделе 1 дизайна был только «Править»/«Сохранить») | Из режима правки нужен выход без сохранения, не закрывая окно | Лишний вопрос при отмене | +| R-6 | 2026-10-15 | В Task 4 тесты адаптированы минимально: `within(main)` вместо глобального `getByRole("table")` (в окне есть таблица команд); убран `{exact: true}` (нет в типах); тест ErrorBoundary в App.test.tsx добавляет Boom-маршрут в `PAGE_ROUTES` (unshift/splice в finally), т.к. Layout больше не рисует Outlet | Код плана не компилировался/находил две таблицы; поведение и проверяемые утверждения не изменены | Низкая: только тесты | -- 2.47.2 From 102cef412528721d1152726464c12d501f382655 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 13:14:39 +0400 Subject: [PATCH 08/15] =?UTF-8?q?feat(web):=20=D1=80=D0=B0=D1=81=D0=BA?= =?UTF-8?q?=D0=BB=D0=B0=D0=B4=D0=BA=D0=B0=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B8=20=E2=80=94=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B0?= =?UTF-8?q?=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D1=8F,=20?= =?UTF-8?q?=D1=81=D0=B2=D1=8F=D0=B7=D0=B8=20=D0=BF=D0=BE=20=D1=81=D0=BC?= =?UTF-8?q?=D1=8B=D1=81=D0=BB=D1=83,=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8,=20=D0=B7=D0=B0=D0=BC?= =?UTF-8?q?=D0=B5=D1=82=D0=BA=D0=B8=20=D0=B2=20=D1=80=D0=B5=D0=B6=D0=B8?= =?UTF-8?q?=D0=BC=D0=B5=20=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D1=8F=20(#37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/issues/IssueCard.test.tsx | 166 +++++++++++++++++++++--- web/src/issues/IssueCard.tsx | 199 ++++++++++++++++++++++------- web/src/issues/IssueModal.test.tsx | 1 + web/src/issues/NoteForm.tsx | 27 ++-- web/src/issues/Notes.tsx | 26 ++++ web/src/issues/issues.css | 47 ++++++- 6 files changed, 391 insertions(+), 75 deletions(-) create mode 100644 web/src/issues/Notes.tsx diff --git a/web/src/issues/IssueCard.test.tsx b/web/src/issues/IssueCard.test.tsx index 452c79d..6d3c4f8 100644 --- a/web/src/issues/IssueCard.test.tsx +++ b/web/src/issues/IssueCard.test.tsx @@ -1,4 +1,4 @@ -import { act, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClientProvider } from "@tanstack/react-query"; import { createMemoryRouter, RouterProvider, useNavigate, useParams } from "react-router"; @@ -31,16 +31,109 @@ function open(path: string, client = makeQueryClient()) { describe("карточка задачи", () => { beforeEach(() => setToken("tok")); - it("поля, описание с переносами, связи с подписями, команды", async () => { + it("шапка: ключ, статусы, тема, исполнитель · срок · спринт · эпик по имени; ссылка в Jira", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { planned_end: "2020-01-10" }))); + open("/issues/AURORA-9001"); + expect(await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9001" })).toBeInTheDocument(); + const head = screen.getByTestId("issue-head"); + expect(within(head).getByText("AURORA-9001")).toBeInTheDocument(); + expect(within(head).getByText("В работе", { selector: ".badge.status" })).toBeInTheDocument(); + expect(within(head).getByText("В работе", { selector: ".badge.fact" })).toHaveClass("fact-in-progress"); + expect(within(head).getByTestId("issue-facts")).toHaveTextContent("Иванов Иван Иванович · срок 10.01 просрочено · AWPCA_Спринт 2 · эпик: Тестовый эпик"); + expect(within(head).getByText("просрочено")).toHaveClass("overdue"); + expect(screen.getByRole("link", { name: "Открыть в Jira ↗" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001"); + }); + + it("связи по группам с темой и статусом; задача вне зеркала; прочий тип с направлением", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { + links_out: [{ target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала", summary: "API свода", status: "Новая" }, + { target_key: "lower-1", link_type: "ASUPR_Связанные", summary: null, status: null }], + links_in: [{ source_key: "AURORA-9003", link_type: "ASUPR_Блокер_Завершения", summary: "Макет экрана", status: "Готово" }, + { source_key: "AURORA-9008", link_type: "Duplicate", summary: "Актуализация ТЗ", status: "В работе" }], + }))); + open("/issues/AURORA-9001"); + const depends = await screen.findByRole("group", { name: "Зависит от" }); + expect(within(depends).getByRole("listitem")).toHaveTextContent("завершение AURORA-9003 Макет экрана Готово"); + const blocks = screen.getByRole("group", { name: "Блокирует" }); + expect(within(blocks).getByRole("link", { name: "AURORA-9002" })).toHaveAttribute("href", "/issues/AURORA-9002"); + expect(within(blocks).getByRole("listitem")).toHaveTextContent("начало AURORA-9002 API свода Новая"); + expect(within(screen.getByRole("group", { name: "Связана с" })).getByRole("listitem")).toHaveTextContent("lower-1 нет в зеркале"); + expect(within(screen.getByRole("group", { name: "Прочие" })).getByRole("listitem")).toHaveTextContent("← Duplicate AURORA-9008"); + expect(screen.getByRole("link", { name: "AURORA-9002" }).closest(".issue")).not.toBeNull(); // O-10: цвет темы + }); + + it("без связей — «связей нет»", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { links_out: [], links_in: [] }))); + open("/issues/AURORA-9001"); + expect(await screen.findByText("связей нет")).toBeInTheDocument(); + }); + + it("описание свыше 8 строк свёрнуто, «развернуть»/«свернуть»; короткое — без кнопки", async () => { + const long = Array.from({ length: 10 }, (_, n) => `строка ${n + 1}`).join("\n"); + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: long }))); + open("/issues/AURORA-9001"); + const pre = await screen.findByText(long, { normalizer: (s) => s }); + expect(pre).toHaveClass("description", "clamped"); + await userEvent.click(screen.getByRole("button", { name: "развернуть" })); + expect(pre).not.toHaveClass("clamped"); + await userEvent.click(screen.getByRole("button", { name: "свернуть" })); + expect(pre).toHaveClass("clamped"); + }); + + it("короткое описание — без кнопки; нет описания — нет раздела", async () => { + let d: string | null = "строка 1\nстрока 2"; + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: d }))); + open("/issues/AURORA-9001"); + expect(await screen.findByText("строка 1\nстрока 2", { normalizer: (s) => s })).toHaveClass("description"); + expect(screen.queryByRole("button", { name: "развернуть" })).toBeNull(); + d = null; + cleanup(); + open("/issues/AURORA-9001"); + await screen.findByRole("heading", { level: 2 }); + expect(screen.queryByRole("heading", { name: "Описание" })).toBeNull(); + }); + + it("комментарии новые сверху; «Скрыть» запоминается для следующей карточки", async () => { + const comments = [ + { comment_id: "2", author: "Петров Пётр", created: "2026-10-15T12:00:00.000+0300", updated: null, body: "новый\nвторая строка" }, + { comment_id: "1", author: "Иванов Иван", created: "2026-10-14T12:00:00.000+0300", updated: null, body: "старый" }]; + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { comments }))); + open("/issues/AURORA-9001"); + expect(await screen.findByRole("heading", { name: "Комментарии (2)" })).toBeInTheDocument(); + const items = within(screen.getByTestId("comments")).getAllByRole("listitem"); + expect(items[0]).toHaveTextContent("Петров Пётр"); + expect(within(items[0]).getByText("новый\nвторая строка", { normalizer: (s) => s })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Скрыть" })); + expect(screen.queryByText("старый")).toBeNull(); + expect(localStorage.getItem("jira-hub-comments-hidden")).toBe("1"); + cleanup(); + open("/issues/AURORA-9001"); + expect(await screen.findByRole("button", { name: "Показать" })).toBeInTheDocument(); + expect(screen.queryByText("старый")).toBeNull(); + }); + + it("комментарий без автора и текста не ломает карточку; без комментариев — «комментариев нет»", async () => { + let comments = [{ comment_id: "1", author: null, created: null, updated: null, body: "" }]; + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { comments }))); + open("/issues/AURORA-9001"); + expect(await screen.findByRole("heading", { name: "Комментарии (1)" })).toBeInTheDocument(); + expect(within(screen.getByTestId("comments")).getByRole("listitem")).toHaveTextContent("—"); + comments = []; + cleanup(); + open("/issues/AURORA-9001"); + expect(await screen.findByText("комментариев нет")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Скрыть" })).toBeNull(); + }); + + it("прочие поля и команды моста свёрнуты", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); open("/issues/AURORA-9001"); - expect(await screen.findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument(); - expect(screen.getByText("строка 1\nстрока 2", { normalizer: (s) => s })).toHaveClass("description"); - expect(await screen.findByText("блокирует начало")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "AURORA-9002" })).toHaveAttribute("href", "/issues/AURORA-9002"); - expect(screen.getByText("Duplicate")).toBeInTheDocument(); // неизвестный тип — имя Jira - expect(screen.getByRole("cell", { name: "ensure_fields" })).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Открыть в Jira" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001"); + const other = await screen.findByRole("region", { name: "Прочие поля" }); + expect(other).toHaveTextContent("Приоритет"); + expect(other).toHaveTextContent("Высокий"); + const details = screen.getByText("Команды моста (1)").closest("details")!; + expect(details).not.toHaveAttribute("open"); + expect(within(details).getByRole("cell", { name: "ensure_fields" })).toBeInTheDocument(); }); it("заголовок карточки — h2 с id для aria-labelledby окна", async () => { @@ -65,13 +158,15 @@ describe("карточка задачи", () => { return json(200, issue("AURORA-9001")); }); open("/issues/AURORA-9001"); + await userEvent.click(await screen.findByRole("button", { name: "Править" })); const rest = await screen.findByLabelText("Осталось"); await userEvent.clear(rest); await userEvent.type(rest, "тесты"); await userEvent.clear(screen.getByLabelText("Сделано")); await userEvent.click(screen.getByRole("button", { name: "Сохранить" })); await waitFor(() => expect(put).toEqual({ rest_text: "тесты", done_text: null, updated_by: "web" })); - expect(await screen.findByText(/сохранено .*web/)).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Сохранить" })).toBeDisabled(); + expect(await screen.findByText(/изменено .*web/)).toBeInTheDocument(); // снова режим чтения + expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Сохранить" })).toBeNull(); }); it("422 — текст ошибки у формы, введённое на месте", async () => { @@ -81,6 +176,7 @@ describe("карточка задачи", () => { return json(200, issue("AURORA-9001")); }); open("/issues/AURORA-9001"); + await userEvent.click(await screen.findByRole("button", { name: "Править" })); const owner = await screen.findByLabelText("Заметка владельца"); await userEvent.type(owner, "проверить"); await userEvent.click(screen.getByRole("button", { name: "Сохранить" })); @@ -91,6 +187,7 @@ describe("карточка задачи", () => { it("уход с несохранёнными правками требует подтверждения", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); const router = open("/issues/AURORA-9001"); + await userEvent.click(await screen.findByRole("button", { name: "Править" })); await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик"); await act(async () => { await router.navigate("/board"); }); expect(await screen.findByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeInTheDocument(); @@ -128,6 +225,7 @@ describe("карточка задачи", () => { const client = makeQueryClient(); client.setQueryData(qk.issue("AURORA-9002"), issue("AURORA-9002")); const router = open("/issues/AURORA-9001", client); + await userEvent.click(await screen.findByRole("button", { name: "Править" })); await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик"); await userEvent.click(screen.getByRole("button", { name: "Сохранить" })); await act(async () => { await router.navigate("/issues/AURORA-9002"); }); @@ -136,8 +234,8 @@ describe("карточка задачи", () => { slowPut.resolve(json(200, note({ owner_note: "черновик", updated_by: "web" }))); await waitFor(() => expect(client.isMutating()).toBe(0)); expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); - expect(screen.getByLabelText("Заметка владельца")).toHaveValue(""); - expect(screen.getByRole("button", { name: "Сохранить" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument(); // новая карточка — в режиме чтения + expect(screen.queryByText("черновик")).toBeNull(); }); it("T14.1: поля заметки заблокированы на время сохранения, ответ не затирает ввод", async () => { @@ -148,13 +246,14 @@ describe("карточка задачи", () => { return json(200, issue("AURORA-9001")); }); open("/issues/AURORA-9001"); + await userEvent.click(await screen.findByRole("button", { name: "Править" })); await userEvent.type(await screen.findByLabelText("Заметка владельца"), "раз"); await userEvent.click(screen.getByRole("button", { name: "Сохранить" })); const box = screen.getByLabelText("Заметка владельца"); await waitFor(() => expect(box).toBeDisabled()); slowPut.resolve(json(200, note({ owner_note: "раз", updated_by: "web" }))); - await waitFor(() => expect(box).toBeEnabled()); - expect(box).toHaveValue("раз"); + await screen.findByRole("button", { name: "Править" }); // режим чтения (findByText("раз") ловил бы ещё живой textarea) + expect(screen.getByText("раз")).toBeInTheDocument(); // с сохранённым значением }); it("M-2: ошибка повторной загрузки при уже имеющихся данных — данные остаются, строка с «Повторить»", async () => { @@ -186,9 +285,46 @@ describe("карточка задачи", () => { it("T14.3: id подсказок «статуса по факту» уникален и связан с полем", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); open("/issues/AURORA-9001"); + await userEvent.click(await screen.findByRole("button", { name: "Править" })); const input = await screen.findByLabelText("Статус по факту"); const id = input.getAttribute("list")!; expect(id).not.toBe("fact-statuses"); expect(document.getElementById(id)?.tagName).toBe("DATALIST"); }); + + it("заметки в режиме чтения: пустые — «—», время и автор; «Отмена» без правок — сразу", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); + open("/issues/AURORA-9001"); + const view = await screen.findByRole("region", { name: "Служебные заметки" }); + expect(view).toHaveTextContent("Сделано"); + expect(view).toHaveTextContent("схема"); + expect(within(view).getByText("Заметка владельца").nextElementSibling).toHaveTextContent("—"); + expect(view).toHaveTextContent(/изменено .*chat/); + await userEvent.click(within(view).getByRole("button", { name: "Править" })); + await userEvent.click(screen.getByRole("button", { name: "Отмена" })); + expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument(); + }); + + it("«Отмена» с правками спрашивает; «Отменить правки» возвращает чтение без изменений", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); + open("/issues/AURORA-9001"); + await userEvent.click(await screen.findByRole("button", { name: "Править" })); + await userEvent.type(screen.getByLabelText("Заметка владельца"), "черновик"); + await userEvent.click(screen.getByRole("button", { name: "Отмена" })); + expect(screen.getByText("Отменить правки заметки?")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Продолжить правку" })); + expect(screen.getByLabelText("Заметка владельца")).toHaveValue("черновик"); + await userEvent.click(screen.getByRole("button", { name: "Отмена" })); + await userEvent.click(screen.getByRole("button", { name: "Отменить правки" })); + expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument(); + expect(screen.queryByText("черновик")).toBeNull(); + }); + + it("заметки ещё нет — режим чтения с «—» и «Править»", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { note: null }))); + open("/issues/AURORA-9001"); + const view = await screen.findByRole("region", { name: "Служебные заметки" }); + expect(within(view).getAllByText("—")).toHaveLength(5); + expect(within(view).getByRole("button", { name: "Править" })).toBeInTheDocument(); + }); }); diff --git a/web/src/issues/IssueCard.tsx b/web/src/issues/IssueCard.tsx index 222e4da..f25bca2 100644 --- a/web/src/issues/IssueCard.tsx +++ b/web/src/issues/IssueCard.tsx @@ -1,68 +1,177 @@ -import { IssueLink } from "./IssueLink.tsx"; +import { useLayoutEffect, useRef, useState } from "react"; import { ApiError } from "../api/client.ts"; import { useIssue, useSettings } from "../api/queries.ts"; +import type { IssueComment, IssueDetail } from "../api/types.ts"; import { JIRA_URL } from "../board/constants.ts"; -import { fmtStamp } from "../board/model/util.ts"; -import { NoteForm } from "./NoteForm.tsx"; +import { fmtDate, fmtStamp } from "../board/model/util.ts"; +import { factClass, groupLinks, isOverdue, todayISO } from "./card.ts"; +import { useCommentsHidden } from "./commentsPref.ts"; +import { IssueLink } from "./IssueLink.tsx"; +import { Notes } from "./Notes.tsx"; import { dueOf } from "./options.ts"; import "./issues.css"; +const DESC_LINES = 8; + export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClose: () => void }) { const issue = useIssue(key); const settings = useSettings(); - // подпись типа связи: логический тип из link_types настроек, иначе имя типа Jira - const typeName = (jira: string) => - Object.entries(settings.data?.link_types ?? {}).find(([, j]) => j === jira)?.[0] ?? jira; - - if (issue.isPending) return

загрузка {key}…

; + const close = ; + if (issue.isPending) return

загрузка {key}…

{close}
; if (issue.error instanceof ApiError && issue.error.status === 404) { return

Задачи {key} нет в зеркале хаба.

; } if (issue.isError && !issue.data) { return ( -

{issue.error.message}{" "} -

+
+

{issue.error.message}{" "} +

+ +
); } const i = issue.data!; - const fields: [string, string | null][] = [ - ["Тип", i.issue_type], ["Статус Jira", i.status], ["Приоритет", i.priority], ["Исполнитель", i.assignee], - ["Автор", i.reporter], ["Спринт", i.sprint], ["Срок", dueOf(i) || null], ["Плановое начало", i.planned_start], - ["Эпик", i.epic_key], ["Метки", i.labels.join(", ") || null], ["Сосед по связям", i.neighbor ? "да" : null], - ["Обновлена в Jira", i.jira_updated ? fmtStamp(i.jira_updated) : null], - ]; + const labels = settings.data?.status_labels as Record | undefined; + const fact = i.note?.fact_status ?? null; + const cls = factClass(fact, labels); + const due = dueOf(i); + const facts = [ + i.assignee && {i.assignee}, + due && срок {fmtDate(due)}{isOverdue(due, fact, labels, todayISO()) && <> просрочено}, + i.sprint && {i.sprint}, + i.epic_key && эпик: {i.epic_summary ?? i.epic_key}, + ].filter(Boolean); return ( -
- {issue.isError && ( -

{issue.error.message}{" "} -

- )} -

-

{i.key} · {i.summary}

-

Открыть в Jira

-
-
-
{fields.filter(([, v]) => v).map(([k, v]) =>
{k}
{v}
)}
- {i.description &&
{i.description}
} -

Связи

-
    - {i.links_out.map((l) =>
  • {typeName(l.link_type)} →
  • )} - {i.links_in.map((l) =>
  • → {typeName(l.link_type)} → эта задача
  • )} - {!i.links_out.length && !i.links_in.length &&
  • связей нет
  • } -
-

Последние команды

- {i.commands.length ? ( - - - {i.commands.map((c) => ( - - - ))} -
КомандаПакетТипСтатусЗавершенаОшибка
#{c.id}#{c.batch_id}{c.type}{c.status}{c.finished_at ? fmtStamp(c.finished_at) : ""}{c.error}
- ) :

команд по задаче не было

} +
+
+
+ {i.key} + {i.status && {i.status}} + {fact && {fact}} + + Открыть в Jira ↗ + {close} +
+

{i.summary || i.key}

+ {facts.length > 0 &&

{facts.flatMap((f, n) => (n ? [" · ", f] : [f]))}

} + {issue.isError && ( +

{issue.error.message}{" "} +

+ )} +
+
+
+ } /> + {i.description && } + +
+
+ + +
-
); } + +function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record }) { + const groups = groupLinks(issue, linkTypes); + return ( +
+

Связи

+ {!groups.length &&

связей нет

} + {groups.map(([id, title, links]) => ( +
+ +
    + {links.map((l) => ( +
  • + {l.tag && <>{l.tag}{" "}} + {" "} + {l.summary === null && l.status === null + ? нет в зеркале + : <>{l.summary}{l.status && <>{" "}{l.status}}} +
  • + ))} +
+
+ ))} +
+ ); +} + +function Description({ text }: { text: string }) { + const ref = useRef(null); + const [open, setOpen] = useState(false); + const [overflow, setOverflow] = useState(false); + useLayoutEffect(() => { + const el = ref.current; + if (el && !open) setOverflow(el.scrollHeight > el.clientHeight + 1); + }, [text, open]); + const long = text.split("\n").length > DESC_LINES || overflow; + return ( +
+

Описание

+
{text}
+ {(long || open) && } +
+ ); +} + +function Comments({ items }: { items: IssueComment[] }) { + const [hidden, setHidden] = useCommentsHidden(); + return ( +
+
+

Комментарии ({items.length})

+ {items.length > 0 && } +
+ {!items.length &&

комментариев нет

} + {items.length > 0 && !hidden && ( +
    + {items.map((c) => ( +
  • +
    {c.author ?? "—"}{c.created ? `, ${fmtStamp(c.created)}` : ""}
    +
    {c.body}
    +
  • + ))} +
+ )} +
+ ); +} + +function OtherFields({ issue: i }: { issue: IssueDetail }) { + const rows: [string, string | null][] = [ + ["Тип", i.issue_type], ["Приоритет", i.priority], ["Автор", i.reporter], ["Метки", i.labels.join(", ") || null], + ["Плановое начало", i.planned_start], ["Обновлена в Jira", i.jira_updated ? fmtStamp(i.jira_updated) : null], + ["Сосед по связям", i.neighbor ? "да" : null], + ]; + return ( +
+

Прочие поля

+
{rows.filter(([, v]) => v).map(([k, v]) =>
{k}
{v}
)}
+
+ ); +} + +function Commands({ issue: i }: { issue: IssueDetail }) { + if (!i.commands.length) { + return

Команды моста

команд по задаче не было

; + } + return ( +
+
+ Команды моста ({i.commands.length}) + + + {i.commands.map((c) => ( + + + ))} +
КомандаПакетТипСтатусЗавершенаОшибка
#{c.id}#{c.batch_id}{c.type}{c.status}{c.finished_at ? fmtStamp(c.finished_at) : ""}{c.error}
+
+
+ ); +} diff --git a/web/src/issues/IssueModal.test.tsx b/web/src/issues/IssueModal.test.tsx index 1e9f6d8..90e300f 100644 --- a/web/src/issues/IssueModal.test.tsx +++ b/web/src/issues/IssueModal.test.tsx @@ -115,6 +115,7 @@ describe("карточка окном", () => { const router = at(["/app/issues"]); render(); await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" })); + await userEvent.click(await within(dialog()).findByRole("button", { name: "Править" })); const box = await within(dialog()).findByLabelText("Заметка владельца"); await userEvent.type(box, "черновик"); fireEvent(dialog(), new Event("cancel", { cancelable: true })); diff --git a/web/src/issues/NoteForm.tsx b/web/src/issues/NoteForm.tsx index e4ea965..d2e229b 100644 --- a/web/src/issues/NoteForm.tsx +++ b/web/src/issues/NoteForm.tsx @@ -1,9 +1,8 @@ -import { useEffect, useId, useMemo, useState } from "react"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; import { useBlocker } from "react-router"; import { ApiError } from "../api/client.ts"; import { useSaveNote, useSettings } from "../api/queries.ts"; import { NOTE_FIELDS, type IssueDetail, type NoteField, type NoteFields } from "../api/types.ts"; -import { fmtStamp } from "../board/model/util.ts"; type Values = Record; const valuesOf = (n: IssueDetail["note"]): Values => @@ -18,9 +17,11 @@ export function changedFields(initial: Values, values: Values): NoteFields { const TEXTS: [NoteField, string][] = [["done_text", "Сделано"], ["rest_text", "Осталось"], ["owner_note", "Заметка владельца"]]; -export function NoteForm({ issue }: { issue: IssueDetail }) { +export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () => void }) { const settings = useSettings(); const save = useSaveNote(issue.key); + const [askCancel, setAskCancel] = useState(false); + const confirmRef = useRef(null); const [initial, setInitial] = useState(() => valuesOf(issue.note)); const [values, setValues] = useState(initial); const changed = useMemo(() => changedFields(initial, values), [initial, values]); @@ -33,6 +34,8 @@ export function NoteForm({ issue }: { issue: IssueDetail }) { window.addEventListener("beforeunload", warn); return () => window.removeEventListener("beforeunload", warn); }, [dirty]); + // подтверждение ухода может оказаться ниже видимой части окна — прокрутить к нему + useEffect(() => { if (blocker.state === "blocked") confirmRef.current?.scrollIntoView?.({ block: "nearest" }); }, [blocker.state]); const kinds = Object.keys(settings.data?.kind_labels ?? {}); const kindOptions = values.work_kind && !kinds.includes(values.work_kind) ? [values.work_kind, ...kinds] : kinds; @@ -41,14 +44,13 @@ export function NoteForm({ issue }: { issue: IssueDetail }) { const set = (f: NoteField) => (e: { target: { value: string } }) => setValues((v) => ({ ...v, [f]: e.target.value })); const submit = () => save.mutate(changed, { - onSuccess: (note) => { const v = valuesOf(note); setInitial(v); setValues(v); }, + onSuccess: (note) => { const v = valuesOf(note); setInitial(v); setValues(v); onDone(); }, }); const err = save.error instanceof ApiError ? save.error.detail ?? save.error.message : save.error?.message; - const saved = save.data ?? issue.note; return ( - { e.preventDefault(); submit(); }}> -

Служебные заметки

+ { e.preventDefault(); submit(); }}> +

Служебные заметки