Карточка задачи окном и комментарии из Jira (#37) #40

Merged
aep merged 15 commits from feat/37-issue-card-modal into main 2026-10-15 14:31:53 +03:00
51 changed files with 4126 additions and 363 deletions

1
.gitignore vendored
View File

@ -16,3 +16,4 @@ src/jira_hub/web/app/
web/test-results/
web/playwright-report/
web/.e2e-hub/
*.tsbuildinfo

View File

@ -9,7 +9,7 @@
| `paths` | каталог данных (`JIRA_HUB_HOME`), пути базы и `backups/`, права 0700 (`secure_dir`) | stdlib |
| `db` | схема SQLite, миграции по `schema_version` (копия базы перед миграцией), `connect` | `backup`, `paths` |
| `settings` | настройки (`setting`), токены, темп, перенос из `config.example.json` | `db` |
| `mirror` | зеркало задач и связей, `apply_state`, заметки, фильтры, снимок доски `board` | `db` |
| `mirror` | зеркало задач, связей и комментариев, `apply_state` (с проверкой `check_comments`), заметки, фильтры, снимок доски `board` | `db` |
| `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` |
| `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` |
| `backup` | копии файла базы, ротация | `paths` |
@ -22,7 +22,8 @@
| `cli` | `init`, `serve`, `import`, `mcp`, `tokens`, `agent publish` | `api`, `mcp_server`, `importer`, `release`, `settings`, `db`, `paths` |
`web/` — веб-приложение хаба (React + TypeScript + Vite): вход по клиентскому токену, страницы «Доска» (`/app/board`)
и «Задачи» (`/app/issues`, `/app/issues/{key}`). Собирается `make web` в `src/jira_hub/web/app/`; `api` отдаёт сборку
и «Задачи» (`/app/issues`); карточка `/app/issues/{key}` — окно поверх страницы, с которой её открыли: `shell/Layout.tsx` рисует
страницу по адресу из `location.state.background` (`useRoutes` с явным адресом) и `issues/IssueModal.tsx` поверх неё. Собирается `make web` в `src/jira_hub/web/app/`; `api` отдаёт сборку
без токена (`/app/assets/*` — файлы, прочие `/app…` — `index.html`, 503 без сборки), `/` и `/links` перенаправляет на
`/app/board`. Данные — только через HTTP API с клиентским токеном; `mirror.board` отдаёт зеркало без преобразований,
перевод в модель доски — в `web/src/board/model`. Логика доски — чистые модули `web/src/board/{model,analysis,layout}`

View File

@ -158,7 +158,7 @@ curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8790/issues?sprint=
## Веб-приложение
Адреса: `http://127.0.0.1:8790/app/` — доска `/app/board`, задачи `/app/issues`, карточка `/app/issues/{key}`; `/` и
Адреса: `http://127.0.0.1:8790/app/` — доска `/app/board`, задачи `/app/issues`, карточка `/app/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и
`/links` перенаправляют на доску. Вход по клиентскому токену (`uv run jira-hub tokens`), токен хранится в браузере;
`localhost` и `127.0.0.1` — разные сайты для браузера, токен и раскладка у каждого свои. Выкатка на Mac mini:
@ -174,6 +174,11 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub
читает те же ключи `localStorage`, что прежняя страница. Разработка: `bash web/scripts/test-hub.sh` (тестовый хаб
:8791 с синтетическими данными) и `cd web && npm run dev` (http://127.0.0.1:5173/app/).
Карточка задачи: в шапке — статус Jira, статус по факту, исполнитель, срок (просроченный — красным), спринт и эпик;
связи сгруппированы «Зависит от» / «Блокирует» / «Связана с» / «Прочие» с темой и статусом связанной задачи;
комментарии из Jira (агент v10+) новые сверху, «Скрыть» запоминается в браузере; заметки — режим чтения, «Править»
открывает форму. Комментарии появляются после первого обновления из Jira агентом v10.
## Контракт команд
Каждая команда описывает желаемое состояние одной задачи; агент читает, сравнивает и пишет только разницу, повтор с тем же

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-15

View File

@ -0,0 +1,173 @@
## 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` с `<Outlet>`; маршруты `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, direct}`: `background` — адрес страницы под окном (`pathname + search`),
`depth` — сколько записей истории занято карточками поверх неё (1 при открытии со страницы, +1 при переходе по
связи), `direct` — окно открыто прямым адресом (переносится в переходы по связям). Маршрут верхнего уровня —
`{path: "*", element: <Layout/>}`; `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. **Элемент `<dialog>`** с `showModal()`: фокус удерживается в окне, фон недоступен, Esc вызывает `cancel` (обрабатываем
сами — закрытие по п. 12 с проверкой заметок; если `cancel` не отменяемый — браузер закрыл окно сам без активации
пользователя, — по событию `close` окно открывается снова и закрытие идёт тем же путём). Щелчок по фону — щелчок по
самому `<dialog>` вне области содержимого, причём нажатие (`pointerdown`) тоже было на фоне (I-2: выделение текста,
отпущенное за краем окна, иначе закрывало окно).
Прокрутка страницы под окном на время открытия блокируется (`overflow: hidden` у `body`), содержимое окна
прокручивается внутри, шапка окна закреплена. Ширина до 1000 px, высота до 90vh; на экранах уже 700 px — на всю
ширину.
14. **Несохранённые заметки.** Подтверждение «Есть несохранённые правки заметки. Уйти без сохранения?» при закрытии окна,
переходе по связи и уходе в раздел; условие блокировки — смена `pathname` при правках (как сейчас), закрытие по
п. 12 проходит через тот же блокировщик. Отказ — окно остаётся с введённым текстом.
15. **Страница под окном после сохранения заметки.** Сохранение уже инвалидирует `board` и `["issues"]`
(`useSaveNote`): фоновая страница смонтирована и перечитывается. Список задач показывает новые «вид работы» и
«статус по факту» (колонки из заметки). Доска перечитывает снимок, но её карточки берут вид работы из меток
`kind-*` Jira, а статус — из Jira (поведение `links.html`, требование «Доска связей»), поэтому правка заметки на
карточке доски не видна (R-7). Отдельного кода нет — поведение закрепляется тестом.
### Веб-приложение: раскладка карточки
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)** — свёрнуты (`<details>`), внутри нынешняя таблица; команд нет — «команд по задаче не было» и
раздел не сворачивается.
23. **Колонки**: под шапкой слева — «Связи», «Описание», «Комментарии»; справа (≈320 px) «Заметки», «Прочие
поля», «Команды моста». Уже 900 px — одна колонка: заметки, связи, описание, комментарии, прочие поля, команды.
24. **Состояния окна**: загрузка — «загрузка <key>…»; 404 — «Задачи <key> нет в зеркале хаба.» и «Закрыть»; хаб не
отвечает — сообщение и «Повторить», ранее загруженные данные остаются; поверх ошибки при данных — предупреждение
в шапке окна.
### Проверки
25. **Тестовый хаб**: `web/scripts/seed-extras.json` — дополнения к фикстуре доски только для тестового хаба: у AURORA-9001
описание длиннее 8 строк, три комментария и две команды (пакет автора `seed`, команды завершены). `seed-hub.py`
применяет их после фикстуры. Связи не добавляются (они меняют доску и её e2e); группа «Прочие» и связь на задачу вне
зеркала проверяются в vitest, а связь на задачу вне зеркала (`lower-1`) уже есть в фикстуре. Фикстура доски и её
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 дизайна был только «Править»/«Сохранить») | Из режима правки нужен выход без сохранения, не закрывая окно | Лишний вопрос при отмене |
| 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 | Код плана не компилировался/находил две таблицы; поведение и проверяемые утверждения не изменены | Низкая: только тесты |
| R-7 | 2026-10-15 | Сценарий «Заметка видна на доске» (web-app) сужен: проверяется перезапрос `GET /board` с новым `work_kind` и сохранность доски под окном, а не отображение вида работы на карточке доски | Доска берёт вид работы из меток `kind-*` Jira, `work_kind` заметки в карточку доски не попадает; менять отрисовку доски вне объёма change (Non-Goals) | Владелец может ждать видимого обновления вида работы на доске; нужен отдельный change |

View File

@ -0,0 +1,62 @@
## 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».
- **Веб-приложение**:
- карточка задачи — модальное окно (`<dialog>`) поверх списка «Задачи» или доски; адрес `/app/issues/{key}`; прямой
адрес открывает окно поверх списка задач без фильтров; закрытие (✕, Esc, щелчок по фону) возвращает туда, откуда
открыли, и «назад» после закрытия карточку не открывает;
- новая раскладка: шапка (ключ, статус Jira, статус по факту, тема, исполнитель · срок · спринт · эпик по имени,
«Открыть в Jira», ✕); связи по группам «Зависит от», «Блокирует», «Связана с», «Прочие» с темой и статусом;
описание свёрнуто до 8 строк; комментарии со «Скрыть/Показать», выбор запоминается в браузере; заметки в режиме
чтения с «Править»; «Прочие поля»; «Команды моста» свёрнуты;
- после сохранения заметки список задач под окном показывает новые «вид работы» и «статус по факту»; доска под
окном перечитывает снимок, но её карточки берут вид работы из меток Jira, а статус — из Jira (R-7), поэтому
правка заметки на карточке доски не видна.
- Спеки: `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.

View File

@ -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 = []`

View File

@ -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 обновлены, три комментария на месте

View File

@ -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 = "Тестовый эпик"`

View File

@ -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<N> от <дата>» или «публикации нет»), состояние обновления («обновление: <state> v<from> → v<to>»), запускатель («запускатель: v<N>», «без запускателя» или, пока `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»

View File

@ -0,0 +1,165 @@
## 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
показывать окно поверх списка задач без фильтров. Окно 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
показывать новые «вид работы» и «статус по факту» без «Перечитать», доска под окном SHALL перечитать снимок
(`GET /board`) без потери раскладки и прокрутки; при 422 — текст ошибки у формы без
потери введённого. Закрытие окна, переход по связи и уход в другой раздел с несохранёнными правками SHALL требовать
подтверждения. Заметки пишутся только в хаб, в Jira приложение ничего не отправляет.
#### Scenario: Только изменённые поля
- **WHEN** в заметке нажато «Править», изменено только «осталось» и нажато «Сохранить»
- **THEN** тело `PUT /issues/{key}/note` — `{rest_text: "…", updated_by: "web"}`; карточка снова в режиме чтения с новым
«Осталось»
#### Scenario: Уход с несохранёнными правками
- **WHEN** в форме заметки есть несохранённая правка и нажата Esc
- **THEN** приложение спрашивает подтверждение; при отказе окно открыто, форма с введённым текстом
#### Scenario: Заметка видна на доске
- **WHEN** карточка AURORA-9002 открыта с доски, вид работы изменён на «тестирование» и сохранён
- **THEN** доска под окном остаётся на месте и перезапрашивается (`GET /board` отдаёт `work_kind` «тестирование»); окно закрывается без потери раскладки и прокрутки доски

View File

@ -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"])

View File

@ -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), данных переносить не нужно
}

View File

@ -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:

View File

@ -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",

View File

@ -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"] == "Эпик"

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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="")])])

View File

@ -53,13 +53,15 @@ test("«Обновить из Jira» с учебным агентом доход
await expect(page.locator(".board .card[data-key]")).not.toHaveCount(0);
});
test("ⓘ открывает карточку задачи в приложении", async ({ page }) => {
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("heading", { name: /AURORA-9001/ })).toBeVisible();
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 }) => {
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; });
@ -72,9 +74,37 @@ test("M-3: прокрутка доски после возврата из кар
return a?.closest<HTMLElement>(".card")?.dataset.key ?? null;
});
expect(key).not.toBeNull();
const link = page.locator(`.board .card[data-key="${key}"] a.open-card`);
await link.click();
await expect(page).toHaveURL(/\/app\/issues\//);
await page.getByRole("link", { name: "Доска", exact: true }).click();
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); // доска не перезагружалась
// ссылки меню вверху страницы: обычный click прокрутил бы доску к ним до перехода и затёр запомненное, поэтому dispatchEvent
await page.getByRole("link", { name: "Задачи", exact: true }).dispatchEvent("click");
await expect(page).toHaveURL(/\/app\/issues$/);
await page.getByRole("link", { name: "Доска", exact: true }).dispatchEvent("click");
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
});
test("заметка из окна уходит в хаб, карточка на доске остаётся на месте, прокрутка сохраняется", async ({ page }) => {
// доска показывает вид работы по меткам Jira (fromHub), заметка work_kind в карточку доски не попадает: проверяем снимок /board
await page.locator('.board .card[data-key="AURORA-9002"]').evaluate((e) => { (e as HTMLElement).dataset.marker = "keep"; });
await page.locator('.board .card[data-key="AURORA-9002"] a.open-card').click();
const d = page.getByRole("dialog");
const y = await page.evaluate(() => window.scrollY);
await d.getByRole("button", { name: "Править" }).click();
await d.getByLabel("Вид работы").selectOption("тестирование");
const snap = page.waitForResponse((r) => new URL(r.url()).pathname === "/board" && r.request().method() === "GET");
await d.getByRole("button", { name: "Сохранить" }).click();
const body = await (await snap).json();
expect(body.issues.find((i: { key: string }) => i.key === "AURORA-9002").work_kind).toBe("тестирование");
await expect(d.getByRole("button", { name: "Править" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("page-board")).toBeAttached();
await expect(page.locator('.board .card[data-key="AURORA-9002"]')).toBeVisible();
await expect(page.locator('.board .card[data-key="AURORA-9002"]')).toHaveAttribute("data-marker", "keep"); // узел не пересоздан
expect(await page.evaluate(() => window.scrollY)).toBe(y);
});

View File

@ -66,12 +66,13 @@ test("смена спринта и исполнителя, убирающая к
expect(errs).toEqual([]);
});
test("h1 на страницах доски, задач и карточки", async ({ page }) => {
test("h1 на страницах доски и задач, h2 в окне карточки", async ({ page }) => {
await login(page);
await expect(page.locator("h1")).toHaveText("Доска связей");
await page.getByRole("link", { name: "Задачи" }).click();
await expect(page.getByRole("heading", { level: 1, name: "Задачи" })).toBeVisible();
await page.getByRole("link", { name: "AURORA-9002" }).click();
await expect(page.getByRole("heading", { level: 1, name: /AURORA-9002/ })).toBeVisible();
// карточка — окно поверх списка: её заголовок h2 (тема задачи), h1 остаётся у страницы под окном
await expect(page.getByRole("dialog").getByRole("heading", { level: 2, name: "API свода" })).toBeVisible();
await expect(page.locator("h1")).toHaveCount(1);
});

View File

@ -1,15 +1,121 @@
import { expect, test } from "@playwright/test";
import { login } from "./hub.ts";
test("фильтр → карточка → заметка сохраняется", async ({ page }) => {
test("фильтр → карточка окном → заметка сохраняется и переживает перезагрузку", async ({ page }) => {
await login(page, "/app/issues");
await page.getByLabel("Исполнитель").selectOption("Петров Пётр Петрович");
await expect(page).toHaveURL(/assignee=/);
await page.getByRole("link", { name: "AURORA-9002" }).click();
const rest = page.getByLabel("Осталось");
await rest.fill("e2e: дописать тесты");
await page.getByRole("button", { name: "Сохранить" }).click();
await expect(page.getByText(/сохранено .*web/)).toBeVisible();
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.getByLabel("Осталось")).toHaveValue("e2e: дописать тесты");
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.locator("pre.description")).toHaveClass(/clamped/); // свёрнуто построчным обрезанием (текст в DOM остаётся)
const hClamped = (await d.locator("pre.description").boundingBox())!.height;
expect(await d.locator("pre.description").evaluate((e) => e.scrollHeight > e.clientHeight)).toBe(true);
await d.getByRole("button", { name: "развернуть" }).click();
await expect(d.locator("pre.description")).not.toHaveClass(/clamped/);
expect((await d.locator("pre.description").boundingBox())!.height).toBeGreaterThan(hClamped);
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);
});

View File

@ -0,0 +1,16 @@
{
"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": "нет перехода в «В работе»"}
]
}

View File

@ -1,5 +1,6 @@
"""Заполнить зеркало тестового хаба фикстурой доски и записать токены рядом с базой (для e2e и npm run dev).
Пишет в базу напрямую (mirror.apply_state, set_note), до запуска serve: гонки с первым запросом нет."""
Пишет в базу напрямую (mirror.apply_state, set_note), до запуска serve: гонки с первым запросом нет.
Дополнения только для тестового хаба (описание, комментарии, команды) — из seed-extras.json рядом со скриптом."""
import json
import sys
from pathlib import Path
@ -7,15 +8,28 @@ from pathlib import Path
from jira_hub import db, mirror, paths, settings
snap = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
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", {})
conn = db.connect(paths.db_path())
settings.ensure_defaults(conn)
issues = [{k: v for k, v in i.items() if k not in ("fact_status", "work_kind", "neighbor")} | {"id": i["key"]} for i in snap["issues"]]
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"]]
links = [{"source_key": l["source"], "target_key": l["target"], "link_type": l["link_type"]} for l in snap["links"]]
mirror.apply_state(conn, issues, links, neighbor_keys=[i["key"] for i in snap["issues"] if i["neighbor"]])
for i in snap["issues"]:
note = {k: i[k] for k in ("fact_status", "work_kind") if i.get(k)}
if note:
mirror.set_note(conn, i["key"], note, "seed")
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")))
(paths.home() / "tokens.json").write_text(json.dumps(settings.tokens(conn)), encoding="utf-8")
conn.close()
print("зеркало:", len(issues), "задач,", len(links), "связей; токены —", paths.home() / "tokens.json")

View File

@ -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: <Layout />, children: [{ path: "x", element: <Boom /> }] }],
{ initialEntries: ["/x"] });
render(<App router={router} />);
expect(await screen.findByText(/сломалось/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Перезагрузить" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Доска" })).toBeInTheDocument();
const boom = { path: "/x", element: <Boom /> };
PAGE_ROUTES.unshift(boom);
try {
const router = createMemoryRouter(routes, { initialEntries: ["/app/x"], basename: "/app" });
render(<App router={router} />);
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("без токена — только экран входа", () => {

View File

@ -35,11 +35,17 @@ export type Note = Record<NoteField, string | null> & { 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";

View File

@ -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}>
<div className="head">
<a className="key" href={`${JIRA_URL}/browse/${n.key}`} target="_blank" rel="noopener">{n.key}</a>
{!n.stub && <Link className="open-card" to={`/issues/${n.key}`} title="Открыть карточку задачи"
{!n.stub && <Link className="open-card" to={`/issues/${n.key}`} state={BOARD_CARD_STATE} title="Открыть карточку задачи"
aria-label={`Открыть карточку ${n.key}`}>ⓘ</Link>}
{showTag && (n.sprint
? <span className={"sprint-tag" + (sprintSel.has(n.sprint) ? " sel" : "")} title={n.sprint}>{n.sprint.replace(/^AWPCA_/, "")}</span>

View File

@ -0,0 +1,341 @@
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";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { qk } from "../api/queries.ts";
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 { IssueCard } from "./IssueCard.tsx";
const deferred = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((r) => { resolve = r; }); return { promise, resolve }; };
function CardAt() {
const { key = "" } = useParams();
const navigate = useNavigate();
return <IssueCard issueKey={key} onClose={() => void navigate("/board")} />;
}
function open(path: string, client = makeQueryClient()) {
const router = createMemoryRouter([
{ path: "/issues/:key", element: <CardAt /> },
{ path: "/board", element: <p>доска</p> },
], { initialEntries: [path] });
render(<QueryClientProvider client={client}><RouterProvider router={router} /></QueryClientProvider>);
return router;
}
describe("карточка задачи", () => {
beforeEach(() => setToken("tok"));
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"); // как в макете раздела 1
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();
});
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");
});
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");
});
it("сохраняются только изменённые поля, очищенное — null", async () => {
let put: unknown = null;
mockFetch((m, url, body) => {
if (url.pathname === "/settings") return json(200, settings);
if (m === "PUT") { put = body; return json(200, note({ rest_text: "тесты", done_text: null, updated_by: "web" })); }
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: "Править" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Сохранить" })).toBeNull();
});
it("422 — текст ошибки у формы, введённое на месте", async () => {
mockFetch((m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (m === "PUT") return json(422, { error: "bad_note", detail: "неизвестные поля заметки: x" });
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: "Сохранить" }));
expect(await screen.findByRole("alert")).toHaveTextContent("неизвестные поля заметки: x");
expect(owner).toHaveValue("проверить");
});
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();
await userEvent.click(screen.getByRole("button", { name: "Остаться" }));
expect(router.state.location.pathname).toBe("/issues/AURORA-9001");
expect(screen.getByLabelText("Заметка владельца")).toHaveValue("черновик");
});
it("поздний ответ прежней карточки не подменяет новую", async () => {
const client = makeQueryClient();
const slow = deferred<Response>();
mockFetch((_m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (url.pathname === "/issues/AURORA-9001") return slow.promise;
return json(200, issue("AURORA-9002"));
});
const router = open("/issues/AURORA-9001", client);
await act(async () => { await router.navigate("/issues/AURORA-9002"); });
expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
slow.resolve(json(200, issue("AURORA-9001")));
await waitFor(() => expect(client.getQueryData(qk.issue("AURORA-9001"))).toBeDefined());
expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: /AURORA-9001/ })).toBeNull();
});
it("ответ «Сохранить» после ухода в другую карточку не пишет её заметку в форму новой", async () => {
const slowPut = deferred<Response>();
mockFetch((m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (m === "PUT") return slowPut.promise;
if (url.pathname === "/issues/AURORA-9002") return json(200, issue("AURORA-9002"));
return json(200, issue("AURORA-9001"));
});
// 9002 уже в кэше: форма не размонтируется при переходе, её состояние переживает навигацию
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"); });
await userEvent.click(await screen.findByRole("button", { name: "Уйти" }));
expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
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.getByRole("button", { name: "Править" })).toBeInTheDocument(); // новая карточка — в режиме чтения
expect(screen.queryByText("черновик")).toBeNull();
});
it("T14.1: поля заметки заблокированы на время сохранения, ответ не затирает ввод", async () => {
const slowPut = deferred<Response>();
mockFetch((m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (m === "PUT") return slowPut.promise;
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 screen.findByRole("button", { name: "Править" }); // режим чтения (findByText("раз") ловил бы ещё живой textarea)
expect(screen.getByText("раз")).toBeInTheDocument(); // с сохранённым значением
expect(screen.queryByLabelText("Заметка владельца")).toBeNull(); // форма закрыта
});
it("M-2: ошибка повторной загрузки при уже имеющихся данных — данные остаются, строка с «Повторить»", async () => {
const client = makeQueryClient();
client.setQueryData(qk.issue("AURORA-9001"), issue("AURORA-9001"));
let down = true;
mockFetch((_m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (down) throw new TypeError("Failed to fetch");
return json(200, issue("AURORA-9001"));
});
open("/issues/AURORA-9001", client);
expect(await screen.findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
await act(async () => { await client.invalidateQueries({ queryKey: qk.issue("AURORA-9001") }); }); // повторная загрузка при данных в кэше
expect(await screen.findByRole("alert")).toHaveTextContent("хаб не отвечает");
expect(screen.getByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
down = false;
await userEvent.click(screen.getByRole("button", { name: "Повторить" }));
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
});
it("O-11: задача без команд — текст вместо пустой таблицы", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { commands: [] })));
open("/issues/AURORA-9001");
expect(await screen.findByText("команд по задаче не было")).toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: "Команда" })).toBeNull();
});
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();
});
it("T5.3: одинаковые связи не дают предупреждения о повторяющихся key", async () => {
const err = vi.spyOn(console, "error").mockImplementation(() => {});
const dup = { target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала", summary: "API", status: "Новая" };
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { links_out: [dup, dup] })));
open("/issues/AURORA-9001");
await screen.findAllByRole("link", { name: "AURORA-9002" });
expect(err.mock.calls.flat().join(" ")).not.toContain("same key");
err.mockRestore();
});
});

View File

@ -0,0 +1,177 @@
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 = <button className="close" type="button" aria-label="Закрыть" onClick={onClose}>✕</button>;
if (issue.isPending) return <div className="issue" data-testid="issue-card"><div className="issue-head"><p className="hint">загрузка {key}…</p>{close}</div></div>;
if (issue.error instanceof ApiError && issue.error.status === 404) {
return <div className="issue" data-testid="issue-card"><p>Задачи {key} нет в зеркале хаба.</p><button className="btn" type="button" onClick={onClose}>Закрыть</button></div>;
}
if (issue.isError && !issue.data) {
return (
<div className="issue" data-testid="issue-card">
<p className="warn" role="alert">{issue.error.message}{" "}
<button className="btn sm" type="button" onClick={() => void issue.refetch()}>Повторить</button></p>
<button className="btn" type="button" onClick={onClose}>Закрыть</button>
</div>
);
}
const i = issue.data!;
const labels = settings.data?.status_labels as Record<string, string> | undefined;
const fact = i.note?.fact_status ?? null;
const cls = factClass(fact, labels);
const due = dueOf(i);
const facts = [
i.assignee && <span key="who">{i.assignee}</span>,
due && <span key="due">срок {fmtDate(due)}{isOverdue(due, fact, labels, todayISO()) && <> <span className="overdue">просрочено</span></>}</span>,
i.sprint && <span key="sprint">{i.sprint}</span>,
i.epic_key && <span key="epic">эпик: {i.epic_summary ?? i.epic_key}</span>,
].filter(Boolean);
return (
<div className="issue" data-testid="issue-card">
<header className="issue-head" data-testid="issue-head">
<div className="row1">
<span className="key">{i.key}</span>
{i.status && <span className="badge status">{i.status}</span>}
{fact && <span className={"badge fact" + (cls ? " " + cls : "")}>факт: {fact}</span>}
<span className="spacer" />
<a href={`${JIRA_URL}/browse/${i.key}`} target="_blank" rel="noopener">Открыть в Jira ↗</a>
{close}
</div>
<h2 id="issue-modal-title">{i.summary || i.key}</h2>
{facts.length > 0 && <p className="facts" data-testid="issue-facts">{facts.flatMap((f, n) => (n ? [" · ", f] : [f]))}</p>}
{issue.isError && (
<p className="warn" role="alert">{issue.error.message}{" "}
<button className="btn sm" type="button" onClick={() => void issue.refetch()}>Повторить</button></p>
)}
</header>
<div className="cols">
<div className="main">
<Links issue={i} linkTypes={(settings.data?.link_types ?? {}) as Record<string, string>} />
{i.description && <Description text={i.description} />}
<Comments items={i.comments ?? []} />
</div>
<div className="side">
<Notes key={i.key} issue={i} />
<OtherFields issue={i} />
<Commands issue={i} />
</div>
</div>
</div>
);
}
function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record<string, string> }) {
const groups = groupLinks(issue, linkTypes);
return (
<section className="links-sec">
<h3>Связи</h3>
{!groups.length && <p className="hint">связей нет</p>}
{groups.map(([id, title, links]) => (
<div key={id} className="group" role="group" aria-labelledby={`links-${id}`}>
<h4 id={`links-${id}`}>{title}</h4>
<ul className="links">
{links.map((l, n) => (
<li key={`${n}:${l.key}:${l.tag ?? ""}`}>
{l.tag && <><span className="tag">{l.tag}</span>{" "}</>}
<IssueLink issueKey={l.key} />{" "}
{l.summary === null && l.status === null
? <span className="sum absent">нет в зеркале</span>
: <><span className="sum">{l.summary}</span>{l.status && <>{" "}<span className="badge status">{l.status}</span></>}</>}
</li>
))}
</ul>
</div>
))}
</section>
);
}
function Description({ text }: { text: string }) {
const ref = useRef<HTMLPreElement>(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 (
<section>
<h3>Описание</h3>
<pre ref={ref} className={"description" + (open ? "" : " clamped")}>{text}</pre>
{(long || open) && <button className="link" type="button" onClick={() => setOpen(!open)}>{open ? "свернуть" : "развернуть"}</button>}
</section>
);
}
function Comments({ items }: { items: IssueComment[] }) {
const [hidden, setHidden] = useCommentsHidden();
return (
<section className="comments" data-testid="comments">
<div className="sec-head">
<h3>Комментарии ({items.length})</h3>
{items.length > 0 && <button className="link" type="button" onClick={() => setHidden(!hidden)}>{hidden ? "Показать" : "Скрыть"}</button>}
</div>
{!items.length && <p className="hint">комментариев нет</p>}
{items.length > 0 && !hidden && (
<ul>
{items.map((c) => (
<li key={c.comment_id}>
<div className="who">{c.author ?? "—"}{c.created ? `, ${fmtStamp(c.created)}` : ""}</div>
<pre className="body">{c.body}</pre>
</li>
))}
</ul>
)}
</section>
);
}
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 (
<section className="other" aria-labelledby="other-title">
<h3 id="other-title">Прочие поля</h3>
<dl className="fields">{rows.filter(([, v]) => v).map(([k, v]) => <div key={k}><dt>{k}</dt><dd>{v}</dd></div>)}</dl>
</section>
);
}
function Commands({ issue: i }: { issue: IssueDetail }) {
if (!i.commands.length) {
return <section className="commands"><h3>Команды моста</h3><p className="hint">команд по задаче не было</p></section>;
}
return (
<section className="commands">
<details>
<summary>Команды моста ({i.commands.length})</summary>
<table className="grid">
<thead><tr><th>Команда</th><th>Пакет</th><th>Тип</th><th>Статус</th><th>Завершена</th><th>Ошибка</th></tr></thead>
<tbody>{i.commands.map((c) => (
<tr key={c.id}><td>#{c.id}</td><td>#{c.batch_id}</td><td>{c.type}</td><td>{c.status}</td>
<td>{c.finished_at ? fmtStamp(c.finished_at) : ""}</td><td>{c.error}</td></tr>
))}</tbody>
</table>
</details>
</section>
);
}

View File

@ -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 <Link className={className} to={`/issues/${encodeURIComponent(issueKey)}`} state={openCardState(loc)}>{children ?? issueKey}</Link>;
}

View File

@ -0,0 +1,169 @@
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(<App router={router} />);
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(<App router={router} />);
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(<App router={router} />);
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" }));
await within(dialog()).findByRole("heading", { name: /AURORA-9001/ });
await userEvent.click(dialog()); // цель — сам <dialog>, не содержимое
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(router.state.location.pathname).toBe("/app/issues");
});
it("I-2: выделение текста внутри окна, отпущенное над фоном, окно не закрывает", async () => {
const router = at(["/app/issues"]);
render(<App router={router} />);
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" }));
const title = await within(dialog()).findByRole("heading", { name: /AURORA-9001/ });
fireEvent.pointerDown(title); // нажали на тексте карточки…
fireEvent.pointerUp(dialog());
fireEvent.click(dialog()); // …отпустили над фоном: click — на <dialog>
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001");
});
it("с доски: окно поверх доски, вкладка «Доска» активна, после закрытия доска та же", async () => {
const router = at(["/app/board"]);
render(<App router={router} />);
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(<App router={router} />);
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(<App router={router} />);
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(<App router={router} />);
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 }));
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("черновик");
});
it("T4.1: отказ от закрытия («Остаться») сбрасывает ожидание закрытия: «назад» на запись прямого адреса не уводит в список", async () => {
const router = at(["/app/issues/AURORA-9001"]);
render(<App router={router} />);
await userEvent.click(await within(await screen.findByRole("dialog")).findByRole("link", { name: "AURORA-9002" }));
await within(dialog()).findByRole("heading", { name: /AURORA-9002/ });
await userEvent.click(await within(dialog()).findByRole("button", { name: "Править" }));
await userEvent.type(await within(dialog()).findByLabelText("Заметка владельца"), "черновик");
fireEvent(dialog(), new Event("cancel", { cancelable: true }));
await userEvent.click(await within(dialog()).findByRole("button", { name: "Остаться" }));
await userEvent.click(within(dialog()).getByRole("button", { name: "Отмена" }));
await userEvent.click(await within(dialog()).findByRole("button", { name: "Отменить правки" }));
await act(async () => { await router.navigate(-1); });
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001");
});
it("T4.4: у окна есть имя в состоянии загрузки и 404", async () => {
mockFetch((_m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (url.pathname === "/issues/AURORA-1") return new Promise<Response>(() => {});
if (url.pathname === "/issues/AURORA-2") return json(404, { detail: "нет" });
return json(200, {});
});
const r1 = render(<App router={at(["/app/issues/AURORA-1"])} />);
expect(await screen.findByRole("dialog", { name: "AURORA-1" })).toBeInTheDocument();
r1.unmount();
render(<App router={at(["/app/issues/AURORA-2"])} />);
expect(await screen.findByRole("dialog", { name: "AURORA-2" })).toBeInTheDocument();
});
});

View File

@ -0,0 +1,34 @@
import { useEffect, useRef } from "react";
import { ErrorBoundary } from "../shell/ErrorBoundary.tsx";
import { IssueCard } from "./IssueCard.tsx";
// имя окна — заголовок карточки; пока его нет (загрузка, 404, ошибка), запасное имя — ключ задачи
// окно карточки (design 13): <dialog> держит фокус и делает фон недоступным; Esc и щелчок по фону — onClose.
// Сами окно не закрываем: оно исчезает вместе с адресом карточки. Если браузер закрыл его сам (Esc без активации
// пользователя — cancel не отменяемый), открываем снова и просим закрытия по общему пути (блокировщик заметок).
export function IssueModal({ issueKey, onClose }: { issueKey: string; onClose: () => void }) {
const ref = useRef<HTMLDialogElement>(null);
// I-2: закрывать щелчком по фону, только если и нажатие было на фоне — выделение текста, отпущенное за краем окна,
// даёт click на самом <dialog>, но нажатие было внутри содержимого
const downOnBackdrop = useRef(false);
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 (
<dialog ref={ref} className="issue-modal" aria-labelledby="issue-modal-title" aria-label={issueKey}
onCancel={(e) => { if (e.cancelable) { e.preventDefault(); onClose(); } }}
onClose={() => { const d = ref.current; if (d?.isConnected) { d.showModal(); onClose(); } }}
onPointerDown={(e) => { downOnBackdrop.current = e.target === e.currentTarget; }}
onClick={(e) => {
const fromBackdrop = downOnBackdrop.current;
downOnBackdrop.current = false;
if (e.target === e.currentTarget && fromBackdrop) onClose();
}}>
<ErrorBoundary key={issueKey}><IssueCard issueKey={issueKey} onClose={onClose} /></ErrorBoundary>
</dialog>
);
}

View File

@ -1,205 +0,0 @@
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 { beforeEach, describe, expect, it } from "vitest";
import { qk } from "../api/queries.ts";
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 { 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 = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((r) => { resolve = r; }); return { promise, resolve }; };
function open(path: string, client = makeQueryClient()) {
const router = createMemoryRouter([
{ path: "/issues/:key", element: <IssuePage /> },
{ path: "/board", element: <p>доска</p> },
], { initialEntries: [path] });
render(<QueryClientProvider client={client}><RouterProvider router={router} /></QueryClientProvider>);
return router;
}
describe("карточка задачи", () => {
beforeEach(() => setToken("tok"));
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");
});
it("заголовок страницы — h1 (бренд в оболочке — span)", 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();
});
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");
});
it("сохраняются только изменённые поля, очищенное — null", async () => {
let put: unknown = null;
mockFetch((m, url, body) => {
if (url.pathname === "/settings") return json(200, settings);
if (m === "PUT") { put = body; return json(200, note({ rest_text: "тесты", done_text: null, updated_by: "web" })); }
return json(200, issue("AURORA-9001"));
});
open("/issues/AURORA-9001");
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();
});
it("422 — текст ошибки у формы, введённое на месте", async () => {
mockFetch((m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (m === "PUT") return json(422, { error: "bad_note", detail: "неизвестные поля заметки: x" });
return json(200, issue("AURORA-9001"));
});
open("/issues/AURORA-9001");
const owner = await screen.findByLabelText("Заметка владельца");
await userEvent.type(owner, "проверить");
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
expect(await screen.findByRole("alert")).toHaveTextContent("неизвестные поля заметки: x");
expect(owner).toHaveValue("проверить");
});
it("уход с несохранёнными правками требует подтверждения", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
const router = open("/issues/AURORA-9001");
await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик");
await act(async () => { await router.navigate("/board"); });
expect(await screen.findByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Остаться" }));
expect(router.state.location.pathname).toBe("/issues/AURORA-9001");
expect(screen.getByLabelText("Заметка владельца")).toHaveValue("черновик");
});
it("поздний ответ прежней карточки не подменяет новую", async () => {
const client = makeQueryClient();
const slow = deferred<Response>();
mockFetch((_m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (url.pathname === "/issues/AURORA-9001") return slow.promise;
return json(200, issue("AURORA-9002"));
});
const router = open("/issues/AURORA-9001", client);
await act(async () => { await router.navigate("/issues/AURORA-9002"); });
expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
slow.resolve(json(200, issue("AURORA-9001")));
await waitFor(() => expect(client.getQueryData(qk.issue("AURORA-9001"))).toBeDefined());
expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: /AURORA-9001/ })).toBeNull();
});
it("ответ «Сохранить» после ухода в другую карточку не пишет её заметку в форму новой", async () => {
const slowPut = deferred<Response>();
mockFetch((m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (m === "PUT") return slowPut.promise;
if (url.pathname === "/issues/AURORA-9002") return json(200, issue("AURORA-9002"));
return json(200, issue("AURORA-9001"));
});
// 9002 уже в кэше: форма не размонтируется при переходе, её состояние переживает навигацию
const client = makeQueryClient();
client.setQueryData(qk.issue("AURORA-9002"), issue("AURORA-9002"));
const router = open("/issues/AURORA-9001", client);
await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик");
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await act(async () => { await router.navigate("/issues/AURORA-9002"); });
await userEvent.click(await screen.findByRole("button", { name: "Уйти" }));
expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
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();
});
it("T14.1: поля заметки заблокированы на время сохранения, ответ не затирает ввод", async () => {
const slowPut = deferred<Response>();
mockFetch((m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (m === "PUT") return slowPut.promise;
return json(200, issue("AURORA-9001"));
});
open("/issues/AURORA-9001");
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("раз");
});
it("M-2: ошибка повторной загрузки при уже имеющихся данных — данные остаются, строка с «Повторить»", async () => {
const client = makeQueryClient();
client.setQueryData(qk.issue("AURORA-9001"), issue("AURORA-9001"));
let down = true;
mockFetch((_m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (down) throw new TypeError("Failed to fetch");
return json(200, issue("AURORA-9001"));
});
open("/issues/AURORA-9001", client);
expect(await screen.findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
await act(async () => { await client.invalidateQueries({ queryKey: qk.issue("AURORA-9001") }); }); // повторная загрузка при данных в кэше
expect(await screen.findByRole("alert")).toHaveTextContent("хаб не отвечает");
expect(screen.getByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
down = false;
await userEvent.click(screen.getByRole("button", { name: "Повторить" }));
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");
expect(await screen.findByText("команд по задаче не было")).toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: "Команда" })).toBeNull();
});
it("T14.3: id подсказок «статуса по факту» уникален и связан с полем", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
open("/issues/AURORA-9001");
const input = await screen.findByLabelText("Статус по факту");
const id = input.getAttribute("list")!;
expect(id).not.toBe("fact-statuses");
expect(document.getElementById(id)?.tagName).toBe("DATALIST");
});
});

View File

@ -1,69 +0,0 @@
import { Link, useParams } from "react-router";
import { ApiError } from "../api/client.ts";
import { useIssue, useSettings } from "../api/queries.ts";
import { JIRA_URL } from "../board/constants.ts";
import { fmtStamp } from "../board/model/util.ts";
import { NoteForm } from "./NoteForm.tsx";
import { dueOf } from "./options.ts";
import "./issues.css";
export function IssuePage() {
const { key = "" } = useParams();
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 <p className="hint" data-testid="page-issue">загрузка {key}…</p>;
if (issue.error instanceof ApiError && issue.error.status === 404) {
return <p className="issue" data-testid="page-issue">Задачи {key} нет в зеркале хаба. <Link to="/issues">к списку задач</Link></p>;
}
if (issue.isError && !issue.data) {
return (
<p className="warn" role="alert" data-testid="page-issue">{issue.error.message}{" "}
<button className="btn sm" type="button" onClick={() => void issue.refetch()}>Повторить</button></p>
);
}
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],
];
return (
<div data-testid="page-issue" className="issue">
{issue.isError && (
<p className="warn" role="alert">{issue.error.message}{" "}
<button className="btn sm" type="button" onClick={() => void issue.refetch()}>Повторить</button></p>
)}
<p><Link to="/issues">← задачи</Link></p>
<h1>{i.key} · {i.summary}</h1>
<p><a href={`${JIRA_URL}/browse/${i.key}`} target="_blank" rel="noopener">Открыть в Jira</a></p>
<div className="cols">
<div>
<dl className="fields">{fields.filter(([, v]) => v).map(([k, v]) => <div key={k}><dt>{k}</dt><dd>{v}</dd></div>)}</dl>
{i.description && <pre className="description">{i.description}</pre>}
<h3>Связи</h3>
<ul className="links">
{i.links_out.map((l) => <li key={"o" + l.target_key + l.link_type}><span>{typeName(l.link_type)}</span> → <Link to={`/issues/${l.target_key}`}>{l.target_key}</Link></li>)}
{i.links_in.map((l) => <li key={"i" + l.source_key + l.link_type}><Link to={`/issues/${l.source_key}`}>{l.source_key}</Link> → <span>{typeName(l.link_type)}</span> → эта задача</li>)}
{!i.links_out.length && !i.links_in.length && <li className="hint">связей нет</li>}
</ul>
<h3>Последние команды</h3>
{i.commands.length ? (
<table className="grid">
<thead><tr><th>Команда</th><th>Пакет</th><th>Тип</th><th>Статус</th><th>Завершена</th><th>Ошибка</th></tr></thead>
<tbody>{i.commands.map((c) => (
<tr key={c.id}><td>#{c.id}</td><td>#{c.batch_id}</td><td>{c.type}</td><td>{c.status}</td>
<td>{c.finished_at ? fmtStamp(c.finished_at) : ""}</td><td>{c.error}</td></tr>
))}</tbody>
</table>
) : <p className="hint">команд по задаче не было</p>}
</div>
<NoteForm key={i.key} issue={i} />
</div>
</div>
);
}

View File

@ -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() {
<tbody>
{rows.map((i) => (
<tr key={i.key}>
<td><Link to={`/issues/${i.key}`}>{i.key}</Link></td><td>{i.summary}</td><td>{i.issue_type}</td><td>{i.status}</td>
<td><IssueLink issueKey={i.key} /></td><td>{i.summary}</td><td>{i.issue_type}</td><td>{i.status}</td>
<td>{i.assignee}</td><td>{i.sprint}</td><td>{dueOf(i)}</td><td>{i.epic_key ? names[i.epic_key] ?? i.epic_key : ""}</td>
<td>{i.work_kind}</td><td>{i.fact_status}</td><td>{i.neighbor ? "да" : ""}</td>
</tr>

View File

@ -1,9 +1,9 @@
import { useEffect, useId, useMemo, useState } from "react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useBlocker } from "react-router";
import { useCloseAbort } from "./closeAbort.ts";
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<NoteField, string>;
const valuesOf = (n: IssueDetail["note"]): Values =>
@ -18,14 +18,17 @@ 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<HTMLDivElement>(null);
const [initial, setInitial] = useState(() => valuesOf(issue.note));
const [values, setValues] = useState(initial);
const changed = useMemo(() => changedFields(initial, values), [initial, values]);
const dirty = Object.keys(changed).length > 0;
const abortClose = useCloseAbort();
const blocker = useBlocker(({ currentLocation, nextLocation }) => dirty && currentLocation.pathname !== nextLocation.pathname);
useEffect(() => {
if (!dirty) return;
@ -33,6 +36,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 +46,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 (
<form className="note" onSubmit={(e) => { e.preventDefault(); submit(); }}>
<h3>Служебные заметки</h3>
<form className="note" aria-labelledby="notes-title" onSubmit={(e) => { e.preventDefault(); submit(); }}>
<h3 id="notes-title">Служебные заметки</h3>
<fieldset className="note-fields" disabled={save.isPending}>
<label>Вид работы
<select value={values.work_kind} onChange={set("work_kind")}>
@ -67,13 +71,20 @@ export function NoteForm({ issue }: { issue: IssueDetail }) {
{err && <p className="err" role="alert">{err}</p>}
<div className="row">
<button className="btn" type="submit" disabled={!dirty || save.isPending}>Сохранить</button>
{saved && <span className="hint">сохранено {fmtStamp(saved.updated_at)}, {saved.updated_by}</span>}
<button className="btn" type="button" disabled={save.isPending} onClick={() => (dirty ? setAskCancel(true) : onDone())}>Отмена</button>
</div>
{askCancel && (
<div className="confirm" role="alertdialog" aria-label="Отменить правки заметки?">
<span>Отменить правки заметки?</span>
<button className="btn" type="button" onClick={onDone}>Отменить правки</button>
<button className="btn" type="button" onClick={() => setAskCancel(false)}>Продолжить правку</button>
</div>
)}
{blocker.state === "blocked" && (
<div className="confirm" role="dialog">
<div className="confirm" role="alertdialog" ref={confirmRef}>
<span>Есть несохранённые правки заметки. Уйти без сохранения?</span>
<button className="btn" type="button" onClick={() => blocker.proceed()}>Уйти</button>
<button className="btn" type="button" onClick={() => blocker.reset()}>Остаться</button>
<button className="btn" type="button" onClick={() => { blocker.reset(); abortClose(); }}>Остаться</button>
</div>
)}
</form>

26
web/src/issues/Notes.tsx Normal file
View File

@ -0,0 +1,26 @@
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 <NoteForm issue={issue} onDone={() => setEditing(false)} />;
const n = issue.note;
return (
<section className="note view" aria-labelledby="notes-title">
<div className="sec-head">
<h3 id="notes-title">Служебные заметки</h3>
<button className="btn sm" type="button" onClick={() => setEditing(true)}>Править</button>
</div>
<dl className="fields">
{ROWS.map(([f, label]) => <div key={f}><dt>{label}</dt><dd>{n?.[f] || "—"}</dd></div>)}
</dl>
{n && <p className="hint">изменено {fmtStamp(n.updated_at)}, {n.updated_by}</p>}
</section>
);
}

View File

@ -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<string, string>;
const labels = { ...(settings.status_labels as Record<string, string>), "Снято": "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");
});
});

38
web/src/issues/card.ts Normal file
View File

@ -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<string, string> = { "блокирует начало": "начало", "блокирует завершение": "завершение" };
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<IssueDetail, "links_out" | "links_in">, linkTypes: Record<string, string>): [LinkGroupId, string, CardLink[]][] {
const logical = new Map(Object.entries(linkTypes).map(([name, jira]) => [jira, name]));
const groups: Record<LinkGroupId, CardLink[]> = { 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<string, string> | undefined): string | null =>
(fact && statusLabels?.[fact]) || null;
// R-2: просрочено — срок раньше сегодня, а статус по факту не «закрыт» (классы fact-done, fact-cancelled)
export function isOverdue(due: string, fact: string | null | undefined, statusLabels: Record<string, string> | 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")}`;

View File

@ -0,0 +1,5 @@
import { createContext, useContext } from "react";
// окно закрывали, но пользователь отказался (блокировщик заметок, «Остаться»): Layout сбрасывает ожидание закрытия
export const CloseAbortContext = createContext<() => void>(() => {});
export const useCloseAbort = () => useContext(CloseAbortContext);

View File

@ -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];
}

View File

@ -7,16 +7,60 @@
.grid th{color:var(--muted);font-weight:500;position:sticky;top:0;background:var(--bg)}
.grid a{color:var(--accent)}
.issue a{color:var(--accent)}
.issue .cols{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:24px}
@media (max-width:900px){.issue .cols{grid-template-columns:1fr}}
.issue .fields{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:4px 16px;margin:0}
.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 .side .note{align-self:stretch} /* .note{align-self:start} — с прежней страницы; в колонке окна — на всю ширину */
.issue .main{min-width:0}
@media (max-width:899.98px){
.issue .cols{display:flex;flex-direction:column;align-items:stretch;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;
/* нижний отступ — рамкой цвета фона: overflow обрезает по краю padding, и в нижнем padding была видна 9-я строка */
padding-bottom:0;border-bottom:10px solid var(--panel);box-shadow:0 1px 0 var(--line)}
.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}
.issue .description{white-space:pre-wrap;font:inherit;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:10px}
.issue .links{padding-left:18px}
.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}
.note{display:flex;flex-direction:column;gap:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px;align-self:start}
.note fieldset{display:contents;border:0;margin:0;padding:0;min-width:0}
.note label{display:flex;flex-direction:column;gap:4px;font-size:13px;color:var(--muted)}
.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}}

View File

@ -0,0 +1,27 @@
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 });
});
it("глубина — только целое неотрицательное, иначе 0", () => {
const bg = { pathname: "/issues", search: "" };
for (const depth of [-1, 1.5, NaN, Infinity]) expect(cardState({ background: bg, depth })?.depth).toBe(0);
expect(cardState({ background: bg, depth: 3 })?.depth).toBe(3);
});
});

23
web/src/issues/modal.ts Normal file
View File

@ -0,0 +1,23 @@
// состояние окна карточки в записи истории (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;
const depth = Number.isInteger(c.depth) && c.depth >= 0 ? c.depth : 0; // NaN, дробные и отрицательные не доходят до navigate(-depth)
return { background: { pathname, search }, 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 };
}

View File

@ -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: <Layout />,
children: [
{ index: true, element: <Navigate to="/board" replace /> },
{ path: "board", element: <BoardPage /> },
{ path: "issues", element: <IssuesPage /> },
{ path: "issues/:key", element: <IssuePage /> },
{ path: "*", element: <NotFound /> },
],
}];
// адреса относительно basename "/app": страницы и окно карточки рисует Layout (shell/pages.tsx, design 10)
export const routes: RouteObject[] = [{ path: "*", element: <Layout /> }];

View File

@ -1,26 +1,69 @@
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 { CloseAbortContext } from "../issues/closeAbort.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 abortClose = useCallback(() => { closingDirect.current = false; }, []);
const at = pageLocation.pathname;
const tab = (to: string, label: string) => {
const active = at === to || at.startsWith(to + "/");
return <Link to={to} className={active ? "active" : undefined} aria-current={active ? "page" : undefined}>{label}</Link>;
};
return (
<>
<header className="appbar">
<span className="brand">Хаб Jira</span>
<nav>
<NavLink to="/board">Доска</NavLink>
<NavLink to="/issues" end={false}>Задачи</NavLink>
</nav>
<nav>{tab("/board", "Доска")}{tab("/issues", "Задачи")}</nav>
<span className="spacer" />
<button type="button" className="link" onClick={() => { clearToken(); qc.clear(); }}>Сменить токен</button>
</header>
<main className="page">
<ErrorBoundary key={pathname}><Outlet /></ErrorBoundary>
<ErrorBoundary key={at}>{page}</ErrorBoundary>
</main>
{issueKey && <CloseAbortContext value={abortClose}><IssueModal issueKey={issueKey} onClose={close} /></CloseAbortContext>}
</>
);
}

12
web/src/shell/pages.tsx Normal file
View File

@ -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: <Navigate to="/board" replace /> },
{ path: "/board", element: <BoardPage /> },
{ path: "/issues", element: <IssuesPage /> },
{ path: "*", element: <NotFound /> },
];

View File

@ -0,0 +1,19 @@
import type { IssueDetail, Note } from "../api/types.ts";
export const note = (over: Partial<Note> = {}): 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> = {}): 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,
});

View File

@ -3,6 +3,12 @@ import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
import { saveBoardScroll } from "../board/scrollMemory.ts";
// jsdom 29 не умеет showModal/close у <dialog>: открытость — атрибут 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);