diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ec2c6be..a5f9809 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -58,19 +58,20 @@ | Модуль | Ответственность | Зависит от | |---|---|---| -| `schema` | таблицы модуля: `wiki_project`, `wiki_page`, `wiki_attachment`, `wiki_sync_stage`; таблица FTS5 `wiki_page_fts` создаётся лениво при первой синхронизации (`search.ensure`), в `schema` её нет | stdlib | +| `schema` | таблицы модуля: `wiki_project`, `wiki_page`, `wiki_attachment`, `wiki_sync_stage`, `wiki_version`, `wiki_version_stage`, `wiki_history`; таблица FTS5 `wiki_page_fts` создаётся лениво при первой синхронизации (`search.ensure`), в `schema` её нет | stdlib | | `settings` | умолчания (`wiki_pacing`, `wiki_auto_sync_hours`, `wiki_min_agent_version`), ключи для агента, проверка значений | `core.settings` | | `paths` | каталог копий `<каталог базы>/wiki/<метка>/` | `core.db` | -| `projects` | поле проекта `wiki_root` (изменяемое), `project_view` (`wiki_root`, `pages_count`, `synced_at`…), `on_project_created`/`on_project_updated` (постановка `wiki_sync`, отмена `queued`, сброс копии при смене корня) | `core.projects`, `core.queue`, `commands`, `paths` | -| `commands` | типы `wiki_sync` и `wiki_fetch` (оба — чтение Eva, без хвоста и копии базы), `CommandHooks` модуля: проверки, паузы, `before_result`/`on_result`/`after_result`/`abort_result`, отбрасывание отчёта по сменённому корню | `core.queue`, `stage`, `sync`, `attachments` | -| `convert` | HTML Eva → Markdown (`markdownify`): замена ссылок по `ref` на `attachment:` и `page:<код>`; HTML считается недоверенным | stdlib, `markdownify` | -| `stage` | приём пачек страниц от агента: проверка формы, перевод вне транзакции, стадия `wiki_sync_stage` | `core.db`, `convert` | -| `sync` | сборка копии из стадии в `wiki/.<метка>.new/` (`prepare`), запись строк и подмена каталога (`commit`), FTS | `core.db`, `paths`, `search`, `convert` | +| `projects` | поле проекта `wiki_root` (изменяемое), `project_view` (`wiki_root`, `pages_count`, `synced_at`…), `on_project_created`/`on_project_updated` (постановка `wiki_sync`, отмена `queued`, сброс копии и истории при смене корня) | `core.projects`, `core.queue`, `commands`, `paths` | +| `commands` | типы `wiki_sync` и `wiki_fetch` (оба — чтение Eva, без хвоста и копии базы), `CommandHooks` модуля: проверки, паузы, `deliver_params` дописывает `known_versions`, `before_result`/`on_result`/`after_result`/`abort_result`, отбрасывание отчёта по сменённому корню | `core.queue`, `stage`, `sync`, `attachments` | +| `convert` | HTML Eva → Markdown (`markdownify`): замена ссылок по `ref` на `attachment:` и `page:<код>`; режим без ссылок на вложения для версий (`file_links=False`); HTML считается недоверенным | stdlib, `markdownify` | +| `stage` | приём пачек страниц от агента: проверка формы, перевод вне транзакции, стадия `wiki_sync_stage`; и пачек версий (`accept_versions`, `POST /agent/wiki/versions`), стадия `wiki_version_stage` | `core.db`, `convert` | +| `history` | история правок: время версий в UTC (`utc`), `known_versions` для выдачи, подсчёт «+/−» (`line_delta`), `history_at`; чтение истории: лента `feed`, версии страницы `page_versions`, дифф по запросу (`diff`, `difflib`, части по 20 000 — `CHUNK`), последняя правка `last_change`; HTTP не знает | stdlib | +| `sync` | сборка копии из стадии в `wiki/.<метка>.new/` (`prepare`), запись строк и подмена каталога (`commit`), FTS; запись версий, «+/−» (подсчёт в `prepare`, вне транзакции), `history_at` | `core.db`, `paths`, `search`, `convert`, `history` | | `search` | поиск по FTS5 (запасной LIKE по `index.md`, если FTS5 в SQLite нет), фрагменты | `paths` | | `attachments` | вложения по запросу: состояние `none`/`fetching`/`ready`/`failed`, постановка `wiki_fetch`, хуки результата; адрес берётся только из `wiki_attachment` | `core.queue`, `core.files` | | `tick` | фоновая синхронизация проектов с корнем раз в `wiki_auto_sync_hours` | `core.db`, `core.settings`, `commands`, `sync`, `search` | -| `api` | маршруты `/wiki/tree`, `/wiki/pages/{code}`, `/wiki/search`, `/wiki/sync`, `/wiki/status`, `/wiki/attachments/{id}[/fetch\|/content]` (клиент) и `POST /agent/wiki/pages` (агент `wiki`) | `core.http`, `core.queue`, `stage`, `attachments`, `search` | -| `mcp` | инструменты сервера `sdlc-wiki`: `projects`, `project_create`, `root_set`, `tree`, `page`, `search`, `attachment`, `sync`, `status`; Markdown и имена вложений недоверенные | `core.mcp`, `core.client` | +| `api` | маршруты `/wiki/tree`, `/wiki/pages/{code}`, `/wiki/pages/{code}/versions`, `/wiki/pages/{code}/diff`, `/wiki/changes` (лента правок), `/wiki/search`, `/wiki/sync`, `/wiki/status`, `/wiki/attachments/{id}[/fetch\|/content]` (клиент) и `POST /agent/wiki/pages`, `POST /agent/wiki/versions` (агент `wiki`) | `core.http`, `core.queue`, `stage`, `attachments`, `search` | +| `mcp` | инструменты сервера `sdlc-wiki`: `projects`, `project_create`, `root_set`, `tree`, `page`, `search`, `changes`, `diff`, `attachment`, `sync`, `status`; `sync` отвечает коротко; `page`, `changes`, `diff` читают только копию; Markdown, правки и имена вложений недоверенные | `core.mcp`, `core.client` | ### Контракт модуля @@ -97,9 +98,12 @@ (`freshness(project)`), действие обновления (`refresh`: подпись, `start`, `isOwn`) и перечитывание после пакета (`invalidate`). Рисует и ведёт его ядро: трекер пакетов и агентов — `web/src/shell/activity/` (`ActivityProvider`: подхват идущего пакета и `GET /agents` раз в 60 с, опрос пакета каждые 3 с, `useModuleActivity(module)`, тексты `events.ts`/`agentText.ts`), блок состояния панели — `ModuleStatusBlock`, а модуль -даёт только `Summary` — карточку на «Обзоре» (`OverviewPage`: карточки модулей и «Последние пакеты проекта»). Адреса: «Обзор» — `/app/`, -страницы модуля — `/app/<модуль>/…` (`/app/jira/links`, `/app/jira/issues[/{key}]`, `/app/jira/feed`, `/app/wiki`); прежние `/app/board`, `/app/jira/board`, -`/app/issues`, `/app/issues/{key}`, `/app/feed` перенаправляются с сохранением `search` и `replace`. Пункты меню могут зависеть от проекта (`NavItem.when`, `visibleNav` в `shell/nav.tsx`): пока проект не определён, условный пункт скрыт; «не найдено» перечисляет только безусловные. +даёт только `Summary` — карточку на «Обзоре» (`OverviewPage`: карточки модулей и «Последние пакеты проекта»). Контракт `HubModule` — плюс необязательное +`feed` (`FeedSource`: источник общей ленты — `available(project)`, хук `useFeed(input)` с группами/состоянием, `Controls` — свои настройки над лентой): +страница ядра «Лента» (`web/src/shell/feed/`: `FeedPage`, `period`, `sources`; `FEED_MODULES`/`useFeeds` в `modules/index.ts`) ведёт период, выбор +источников (`sources=`, id источника — имя модуля) и порядок групп по времени, модуль — запросы, группы и виды событий; ядро не знает видов событий модулей. Адреса: «Обзор» — `/app/`, +«Лента» — страница ядра `/app/feed`; страницы модуля — `/app/<модуль>/…` (`/app/jira/links`, `/app/jira/issues[/{key}]`, `/app/wiki`); прежние `/app/board`, `/app/jira/board`, +`/app/issues`, `/app/issues/{key}`, `/app/jira/feed` (на `/app/feed`) перенаправляются с сохранением `search` и `replace`. Пункты меню могут зависеть от проекта (`NavItem.when`, `visibleNav` в `shell/nav.tsx`): пока проект не определён, условный пункт скрыт; «не найдено» перечисляет только безусловные. Экраны доски модуля jira — `web/src/modules/jira/agile/`: `model` (даты Jira, «осталось N дн.», склонение, состояние данных `boardState`), `columns` (раскладка задач по колонкам доски, «Без колонки»), `AgileGate` (тип доски проекта, загрузка, ошибка, три @@ -115,14 +119,18 @@ (`WikiPage`, `PageView` — `react-markdown` + `remark-gfm`, без `rehype-raw`; ссылки `page:` ведут на страницу вики, `attachment:` скачивают файл через `download.ts`, `urlTransform.ts` пропускает только эти схемы и http(s)), форма корня без привязки (`RootForm`), сводка для «Обзора» (`Summary`) и `status` для ядра; поле «Корень вики» в окне «Новый проект» — через `projectFields`. +Источник ленты (`feed/`): правки страниц группами по странице. Окно правки `/app/wiki/pages/{code}/versions/{version}` — поверх +страницы под ним (`versions/overlay.tsx`, `useOverlay`; фон — `location.state.background`, прямой адрес — поверх «Вики» с этой +страницей), «Предыдущая/Следующая правка» заменяют запись истории, дифф (`versions/`: `VersionModal`, `diffLines` — склейка частей +`GET …/diff` и разбор на строки) без горизонтальной прокрутки страницы. -Модуль `jira` (`web/src/modules/jira/index.tsx`): страницы «Связи» (`/app/jira/links`, заголовок страницы — «Доска связей»), «Задачи» (`/app/jira/issues`), «Лента» -(`/app/jira/feed`), карточка `/app/jira/issues/{key}` окном поверх страницы (`issues/overlay.tsx`, `IssueModal.tsx`; оболочка рисует +Модуль `jira` (`web/src/modules/jira/index.tsx`): страницы «Связи» (`/app/jira/links`, заголовок страницы — «Доска связей»), «Задачи» (`/app/jira/issues`) (страницы «Лента» у модуля нет — она в ядре; `/app/jira/feed` +перенаправляется на `/app/feed`), карточка `/app/jira/issues/{key}` окном поверх страницы (`issues/overlay.tsx`, `IssueModal.tsx`; оболочка рисует страницу по `location.state.background`). Внутри: `board/` (чистые `model`, `analysis`, `layout`, `storage.ts` — ключи `localStorage` прежней страницы, у проекта AWPCA прежние, у остальных с суффиксом `@<метка>`; отрисовка `components/`; golden-эталоны `__fixtures__/`), `issues/` (список, карточка, `wiki/` — разбор вики-разметки, `attachments/` — список, плашки и скачивание: `POST /attachments/{id}/fetch`, опрос `GET /attachments/{id}`, затем `api.blob` на `/content`), -`feed/` (период, виды, группировка событий, `EventList`, общий с блоком «История» карточки), `RefreshButton.tsx` (кнопка «Обновить из Jira» в шапках страниц), `Summary`/`status` для ядра, `api/queries.ts`. +`feed/` (источник ленты `jiraFeed` — `source.tsx`: виды событий, группировка по задаче, `FeedGroup`; `EventList`, общий с блоком «История» карточки), `RefreshButton.tsx` (кнопка «Обновить из Jira» в шапках страниц), `Summary`/`status` для ядра, `api/queries.ts`. Собирается `make web` в `src/sdlc_hub/web/app/`; `core/api` отдаёт сборку без токена (`/app/assets/*` — файлы, прочие `/app…` — `index.html`, 503 без сборки), `/` и `/links` перенаправляет на `/app/` («Обзор»). Данные — только через HTTP API с @@ -130,7 +138,7 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк Цепочки зависимостей (Python): - ядро: `api → queue → settings/projects/files/registry → db → backup → paths`; `api → relay → settings`; `api → release → paths`; `files → paths`; `cli → api/mcp/registry`; `mcp → client → HTTP API`; `registry → (лениво) modules.*`; -- модуль wiki: `commands → stage/sync/attachments → convert/search/paths → core.*`; `projects → commands/paths`; `api → stage/attachments/search → core.http/core.queue`; `tick → commands/sync`; +- модуль wiki: `commands → stage/sync/attachments → convert/search/paths → core.*`; `projects → commands/paths`; `api → stage/attachments/search → core.http/core.queue`; `tick → commands/sync`; `api → history`; `sync → history`; - модуль jira: `hooks → commands/mirror/history/refresh/attachments/fetch → core.*`; `mirror → history → projects`; `mirror → attachments → core.files`; `api → mirror/history/fetch/refresh → core.http/core.queue`; `api → agile → history`; `importer → mirror`; - веб: `shell → modules/index → modules/jira, modules/wiki → ui/api/projects` (модуль не импортирует оболочку, кроме типов контракта). @@ -144,7 +152,8 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк агент v13 после состояния читает changelog Jira → `POST /agent/history` → `history.record_changes` (повторы отбрасываются по `(history_id, seq)`) → отчёт `refresh` с `result.history` → `sync_run.history_at` у полного прогона (новая отметка) → `GET /changes`, `GET /issues/{key}.changes`, MCP `changes`. -- Синхронизация вики: `wiki_sync {label, root}` ставят MCP `sync`, кнопка «Обновить» (`POST /wiki/sync`), привязка/смена корня (`on_project_created`/`on_project_updated`) и `tick` (раз в `wiki_auto_sync_hours`) → `claim` агента `wiki` → eva-agent обходит дерево и шлёт пачки `POST /agent/wiki/pages` (хаб принимает ≤50 страниц в пачке; срез по 4 МБ делает агент) → `stage.accept` переводит HTML в Markdown вне транзакции и кладёт в `wiki_sync_stage` → отчёт `done {root_title, codes}` → `before_result` (`sync.prepare`: сборка `wiki/.<метка>.new/`, выделение id вложений, под per-label `Lock`) → транзакция отчёта (`on_result` = `sync.commit`: строки `wiki_page`/`wiki_attachment`/FTS, атомарная подмена каталога) → `after_result` снимает подготовку; при сбое — `abort_result`. Отчёт по команде с устаревшим корнем отбрасывается (`result.discarded`), копия прежняя. +- Синхронизация вики: `wiki_sync {label, root}` ставят MCP `sync`, кнопка «Обновить» (`POST /wiki/sync`), привязка/смена корня (`on_project_created`/`on_project_updated`) и `tick` (раз в `wiki_auto_sync_hours`) → `claim` агента `wiki` → eva-agent обходит дерево и шлёт пачки `POST /agent/wiki/pages` (хаб принимает ≤50 страниц в пачке; срез по 4 МБ делает агент) → `stage.accept` переводит HTML в Markdown вне транзакции и кладёт в `wiki_sync_stage` → после страниц eva-agent v2 шлёт версии `POST /agent/wiki/versions` (≤50 в пачке; хаб при выдаче дописывает `known_versions`), отчёт `done {root_title, codes, versions}`; без `versions` (агент v1) история не меняется → `before_result` (`sync.prepare`: сборка `wiki/.<метка>.new/`, выделение id вложений, под per-label `Lock`) → транзакция отчёта (`on_result` = `sync.commit`: строки `wiki_page`/`wiki_attachment`/FTS, атомарная подмена каталога) → `after_result` снимает подготовку; при сбое — `abort_result`. Отчёт по команде с устаревшим корнем отбрасывается (`result.discarded`), копия прежняя. +- История вики: `GET /wiki/changes` (лента правок за окно `since`/`until`, фильтр `author`), `GET /wiki/pages/{code}/versions`, `GET /wiki/pages/{code}/diff` (дифф по частям, `offset`), MCP `changes`/`diff`, веб «Лента» (только база хаба) → `history.feed`/`page_versions`/`diff` по `wiki_version` и `wiki_page` (только текущая копия); команд не ставит и Eva не вызывает. - Вложение вики: `POST /wiki/attachments/{id}/fetch` (веб, MCP `attachment`) → `attachments.request_fetch` (одна транзакция: `state = fetching` + пакет `wiki_fetch {attachment_id, url}`) → `claim` → eva-agent качает файл только с хоста Eva → `POST /agent/files` → результат `{file, size}` → хаб проверяет файл в `files/` и ставит `ready` (иначе `failed`) → `GET /wiki/attachments/{id}/content`; в MCP копия `downloads/wiki-/<имя>`. - Агент за VPN: агент → файлы в общей папке (`relay_dir`) → `relay` → HTTP хаба → ответ файлом. - Самообновление агента: `sdlc-hub agent publish [--module M] PATH` → `release/<модуль>/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти (вместе с признаком `agent_reported` — был ли `claim` с запуска хаба) и отдаёт в `GET /agent`. @@ -154,7 +163,7 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк - Ядро (`core/`) не импортирует модули (`sdlc_hub.modules.*`): исключения — `core/registry.py` (ленивый импорт внутри функции) и шаги истории миграций в `core/db.py` (ленивый импорт внутри шага). Проверяет `tests/test_registry.py::test_core_imports`. - Типы команд (`ensure_*`, `refresh`, `fetch_attachment`, `wiki_sync`, `wiki_fetch` и др.) и термины модулей (jira: `sync_run`, `issue_attachment`, `attachment_file`, `issue_comment`, `jql`, поля досок; wiki: `eva`, `wiki_`, `wiki/`, `DOC-`) живут только в модулях; ядро знает их лишь через `CommandHooks` и поля проекта. Проверяют `test_core_has_no_jira_terms` (исключение — `core/db.py`, история миграций) и `test_core_has_no_module_terms` (исключение — `core/registry.py`). Меток-исключений «долг» нет. - Новые имена в ядре — без названий модулей. Модуль не вызывается из ядра иначе как через `registry` и контракт `Module`. -- Копию вики (`wiki/<метка>/`, `wiki_page`, FTS) пишет только `sync.commit` в отчёте `wiki_sync` (и удаляет при сбросе корня `projects._drop_copy` в транзакции PATCH); MCP и API её читают. Модуль wiki не обращается к Eva и не хранит токен Eva. +- Копию вики (`wiki/<метка>/`, `wiki_page`, FTS) пишет только `sync.commit` в отчёте `wiki_sync` (и удаляет при сбросе корня `projects._drop_copy` в транзакции PATCH); MCP и API её читают. Историю (`wiki_version`, `wiki_history`) пишет только `sync.commit` (удаляет — и `projects._drop_copy`). Модуль wiki не обращается к Eva и не хранит токен Eva. - Зеркало (включая `issue_attachment`) пишет только `mirror.apply_state` по запросу агента (и заметки через `PUT /issues/{key}/note`); из очереди и API задач напрямую не правят. - Журнал `issue_change` пишет только `history.record_changes` по `POST /agent/history`; API и MCP его только читают. - Хаб не знает пароль Jira и не обращается к Jira; `init` не переносит `auth`, `base_url`, `verify_ssl`, `ca_bundle`. @@ -174,7 +183,7 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк - Правки из UI в Jira (спринт, срок, связи, исполнитель) — change `viewer-edit`; страницы очереди, агента и настроек — позже. - Автоподстройка темпа по фактам 429 не реализована: хаб только показывает их. - Запись в вики Eva (правка страниц из хаба) не делается: модуль только читает. -- История страниц вики и частичная перекачка: сейчас каждая синхронизация перекачивает всё дерево, копия без истории. +- Частичная перекачка: каждая синхронизация перекачивает всё дерево страниц (версии — только новые, change `wiki-history`). - Переименование проекта-метки и перенос корня вики между проектами не предусмотрены (меняется только корень — `PATCH /projects`). - Ручной просмотр вёрстки веб-страницы «Вики» на живых данных (таблицы, вложенные списки) — за владельцем после выкатки. - Хаб переименован в sdlc-hub (24.10.2026, change `sdlc-rename`): пакет `sdlc_hub`, команда `sdlc-hub`, каталог данных `~/.local/share/sdlc-hub/` (`SDLC_HUB_HOME`), MCP-серверы `sdlc-jira` и `sdlc-wiki`, launchd `ru.aepif.sdlc-hub`. diff --git a/CLAUDE.md b/CLAUDE.md index 91af62d..5d10bb2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ SDLC-хаб с модулями jira и wiki. Мост Jira для АРМ КА: - `src/sdlc_hub/modules/wiki/` — модуль wiki: `schema`, `settings`, `paths`, `projects` (поле `wiki_root`), `commands` (`wiki_sync`, `wiki_fetch` и хуки очереди), `convert` (HTML → Markdown), `stage`, `sync`, `search` (FTS5), `attachments`, `tick`, `api`, `mcp`. Входная точка — `MODULE` в `modules/wiki/__init__.py`. - Граница: ядро не импортирует модули (кроме ленивого импорта в `registry.py` и шагах миграций `db.py`), типы команд и термины модулей (jira, wiki, eva) живут в модуле, новые имена в ядре — без названий модулей; проверяют `test_core_imports`, `test_core_has_no_jira_terms`, `test_core_has_no_module_terms` в `tests/test_registry.py`. Меток-исключений нет. - Цепочка ядра: `db` ← `settings`, `projects` ← `queue` ← `api` ← `cli`; `files` ← `queue`, `api`; реле `relay` — поток в `serve`; `backup` вызывается из `api` и из `db`. Модуль jira: `hooks → commands/mirror/history/refresh/attachments/fetch → core`. -- Веб: оболочка и общее — `web/src/{shell,ui,api,projects,auth,styles}`, модули — `web/src/modules/<имя>/` (контракт `HubModule` в `modules/types.ts`, список — `modules/index.ts`); состояние модулей (агент, свежесть, обновление) ведёт ядро в `web/src/shell/activity/`, модуль даёт `status` и `Summary` для «Обзора»; модуль jira — страницы доски, задач и ленты; модуль wiki (`web/src/modules/wiki/`) — страница «Вики» (`/app/wiki?project=<метка>`). +- Веб: оболочка и общее — `web/src/{shell,ui,api,projects,auth,styles}`, модули — `web/src/modules/<имя>/` (контракт `HubModule` в `modules/types.ts`, список — `modules/index.ts`); состояние модулей (агент, свежесть, обновление) ведёт ядро в `web/src/shell/activity/`, модуль даёт `status` и `Summary` для «Обзора»; модуль jira — страницы доски и задач; лента — страница ядра; модуль wiki (`web/src/modules/wiki/`) — страница «Вики» (`/app/wiki?project=<метка>`). - Подробная карта модулей и границ — `ARCHITECTURE.md`. ## Команды @@ -26,7 +26,7 @@ SDLC-хаб с модулями jira и wiki. Мост Jira для АРМ КА: - `uv sync` — установка окружения. - `uv run sdlc-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов. - `uv run sdlc-hub serve [--host] [--port]` — локальный запуск хаба. -- Веб-приложение — `http://127.0.0.1:8790/app/` («Обзор» — `/app/?project=<метка>`, «Связи» (доска связей) — `/app/jira/links?project=<метка>`, задачи — `/app/jira/issues?project=<метка>`, «Бэклог» — `/app/jira/backlog?project=<метка>`, «Спринт» — `/app/jira/sprint?project=<метка>`, «Kanban» — `/app/jira/kanban?project=<метка>`, лента изменений — `/app/jira/feed?project=<метка>`, вики — `/app/wiki?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на «Обзор», прежние `/app/board`, `/app/jira/board`, `/app/issues`, `/app/feed` — на страницы модуля Jira; пункты меню могут зависеть от проекта (`NavItem.when`). +- Веб-приложение — `http://127.0.0.1:8790/app/` («Обзор» — `/app/?project=<метка>`, «Связи» (доска связей) — `/app/jira/links?project=<метка>`, задачи — `/app/jira/issues?project=<метка>`, «Бэклог» — `/app/jira/backlog?project=<метка>`, «Спринт» — `/app/jira/sprint?project=<метка>`, «Kanban» — `/app/jira/kanban?project=<метка>`, лента изменений — `/app/feed?project=<метка>` (источники Jira и Вики), вики — `/app/wiki?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на «Обзор», прежние `/app/board`, `/app/jira/board`, `/app/issues` — на страницы модуля Jira, `/app/jira/feed` — на `/app/feed`; пункты меню могут зависеть от проекта (`NavItem.when`). - `make web` — сборка приложения (после `git pull` на Mac mini, до перезапуска хаба); `make check` — pytest + lint и тесты фронта; `cd web && npm run dev` — разработка с прокси на тестовый хаб `bash web/scripts/test-hub.sh` (:8791); `cd web && npm run e2e` — Playwright; `cd web && npm run golden` — перегенерировать эталоны доски из `links.html@3931a88` (только при смене фикстур). - `uv run sdlc-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run sdlc-hub mcp <модуль> [--project <метка>]` — MCP модуля по stdio (модуль обязателен: `jira` — сервер `sdlc-jira`, `wiki` — сервер `sdlc-wiki`; с привязкой сессии к проекту); `uv run sdlc-hub tokens` — показать токены (клиентский и по агенту на модуль). - `uv run sdlc-hub agent publish [--module jira|wiki] PATH` — опубликовать версию агента модуля для самообновления (только вверх; по умолчанию `jira`; публикация лежит в `release/<модуль>/`). diff --git a/README.md b/README.md index d3dbb30..94bbaaf 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,16 @@ MCP вики — отдельный сервер `sdlc-wiki` (`sdlc-hub mcp wiki } ``` -Инструменты: `projects`, `project_create`, `root_set`, `tree`, `page`, `search`, `attachment`, `sync`, `status`. Пока у проекта нет корня +Инструменты: `projects`, `project_create`, `root_set`, `tree`, `page`, `search`, `changes`, `diff`, `attachment`, `sync`, `status`. Пока у проекта нет корня вики (`root_set DOC-…` или `wiki_root` в `project_create`), они отвечают «у проекта <метка> нет корня вики». +- `changes [hours] [since] [until] [author]` — правки страниц за период (по умолчанию — с начала суток), по страницам, новые сверху; +- `diff code [version] [offset]` — дифф правки (без `version` — последняя), по частям 20 000 символов с `продолжение: offset=`; +- `page` — шапка с «изменено: ДД.ММ ЧЧ:ММ <автор> (версий: N)»; `sync` — коротко: «поставлено: пакет N», «уже идёт: пакет N», с `wait` — «ок: синхронизировано …» или «ошибка: …»; +- `status` — дополнительно строка «история: <время>» или «история не загружена». + +`page`, `changes`, `diff` команд не ставят: читают копию хаба. + Спринты и бэклог (нужна привязка к проекту; читают `GET /agile` проекта сессии): `sprints` — спринты доски с суммами задач и SP «выполнено/всего»; `sprint [name]` — спринт (по умолчанию активный) с задачами по колонкам доски; `backlog [limit]` — бэклог по рангу (по умолчанию 50 строк). В `issues` есть колонка SP. Команды спринтов и ранга ставятся @@ -237,6 +244,11 @@ uv run sdlc-hub agent publish --module wiki ../eva-agent/src/agent.py | `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` (общая JQL по меткам всех проектов; проектов нет — 422 «нет проектов») | | `POST /files` | клиент | загрузить файл вложения `{content: base64}` → `{file: sha256, size}`; не base64 — 400, пустой — 422 `empty_file`, больше 10 МБ (10 485 760 байт) — 413 `too_large`; повтор того же содержимого — тот же `file` | | `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `agent_reported`, `update_hint`), журнал загрузок (`history_at` — когда принят журнал изменений полного прогона) | +| `GET /wiki/changes` | клиент | лента правок вики проекта: `project`, `since`, `until` (ISO со смещением или `Z`, иначе 422; по умолчанию — последние 24 ч), `author` (подстрока имени или логина, без учёта регистра), `limit` (1–2000, по умолчанию 500) → `{since, until, synced_at, history_at, truncated, changes}` | +| `GET /wiki/pages/{code}/versions` | клиент | версии страницы, новые первыми: `{versions: [{version, at, author, added, removed}]}`; страницы нет в копии — 404 | +| `GET /wiki/pages/{code}/diff` | клиент | дифф версии (`version`, по умолчанию последняя) относительно предыдущей, по частям `offset` (части по 20 000 символов, `next_offset`); нет версий — 404 `no_versions`, нет такой версии — 404 `no_version` | +| `GET /wiki/status` | клиент | состояние синхронизации вики проекта, в том числе `history_at` (когда загружена история; `null` — ещё не загружалась) | +| `GET /wiki/pages/{code}` | клиент | страница копии, в том числе `modified_at`, `modified_by` (`{login, name}` или `null`) и `versions` — последняя правка и число версий | | `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`; ответ — все настройки после записи, как у GET); `base_jql` вычисляется из проектов (без проектов — `null`), запись `base_jql` и `board_id` — 422 | ```bash @@ -246,8 +258,8 @@ curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8790/issues?sprint= ## Веб-приложение -Адреса: `http://127.0.0.1:8790/app/` — «Обзор» `/app/?project=<метка>` (карточки модулей Jira и «Вики» с состоянием агента, свежестью данных и обновлением, последние пакеты проекта), «Связи» (доска связей) `/app/jira/links?project=<метка>`, задачи `/app/jira/issues?project=<метка>`, лента изменений `/app/jira/feed?project=<метка>` (период и виды событий, история — и в карточке задачи), вики `/app/wiki?project=<метка>`, карточка `/app/jira/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и -`/links` перенаправляют на «Обзор»; прежние адреса `/app/board`, `/app/jira/board`, `/app/issues`, `/app/feed` ведут на страницы модуля Jira; пункты меню могут зависеть от проекта (`NavItem.when`). Вход по клиентскому токену (`uv run sdlc-hub tokens`), токен хранится в браузере; +Адреса: `http://127.0.0.1:8790/app/` — «Обзор» `/app/?project=<метка>` (карточки модулей Jira и «Вики» с состоянием агента, свежестью данных и обновлением, последние пакеты проекта), «Связи» (доска связей) `/app/jira/links?project=<метка>`, задачи `/app/jira/issues?project=<метка>`, лента изменений `/app/feed?project=<метка>` (источники Jira и Вики, период и виды событий Jira, история — и в карточке задачи), вики `/app/wiki?project=<метка>`, карточка `/app/jira/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и +`/links` перенаправляют на «Обзор»; прежние адреса `/app/board`, `/app/jira/board`, `/app/issues` ведут на страницы модуля Jira, `/app/jira/feed` — на `/app/feed`; пункты меню могут зависеть от проекта (`NavItem.when`). Вход по клиентскому токену (`uv run sdlc-hub tokens`), токен хранится в браузере; `localhost` и `127.0.0.1` — разные сайты для браузера, токен и раскладка у каждого свои. Выкатка на Mac mini: ``` diff --git a/docs/superpowers/plans/2026-10-28-wiki-history.md b/docs/superpowers/plans/2026-10-28-wiki-history.md new file mode 100644 index 0000000..4807a2e --- /dev/null +++ b/docs/superpowers/plans/2026-10-28-wiki-history.md @@ -0,0 +1,843 @@ +# История правок вики, общая лента и дифф — план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** хаб хранит опубликованные версии страниц дерева проекта и отдаёт ленту правок и дифф через HTTP API, MCP +`sdlc-wiki` и общую страницу «Лента» веба с источниками «Jira» и «Вики» (sdlc-hub#140). + +**Architecture:** модуль `wiki` хаба получает приём версий от агента (`stage.accept_versions`, `POST /agent/wiki/versions`), +запись версий в той же транзакции, что и копия (`sync.prepare` считает «+/−» вне транзакции, `sync._commit` пишет), и +модуль чтения `history.py` (лента, версии, дифф по запросу). В вебе страница «Лента» переезжает в ядро +(`web/src/shell/feed/`), модули дают ей источники новым полем контракта `HubModule.feed`; модуль wiki добавляет источник и +окно диффа поверх страницы. Агентская часть (eva-agent v2) — отдельный план; контракт — specs/wiki «История правок страниц». + +**Tech Stack:** Python 3.13 (uv), stdlib `difflib`/`sqlite3`, pytest; React 19 + TypeScript, TanStack Query, Vitest, +Testing Library, Playwright. + +**Ветка:** `feat/140-wiki-history` (worktree `sdlc-hub/.wt/wiki-history`). + +**Spec:** `openspec/changes/wiki-history/` (proposal.md, design.md — решения 1–15 и «Decisions made without discussion», +specs/wiki/spec.md, specs/mcp/spec.md, specs/web-app/spec.md). Текущее поведение — `openspec/specs/`, карта — `ARCHITECTURE.md`. + +## Global Constraints + +- Только хаб `sdlc-hub`; eva-agent и jira-agent не меняются. `SCHEMA_VERSION` не меняется: новые таблицы модуля + создаются `CREATE TABLE IF NOT EXISTS` в `modules/wiki/schema.py` (решение плана П-1). +- Ядро Python не знает терминов модулей: в `src/sdlc_hub/core/` нет `eva`, `wiki_`, `wiki/`, `DOC-` и импортов + `sdlc_hub.modules` (`tests/test_registry.py`). Новые имена в ядре — без названий модулей. +- Веб: ядро (`web/src/{shell,ui,api,projects,auth,styles}`) не импортирует модули (кроме `modules/index.ts`, `modules/types.ts`) + и не знает их терминов; модуль не импортирует другой модуль; класс CSS одного владельца не объявляется у другого + (`web/src/boundary.test.ts`). +- Время в базе — UTC в формате `db.now()` (`2026-10-27T20:48:02+00:00`, секунды без дробной части). +- Знак «−» в «+A / −R» — U+2212 (как в спеке), в MCP и вебе. +- Части текста — по 20 000 символов (`CHUNK`), как у `page`. +- Пачка версий — не больше 50; `limit` ленты — по умолчанию 500, 1–2000. +- Чтение истории (`GET /wiki/changes`, `…/versions`, `…/diff`, MCP `changes`/`diff`/`page`) не ставит команд. +- Тексты ошибок, комментарии, сообщения коммитов — по-русски; идентификаторы — английские. +- Эталоны (`tests/fixtures/baseline/*.expected.json`) перезаписываются только `BASELINE_WRITE=1 uv run pytest + tests/test_baseline.py` в задаче, которая меняет маршруты/схему/инструменты; эталон `mcp_tools` (сервер jira) не меняется. +- Соединения SQLite в тестах — через фикстуры `open_db`/`api` или с явным `close()`. +- Зелёным считается только весь набор: `make check` (`uv run pytest -q` + `cd web && npm run lint && npm test`). + +## Review Focus + +1. Фильтр автора кириллицей без учёта регистра (`author=СОСНОВ`, `author=соснов`): SQLite `LOWER`/`LIKE` знают только + ASCII — фильтр должен находить «Дмитрий Соснов». Тест `test_changes_author_cyrillic_case` в Task 3. +2. Агент переотправил ту же пачку версий (сетевая ошибка, 5xx) или прислал уже известный хабу код: в стадии одна строка + на код, «пришло N из M» считает уникальные коды, в истории дубля нет и «+/−» прежние. Тесты + `test_versions_resend_same_batch` (Task 1) и `test_known_version_resent_ignored` (Task 2). +3. Тяжёлая работа вне блокировки записи (рулинг R-6): перевод HTML версий и подсчёт «+/−» по сотням версий первой + загрузки не идут внутри `BEGIN IMMEDIATE`. Тест `test_history_heavy_work_off_write_lock` в Task 2. +4. Время версии от Eva с дробными секундами, `Z` или `+0300` нормализуется в UTC без дробей; без смещения — 422. + Тест `test_versions_created_at_forms` в Task 1. +5. Дифф частями: часть может оборваться посреди строки — окно склеивает части и показывает строку один раз и целиком. + Тест `«Показать дальше»: часть рвёт строку — строка целая и одна` в Task 7. + +## Решения плана (записать в design.md) + +Task 1, шаг 6 дописывает их в `openspec/changes/wiki-history/design.md`, раздел «Decisions made without discussion» +(дата 28.10.2026, решение, обоснование, цена ошибки — коротко, по образцу раздела): + +- П-1 `history_at` — своя таблица `wiki_history(label, history_at)` без миграции. Цена: ещё одна таблица на проект. +- П-2 Версия, код которой уже есть в хабе, не перезаписывается (`INSERT OR IGNORE`, design 1 — опубликованная неизменна); + в стадии повтор кода заменяет строку (`INSERT OR REPLACE`), N в «пришло N из M» — число уникальных кодов в стадии. +- П-3 `result.versions` не целое ≥ 0 (строка, `true`, −1) — команда `failed` «versions — неотрицательное целое»; присланных + больше `M` — применяются все. +- П-4 «+/−» считаются в `sync.prepare` (вне транзакции), пишутся в `sync._commit`; число — строки `+`/`-` из + `difflib.unified_diff` (те же, что в диффе правки). +- П-5 Время версии: дробные секунды отбрасываются; равное время — порядок по коду строкой (спека: «затем по коду»). +- П-6 Фильтр автора — в Python (`casefold`) до среза `limit`. +- П-7 `GET …/diff`: `offset` не целое ≥ 0 — 422 `bad_request` «offset: целое ≥ 0»; `offset` за концом — `diff = ""`, + `next_offset = null`. +- П-8 MCP: шапка `diff`, строка `status` «история: » / «история не загружена», `sync` при отменённом пакете — + «ошибка: пакет N отменён», время в «ок: синхронизировано …» — `synced_at` из `GET /wiki/status`; автор правки — имя, + иначе логин, иначе «—» (и в вебе). +- П-9 Помощники времени MCP (`local_if_naive`, `since_of`) — в `core/mcp.py`, сервер jira берёт их оттуда. +- П-10 Веб: в шапке «Ленты» — вторичные кнопки обновления модулей выбранных источников («Обновить из Jira», «Обновить вики»). +- П-11 Веб: сообщения `truncated` и ошибки источника — с префиксом «<Источник>: »; «выберите хотя бы один вид» и + «история вики ещё не загружена…» — без префикса; «за период изменений нет» — когда ни один выбранный источник не + грузится и не в ошибке, хотя бы один загружен без своего сообщения и групп нет; `sources=none` — явный пустой набор + (как `kinds=none`). +- П-12 Веб: `eventTime` переезжает в `ui/format.ts`, период (`PERIODS`, `parsePeriod`, `periodRange`) — в `shell/feed/period.ts`; + прежнее перенаправление `/app/feed` → `/app/jira/feed` снимается (адрес теперь страница ядра). + +--- + +### Task 1: Схема версий, перевод без id вложений, приём `POST /agent/wiki/versions` + +**Model:** haiku + +**Files:** +- Modify: `src/sdlc_hub/modules/wiki/schema.py` (таблицы `wiki_version`, `wiki_version_stage`, `wiki_history`) +- Create: `src/sdlc_hub/modules/wiki/history.py` (пока только `utc`) +- Modify: `src/sdlc_hub/modules/wiki/convert.py` (`to_markdown(..., file_links=True)`) +- Modify: `src/sdlc_hub/modules/wiki/stage.py` (`accept_versions`, `MAX_VERSIONS`, `MAX_VERSION_STAGE`) +- Modify: `src/sdlc_hub/modules/wiki/api.py` (`h_agent_versions`, маршрут) +- Modify: `tests/fixtures/baseline/schema.expected.json`, `tests/fixtures/baseline/routes.expected.json` (перезапись) +- Modify: `openspec/changes/wiki-history/design.md` (решения плана П-1…П-12) +- Modify: `ARCHITECTURE.md` (строки `schema`, `stage`, `convert` таблицы модуля wiki) +- Test: `tests/test_wiki_history.py` (новый), `tests/test_wiki_convert.py` + +**Interfaces:** +- Produces: + - таблицы: + ```sql + CREATE TABLE IF NOT EXISTS wiki_version ( + label TEXT NOT NULL, code TEXT NOT NULL, page_code TEXT NOT NULL, created_at TEXT NOT NULL, + author_login TEXT, author_name TEXT, markdown TEXT NOT NULL, + added INTEGER NOT NULL DEFAULT 0, removed INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (label, code)); + CREATE INDEX IF NOT EXISTS wiki_version_page ON wiki_version(label, page_code, created_at); + CREATE INDEX IF NOT EXISTS wiki_version_time ON wiki_version(label, created_at); + CREATE TABLE IF NOT EXISTS wiki_version_stage ( + command_id INTEGER NOT NULL, code TEXT NOT NULL, page_code TEXT NOT NULL, payload TEXT NOT NULL, + PRIMARY KEY (command_id, code)); + CREATE TABLE IF NOT EXISTS wiki_history (label TEXT PRIMARY KEY REFERENCES project(label), history_at TEXT NOT NULL); + ``` + - `history.utc(value) -> str | None` — ISO-8601 со смещением или `Z` (`+03:00`, `+0300`, дробные секунды) → UTC в + формате `db.now()`; без смещения, не строка или неразбираемое — `None`. + - `convert.to_markdown(html, attachments, links, *, file_links: bool = True) -> tuple[str, list[dict]]`; при + `file_links=False` ссылка вложения — текст `📎 <имя>` без ссылки (имя экранировано как в ссылке), список файлов `[]`; + ссылки на страницы — `page:<код>` как прежде. + - `stage.accept_versions(conn, command_id, versions) -> {"accepted": n}`; payload стадии — JSON + `{page_code, code, created_at (UTC), author_login, author_name, markdown}`. + - маршрут `("POST", r"^/agent/wiki/versions$", h_agent_versions, "agent")`. + +- [ ] **Step 1: Написать падающие тесты.** В `tests/test_wiki_history.py` — помощники для Task 2–3: + +```python +from test_wiki_sync import CODES, TREE, api, first_sync, page # noqa: F401 — api — фикстура +SOSNOV = {"login": "sosnov-dvi@rshbeco.ru", "name": "Дмитрий Соснов"} +MOLCH = {"login": "molchanov-iv@rshbeco.ru", "name": "Илья Молчанов"} + +def version(page_code, code, created_at, html="

текст

", author=SOSNOV, attachments=None, links=None): + return {"page_code": page_code, "code": code, "created_at": created_at, "author": author, "html": html, + "attachments": attachments or [], "links": links or []} + +def stage_rows(api, cmd_id): + return api.rows("SELECT code, page_code, payload FROM wiki_version_stage WHERE command_id = ? ORDER BY code", cmd_id) +``` + +Тесты и утверждения: + +```python +def test_versions_staged(api): # перевод и форма payload + cmd = api.claim() + out = api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [ + version("DOC-3", "DOCH-1", "2026-10-27T23:48:02+03:00", "

статус новый

")]}) + assert out == {"accepted": 1} + (row,) = stage_rows(api, cmd["id"]) + p = json.loads(row["payload"]) + assert p == {"page_code": "DOC-3", "code": "DOCH-1", "created_at": "2026-10-27T20:48:02+00:00", + "author_login": "sosnov-dvi@rshbeco.ru", "author_name": "Дмитрий Соснов", "markdown": "статус **новый**\n"} + +def test_versions_resend_same_batch(api): # Review Focus 2: повтор пачки — одна строка на код + cmd = api.claim(); batch = [version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z"), version("DOC-3", "DOCH-2", "2026-10-27T11:00:00Z")] + for _ in range(2): + api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": batch}) + assert [r["code"] for r in stage_rows(api, cmd["id"])] == ["DOCH-1", "DOCH-2"] + +@pytest.mark.parametrize("raw,stored", [("2026-10-27T23:48:02.123456+03:00", "2026-10-27T20:48:02+00:00"), + ("2026-10-27T20:48:02Z", "2026-10-27T20:48:02+00:00"), + ("2026-10-27T23:48:02+0300", "2026-10-27T20:48:02+00:00")]) +def test_versions_created_at_forms(api, raw, stored): # Review Focus 4 → created_at в payload == stored +def test_versions_created_at_without_offset_422(api): # "2026-10-27T23:48:02" → 422 bad_versions, detail "DOCH-1: created_at" +def test_versions_author_nulls(api): # author {"login": None, "name": None} принят; author_login/author_name None + +@pytest.mark.parametrize("field,value", [("page_code", ""), ("page_code", 5), ("html", None), ("author", "Соснов"), + ("author", {"login": 1, "name": "x"}), ("attachments", [{"ref": 1}]), ("links", "x")]) +def test_versions_bad_field(api, field, value): # 422 bad_versions, detail == f"DOCH-1: {field}" + +def test_versions_bad_batch(api): # [] и 51 версия → 422 bad_versions; command_id "1" → 422 bad_request; + # версия-не объект → detail "версия 1: не объект"; без code → "версия 1: code" +def test_versions_not_claimed(api): # команда queued → 409 not_claimed; несуществующая → 404 +def test_versions_wrong_agent(api): # токеном агента jira (api.jira) → 401 +``` + +В `tests/test_wiki_convert.py`: + +```python +def test_version_markdown_attachment_as_text(): + md, files = convert.to_markdown(_html("page_file.html"), [THUMB, FILE], [], file_links=False) + assert "📎 model.xlsx" in md and "attachment:" not in md and files == [] + +def test_version_markdown_page_links_kept(): + md, _ = convert.to_markdown('

т

', [], [LINK], file_links=False) + assert md == "[т](page:DOC-1)\n" +``` + +(`THUMB`, `FILE`, `LINK` — константы файла, заведённые пакетом D.) + +- [ ] **Step 2: Запустить — падают.** Run: `uv run pytest tests/test_wiki_history.py tests/test_wiki_convert.py -q` — + Expected: FAIL (404 на `/agent/wiki/versions`, `TypeError: unexpected keyword 'file_links'`). + +- [ ] **Step 3: Реализовать.** + - `schema.SQL` — таблицы из Interfaces. + - `history.utc` — `datetime.fromisoformat`; `tzinfo is None` → `None`; `.astimezone(timezone.utc).replace(microsecond=0).isoformat()`. + - `convert.to_markdown(..., file_links=False)`: после готового Markdown заменить каждую `[📎 <имя>](attachment:@N)` на + `📎 <имя>` (регэксп по `\[📎 ((?:\\.|[^\]\\])*)\]\(attachment:@\d+\)`; такую ссылку выдаёт только конвертер — см. + docstring модуля) и вернуть `[]` вместо файлов. + - `stage.accept_versions` — по образцу `accept`: `MAX_VERSIONS = 50`, `MAX_VERSION_STAGE = 20000`; ошибки формы — + `ApiError(422, "bad_versions", "<код версии>: <поле>")` (до кода — `"версия : …"`); список — «versions — список от 1 до + 50 версий»; `_clean` на версию; `page_code`/`code` — непустые строки ≤ 200; `author` — объект, `login`/`name` — строка или + `None`; `html` — строка; `attachments`/`links` — те же проверки, что у страниц (вынести общую проверку из `_check`); + `created_at` через `history.utc`. Перевод — `convert.to_markdown(html, attachments, links, file_links=False)` до + транзакции (сбой — `"<код>: html (<ИмяИсключения>)"`); в транзакции — повторная проверка `claimed`, предел стадии + («versions: в команде больше 20000 версий»), `INSERT OR REPLACE INTO wiki_version_stage(command_id, code, page_code, payload)`. + - `api.h_agent_versions` → `stage.accept_versions(conn, body.get("command_id"), body.get("versions"))`; маршрут в `ROUTES` + сразу после `/agent/wiki/pages`. + +- [ ] **Step 4: Запустить — проходят.** Run: `uv run pytest tests/test_wiki_history.py tests/test_wiki_convert.py tests/test_wiki_sync.py -q` — Expected: PASS. + +- [ ] **Step 5: Эталоны.** Run: `BASELINE_WRITE=1 uv run pytest tests/test_baseline.py -q && git diff --stat tests/fixtures/baseline/` + — Expected: изменены только `schema.expected.json` (три таблицы, два индекса, `schema_version` прежний) и + `routes.expected.json` (одна строка `/agent/wiki/versions`). Затем `uv run pytest tests/test_baseline.py -q` — PASS. + +- [ ] **Step 6: Документы.** design.md — решения П-1…П-12 из раздела «Решения плана». ARCHITECTURE.md, таблица модуля + wiki: `schema` — плюс `wiki_version`, `wiki_version_stage`, `wiki_history`; `stage` — «и пачек версий + (`accept_versions`, `POST /agent/wiki/versions`)»; `convert` — «режим без ссылок на вложения для версий (`file_links=False`)». + +- [ ] **Step 7: Commit.** +```bash +git add src/sdlc_hub/modules/wiki/{schema,history,convert,stage,api}.py tests/test_wiki_history.py tests/test_wiki_convert.py \ + tests/fixtures/baseline/ ARCHITECTURE.md openspec/changes/wiki-history/design.md +git commit -m "Вики: таблицы версий и приём POST /agent/wiki/versions (#140)" +``` + +--- + +### Task 2: Выдача `known_versions` и запись версий в синхронизации + +**Model:** sonnet + +**Files:** +- Modify: `src/sdlc_hub/modules/wiki/history.py` (`known_versions`, `line_delta`, `history_at`) +- Modify: `src/sdlc_hub/modules/wiki/commands.py` (`deliver_params` в `HOOKS`) +- Modify: `src/sdlc_hub/modules/wiki/sync.py` (`Prepared`, `prepare`, `_plan_versions`, `_commit`, очистка стадии версий) +- Modify: `src/sdlc_hub/modules/wiki/projects.py` (`_drop_copy`) +- Modify: `ARCHITECTURE.md` (поток «Синхронизация вики», границы, строки `commands`/`sync`/`projects`) +- Test: `tests/test_wiki_history.py` + +**Interfaces:** +- Consumes: таблицы и `stage.accept_versions` из Task 1. +- Produces: + - `history.known_versions(conn, label) -> dict[str, list[str]]` — коды версий страниц текущей копии (`JOIN wiki_page`), + по странице в порядке `(created_at, code)`; без копии — `{}`. + - `history.line_delta(old: str, new: str) -> tuple[int, int]` — `(added, removed)`: строки `+`/`-` из + `difflib.unified_diff(old.splitlines(), new.splitlines(), lineterm="")` без заголовков `+++`/`---`. + - `history.history_at(conn, label) -> str | None`. + - `commands.deliver_params(conn, cmd, files_dir) -> dict` — `wiki_sync`: `{**cmd["params"], "known_versions": …}`; иначе `cmd["params"]`. + - `sync._plan_versions(conn, label, codes, staged) -> tuple[list[dict], list[tuple[int, int, str]]]` — (новые строки + `wiki_version` с `added`/`removed`, пересчёт `(added, removed, code)` известных версий, чей предшественник новый). + +- [ ] **Step 1: Написать падающие тесты** (в `tests/test_wiki_history.py`; помощник обмена): + +```python +def run_history(api, pages, versions, codes=None, reported=None): + """Взять wiki_sync, прислать страницы и версии (пачками по 50), отчитаться done с versions=reported (None — len).""" + cmd = api.claim() + api.wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": pages}) + for i in range(0, len(versions), 50): + api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": versions[i:i + 50]}) + result = {"root_title": "АРМ КА", "codes": codes or [p["code"] for p in pages], "eva_calls": 3, + "versions": len(versions) if reported is None else reported} + return cmd, api.report(cmd, "done", result) + +def versions_of(api, page_code=None): ... # SELECT code, page_code, added, removed, created_at … ORDER BY page_code, created_at, code +``` + +```python +def test_first_history_load(api): # сценарий «Первая загрузка истории» + cmd, done = run_history(api, TREE, [version("DOC-1", f"DOCH-1{i}", f"2026-10-2{i}T10:00:00Z", "

а

" * (i + 1)) for i in range(3)] + + [version("DOC-3", f"DOCH-3{i}", f"2026-10-2{i}T10:00:00Z") for i in range(2)], CODES) + assert cmd["params"]["known_versions"] == {} and done["status"] == "done" + rows = versions_of(api) + assert len(rows) == 5 + assert [(r["added"], r["removed"]) for r in rows if r["code"] in ("DOCH-10", "DOCH-30")] == [(1, 0), (1, 0)] + assert api.rows("SELECT history_at FROM wiki_history")[0]["history_at"] + assert "known_versions" not in api.rows("SELECT params FROM command WHERE id = ?", cmd["id"])[0]["params"] + +def test_known_versions_on_delivery_and_recovery(api): # сценарий «Догрузка» + GET /agent/commands/{id} + # после первой загрузки: следующий claim — known_versions == {"DOC-1": ["DOCH-10", "DOCH-11"], "DOC-3": [...]} + # (порядок — по времени); GET /agent/commands/ токеном wiki отдаёт те же known_versions + # присланная DOCH-12 (DOC-1): added/removed == history.line_delta(md DOCH-11, md DOCH-12) + +def test_agent_without_history(api): # done без versions: копия обновлена (synced_at новее), версии и history_at прежние +def test_page_left_tree(api): # у DOC-3 версии; новая синхронизация с versions=0 и codes без DOC-3 → версий DOC-3 нет +def test_foreign_page_version_dropped(api): # версия page_code DOC-99 → не записана, команда done +def test_lost_versions_batch(api): # прислано 3, versions=5 → failed, error == "нет версий: пришло 3 из 5"; + # wiki_page.synced_at и wiki_version прежние, стадии обеих пусты +def test_resend_counts_unique(api): # одна и та же пачка из 2 версий дважды, versions=2 → done (не «пришло 4 из 2») +@pytest.mark.parametrize("bad", ["5", True, -1, 1.5]) +def test_versions_field_bad(api, bad): # failed, error == "versions — неотрицательное целое" +def test_late_version_recounts_successor(api): # есть V1 10:00, V3 12:00; приходит V2 11:00: + # V2 = line_delta(V1, V2), V3 пересчитана = line_delta(V2, V3) +def test_known_version_resent_ignored(api): # Review Focus 2: повтор известного кода с другим html — markdown/added прежние +def test_reset_root_drops_history(api): # PATCH wiki_root=None → wiki_version и wiki_history проекта пусты +def test_redelivery_clears_version_stage(api): # повторная выдача → wiki_version_stage команды пуста +def test_discarded_report_keeps_history(api): # корень сменён до отчёта → result.discarded, версии прежние, стадия версий пуста +def test_failed_report_clears_version_stage(api): # отчёт failed → стадия версий пуста, история прежняя +def test_history_heavy_work_off_write_lock(api, monkeypatch): # Review Focus 3 + # по образцу test_no_heavy_work_under_write_lock (test_wiki_sync.py): probe() в обёртках stage.convert.to_markdown + # и sync.history.line_delta; после run_history с 3 версиями — ("delta", "free") в seen, "locked" нет + +def test_line_delta(): + assert history.line_delta("", "a\nb\n") == (2, 0) + assert history.line_delta("a\nb\nc\n", "a\nB\nc\nd\n") == (2, 1) + assert history.line_delta("a\n", "a\n") == (0, 0) +``` + +Ещё `test_housekeeping_clears_version_stage` (строка стадии версий у `cancelled` команды удаляется `sync.housekeeping`). + +- [ ] **Step 2: Запустить — падают.** Run: `uv run pytest tests/test_wiki_history.py -q` — Expected: FAIL (`KeyError: 'known_versions'`, версии не записаны). + +- [ ] **Step 3: Реализовать.** + - `commands.HOOKS.deliver_params = deliver_params` — один хук покрывает и `claim`, и `GET /agent/commands/{id}` + (`core/api.py`, `h_agent_command`). + - `sync.Prepared`: поля `with_history: bool = False`, `versions: list`, `recount: list`. + - `sync.prepare` после проверки страниц: если в `result` есть ключ `versions` — `M` целое ≥ 0 и не `bool`, иначе + `SyncFailed("versions — неотрицательное целое")`; `N = COUNT(*)` стадии версий команды (уникальные коды — ключ + `(command_id, code)`); `N < M` → `SyncFailed(f"нет версий: пришло {N} из {M}")`; затем + `p.versions, p.recount = _plan_versions(...)`, `p.with_history = True`. Это до транзакции отчёта (R-6). + - `_plan_versions`: из стадии — только `page_code in codes` и код, которого нет в `wiki_version` проекта; по каждой + затронутой странице — известные версии + новые, сортировка `(created_at, code)`, предыдущий текст первой — `""`; новая — + `line_delta(пред., своя)`; известная, чей предшественник новый, — пересчёт, в `recount` если значения другие. + - `sync._commit`: при `p.with_history` — `DELETE FROM wiki_version WHERE label = ? AND page_code NOT IN (SELECT value + FROM json_each(?))` (коды — `json.dumps`), `INSERT OR IGNORE` новых, `UPDATE … SET added, removed` по `recount`, + `INSERT OR REPLACE INTO wiki_history(label, history_at) VALUES (?, now)`. Всегда — `DELETE FROM wiki_version_stage + WHERE command_id = ?`. + - Стадию версий чистить там же, где стадию страниц: `on_claim` (redelivery), `on_failed_or_reported_failed`, + `housekeeping`, ветка «корень сменился» в `on_result`. + - `projects._drop_copy`: `DELETE FROM wiki_version WHERE label = ?`, `DELETE FROM wiki_history WHERE label = ?`. + +- [ ] **Step 4: Запустить — проходят.** Run: `uv run pytest tests/test_wiki_history.py tests/test_wiki_sync.py tests/test_wiki_projects.py tests/test_wiki_commands.py -q` — Expected: PASS. + +- [ ] **Step 5: Документы.** ARCHITECTURE.md: строка `commands` — «`deliver_params` дописывает `known_versions`»; `sync` — + «запись версий, «+/−» (подсчёт в `prepare`), `history_at`»; `projects` — сброс корня удаляет историю; поток + «Синхронизация вики» — шаг «после страниц eva-agent v2 шлёт версии `POST /agent/wiki/versions` (≤50 в пачке), отчёт + `done {…, versions}`; хаб при выдаче дописывает `known_versions`; без `versions` (агент v1) история не меняется»; раздел + «Границы» — «Историю (`wiki_version`, `wiki_history`) пишет только `sync.commit` (удаляет — и `projects._drop_copy`)». + +- [ ] **Step 6: Commit.** +```bash +git add src/sdlc_hub/modules/wiki/{history,commands,sync,projects}.py tests/test_wiki_history.py ARCHITECTURE.md +git commit -m "Вики: known_versions при выдаче и запись версий в синхронизации (#140)" +``` + +--- + +### Task 3: Чтение истории — лента, версии, дифф, поля страницы и состояния + +**Model:** haiku + +**Files:** +- Modify: `src/sdlc_hub/modules/wiki/history.py` (`feed`, `page_versions`, `diff`, `last_change`, `NoVersions`, `NoVersion`, `CHUNK`) +- Modify: `src/sdlc_hub/modules/wiki/api.py` (`h_changes`, `h_versions`, `h_diff`, поля в `h_page`, `h_status`) +- Modify: `tests/fixtures/baseline/routes.expected.json` (перезапись) +- Modify: `ARCHITECTURE.md` (строка `history`, `api`, открытый вопрос), `README.md` (таблица маршрутов) +- Test: `tests/test_wiki_history_api.py` (новый) + +**Interfaces:** +- Consumes: `history.utc`, `history.history_at`, `history.line_delta`; помощники `run_history`, `version`, `SOSNOV`, `MOLCH` из `test_wiki_history`. +- Produces: + - `history.feed(conn, label, since: str, until: str, author: str | None, limit: int) -> {"truncated": bool, "changes": [...]}` + — элементы `{at, page_code, title, path, version, author: {login, name}, added, removed}`, только страницы текущей + копии (`JOIN wiki_page`), `at` в `[since, until]`, по `at` ↓, затем `version` ↓; автор — подстрока `casefold` имени + или логина (в Python, до среза). + - `history.page_versions(conn, label, code) -> list[dict]` — `[{version, at, author, added, removed}]`, новые первыми. + - `history.diff(conn, label, code, version: str | None, offset: int) -> dict` — поля ответа маршрута, кроме `title`/`path` + (их добавляет api); `NoVersions` — у страницы нет версий, `NoVersion` — нет такой версии. + `difflib.unified_diff(prev.splitlines(), cur.splitlines(), fromfile=prev_code or "", tofile=code, n=3, lineterm="")`, + строки через `"\n"` и `"\n"` в конце (пустой дифф — `""`); часть `[offset:offset + CHUNK]`, `CHUNK = 20000`. + - `history.last_change(conn, label, code) -> {"modified_at", "modified_by", "versions"}` (`modified_by` — `{login, name}` или `None`). + - маршруты `GET ^/wiki/changes$`, `GET ^/wiki/pages/([^/]+)/versions$`, `GET ^/wiki/pages/([^/]+)/diff$` (клиент). + +- [ ] **Step 1: Написать падающие тесты.** Фикстура `history` (через `api`): синхронизация `TREE`/`CODES` с версиями + DOC-3 (Соснов) `2026-10-27T17:59:00+03:00`, `2026-10-27T18:02:00+03:00` и DOC-2 (Молчанов) `2026-10-26T12:00:00+03:00`. + +```python +def test_changes_today(api, history): # сценарий «Что сделали за сегодня» + d = api.client.get("/wiki/changes", project="AWPCA", since="2026-10-27T00:00:00+03:00", until="2026-10-28T00:00:00+03:00") + assert [c["at"] for c in d["changes"]] == ["2026-10-27T15:02:00+00:00", "2026-10-27T14:59:00+00:00"] + c = d["changes"][0] + assert c["page_code"] == "DOC-3" and c["title"] == "Статусы" and c["path"] == "АРМ КА/01_Статусы" + assert c["author"] == {"login": "sosnov-dvi@rshbeco.ru", "name": "Дмитрий Соснов"} + assert isinstance(c["added"], int) and isinstance(c["removed"], int) + assert d["since"] == "2026-10-26T21:00:00+00:00" and d["truncated"] is False and d["synced_at"] and d["history_at"] + +@pytest.mark.parametrize("author", ["соснов", "СОСНОВ", "Sosnov-DVI"]) +def test_changes_author_cyrillic_case(api, history, author): # Review Focus 1 → только две правки DOC-3 +def test_changes_default_window(api, history): # без since/until: until − since == 24 ч (по ответу) +def test_changes_limit_truncated(api, history): # limit=1 → одна правка, truncated True +@pytest.mark.parametrize("q,param", [({"since": "2026-10-27T00:00:00"}, "since"), ({"until": "вчера"}, "until"), + ({"limit": "0"}, "limit"), ({"limit": "2001"}, "limit"), ({"limit": "x"}, "limit"), + ({"since": "2026-10-28T00:00:00Z", "until": "2026-10-27T00:00:00Z"}, "since")]) +def test_changes_bad_params(api, history, q, param): # 422 bad_request, detail.startswith(param) +def test_changes_history_not_loaded(api): # только синхронизация без versions → 200, history_at None, changes [] +def test_changes_no_root_and_unknown(api): # проект без корня → 409 no_root; неизвестный → 404 +def test_versions_list(api, history): # DOC-3 → versions[0]["at"] == "2026-10-27T15:02:00+00:00", 2 шт.; DOC-99 → 404 +def test_diff_last(api): # сценарий «Дифф последней правки»: 4 версии DOC-3 → + # index 4, total 4, prev_version == третий код, diff.startswith(f"--- {v3}\n+++ {v4}\n"), next_offset None +def test_diff_first_version(api, history): # version=первая → prev_version None, diff.startswith("--- \n+++ "), removed 0 +def test_diff_chunks(api): # сценарий «Длинный дифф»: версия ~3000 строк; full = diff целиком + # (склейка частей): offset=0 → len 20000, next_offset 20000; offset=40000 → diff == full[40000:], next_offset None; + # части 0, 20000, 40000 вместе == full; offset=-1 и "x" → 422 bad_request "offset: целое ≥ 0" +def test_diff_errors(api, history): # DOC-99 → 404 not_found; version=DOCH-404 → 404 no_version; + # DOC-1 без версий → 404 no_versions +def test_page_last_change(api): # сценарий «Последняя правка в странице»: последняя версия + # created_at "2026-10-27T23:48:02+03:00" → modified_at "2026-10-27T20:48:02+00:00", modified_by.name "Дмитрий Соснов", + # versions 4; страница без версий → modified_at None, modified_by None, versions 0 +def test_status_history_at(api, history): # GET /wiki/status → history_at == строке wiki_history; без истории — None +``` + +- [ ] **Step 2: Запустить — падают.** Run: `uv run pytest tests/test_wiki_history_api.py -q` — Expected: FAIL (404 маршрутов, нет `modified_at`). + +- [ ] **Step 3: Реализовать** функции из Interfaces и обработчики. `h_changes`: `_project(conn, q)`; время — свой + `_time_param(q, name, default)` через `history.utc` (без смещения — 422 `bad_request` «<имя>: ожидается время ISO-8601 со + смещением или Z»); `until` по умолчанию `db.now()`, `since` — `until − 24 ч`; `since > until` — «since: позже until»; + `limit` — «limit: целое от 1 до 2000»; ответ `{since, until, synced_at, history_at, truncated, changes}`. `h_diff`: `offset` + (по умолчанию 0) — «offset: целое ≥ 0»; `NoVersions` → `ApiError(404, "no_versions", …)`, `NoVersion` → `"no_version"`; + страница не в копии — 404 `not_found`; в ответ добавить `code`, `title`, `path`. `h_page` — `**history.last_change(…)`; + `h_status` — `history_at`. Маршруты — после `^/wiki/pages/([^/]+)$`. + +- [ ] **Step 4: Запустить — проходят.** Run: `uv run pytest tests/test_wiki_history_api.py tests/test_wiki_api.py -q` — Expected: PASS. + +- [ ] **Step 5: Эталон маршрутов.** Run: `BASELINE_WRITE=1 uv run pytest tests/test_baseline.py -q && git diff tests/fixtures/baseline/` + — Expected: в `routes.expected.json` плюс три строки, остальные эталоны без изменений. + +- [ ] **Step 6: Документы.** ARCHITECTURE.md: строка `history` в таблице модуля wiki — «чтение истории: лента `feed`, + версии страницы, дифф по запросу (`difflib`, части по 20 000), `known_versions`, `line_delta`; HTTP не знает»; строка `api` — + новые маршруты; поток «История вики: … → `GET /wiki/changes`, `…/versions`, `…/diff`, MCP `changes`/`diff`, веб «Лента» + (только база хаба)»; цепочка `api → history`, `sync → history`. Открытый вопрос «История страниц вики и частичная + перекачка» заменить на «Частичная перекачка: каждая синхронизация перекачивает всё дерево страниц (версии — только новые, + change `wiki-history`)». README.md — строки `GET /wiki/changes`, `GET /wiki/pages/{code}/versions`, + `GET /wiki/pages/{code}/diff`, поля `modified_at`/`modified_by`/`versions` у страницы и `history_at` у состояния. + +- [ ] **Step 7: Commit.** +```bash +git add src/sdlc_hub/modules/wiki/{history,api}.py tests/test_wiki_history_api.py tests/fixtures/baseline/ ARCHITECTURE.md README.md +git commit -m "Вики: лента правок, версии и дифф в HTTP API (#140)" +``` + +--- + +### Task 4: MCP вики — `changes`, `diff`, «изменено» в `page`, короткий `sync`, история в `status` + +**Model:** haiku + +**Files:** +- Modify: `src/sdlc_hub/core/mcp.py` (`local_if_naive`, `since_of`) +- Modify: `src/sdlc_hub/modules/jira/mcp.py` (берёт помощники из ядра; описания инструментов не меняются) +- Modify: `src/sdlc_hub/modules/wiki/mcp.py` (`changes`, `diff`, `fmt_wiki_changes`, `page`, `sync`, `status`) +- Modify: `tests/fixtures/baseline/mcp_wiki_tools.expected.json` (перезапись) +- Modify: `ARCHITECTURE.md` (строка `mcp` модуля wiki), `README.md` (таблица инструментов `sdlc-wiki`) +- Test: `tests/test_wiki_mcp.py` + +**Interfaces:** +- Consumes: ответы маршрутов Task 3 (`/wiki/changes`, `/wiki/pages/{code}/diff`, поля `page`/`status`). +- Produces: + - `core.mcp.local_if_naive(value: str) -> str` (перенос `jira.mcp._local_if_naive` без изменений поведения); + `core.mcp.since_of(hours: float | None, since: str, now: datetime) -> str` — `hours` → `now − hours`, пусто → начало + суток `now`, иначе `since`. + - инструменты `changes(hours: float | None = None, since: str = "", until: str = "", author: str = "") -> str`, + `diff(code: str, version: str = "", offset: int = 0) -> str`; порядок инструментов — + `["tree", "page", "search", "changes", "diff", "attachment", "sync", "status", "root_set", "projects", "project_create"]`. + +- [ ] **Step 1: Написать падающие тесты** (`tests/test_wiki_mcp.py`, фикстура `hub` и `Fake` — прежние; + `loc = lambda iso: datetime.fromisoformat(iso).astimezone()`; сегодняшние `at` строятся из местного времени): + +```python +def test_changes_today_grouped(hub): # сценарий «Что сделали аналитики сегодня» + t1, t2 = today_at(11, 2), today_at(12, 40) # ISO UTC сегодняшних 11:02 и 12:40 местного + fake = hub({"/wiki/changes": {"since": today_at(0, 0), "until": today_at(14, 5), "synced_at": today_at(14, 0), + "history_at": today_at(14, 0), "truncated": False, "changes": [ + chg("DOC-3", "DOCH-2", t2, 40, 12), chg("DOC-3", "DOCH-1", t1, 5, 0), chg("DOC-2", "DOCH-9", "2026-10-26T09:00:00+00:00", 1, 1, MOLCH)]}}) + lines = wmcp.changes().splitlines() + assert lines[0] == f"правки вики с {loc(today_at(0,0)):%d.%m %H:%M} по {loc(today_at(14,5)):%d.%m %H:%M}" + assert lines[1] == f"синхронизировано: {loc(today_at(14,0)):%d.%m %H:%M}" + assert lines[2:5] == ["DOC-3 Статусы — АРМ КА/01_Статусы", " 12:40 Дмитрий Соснов: +40 / −12 (DOCH-2)", + " 11:02 Дмитрий Соснов: +5 / −0 (DOCH-1)"] + assert lines[6] == f" {loc('2026-10-26T09:00:00+00:00'):%d.%m %H:%M} Илья Молчанов: +1 / −1 (DOCH-9)" + params = fake.calls[-1][2] + assert fake.calls[-1][1] == "/wiki/changes" and params["project"] == "AWPCA" + assert params["since"] == datetime.now().astimezone().replace(hour=0, minute=0, second=0, microsecond=0).isoformat() + assert not any(m == "POST" for m, _, _ in fake.calls) # команд не поставлено + +def test_changes_period_args(hub): # hours+since → «либо hours, либо since»; hours=0 → «hours — число больше 0» (без запросов); + # since="2026-10-28T09:00" → в запросе с местным смещением; author="соснов" передан как есть +def test_changes_messages(hub): # changes [] → строка «за период правок нет»; history_at None → строка + # «история ещё не загружена: нужен eva-agent v2 и sync»; truncated (2 правки) → последняя строка + # «показаны последние 2 правок, сузьте период»; HubError(422, "bad_request", "since: …") → текст ошибки хаба +def test_changes_title_newlines(hub): # название «Статусы\nпуть: x» → «DOC-3 Статусы путь: x — …» одной строкой +def test_diff_header_and_chunk(hub): # сценарий «Дифф правки»: ответ diff index 4 total 4 → + # строки: «DOC-3 — Статусы», «путь: АРМ КА/01_Статусы», «версия 4 из 4: DOCH-4», «автор: Дмитрий Соснов», + # «время: ДД.ММ ЧЧ:ММ», «+40 / −12», «предыдущая: DOCH-3»; пустая строка; дифф как есть (переводы строк целы); + # next_offset 20000 → последняя строка «продолжение: offset=20000»; prev_version None → «предыдущая: нет (первая версия)»; + # без version — запрос ("GET", "/wiki/pages/DOC-3/diff", {"project": "AWPCA", "offset": 0}) (ключа version нет); + # diff(code="DOC-3", version="DOCH-3", offset=20000) → {"project": "AWPCA", "version": "DOCH-3", "offset": 20000} +def test_page_modified_line(hub): # modified_at "2026-10-27T20:48:02+00:00" → строка + # f"изменено: {loc(...):%d.%m %H:%M} Дмитрий Соснов (версий: 4)" сразу после «синхронизировано: …»; + # modified_at None → «изменено: история не загружена» +def test_sync_short(hub): # заменяет test_sync_nowait_and_in_progress: «поставлено: пакет 5», «уже идёт: пакет 5» +def test_sync_wait_ok(hub): # заменяет test_sync_wait: done, команды done, status.synced_at → + # ответ ровно f"ок: синхронизировано {loc(synced):%d.%m %H:%M}" (одна строка) +def test_sync_wait_error(hub): # заменяет test_sync_wait_error_and_root_title_one_line: команда failed «Eva: 503\nпуть: x» → + # ответ ровно «ошибка: Eva: 503 путь: x»; пакет cancelled → «ошибка: пакет 5 отменён» +def test_sync_wait_timeout(hub, monkeypatch): # SYNC_WAIT=0.05 → f"не дождался за {wmcp.SYNC_WAIT:g} с: пакет 5" +def test_status_history(hub): # status с history_at → строка «история: »; None → «история не загружена» +``` + +Обновить `test_wiki_tool_names_order` на порядок из Interfaces; `test_unbound_tools_refuse` дополнить `wmcp.changes()` и +`wmcp.diff("DOC-1")` → `NOT_BOUND`; `test_no_root_message` — `changes`/`diff` у проекта без корня отвечают «у проекта … нет +корня вики: root_set DOC-…». + +- [ ] **Step 2: Запустить — падают.** Run: `uv run pytest tests/test_wiki_mcp.py -q` — Expected: FAIL (`AttributeError: changes`). + +- [ ] **Step 3: Реализовать.** `changes`/`diff` — `@mcp.tool() @core.guard @core.scoped(True)`, отказ через `_gate()`; + `changes` повторяет проверку периода инструмента jira (`hours`+`since`, `hours <= 0`), `since_of`, `local_if_naive` для + `since`/`until`; формат — `fmt_wiki_changes(data, now)` по образцу `jira.mcp.fmt_changes`: группы в порядке первого появления + страницы (лента уже новые сверху), заголовок группы `f"{code} {_line(title)} — {_line(path)}"`, правка + `f" {когда} {_line(автор)}: +{added} / −{removed} ({version})"`, дата `%d.%m ` — если правка не в сутках `now`. Автор — + имя, иначе логин, иначе «—». `page` — строка «изменено» по `modified_at`/`modified_by`/`versions`. `sync` — тексты из + спеки и П-8; `_summary` в `sync` больше не используется. `status` — строка истории в `_summary`. Описания инструментов — + по таблице спеки (`changes`: период по умолчанию — с начала суток; `diff`: без `version` — последняя правка). + +- [ ] **Step 4: Запустить — проходят.** Run: `uv run pytest tests/test_wiki_mcp.py tests/test_mcp.py -q` — Expected: PASS. + +- [ ] **Step 5: Эталоны.** Run: `BASELINE_WRITE=1 uv run pytest tests/test_baseline.py -q && git diff --stat tests/fixtures/baseline/` + — Expected: изменён только `mcp_wiki_tools.expected.json`; `mcp_tools.expected.json` (jira) не тронут — иначе откатить + правку jira-описаний. `uv run pytest tests/test_baseline.py tests/test_registry.py -q` — PASS. + +- [ ] **Step 6: Документы.** ARCHITECTURE.md, строка `mcp` модуля wiki — плюс `changes`, `diff`; «`sync` отвечает коротко; + `page`, `changes`, `diff` читают только копию». README.md — строки `changes`, `diff`, новая шапка `page`, ответ `sync`, `status`. + +- [ ] **Step 7: Commit.** +```bash +git add src/sdlc_hub/core/mcp.py src/sdlc_hub/modules/{jira,wiki}/mcp.py tests/test_wiki_mcp.py tests/fixtures/baseline/ ARCHITECTURE.md README.md +git commit -m "MCP вики: changes, diff, «изменено» в page, короткий sync (#140)" +``` + +--- + +### Task 5: Веб — общая «Лента» ядра и источник Jira + +**Model:** sonnet + +**Files:** +- Modify: `web/src/modules/types.ts` (типы источника ленты, `HubModule.feed`) +- Modify: `web/src/modules/index.ts` (`FEED_MODULES`, `useFeeds`) +- Create: `web/src/shell/feed/period.ts`, `period.test.ts` (перенос из `modules/jira/feed/events.ts` и его тестов) +- Create: `web/src/shell/feed/sources.ts`, `sources.test.ts` +- Create: `web/src/shell/feed/FeedPage.tsx`, `feed.css`, `FeedPage.test.tsx` (перенос тестов `modules/jira/feed/FeedPage.test.tsx`) +- Modify: `web/src/ui/format.ts` (+ `eventTime`), `web/src/ui/format.test.ts` +- Modify: `web/src/shell/nav.tsx` (`FEED`), `web/src/shell/Sidebar.tsx`, `web/src/shell/pages.tsx` +- Create: `web/src/modules/jira/feed/source.tsx` (`jiraFeed`), `web/src/modules/jira/feed/FeedGroup.tsx` +- Delete: `web/src/modules/jira/feed/FeedPage.tsx`, `web/src/modules/jira/feed/FeedPage.test.tsx` +- Modify: `web/src/modules/jira/feed/events.ts`, `events.test.ts`, `EventList.tsx`, `feed.css`, `web/src/modules/jira/index.tsx`, `web/src/modules/jira/Summary.tsx` +- Modify tests: `web/src/modules/modules.test.tsx`, `web/src/shell/Sidebar.test.tsx`, `web/src/shell/OverviewPage.test.tsx`, + `web/src/modules/jira/agile/BacklogPage.test.tsx` (меню без «Ленты»), `web/src/boundary.test.ts` +- Modify: `web/e2e/feed.spec.ts` (адрес `/app/feed`), `ARCHITECTURE.md` (раздел «Веб»), `CLAUDE.md` (адреса), `README.md` (адреса) + +**Interfaces:** +- Produces (в `web/src/modules/types.ts`): +```ts +export type FeedBounds = { since: Date; until: Date }; +export type FeedSourceInput = { + project: Project; + period: string; // ключ кэша: "2h" | "today" | "yesterday" | "7d" + bounds: () => FeedBounds; // границы на момент запроса (перечитывание берёт «до сейчас») + enabled: boolean; // источник выбран и доступен; false — запросов нет +}; +export type FeedGroup = { key: string; at: string; node: ReactNode }; // at — ISO последнего события группы +export type FeedResult = { + groups: FeedGroup[]; + count: number; // событий в ответе — для подзаголовка + window: { since: string; until: string } | null; // границы из ответа хаба; null — данных нет + pending: boolean; + error: { message: string; retry(): void } | null; + truncated: boolean; + notice: string | null; // своё сообщение на месте событий источника (тогда groups пусты) +}; +export type FeedSource = { + available(p: Project): boolean; + useFeed(input: FeedSourceInput): FeedResult; // хук: ядро вызывает у всех модулей с feed в постоянном порядке + Controls?: ComponentType; // свои настройки над лентой; видны, пока источник выбран +}; +// HubModule: feed?: FeedSource — id источника в адресе sources= — имя модуля (m.name), подпись флажка — m.title +``` + - `modules/index.ts`: `FEED_MODULES: readonly HubModule[]` (модули с `feed`, порядок `MODULES`), + `useFeeds(inputs: FeedSourceInput[]): FeedResult[]` (по образцу `useOverlays`). + - `shell/feed/period.ts`: `Period`, `PERIODS`, `parsePeriod`, `periodRange` — без изменений поведения. + - `shell/feed/sources.ts`: `parseSources(v: string | null, available: string[]): string[]` («none» → `[]`; неизвестные и + недоступные отбрасываются; ни одного — все доступные), `sourcesParam(selected: string[], available: string[]): string | null` + (порядок — как `available`; все доступные → `null`; пусто → `"none"`). + - `ui/format.ts`: `eventTime(at: string | null, now: Date, alwaysDate = false): string` (перенос из jira `events.ts`). + - `shell/nav.tsx`: `FEED: NavItem = { to: "/feed", label: "Лента", icon: , padded: true, keepSearch: true }`; + `NAV = [OVERVIEW, FEED, ...]`. + - `jira/feed/source.tsx`: `jiraFeed: FeedSource` — `available: () => true`; `useFeed` читает `kinds` из адреса + (`parseKinds`), запрос — прежний `useChanges(...)` с `enabled: input.enabled && kinds.length > 0`; `notice` — «выберите + хотя бы один вид», если источник выбран и видов нет; группы — `groupByIssue`, `at` — время первого события группы; + `Controls` — флажки видов (`role="group"`, `aria-label="Виды событий"`, класс `feed__kinds`, `kinds=none` для пустого набора). + +- [ ] **Step 1: Перенести тесты и написать новые** (Vitest, App с `mockFetch`, как в прежнем `FeedPage.test.tsx`; + в `hub()` добавить `GET /wiki/changes` — счётчик вызовов, ответ `{since:"",until:"",synced_at:null,history_at:null,truncated:false,changes:[]}`). + Все прежние тесты ленты переносятся с адресом `/app/feed`; изменения ожиданий: + - «Что закрылось за два часа» (сценарий спеки): проект `A` с `wiki_root: "DOC-1"`; клики «2 часа», снять «Вики», снять + «Описание», «Сроки», «Комментарии» → `location.search === "?project=AWPCA&period=2h&sources=jira&kinds=status"`, + `pathname === "/app/feed"`, последний `GET /changes` с `kinds=status`, `GET /wiki/changes` после снятия «Вики» не было. + - truncated: текст «Jira: показаны последние 1 событий — выберите период короче» (П-11); ошибка — «Jira: сбой» с «Повторить». + - шапка: вторичная «Обновить из Jira», primary нет; у проекта с вики — и «Обновить вики» (П-10). + - новые: `«Проект без вики»: флажков источников нет, GET /wiki/changes нет` (`sfm_kedr` без `wiki_root`); + `сняты все источники — «выберите хотя бы один источник», запросов нет, адрес sources=none`; + `«Ошибка одного источника»: /wiki/changes 500 — события Jira показаны, на месте вики — ошибка с «Повторить»` + (источник вики появится в Task 6: тест помечается `it.todo` здесь и включается в Task 6); + `/app/jira/feed?project=AWPCA&period=7d → адрес /app/feed?project=AWPCA&period=7d (replace)`; + `смена проекта: period, sources, kinds сохраняются (keepSearch)`. + - `period.test.ts` — прежние тесты `periodRange`/`parsePeriod` из `events.test.ts` (сценарий «Вчера»: 22.10 14:05 → since + 21.10 00:00, until 22.10 00:00). `sources.test.ts`: +```ts +expect(parseSources(null, ["jira", "wiki"])).toEqual(["jira", "wiki"]); +expect(parseSources("wiki,foo", ["jira", "wiki"])).toEqual(["wiki"]); +expect(parseSources("wiki", ["jira"])).toEqual(["jira"]); // недоступный → умолчание +expect(parseSources("none", ["jira", "wiki"])).toEqual([]); +expect(sourcesParam(["wiki", "jira"], ["jira", "wiki"])).toBeNull(); +expect(sourcesParam(["jira"], ["jira", "wiki"])).toBe("jira"); +expect(sourcesParam([], ["jira", "wiki"])).toBe("none"); +``` + - `modules.test.tsx`: `PAGE_ROUTES` → `["/", "/feed", "/jira/links", "/jira/backlog", "/jira/sprint", "/jira/kanban", + "/jira/issues", "/jira/board", "/board", "/issues", "/issues/:key", "/jira/feed", "/wiki", "*"]`; пункты модулей без + `["Лента", "/jira/feed"]`; NotFound — 4 ссылки («Обзор», «Лента» + два раздела). + - `Sidebar.test.tsx`: «Лента» — сразу после «Обзора», вне групп, `href="/app/feed?project=AWPCA"`, `aria-current="page"` на `/app/feed`. + - `OverviewPage.test.tsx`: ссылка «Лента» сводки Jira — `/app/feed?project=AWPCA&sources=jira`. + - `BacklogPage.test.tsx`: меню группы «Jira» — `["Связи", "Бэклог", "Спринт", "Задачи"]` и `["Связи", "Задачи"]`. + - `boundary.test.ts`: `MODULE_TERMS` дополнить `ChangeKind|kinds|history_at|page_code|prev_version`; детектор — + `expect(MODULE_TERMS.test("kinds: string")).toBe(true)`. + +- [ ] **Step 2: Запустить — падают.** Run: `cd web && npx vitest run src/shell src/modules src/boundary.test.ts src/ui` — Expected: FAIL (нет `shell/feed/*`). + +- [ ] **Step 3: Реализовать.** + - `FeedPage({ project }: { project: Project })` в `shell/feed/FeedPage.tsx`, `data-testid="page-feed"`, классы `feed-page*` + (в `shell/feed/feed.css`, токены AURA; классы `feed`, `feed__controls`, `feed__hint` убрать из jira `feed.css`). + Доступные = `FEED_MODULES` с `available(project)`; выбранные = `parseSources`; ввод источника — `{project, period, + bounds: () => periodRange(period, new Date()), enabled}`; `useFeeds`. Сверху: `PageHeader` «Лента», подзаголовок + `${count} ${plural(count)} · ${fmtStamp(since)} — ${fmtStamp(until)}` по сумме `count` и первому `window` выбранных; + действия — вторичные кнопки обновления выбранных модулей с `status.refresh` (компонент на модуль, + `useModuleActivity(m.name)`). Ряд управления: `Segmented` периода; флажки источников (`role="group"`, + `aria-label="Источники"`, подписи `m.title`) — только если доступных больше одного; `Controls` выбранных источников. + Сообщения и пустой период — по П-11; группы всех выбранных — одним списком по `at` ↓ (при равенстве — порядок + `FEED_MODULES`), ключ `${m.name}:${g.key}`. + - Маршрут `/feed` в `PAGE_ROUTES` после `/` (`ProjectGate`, `key={p.label}`). Sidebar: `link(FEED)` после `link(OVERVIEW, first)`. + - jira: `FeedGroup` — прежняя разметка группы (`Panel` `feed-group`, `data-testid="feed-group"`, `IssueLink`, тема, + статус, `EventList`); `events.ts` без периода, `eventTime` — из `ui/format.ts`; `index.tsx` — без страницы и пункта + «Лента», `{ path: "/jira/feed", element: }` вместо `/feed`, `feed: jiraFeed`; + `Summary` — «Лента» на `/feed${q}&sources=jira`. Проверить, что история в карточке задачи (`EventList`) не изменилась. + - e2e `feed.spec.ts`: адреса `/app/feed?project=AWPCA…` (поведение прежнее). + +- [ ] **Step 4: Запустить — проходят.** Run: `cd web && npm run lint && npm test` — Expected: PASS (кроме `it.todo`). + +- [ ] **Step 5: Документы.** ARCHITECTURE.md, раздел «Веб»: контракт `HubModule` — плюс `feed` (источник ленты: ядро ведёт + период, источники, порядок групп; модуль — запросы, группы, `Controls`); страница ядра «Лента» — `web/src/shell/feed/`; + адреса — `/app/feed`, прежний `/app/jira/feed` перенаправляется; модуль jira — без страницы «Лента», `feed/` — источник + ленты и `EventList` карточки. CLAUDE.md, строка адресов: «лента изменений — `/app/feed?project=<метка>` (источники Jira + и Вики)», прежние адреса — `/app/board`, `/app/jira/board`, `/app/issues` — на страницы модуля Jira, `/app/jira/feed` — на + `/app/feed`; строка «Веб»: «модуль jira — страницы доски и задач; лента — страница ядра». README.md — адреса так же. + +- [ ] **Step 6: Commit.** +```bash +git add web/src web/e2e/feed.spec.ts ARCHITECTURE.md CLAUDE.md README.md +git commit -m "Веб: общая «Лента» ядра с источниками модулей, источник Jira (#140)" +``` + +--- + +### Task 6: Веб — источник «Вики» в ленте + +**Model:** sonnet + +**Files:** +- Create: `web/src/modules/wiki/paths.ts` (`wikiPageHref`, `versionHref`, `VERSION_PATH`) +- Create: `web/src/modules/wiki/feed/source.tsx` (`wikiFeed`), `web/src/modules/wiki/feed/group.ts`, `group.test.ts`, + `web/src/modules/wiki/feed/WikiFeedGroup.tsx`, `web/src/modules/wiki/feed/feed.css` +- Modify: `web/src/modules/wiki/api/queries.ts` (`WikiChange`, `WikiChanges`, `wikiQk.changes`, `useWikiChanges`, `history_at` в `WikiStatus`) +- Modify: `web/src/modules/wiki/index.tsx` (`feed: wikiFeed`), `web/src/modules/wiki/Summary.tsx` (ссылка «Правки») +- Test: `web/src/shell/feed/FeedPage.test.tsx` (включить `it.todo` из Task 5 и новые), `web/src/modules/wiki/Summary.test.tsx` + +**Interfaces:** +- Consumes: `FeedSource`, `FeedSourceInput`, `FeedResult` (Task 5), `eventTime` (`ui/format.ts`), `GET /wiki/changes` (Task 3). +- Produces: + - `wikiPageHref(project: string, code: string): string` → `/wiki?project=<метка>&page=<код>` (через `URLSearchParams`); + `versionHref(project: string, code: string, version: string): string` → `/wiki/pages/<код>/versions/<версия>?project=<метка>` + (сегменты — `encodeURIComponent`); `VERSION_PATH = "/wiki/pages/:code/versions/:version"`. + - `WikiChange = { at: string; page_code: string; title: string | null; path: string | null; version: string; + author: { login: string | null; name: string | null }; added: number; removed: number }`; + `WikiChanges = { since; until; synced_at; history_at: string | null; truncated: boolean; changes: WikiChange[] }`. + - `wikiQk.changes(p, period) = ["wiki", p, "changes", period]` (перечитывается `wikiStatus.invalidate` по префиксу проекта). + - `useWikiChanges(project, period, bounds, enabled)` — `GET /wiki/changes {project, since, until, limit: 2000}`, + `refetchOnWindowFocus: true`. + - `groupByPage(changes: WikiChange[]): { code, title, path, changes: WikiChange[] }[]` — по первому появлению; + `deltaText(c) = "+" + added + " / −" + removed`; `authorName(a)` — имя, иначе логин, иначе «—». + - `wikiFeed: FeedSource` — `available: (p) => !!p.wiki_root`; `notice` при `history_at === null` — «история вики ещё не + загружена — нужен eva-agent v2». + +- [ ] **Step 1: Написать падающие тесты.** + - `group.test.ts`: группировка двух правок DOC-3 и одной DOC-2 → `["DOC-3", "DOC-2"]`, правки DOC-3 в порядке ответа; + `deltaText({added: 40, removed: 12})` → `"+40 / −12"`; `authorName({login: "x", name: null})` → `"x"`, оба `null` → `"—"`. + - `FeedPage.test.tsx` (проект `AWPCA` с `wiki_root`): + - `«Правка из ленты»: группа — название ссылкой на /app/wiki?project=AWPCA&page=DOC-101252 и путь; правка «12:40 Дмитрий Соснов +40 / −12» — ссылка на /app/wiki/pages/DOC-101252/versions/DOCH-2?project=AWPCA`; + запрос `GET /wiki/changes` с `project=AWPCA`, `since` = полночь, `limit=2000`. + - `«Jira и Вики вперемешку»`: AURORA-1 10:00, DOC-3 11:00, AURORA-2 12:00 (сегодня) → группы `feed-group` сверху вниз + AURORA-2, DOC-3, AURORA-1. + - `«История не загружена»`: `history_at: null` → «история вики ещё не загружена — нужен eva-agent v2»; события Jira на месте. + - `«Ошибка одного источника»` (из `it.todo`): `/wiki/changes` 500 → события Jira, «Вики: …» и «Повторить»; повтор + перезапрашивает только `/wiki/changes`. + - `«Пустой период»`: обе ленты пусты → «за период изменений нет». + - `смена проекта на sfm_kedr при sources=wiki → источник Jira, флажков нет, GET /wiki/changes по sfm_kedr нет`. + - `Summary.test.tsx`: у проекта с корнем ссылка «Правки» → `/app/feed?project=AWPCA&sources=wiki`. + +- [ ] **Step 2: Запустить — падают.** Run: `cd web && npx vitest run src/shell/feed src/modules/wiki` — Expected: FAIL. + +- [ ] **Step 3: Реализовать.** `WikiFeedGroup` — `Panel` с классом `wiki-feed-group` и `data-testid="feed-group"`, шапка: + `Link` на `wikiPageHref` (название, иначе код) и путь; список правок — `Link` на `versionHref` со + `state={{ background: { pathname, search }, depth: 1 }}` (текущий адрес страницы), текст: время + (`eventTime(at, now)`, `now` — момент ответа), имя автора, `deltaText`. Классы — только `wiki-feed-*` в + `modules/wiki/feed/feed.css` (токены, перенос длинных названий `overflow-wrap:anywhere`). `useFeed`: `count` — + `changes.length`, `window` — `{since, until}` ответа, `truncated`, ошибка с `refetch`, `at` группы — `at` первой правки. + +- [ ] **Step 4: Запустить — проходят.** Run: `cd web && npm run lint && npm test` — Expected: PASS. + +- [ ] **Step 5: Commit.** +```bash +git add web/src +git commit -m "Веб: правки вики в общей ленте (#140)" +``` + +--- + +### Task 7: Веб — окно правки с диффом поверх ленты, e2e + +**Model:** sonnet + +**Files:** +- Create: `web/src/modules/wiki/versions/overlay.tsx` (`useVersionOverlay`), `versionState.ts`, `versionState.test.ts` +- Create: `web/src/modules/wiki/versions/VersionModal.tsx`, `diffLines.ts`, `diffLines.test.ts`, `versions.css`, `VersionModal.test.tsx` +- Modify: `web/src/modules/wiki/api/queries.ts` (`WikiDiff`, `WikiVersion`, `useWikiDiff`, `useWikiVersions`, ключи) +- Modify: `web/src/modules/wiki/index.tsx` (`useOverlay: useVersionOverlay`) +- Modify: `web/scripts/seed-hub.py` (две версии `DOC-9003` сегодня и `wiki_history`), `web/e2e/wiki.spec.ts` +- Modify: `ARCHITECTURE.md` (модуль wiki в разделе «Веб») + +**Interfaces:** +- Consumes: `versionHref`, `wikiPageHref`, `VERSION_PATH` (Task 6); `GET /wiki/pages/{code}/diff`, `…/versions` (Task 3); + `Overlay` (`modules/types.ts`), `Dialog` (`ui`). +- Produces: + - `VersionState = { background: { pathname: string; search: string }; depth: number; direct: boolean }`; + `versionState(s: unknown): VersionState | null` (как `cardState` у jira — своя копия, импорт чужого модуля запрещён); + `pageBackground(code, search)` → `{ pathname: "/wiki", search: "?project=<из search>&page=<код>" }`. + - `useVersionOverlay(): Overlay` — по `VERSION_PATH`: нет `state` — `replace` той же записи с + `{background: pageBackground(…), depth: 0, direct: true}`; `pageLocation` = фон; закрытие — `depth ≥ 1` → `navigate(-depth)`, + иначе `navigate(фон, { replace: true })`; переход к соседней правке — `navigate(versionHref(…), { replace: true, state })` + (то же `state`, «назад» закрывает окно). + - `diffLines(text: string): { kind: "head" | "hunk" | "add" | "del" | "ctx"; text: string }[]` — `---`/`+++` → `head`, + `@@` → `hunk`, `+` → `add`, `-` → `del`, прочее → `ctx`; хвостовой пустой строки нет. + - `useWikiDiff(project, code, version)` — `useInfiniteQuery`, `pageParam` — `offset` (0, затем `next_offset`); + `useWikiVersions(project, code)` — `GET …/versions`. + +- [ ] **Step 1: Написать падающие тесты.** + - `diffLines.test.ts`: `"--- A\n+++ B\n@@ -1 +1 @@\n-x\n+y\n z\n"` → kinds `["head","head","hunk","del","add","ctx"]`. + - `versionState.test.ts`: мусорный `state` → `null`; `pageBackground("DOC-1", "?project=AWPCA")` → `{pathname: "/wiki", search: "?project=AWPCA&page=DOC-1"}`. + - `VersionModal.test.tsx` (App, `mockFetch`; `versions` — DOCH-4…DOCH-1 новыми первыми, `diff` по `version`): + - `«Правка из ленты»: окно — «правка 4 из 4», Соснов, 12:40, «+40 / −12», строки диффа с классами add/del и подписью «добавлено»/«удалено» для экранных читалок; запрос diff с version=DOCH-4; лента под окном (page-feed)`. + - `«Предыдущая правка»: правка 3 из 4, «Следующая правка» доступна, «Предыдущая» у первой недоступна; history.back закрывает окно, адрес — лента с прежними period и sources`. + - `Escape и кнопка «Закрыть» закрывают окно — адрес ленты прежний`. + - `«Окно по ссылке»: /app/wiki/pages/DOC-101252/versions/DOCH-3159076?project=AWPCA — окно поверх «Вики» с DOC-101252; закрытие → /app/wiki?project=AWPCA&page=DOC-101252`. + - `«Длинный дифф»: next_offset 20000 — кнопка «Показать дальше», запрос offset=20000, вторая часть ниже первой; next_offset null — кнопки нет`. + - `«Показать дальше»: часть рвёт строку — строка целая и одна` (Review Focus 5: первая часть кончается на `"+строка дл"`, + вторая начинается `"инная\n"` → один элемент `add` с текстом `"+строка длинная"`). + - `«Открыть страницу» ведёт на /app/wiki?project=AWPCA&page=DOC-101252`. + - `окно ничего не пишет: за всё время теста нет запросов, кроме GET`. + - `web/e2e/wiki.spec.ts`: `правка из ленты: окно с диффом, «Предыдущая правка», назад — лента` (сид: две версии DOC-9003); + `окно правки на 390 px: горизонтальной прокрутки страницы нет` (`document.documentElement.scrollWidth <= clientWidth`). + +- [ ] **Step 2: Запустить — падают.** Run: `cd web && npx vitest run src/modules/wiki` — Expected: FAIL. + +- [ ] **Step 3: Реализовать.** `VersionModal` — `Dialog` (`size="lg"`, класс `wiki-version-modal`), закрытие Escape/кнопкой/ + щелчком по фону — как `IssueModal` (нажатие и отпускание на фоне); шапка: `h2` название, путь, «правка N из M», автор + (`authorName`), время (`eventTime(at, now)`), `deltaText`; кнопки «Предыдущая правка» (старее: следующая в списке + `versions`), «Следующая правка» (новее), ссылка «Открыть страницу» (`wikiPageHref`). Дифф — склейка `diff` всех загруженных + частей, затем `diffLines`; строки — моноширинный шрифт, `white-space: pre-wrap; overflow-wrap: anywhere` (без + горизонтальной прокрутки страницы); `add` — `--color-status-success` на `--color-status-success-bg`, `del` — + `--color-status-error` на `--color-status-error-bg`, `head`/`hunk` — `--color-text-secondary`; подписи `add`/`del` — + `visually-hidden` «добавлено: »/«удалено: ». Классы — только `wiki-version-*` в `versions.css`. «Показать дальше» — + `fetchNextPage`, пока `next_offset !== null`. Сид: `INSERT INTO wiki_version` двух версий DOC-9003 (сегодня, Markdown + отличается строкой) и `wiki_history` AWPCA. + +- [ ] **Step 4: Запустить — проходят.** Run: `cd web && npm run lint && npm test && npx playwright test e2e/wiki.spec.ts e2e/feed.spec.ts` + — Expected: PASS. Если браузеры Playwright не установлены (`npx playwright install chromium` недоступен) — отметить O-n в + ledger и не считать задачу выполненной без прогона e2e у контроллера. + +- [ ] **Step 5: Документы.** ARCHITECTURE.md, абзац модуля `wiki` в разделе «Веб»: источник ленты (`feed/`), окно правки + `/app/wiki/pages/{code}/versions/{version}` поверх страницы (`versions/overlay.tsx`, фон — `location.state.background`, прямой + адрес — поверх «Вики» с этой страницей), дифф `versions/`. + +- [ ] **Step 6: Commit.** +```bash +git add web/src web/scripts/seed-hub.py web/e2e/wiki.spec.ts ARCHITECTURE.md +git commit -m "Веб: окно правки вики с диффом поверх ленты (#140)" +``` + +--- + +### Task 8: Проверка всего набора + +**Model:** haiku + +**Files:** без новых изменений; правки — только если проверка нашла расхождение (тогда — отдельный коммит с маркером находки). + +- [ ] **Step 1: Хаб.** Run: `uv run pytest -q` — Expected: все тесты PASS, предупреждений о незакрытых соединениях нет. +- [ ] **Step 2: Границы и эталоны.** Run: `uv run pytest tests/test_registry.py tests/test_baseline.py -q && git diff main --stat -- tests/fixtures/baseline/` + — Expected: PASS; изменены `schema`, `routes`, `mcp_wiki_tools`; `mcp_tools.expected.json` и `settings.expected.json` — нет. +- [ ] **Step 3: Фронт.** Run: `make check` — Expected: pytest, `npm run lint` и `npm test` зелёные. +- [ ] **Step 4: Сборка и e2e.** Run: `cd web && npm run build && npx playwright test` — Expected: сборка без ошибок `tsc`, e2e PASS. +- [ ] **Step 5: Сверка документов.** Run: `grep -n "jira/feed\|История страниц вики" CLAUDE.md ARCHITECTURE.md README.md` + — Expected: `/app/jira/feed` упоминается только как прежний адрес с перенаправлением; открытого вопроса «История страниц вики» нет. +- [ ] **Step 6: Итог.** Записать в ledger результат каждой команды (число тестов). Коммита нет, если правок не было. + +--- + +## Self-review + +- **Покрытие спеки.** specs/wiki «История правок страниц» — Task 1 (форма версий, 409/422, перевод `📎`), Task 2 (выдача + `known_versions`, запись в `commit`, «+/−» и пересчёт следующей, удаление вне дерева, `history_at`, «пришло N из M», агент + v1, отброшенный отчёт, сброс корня, очистка стадии), все шесть сценариев — тесты Task 2. «Маршруты истории вики» — Task 3, + все шесть сценариев. specs/mcp «MCP вики» — Task 4 (таблица инструментов, период `changes`, строки в одну линию, `sync`, + `status`, сценарии «Что сделали аналитики», «Дифф правки», «Синхронизация с ожиданием», «История ещё не загружена»; сервер + jira без изменений — эталон). specs/web-app «Лента изменений» и «Оболочка и адреса» — Task 5 (страница ядра, источники, + флажки, адрес, перенаправление, пункт «Лента», NotFound), Task 6 («Jira и Вики вперемешку», «Проект без вики», «Ошибка + одного источника», «Пустой период»), «Обзор проекта» — Task 5 (Jira) и Task 6 («Правки»), «Спринты и бэклог» (меню без + «Ленты») — Task 5, «Правки вики в ленте» — Task 6 (группа, история не загружена, контракт) и Task 7 (окно, все сценарии). + ARCHITECTURE.md/CLAUDE.md/README.md — шаги «Документы» Task 1–5, 7. +- **Шаги.** Тела функций не выписаны там, где их задают сигнатура и тест; алгоритм пересчёта «+/−» и правила сообщений + ленты описаны, потому что тесты их не задают целиком. +- **Типы.** `history.utc/known_versions/line_delta/history_at/feed/page_versions/diff/last_change` — одинаково в Task 1–4; + `FeedSource/FeedResult/FeedSourceInput` — Task 5, используются в Task 6; `versionHref/wikiPageHref/VERSION_PATH` — Task 6 → 7. +- **Review Focus.** Пять пунктов с тестами в задачах-владельцах. +- **Пропорция.** План ~ того же порядка, что спека (≈ 750 строк), кода — сигнатуры, SQL схемы и утверждения тестов. diff --git a/openspec/changes/wiki-history/.openspec.yaml b/openspec/changes/wiki-history/.openspec.yaml new file mode 100644 index 0000000..1bb6456 --- /dev/null +++ b/openspec/changes/wiki-history/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-28 diff --git a/openspec/changes/wiki-history/design.md b/openspec/changes/wiki-history/design.md new file mode 100644 index 0000000..6e292fb --- /dev/null +++ b/openspec/changes/wiki-history/design.md @@ -0,0 +1,198 @@ +## Context + +Модуль `wiki` держит Markdown-копию дерева страниц проекта: каждая `wiki_sync` перекачивает всё дерево, `sync.commit` +подменяет копию целиком. Истории нет: ни времени правки, ни автора, ни прошлых текстов (ARCHITECTURE.md, «Открытые +вопросы»). Владелец 28.10.2026 хотел из чата узнать, что за день сделали аналитики, и упёрся в это (sdlc-hub#140). + +### Что известно об API Eva + +Проверено 28.10.2026 на живой Eva (`https://skap.space.rshb.pro`) токеном eva-agent, скрипты `eva_probe.py` и +`eva_probe2.py` (только чтение; отчёты — в общей папке `probe/`). Публичная спека EvaTeam API v1.9.22 (69 методов) полей +не описывает, версий документов в ней нет. + +- `CmfDocument` с `fields: ["*"]` отдаёт ~120 полей, среди них `cmf_modified_at`, `cmf_modified_by_id`, + `cmf_author_id`, `cmf_version`, `doc_version`, `text`, `text_draft`, `cur_workflow_version_id`, + `cur_published_version_id`; поле `history` — список ссылок на версии `CmfDocumentHistory`. Ссылочные поля по имени + (`cmf_modified_by`, `cmf_author`) или через `fields: ["**"]` раскрываются в объект с `name` и `login`. +- `CmfDocumentHistory.list` с фильтром `[["parent_id", "==", ""]]` работает; фильтр `parent.code` падает + (`-32001`, вложенный фильтр — как у `CmfDocument.parent`). Поля версии: `code` (`DOCH-…`), `cmf_created_at`, + `cmf_modified_at`, `cmf_author_id`, `text` (полный HTML страницы на момент версии, у проверенных — 43–56 тыс. + символов), `has_published`, `cur_workflow`, `doc_version`, `approved*`, `status_id`. +- Пример DOC-101252: шесть версий; четыре — Дмитрий Соснов 27.10 (две из них опубликованы), две — Илья Молчанов в + августе. Текущий черновик (`cur_workflow = true`, `has_published = false`) Eva дописывает на месте; создание новой + версии меняет `cmf_modified_at` предыдущей. Отдельного поля «время публикации» в проверенных полях нет. +- `CmfAudit` есть (`audit_data`, `html_diff_data`, `operate`…), но все поля токену агента закрыты (`deny`). +- `CmfAttachment`: запись файла заменяется на месте (новая `cmf_version`, `cmf_modified_at`), истории версий файла + нет; классов `CmfAttachmentHistory`, `CmfFileVersion` и подобных нет (`Method not found`). + +### Решения владельца (brainstorming 28.10.2026) + +1. Нужна лента правок за период и полный дифф каждой правки; сценарий — «что за сегодня сделали аналитики». +2. Только зона видимости проекта — страницы дерева корня, не вся вики. +3. Только опубликованные правки; черновики не берём. +4. Версии файлов не нужны: свежий файл по запросу, как сейчас. +5. Обновление отдельно от чтения: `sync` отвечает «ок» или ошибкой; `changes`, `diff`, `page` читают локальную копию. +6. Веб: общая «Лента» в боковой панели с переключателями источников (Jira, Вики); нажатие на правку — окно с диффом + поверх ленты. + +## Goals / Non-Goals + +**Goals:** +- Опубликованные версии страниц дерева проекта — в хабе, с автором, временем и Markdown. +- Лента правок за период и дифф правки — в HTTP API, MCP и вебе; без обращения к Eva при чтении. +- Инкрементальная догрузка: Eva читается только по новым версиям. +- Агент v1 и хаб нового кода работают вместе (история просто не пополняется). + +**Non-Goals:** см. proposal.md. + +## Decisions + +1. **Источник — `CmfDocumentHistory`, только `has_published = true`.** Черновик меняется на месте и не нужен по решению + владельца 3. Опубликованная версия считается неизменной: агент не перечитывает известные коды. +2. **Постраничная выборка по дереву, а не запрос по проекту Eva.** Агент после обхода дерева для каждой страницы дерева + вызывает `CmfDocumentHistory.list` с фильтром `parent_id` (id страницы из обхода) и полями без текста. Так Eva не + отдаёт версии чужих страниц вообще (решение владельца 2), а страница, перенесённая в дерево позже, получает всю свою + историю. Цена: один вызов Eva на страницу за синхронизацию (у AWPCA 28 страниц). +3. **Инкремент — по известным кодам.** Хаб при выдаче `wiki_sync` (`CommandHooks.deliver_params`) дописывает в + параметры `known_versions: {<код страницы>: [<код версии>…]}` — коды версий, которые уже лежат в хабе у страниц + текущего дерева проекта; в `command.params` в базе этого поля нет. Агент запрашивает текст (`CmfDocumentHistory.get` + по коду) только для опубликованных версий, которых нет в `known_versions` его страницы. Первый прогон — + `known_versions = {}`, то есть вся история. +4. **Отдельный маршрут для версий.** `POST /agent/wiki/versions {command_id, versions}` под токеном агента `wiki`; + версия — `{page_code, code, created_at, author: {login, name}, html, attachments, links}` (`attachments`/`links` — как + у страниц, по `html` этой версии); в пачке не больше 50 версий, срез по объёму (4 МБ) делает агент. Версии не + вкладываются в страницы: у страницы с долгой историей тело пачки упёрлось бы в 16 МБ. Хаб переводит HTML в Markdown + вне транзакции и кладёт в стадию версий команды (`wiki_version_stage`), как страницы. +5. **Отчёт.** `done` получает `result.versions` — число присланных версий. Поле есть — хаб применяет стадию версий; + поля нет (агент v1) — история проекта не меняется, копия страниц обновляется как прежде. Присланных кодов меньше, + чем `result.versions`, — команда `failed` «нет версий: пришло N из M». +6. **Хранение.** Таблица `wiki_version`: `label`, `page_code`, `code` (PK — `label, code`), `created_at` (UTC), + `author_login`, `author_name`, `markdown`, `added`, `removed`. Markdown версии — тем же `convert`, но ссылки на + вложения дают текст `📎 <имя>` без ссылки (у прошлых версий нет строк `wiki_attachment`, а новые заводить ради + истории незачем); ссылки на страницы — `page:<код>`, как в копии. У проекта хранится `history_at` — время последнего + применения истории (`null` — история не загружалась, агент v1); где именно — на исполнителя: колонка + `wiki_project` через шаг миграции схемы или своя таблица модуля без миграции. +7. **Время и автор правки.** Время — `cmf_created_at` версии, автор — `cmf_author` версии. Отдельного времени + публикации в API не нашлось; создание версии — начало правки. См. раздел «Decisions made without discussion». +8. **«+строк / −строк» — при записи.** `sync.commit` для каждой новой версии считает строки Markdown, добавленные и + удалённые против предыдущей версии той же страницы (по `created_at`, затем `code`); у первой версии — против пустого + текста. Число пишется в `added`/`removed`. Если новая версия встала между известными (позднее дошедшая история), + пересчитывается и следующая за ней. +9. **Запись атомарно с копией.** Версии пишет только `sync.commit` в той же транзакции, что и копию: новые из стадии + добавляются; версии страниц, которых нет в новом дереве, удаляются. Сброс корня (`projects._drop_copy`) удаляет все + версии проекта. Отброшенный отчёт по сменённому корню версии не трогает. +10. **Сбой чтения версий — сбой команды.** Ошибка Eva на шаге версий даёт `failed`, копия и история прежние. Шаг + версий проверен на живой Eva; отдельный режим «страницы без истории» усложнил бы отчёт ради редкого случая. +11. **Дифф считается по запросу.** `difflib.unified_diff` по строкам Markdown версии и предыдущей, контекст 3 строки, + заголовки `--- <код пред.>` / `+++ <код>`; у первой версии — против пустого. Отдаётся частями по 20 000 символов с + `offset`, как текст страницы. Готовый дифф не хранится: Markdown версий уже в базе. +12. **Чтение — только копия.** `GET /wiki/changes`, `…/versions`, `…/diff`, MCP `changes`/`diff`/`page` не ставят + команд и не ходят к агенту; свежесть — `synced_at` в ответе. Новых команд и частоты синхронизации change не вводит. +13. **MCP `sync` — короткий ответ.** Без `wait` — «поставлено: пакет N» (или «уже идёт: пакет N»); с `wait` — «ок: + синхронизировано ДД.ММ ЧЧ:ММ» или «ошибка: <текст>», или «не дождался за 300 с: пакет N». Сводки страниц и данных + в ответе нет (решение владельца 5). +14. **Веб: лента — страница ядра с источниками от модулей.** Страница «Лента» (`/app/feed`) переезжает в оболочку; + модули отдают ей источники через новое необязательное поле контракта `HubModule.feed`. Ядро ведёт период, + переключатели источников и общий порядок групп; модуль — свои запросы, группы, параметры (виды событий Jira) и окно + по нажатию. Ядро не знает терминов модулей (требование «Граница модулей в вебе»). +15. **Окно диффа — оверлей модуля wiki.** Как карточка задачи: адрес окна поверх фоновой страницы + (`location.state.background`), закрытие возвращает ленту с тем же периодом. Внутри — переход к предыдущей и + следующей правке страницы и ссылка «Открыть страницу». + +## Risks / Trade-offs + +- **Время правки — начало, не публикация.** Правку, начатую вчера и опубликованную сегодня, лента покажет вчерашней. + Смысл времени записан в спеке маршрута; если на живых данных лента разойдётся с тем, что видят в Eva, — отдельная + доработка (поиск времени публикации в Eva). +- **Опубликованная версия может меняться в Eva** (согласование, `approved_*`) — агент её не перечитает. Текст версии + при согласовании, по проверенным данным, не меняется. +- **Вызов Eva на страницу** — при сотнях страниц синхронизация удлиняется на сотни вызовов с паузой `wiki_pacing`. + Сейчас деревья — десятки страниц. +- **Объём базы.** Версия — до ~60 тыс. символов Markdown; 28 страниц × десятки версий — единицы-десятки МБ. Уборки + старых версий нет. +- **Агент v1 после выкатки хаба** — история пустая, лента вики пустая; MCP и веб показывают «история ещё не + загружена», пока не придёт отчёт агента v2. + +## Migration Plan + +1. Хаб: `git pull` → kickstart (владелец) → `make web` → `/mcp` reconnect. Новые таблицы создаются при подключении + (`CREATE TABLE IF NOT EXISTS` схемы модуля); прежний код с такой базой работает — таблицы он не читает. +2. eva-agent v2: `uv run sdlc-hub agent publish --module wiki ../eva-agent/src/agent.py`; агент обновится сам. +3. После `status` с v2 — владелец поднимает `wiki_min_agent_version` до 2; первая синхронизация v2 загружает всю историю. +4. Откат: прежний код хаба; таблицы версий остаются и не мешают. + +## Open Questions + +- Время публикации версии: есть ли поле, отличное от `cmf_created_at` (решение 7). Проверить на живых данных после + выкатки, если лента разойдётся с тем, что видят аналитики в Eva. + +## Decisions made without discussion + +- 28.10.2026 — **постраничная выборка версий (решение 2)** вместо одного запроса по проекту Eva, о котором говорилось + в brainstorming. Обоснование: строже держит зону видимости (решение владельца 2) и подхватывает историю перенесённых + страниц. Цена ошибки: лишние вызовы Eva на больших деревьях; переход на запрос по проекту меняет только агента. +- 28.10.2026 — **время правки = `cmf_created_at` версии (решение 7).** Обоснование: другого времени в API не найдено. + Цена ошибки: правка, опубликованная позже начала, попадает в ленту не в тот день. +- 28.10.2026 — **ссылки на вложения в версиях — текстом без ссылки (решение 6).** Обоснование: у прошлых версий нет + строк вложений, дифф по тексту от этого не теряет. Цена ошибки: из диффа нельзя открыть файл; в копии страницы ссылки + прежние. +- 28.10.2026 — **сбой шага версий валит всю синхронизацию (решение 10).** Цена ошибки: сломанный в Eva метод истории + остановит и обновление страниц до исправления агента. +- 28.10.2026 — **дифф по запросу, не хранится (решение 11)**; «+/−» хранится. Цена ошибки: время ответа `diff` на + больших страницах (difflib на ~1000 строк — миллисекунды). +- 28.10.2026 — **короткий ответ MCP `sync` (решение 13)** — формулировки мои; смысл — решение владельца 5. +- 28.10.2026 — **адрес общей ленты `/app/feed`**, `/app/jira/feed` перенаправляет на него; пункт «Лента» — сразу под + «Обзором». Цена ошибки: закладки на `/app/jira/feed` работают через перенаправление. +- 28.10.2026 — **ссылки «Лента» на «Обзоре»** (по вопросу ревьюера спеки): «Лента» уходит из группы «Jira» боковой + панели (требование «Спринты и бэклог»); в сводке Jira на «Обзоре» ссылка «Лента» ведёт на `/app/feed?sources=jira`, + в сводке вики добавлена ссылка «Правки» на `/app/feed?sources=wiki`. Цена ошибки: две ссылки на «Обзоре». +- 28.10.2026 — **адрес окна правки** `/app/wiki/pages/{code}/versions/{version}` (по вопросу ревьюера спеки); без + фоновой страницы в `location.state` — поверх «Вики» с этой страницей; «Предыдущая/Следующая правка» заменяют адрес + (`replace`), чтобы «назад» закрывал окно. Цена ошибки: ссылка на правку открывается не поверх ленты. +- 28.10.2026 — **фильтр по автору** в `GET /wiki/changes` и MCP `changes` — подстрока имени или логина без учёта + регистра. Цена ошибки: «Иван» находит и «Иванову». +- 28.10.2026 — **П-1: `history_at` — своя таблица `wiki_history(label, history_at)` без миграции.** Обоснование: + схема `wiki_project` не меняется, миграция не нужна. Цена ошибки: ещё одна таблица на проект. +- 28.10.2026 — **П-2: повтор версии.** В хабе версия с уже известным кодом не перезаписывается (`INSERT OR IGNORE`, + решение 1 — опубликованная версия неизменна); в стадии повтор кода заменяет строку (`INSERT OR REPLACE`), N в + «пришло N из M» — число уникальных кодов в стадии. Цена ошибки: исправленная Eva версия не попадёт в историю. +- 28.10.2026 — **П-3: `result.versions` некорректен.** Не целое ≥ 0 (строка, `true`, −1) — команда `failed` + «versions — неотрицательное целое»; присланных больше `M` — применяются все. Цена ошибки: агент с багом в счётчике + уронит команду, а не только историю. +- 28.10.2026 — **П-4: «+/−» считаются в `sync.prepare`** (вне транзакции), пишутся в `sync._commit`; число — строки + `+`/`-` из `difflib.unified_diff` (те же, что в диффе правки). Цена ошибки: при смене алгоритма диффа старые «+/−» + расходятся с новым диффом. +- 28.10.2026 — **П-5: время версии.** Дробные секунды отбрасываются; равное время — порядок по коду строкой (решение + спеки «затем по коду»). Цена ошибки: две правки в одну секунду идут в ленте по коду, а не по порядку правки. +- 28.10.2026 — **П-6: фильтр автора в Python (`casefold`)** до среза `limit`. Цена ошибки: на больших лентах фильтр + медленнее, чем в SQL. +- 28.10.2026 — **П-7: `GET …/diff`.** `offset` не целое ≥ 0 — 422 `bad_request` «offset: целое ≥ 0»; `offset` за концом — + `diff = ""`, `next_offset = null`. Цена ошибки: клиент, ждавший 404, получит пустой дифф. +- 28.10.2026 — **П-8: MCP.** Шапка `diff`; строка `status` «история: » / «история не загружена»; `sync` при + отменённом пакете — «ошибка: пакет N отменён»; время в «ок: синхронизировано …» — `synced_at` из `GET /wiki/status`; + автор правки — имя, иначе логин, иначе «—» (и в вебе). Цена ошибки: формулировки MCP могут не совпасть с ожиданием + владельца; смысл не меняется. +- 28.10.2026 — **П-9: помощники времени MCP (`local_if_naive`, `since_of`) — в `core/mcp.py`**, сервер jira берёт их + оттуда. Цена ошибки: ядро получает функции форматирования времени, общие для двух модулей. +- 28.10.2026 — **П-10: веб — в шапке «Ленты» вторичные кнопки обновления модулей выбранных источников** («Обновить из + Jira», «Обновить вики»). Цена ошибки: лишняя кнопка на одном из источников, если модуль не подключён. +- 28.10.2026 — **П-11: веб — тексты сообщений.** `truncated` и ошибки источника — с префиксом «<Источник>: »; + «выберите хотя бы один вид» и «история вики ещё не загружена…» — без префикса; «за период изменений нет» — когда ни + один выбранный источник не грузится и не в ошибке, хотя бы один загружен без своего сообщения и групп нет; + `sources=none` — явный пустой набор (как `kinds=none`). Цена ошибки: формулировки сообщений, не смысл. +- 28.10.2026 — **П-12: веб — `eventTime` переезжает в `ui/format.ts`, период (`PERIODS`, `parsePeriod`, `periodRange`) + — в `shell/feed/period.ts`;** прежнее перенаправление `/app/feed` → `/app/jira/feed` снимается (адрес теперь страница + ядра). Цена ошибки: закладки на прежний адрес ленты перестают перенаправлять. +- 28.10.2026 — **R-1: `history.line_delta` берёт `difflib.unified_diff(...)[2:]` вместо отбора строк по префиксам `+++`/`---` + (решение контроллера при исполнении, Task 2).** Обоснование: заголовков всегда ровно две, они идут первыми; отбор по + префиксам терял бы строки содержимого вида `++x` и `--x`. Цена ошибки: если difflib изменит формат заголовков, «+/−» + исказятся на две строки у каждой версии; лечится правкой одной функции и пересчётом. +- 28.10.2026 — **R-2: повторное ревью исправления задачи 3 (поля `at`/`author`/`offset` в diff, `code`/`title` в + versions) сделал контроллер по диффу (10 строк, тесты RED→GREEN), а не свежий ревьюер.** Цена ошибки: пропущенная + регрессия в двух ответах; финальное ревью её не нашло. +- 28.10.2026 — **I-1 (финальное ревью M-1, поднято до Important): «за период изменений нет» — если все выбранные + источники загружены без ошибки, хотя бы у одного нет своего сообщения и групп нет (П-11).** Обоснование: в окне выкатки + (хаб новый, агент v1) у вики висит «история не загружена», и пустая Jira не сообщала о пустом периоде. Цена ошибки: два + сообщения рядом. +- 28.10.2026 — **I-2 (финальное ревью T7.1, поднято до Important): строки диффа в окне правки — без зазора между + строками.** Обоснование: дифф — главное, ради чего фича; зазор 12 px делал его нечитаемым. Цена ошибки: нет. diff --git a/openspec/changes/wiki-history/proposal.md b/openspec/changes/wiki-history/proposal.md new file mode 100644 index 0000000..681808e --- /dev/null +++ b/openspec/changes/wiki-history/proposal.md @@ -0,0 +1,60 @@ +## Why + +Владелец 28.10.2026 хотел посмотреть, что за день сделали аналитики в вики проекта, и не смог: копия вики в хабе без +истории, MCP показывает только дату синхронизации. Ни кто правил, ни когда, ни что поменялось — не видно (sdlc-hub#140). +Открытый вопрос из ARCHITECTURE.md «История страниц вики» закрывается этим change. + +Проверка API Eva 28.10.2026 (скрипты `eva_probe.py`, `eva_probe2.py` на виртуалке, токеном eva-agent) показала, что +история есть: у каждой версии страницы (`CmfDocumentHistory`, коды `DOCH-…`) — полный текст на момент версии, автор, +время и признак публикации; выборка по странице — фильтром `parent_id`. Журнал `CmfAudit` токену агента закрыт, +истории версий вложений в API нет. + +## What Changes + +- **Версии страниц в синхронизации.** Команда `wiki_sync` дополнительно забирает опубликованные версии страниц дерева + проекта: постранично, только новые (хаб передаёт агенту уже известные коды версий), первый прогон — вся история. + Агент шлёт версии пачками новым маршрутом `POST /agent/wiki/versions`; хаб переводит их HTML в Markdown и вместе с + копией одной операцией записывает в новую таблицу версий. Версии страниц вне дерева не запрашиваются и не хранятся. +- **Маршруты истории.** `GET /wiki/changes` — лента опубликованных правок проекта за период (время, автор, страница, + «+строк / −строк»); `GET /wiki/pages/{code}/versions` — версии страницы; `GET /wiki/pages/{code}/diff` — unified diff + одной правки против предыдущей, частями. `GET /wiki/pages/{code}` получает поля последней правки. +- **MCP вики.** Новые инструменты `changes` и `diff`; шапка `page` — строка «изменено: когда, кто»; `sync` отвечает + только «ок»/ошибкой. `changes`, `diff`, `page` читают только копию хаба. +- **Веб: общая «Лента».** Лента изменений переезжает из раздела Jira в общий пункт «Лента» боковой панели (`/app/feed`) + с переключателями источников «Jira» и «Вики». Правки вики сгруппированы по странице; нажатие на правку открывает окно + с диффом поверх ленты. Модули дают ленте свои источники через контракт веб-модуля. +- **eva-agent** (aep/eva-agent#7, свой design-only change): шаг версий в `wiki_sync`, `AGENT_VERSION` 2. + +## Non-Goals + +- Черновики и неопубликованные правки — решение владельца 28.10.2026: только опубликованные. +- Версии файлов-вложений — решение владельца: свежий файл по запросу, как сейчас (`attachment`). +- Журнал аудита Eva (`CmfAudit`) — закрыт токену агента. +- Страницы вики вне дерева корня проекта — решение владельца: только зона видимости проекта. +- Запись в Eva, откат версий, комментарии к правкам. +- Ускорение фоновой синхронизации: частота прежняя (`wiki_auto_sync_hours`). + +## Capabilities + +### New Capabilities + +Нет. + +### Modified Capabilities + +- `wiki` — новые требования «История правок страниц» и «Маршруты истории вики». +- `mcp` — «MCP вики». +- `web-app` — «Лента изменений», «Оболочка и адреса»; новое требование «Правки вики в ленте». + +## Impact + +- Хаб: `modules/wiki/` — `schema` (таблицы версий и стадии версий), `commands` (`deliver_params`, учёт версий в + отчёте), `stage` (приём версий), `sync` (запись версий в `commit`, удаление вне дерева), `convert` (режим без id + вложений), `api` (новые маршруты), `mcp`, `projects` (сброс корня удаляет версии); новый модуль чтения истории + (лента, дифф). Тесты `tests/test_wiki_*`. +- Веб: `web/src/shell/` (страница общей ленты, период), `web/src/modules/types.ts` (источник ленты в `HubModule`), + `web/src/modules/jira/feed/` (источник Jira вместо страницы), `web/src/modules/wiki/` (источник вики, окно диффа). +- Контракт с агентом `wiki`: новый маршрут `POST /agent/wiki/versions`, поле `known_versions` в параметрах выдачи + `wiki_sync`, поле `versions` в отчёте `done`. Агент v1 работает как прежде — история не пополняется. +- Схема базы: две новые таблицы модуля `wiki`. Выкатка: `git pull` → kickstart хаба → `make web` → `/mcp` reconnect → + публикация eva-agent v2 → после `agent_status` с v2 владелец поднимает `wiki_min_agent_version`. diff --git a/openspec/changes/wiki-history/specs/mcp/spec.md b/openspec/changes/wiki-history/specs/mcp/spec.md new file mode 100644 index 0000000..2415ca2 --- /dev/null +++ b/openspec/changes/wiki-history/specs/mcp/spec.md @@ -0,0 +1,80 @@ +## MODIFIED Requirements + +### Requirement: MCP вики +`sdlc-hub mcp wiki [--project <метка>]` SHALL запускать по stdio MCP-сервер модуля `wiki` с именем `sdlc-wiki`, +ходящий в HTTP API хаба клиентским токеном; хаб недоступен — каждый инструмент отвечает «хаб не запущен: sdlc-hub serve». +Привязка к проекту проверяется так же, как у сервера jira («Привязка сессии к проекту»). Инструменты: + +| Инструмент | Параметры | Ответ | +|---|---|---| +| `projects` | — | таблица: метка, название, корень вики, страниц, последняя синхронизация; текущий проект отмечен | +| `project_create` | `label`, `name`, `wiki_root` (необязательно) | `POST /projects` с автором `claude`; проект и фрагмент `.mcp.json` с записью `sdlc-wiki` | +| `root_set` | `root` | `PATCH /projects/<текущий> {wiki_root}`; новый корень и id пакета синхронизации | +| `tree` | `code` (необязательно), `depth` (по умолчанию 2) | дерево от корня или от `code`: отступ, код, название, размер | +| `page` | `code` или `title`, `offset` (по умолчанию 0) | шапка (код, путь в дереве, адрес в Eva, дата синхронизации, строка «изменено: ДД.ММ ЧЧ:ММ <автор> (версий: N)» или «изменено: история не загружена», вложения с `id`) и Markdown с `offset` до 20 000 символов, в конце — «продолжение: offset=», если текст длиннее; `title` ищется точно, иначе — до 10 кандидатов с кодами | +| `search` | `query`, `limit` (по умолчанию 10) | страницы: код, название, путь, фрагмент | +| `changes` | `hours`, `since`, `until`, `author` (все необязательные) | правки страниц проекта за период (`GET /wiki/changes`), см. ниже | +| `diff` | `code`, `version` (необязательно), `offset` (по умолчанию 0) | шапка (код, название, путь, версия «N из M», автор, время, «+A / −R», предыдущая версия) и unified diff с `offset` до 20 000 символов, в конце — «продолжение: offset=», если дифф длиннее; без `version` — последняя правка | +| `attachment` | `id` | ставит скачивание, ждёт до 60 с; готово — путь к копии `downloads/wiki-/<имя>`; иначе состояние и подсказка повторить | +| `sync` | `wait` (по умолчанию `false`) | без `wait` — «поставлено: пакет N» или «уже идёт: пакет N»; с `wait` — ждёт до 300 с: «ок: синхронизировано ДД.ММ ЧЧ:ММ», «ошибка: <текст>» или «не дождался за 300 с: пакет N»; данных копии в ответе нет | +| `status` | — | корень, последняя синхронизация, загрузка истории (`history_at` или «история не загружена»), ошибка, пакет в работе, агент `wiki`: онлайн/офлайн, версия, публикация, обновление, запускатель (как `agent_status` сервера jira) | + +Инструменты, кроме `projects` и `project_create`, SHALL требовать привязки к проекту. В привязанной сессии без корня +(кроме `root_set`, `status`) ответ SHALL быть «у проекта <метка> нет корня вики: root_set DOC-…»; с корнем, но без +синхронизаций — «вики проекта ещё не синхронизирована: sync». Набор инструментов и их описания у сервера `sdlc-jira` +(модуль jira) SHALL остаться прежним. Инструменты `page`, `changes`, `diff` MUST NOT ставить команд: они читают только +копию хаба. + +`changes`: период — как у инструмента `changes` сервера jira («Лента изменений в MCP»): `hours` и `since` вместе — +ответ «либо hours, либо since»; без обоих — с начала текущих суток по местному времени машины MCP-сервера; с `hours` — +`since = сейчас − hours`; `since`/`until` без смещения — местное время машины MCP-сервера. `author` передаётся в хаб +как есть. Вид ответа: первая строка — период по местному времени («правки вики с 28.10 00:00 по 28.10 14:05»), вторая +— «синхронизировано: ДД.ММ ЧЧ:ММ»; затем правки, сгруппированные по странице; страницы — по времени последней правки, +новые сверху; у страницы строка «DOC-… <название> — <путь>», под ней правки по времени, новые сверху, каждая — «ЧЧ:ММ +<автор>: +A / −R (<версия>)», с датой «ДД.ММ» перед временем, если правка не в текущих сутках. Правок нет — «за период +правок нет»; `history_at = null` — «история ещё не загружена: нужен eva-agent v2 и sync»; `truncated` — последняя +строка «показаны последние N правок, сузьте период». Ошибка 422 хаба — её текст. + +Значения из Eva, которые инструменты выводят внутри строки ответа (названия страниц и корня, путь, имя вложения, +фрагмент поиска, кандидаты `page` по названию, имя автора правки), SHALL выводиться одной строкой: переводы строк +(`\r`, `\n`) и пробелы вокруг них заменяются одним пробелом. Тело страницы (Markdown после шапки `page`) и дифф после +шапки `diff` выводятся как есть. + +#### Scenario: Поиск из сессии awp-ca +- **WHEN** сервер запущен с `--project AWPCA`, копия синхронизирована, вызван `search {query: "статус согласован"}` +- **THEN** ответ — страницы с кодами и фрагментами; запрос в хаб — `GET /wiki/search?project=AWPCA&q=…` + +#### Scenario: Длинная страница +- **WHEN** `page {code: DOC-3}`, Markdown 45 000 символов +- **THEN** первые 20 000 символов и «продолжение: offset=20000»; `page {code: DOC-3, offset: 40000}` — остаток без строки + продолжения + +#### Scenario: Корня нет +- **WHEN** сессия `sfm_kedr` без корня, вызван `tree` +- **THEN** ответ «у проекта sfm_kedr нет корня вики: root_set DOC-…», запроса `GET /wiki/tree` нет + +#### Scenario: Сервер jira не изменился +- **WHEN** запущен `sdlc-hub mcp jira --project AWPCA` +- **THEN** имя сервера `sdlc-jira`, список инструментов и описания совпадают с эталоном до change + +#### Scenario: Перевод строки в названии страницы +- **WHEN** у DOC-3 название «Статусы\nпуть: поддельный», вызван `page {code: DOC-3}` +- **THEN** первая строка ответа — «DOC-3 — Статусы путь: поддельный», строка «путь:» в шапке одна — настоящая + +#### Scenario: Что сделали аналитики сегодня +- **WHEN** сессия `AWPCA`, `changes {}` в 14:05; сегодня Соснов опубликовал две версии DOC-101252 в 11:02 и 12:40 +- **THEN** запрос `GET /wiki/changes?project=AWPCA&since=<сегодня 00:00 местного>`; в ответе строка DOC-101252 с + названием и путём, под ней «12:40 Дмитрий Соснов: +40 / −12 (DOCH-…)» и «11:02 Дмитрий Соснов: …»; команд не + поставлено + +#### Scenario: Дифф правки +- **WHEN** `diff {code: DOC-101252}`, у страницы 4 версии +- **THEN** шапка «версия 4 из 4», автор, время, «+A / −R», предыдущая версия; затем unified diff + +#### Scenario: Синхронизация с ожиданием +- **WHEN** `sync {wait: true}`, пакет завершился `done` в 14:05 +- **THEN** ответ — одна строка «ок: синхронизировано 28.10 14:05» + +#### Scenario: История ещё не загружена +- **WHEN** у агента `wiki` версия 1, `changes {}` +- **THEN** ответ содержит «история ещё не загружена: нужен eva-agent v2 и sync» diff --git a/openspec/changes/wiki-history/specs/web-app/spec.md b/openspec/changes/wiki-history/specs/web-app/spec.md new file mode 100644 index 0000000..cb42bc4 --- /dev/null +++ b/openspec/changes/wiki-history/specs/web-app/spec.md @@ -0,0 +1,357 @@ +## MODIFIED Requirements + +### Requirement: Лента изменений +Страница «Лента» (`/app/feed?project=<метка>`) SHALL быть страницей ядра и показывать события текущего проекта за +период из источников модулей: «Jira» — журнал изменений своих задач (`GET /changes` с `project`, `since`, `until`, +`kinds`), «Вики» — правки страниц (требование «Правки вики в ленте»). Источник показывается, если он доступен проекту +(«Jira» — всегда, «Вики» — у проекта есть корень вики). Доступных источников больше одного — над лентой +флажки источников «Jira», «Вики» (по умолчанию все доступные), выбор хранится в адресе (`sources=` через запятую; набор +по умолчанию в адрес не пишется; в адресе нет ни одного известного источника — набор по умолчанию); сняты все флажки — +сообщение «выберите хотя бы один источник», запросов нет. Доступный источник один — флажков нет. + +Период SHALL выбираться переключателем «2 часа» / «Сегодня» / «Вчера» / «7 дней» (по умолчанию «Сегодня»; границы — по +местному времени браузера: «Сегодня» — с начала текущих суток до сейчас, «Вчера» — с начала прошлых суток до начала +текущих) и храниться в адресе (`period=2h|today|yesterday|7d`; «Сегодня» в адрес не пишется); период общий для всех +источников. Виды событий Jira SHALL выбираться флажками «Статусы», «Описание», «Сроки», «Комментарии», «Прочие поля» +(по умолчанию все, кроме «Прочие поля»), видными, пока выбран источник «Jira», и храниться в адресе (`kinds=` через +запятую; набор по умолчанию в адрес не пишется; в адресе нет ни одного известного вида — набор по умолчанию); сняты все +флажки видов — сообщение «выберите хотя бы один вид» на месте событий Jira, запроса `GET /changes` нет. + +События SHALL быть сгруппированы: Jira — по задаче, Вики — по странице; группы обоих источников идут одним списком по +времени последнего события, новые сверху. У группы Jira: ключ — ссылка на карточку задачи (окно поверх ленты, +требование «Карточка задачи»), тема и текущий статус; под ней события по времени, новые сверху, каждое — время +(`ЧЧ:ММ`, с датой `ДД.ММ`, если событие не в текущих сутках), автор и текст: статус — «статус «было» → «стало»»; +описание — «описание изменено»; срок, плановое начало и окончание — «срок ДД.ММ → ДД.ММ» (дата по значению +`from_value`/`to_value`, иначе строка журнала; пустое — «—»); комментарий — «комментарий:» и первые 300 символов текста +без вики-разметки Jira (так, как текст читается отрендеренным: без `*`, `{code}`, `h3.`; у `[текст|адрес]` — текст, +`[~логин]` — «@логин») одной строкой с «…»; прочие поля — «<название поля>: «было» → «стало»» (значения до 80 символов +с «…»), где название — подпись из словаря приложения (исполнитель, спринт, метки, приоритет, тема, эпик, связь, +вложение, резолюция), иначе имя поля Jira. Подзаголовок страницы — число событий и границы периода. Событий нет ни в +одном выбранном источнике — сообщение «за период изменений нет»; `truncated` у источника — сообщение «показаны +последние N событий — выберите период короче» с названием источника. Ошибка хаба у источника — сообщение с кнопкой +«Повторить» на месте его событий; события других источников показываются. Ленту SHALL перечитывать завершение +обновления модуля-источника («Обновить из Jira», «Обновить вики») и возврат на вкладку браузера. + +#### Scenario: Что закрылось за два часа +- **WHEN** открыта лента проекта AWPCA, выбран период «2 часа», снят источник «Вики», сняты все виды, кроме «Статусы» +- **THEN** адрес — `/app/feed?project=AWPCA&period=2h&sources=jira&kinds=status`; запрос `GET + /changes?project=AWPCA&since=<сейчас − 2 ч>&kinds=status`; запроса `GET /wiki/changes` нет; задачи сгруппированы, у + каждой — события «ЧЧ:ММ автор: статус «В работе» → «Готово»» + +#### Scenario: Карточка из ленты +- **WHEN** в ленте нажат ключ AURORA-9002 +- **THEN** поверх ленты открыто окно карточки AURORA-9002; закрытие окна возвращает ленту с тем же периодом + +#### Scenario: Пустой период +- **WHEN** за выбранный период событий нет ни в Jira, ни в Вики +- **THEN** показано «за период изменений нет» + +#### Scenario: Вчера +- **WHEN** выбран период «Вчера», сейчас 22.10 14:05 местного времени +- **THEN** запросы источников с `since` = 21.10 00:00 и `until` = 22.10 00:00 местного времени + +#### Scenario: Комментарий с разметкой +- **WHEN** в ленте событие комментария с текстом `h3. Итог` и `*Готово*, см. [протокол|https://x.ru/p] и [~ivanov]` +- **THEN** строка события — «комментарий: Итог Готово, см. протокол и @ivanov» + +#### Scenario: Jira и Вики вперемешку +- **WHEN** сегодня в 10:00 сменился статус AURORA-1, в 11:00 опубликована правка DOC-3, в 12:00 — комментарий к AURORA-2 +- **THEN** группы сверху вниз: AURORA-2, DOC-3, AURORA-1 + +#### Scenario: Проект без вики +- **WHEN** у проекта `sfm_kedr` нет корня вики +- **THEN** флажков источников нет, показаны события Jira; запроса `GET /wiki/changes` нет + +#### Scenario: Ошибка одного источника +- **WHEN** `GET /wiki/changes` ответил 500, `GET /changes` — событиями +- **THEN** события Jira показаны; на месте правок вики — сообщение об ошибке с кнопкой «Повторить» + +### Requirement: Оболочка и адреса +Приложение SHALL открываться под префиксом `/app/`. Главная страница — «Обзор» ядра (`/app/?project=<метка>`, +требование «Обзор проекта»); страница ядра «Лента» — `/app/feed` (требование «Лента изменений»). Страницы модулей SHALL +лежать под префиксом имени модуля: модуль jira — `/app/jira/links` («Связи» — доска связей), `/app/jira/backlog` +(«Бэклог»), `/app/jira/sprint` («Спринт»), `/app/jira/kanban` («Kanban»; три последних — требование «Спринты и +бэклог»), `/app/jira/issues` (список задач) и `/app/jira/issues/{key}` (карточка задачи — окно поверх страницы, +требование «Карточка задачи»); модуль wiki — `/app/wiki` (требование «Вики проекта») и +`/app/wiki/pages/{code}/versions/{version}` (окно правки поверх страницы, требование «Правки вики в ленте»). Прежние адреса `/app/board` и `/app/jira/board` (на `/app/jira/links`), +`/app/issues`, `/app/issues/{key}`, `/app/jira/feed` (на `/app/feed`) SHALL перенаправлять на новые с сохранением +строки запроса, без новой записи истории. Неизвестный путь — страница «не найдено» со ссылками на «Обзор», «Ленту» и +разделы модулей. + +Боковая панель SHALL быть закреплена у левого края окна на всю его высоту и MUST NOT прокручиваться вместе со +страницей; если её содержимое не помещается по высоте, прокручивается сама панель. Сверху вниз панель SHALL содержать: +название «SDLC Hub» и кнопку «Свернуть панель»; выпадающий список проектов («<метка> — <название>», текущий отмечен, в +конце «+ Новый проект…»; требование «Проект в приложении»); пункты «Обзор» и «Лента»; разделы модулей группами — +заголовок группы — название модуля («Jira», «Вики»), под ним ссылки разделов модуля со значками (текущий отмечен +`aria-current="page"`); внизу — блок состояния модулей (требование «Состояние и обновление модулей»), переключатель +темы (требование «Тема») и меню с действием «Сменить токен». Свёрнутая панель SHALL быть узкой полосой с теми же +действиями значками: «Развернуть панель», метка текущего проекта (полностью — в подсказке), «Обзор», «Лента», разделы +(группы разделены линией), по значку состояния на модуль, тема, меню; названия — в подсказках и `aria-label`. Свёртка +SHALL храниться в `localStorage` под ключом `sdlc-hub-sidebar` (`collapsed`; без записи — развёрнута). + +На экранах уже 640 px панели в потоке нет: сверху SHALL быть закреплена строка с кнопкой «Меню», меткой текущего +проекта и сводной точкой состояния модулей (ошибка, если она есть у какого-нибудь модуля; иначе — пакет в работе; +иначе — все агенты на связи или нет), которая открывает панель. «Меню» открывает развёрнутую панель поверх страницы +модальным окном: фокус переходит в панель и остаётся в ней, страница под ней недоступна; панель закрывается выбором +раздела, клавишей Escape (если не открыто вложенное меню — тогда Escape закрывает только его) или щелчком мимо неё; фокус +возвращается на «Меню». Горизонтальной прокрутки страницы на любой ширине нет. + +Заголовок вкладки браузера SHALL быть «<название страницы> · <метка проекта> · SDLC Hub» (без проекта — без средней +части); название страницы даёт оболочка («Обзор», «Лента») или модуль. Неожиданная ошибка отрисовки страницы SHALL +показываться сообщением с кнопкой «Перезагрузить», не пустым экраном. + +#### Scenario: Прежний адрес доски связей +- **WHEN** открыт `/app/jira/board?project=AWPCA` +- **THEN** адрес — `/app/jira/links?project=AWPCA`, показана доска связей, в меню отмечен пункт «Связи», заголовок + вкладки — «Связи · AWPCA · SDLC Hub» + +#### Scenario: Переход между разделами +- **WHEN** на доске в боковой панели выбран раздел «Задачи» группы «Jira» +- **THEN** адрес — `/app/jira/issues` с текущим проектом, показан список задач; «назад» возвращает на доску + +#### Scenario: Раздел «Лента» +- **WHEN** на доске в боковой панели выбран пункт «Лента» +- **THEN** адрес — `/app/feed` с текущим проектом, показана лента изменений за сегодня; пункт отмечен в панели + +#### Scenario: Прежний адрес ленты +- **WHEN** открыт `/app/jira/feed?project=AWPCA&period=7d` +- **THEN** адрес заменён на `/app/feed?project=AWPCA&period=7d`, показана общая лента + +#### Scenario: Сменить токен +- **WHEN** в меню боковой панели выбрано «Сменить токен» +- **THEN** токен очищен, показан экран входа + +#### Scenario: Панель на месте при прокрутке +- **WHEN** доска прокручена до самого низа +- **THEN** боковая панель видна целиком на прежнем месте: разделы и блок состояния модулей доступны без прокрутки вверх + +#### Scenario: Свёрнутая панель +- **WHEN** нажата «Свернуть панель», страница перезагружена +- **THEN** панель свёрнута в полосу значков, доска шире; значок «Задачи» открывает список задач; видны два значка + состояния — Jira и Вики; «Развернуть панель» возвращает полную панель + +#### Scenario: Узкий экран +- **WHEN** ширина окна 390 px, нажата «Меню», в панели выбран раздел «Задачи» +- **THEN** панель открылась поверх страницы, фокус в панели; после выбора панель закрыта, фокус на «Меню», показан список + задач; горизонтальной прокрутки нет + +#### Scenario: Прежний адрес +- **WHEN** открыт `/app/issues/AURORA-1536?project=AWPCA` +- **THEN** адрес заменён на `/app/jira/issues/AURORA-1536?project=AWPCA`, карточка открыта поверх списка задач + +#### Scenario: Заголовок вкладки +- **WHEN** открыта страница «Вики» проекта `AWPCA` +- **THEN** заголовок вкладки — «Вики · AWPCA · SDLC Hub» + +### Requirement: Обзор проекта +Страница ядра «Обзор» (`/app/?project=<метка>`, пункт «Обзор» боковой панели) SHALL показывать шапку «Обзор» с +названием проекта и под ней карточку на каждый модуль, у которого есть данные для проекта: название модуля, агент (с +версией из `GET /agents`), свежесть с охватом, текущее событие пакета, вторичная кнопка обновления и сводка, которую +даёт модуль. Сводка jira: число своих задач проекта, из них открытых и просроченных (по снимку доски проекта), ссылки +«Доска связей», «Задачи», «Лента» (общая лента с источником Jira — `/app/feed?project=<метка>&sources=jira`). Сводка +wiki: число страниц, поле поиска по вики (Enter открывает «Вики» с этим поиском), ссылки «Вики» и «Правки» (общая +лента с источником Вики — `/app/feed?project=<метка>&sources=wiki`). Модуль без данных для проекта SHALL показывать карточку с объяснением и действием модуля (у +вики — «Привязать корень вики» ведёт на страницу «Вики»). Под карточками — «Последние пакеты проекта»: до 10 из +`GET /batches?project=<метка>`: номер, модуль, название, автор, статус, время создания. + +#### Scenario: Главная +- **WHEN** открыт `/app/` без проекта, последний выбранный — `AWPCA` +- **THEN** адрес `/app/?project=AWPCA`, две карточки — «Jira» и «Вики», список последних пакетов + +#### Scenario: Поиск по вики с обзора +- **WHEN** на «Обзоре» `AWPCA` в поле поиска вики введено «согласов» и нажат Enter +- **THEN** открыта «Вики» с результатами поиска «согласов» + +#### Scenario: Проект без вики +- **WHEN** «Обзор» проекта `sfm_kedr` без корня вики +- **THEN** карточка «Вики» с объяснением и ссылкой «Привязать корень вики» + +### Requirement: Спринты и бэклог +Модуль jira SHALL показывать для проекта с доской страницы по образцу Jira — только для просмотра: на них MUST NOT быть +действий, меняющих Jira (перетаскивания, кнопок спринтов, смены статуса или ранга). Пункты меню группы «Jira» — +«Связи», «Задачи» и по типу доски текущего проекта (`board_type` из `GET /projects`): `scrum` — «Бэклог» и +«Спринт»; `kanban` — «Kanban»; доски нет или тип неизвестен — новых пунктов нет, а прямой переход на их адрес +показывает «У проекта нет scrum-доски» («…kanban-доски» для «Kanban») со ссылкой на «Связи». Данные — `GET +/agile?project=<текущий>`; если доска есть, но её данных от агента нет (`board = null` или `board.fetched_at = null`) — +«Нужен агент v15 и обновление из Jira» с кнопкой «Обновить» блока состояния модуля; данные доски есть (`fetched_at` задан), а колонок нет (`columns = null`) — «Не удалось +прочитать настройки доски из Jira» и та же кнопка. Пока проекты не загружены, условные пункты меню скрыты; смена проекта +на странице, которой у нового проекта нет, оставляет адрес и показывает сообщение «У проекта нет …-доски». Даты +спринтов (ISO-8601 Jira) показываются «ДД.ММ» в часовом поясе браузера; «осталось N дн.» — от сегодня до конца спринта. + +Строка задачи и карточка задачи на этих страницах: ключ, значок типа, тема, исполнитель (или «—»), оценка (число или +«—»); выполненная — приглушена. Щелчок открывает карточку задачи (окно поверх страницы, как в списке задач). Оценки и +их суммы SHALL показываться числом не более чем с двумя знаками после запятой без хвостовых нулей (`0.1 + 0.2` — +«0.3», `3` — «3»). + +**Значок типа** SHALL различать по `issue_type` без учёта регистра: эпик («Эпик», «Epic», «Фича»), история +(«История», «Story»), задача («Задача», «Task»), ошибка («Ошибка», «Bug»), подзадача («Подзадача», «Sub-task», +«Subtask»); прочие и пустой тип — нейтральный значок. Значок — цветной квадрат с символом цвета `--color-text-on-primary`, контрастным в обеих темах, у каждого вида свои +цвет и символ (эпик — молния, история — закладка, задача — галочка, ошибка — круг, подзадача — два квадрата), в обеих +темах; название типа — в `aria-label` и подсказке. + +**Панель фильтров** — над содержимым «Бэклога», «Спринта» и «Kanban»: поиск «Ключ или тема» (подстрока без учёта +регистра в ключе или теме), выбор «Исполнитель» («все», «Без исполнителя», исполнители задач страницы по алфавиту), +переключатель «Скрыть выполненные» (только «Бэклог»; на «Спринте» и «Kanban» выполненное скрывают колонкой, сохранённое +значение там не применяется); на «Бэклоге» — кнопка «Эпики» (показать или скрыть панель эпиков); на «Спринте» — выбор +спринтов; на «Спринте» и «Kanban» — меню «Колонки». Кнопка «Сбросить фильтры» SHALL быть в панели всегда и снимать все +фильтры (поиск, исполнитель, эпик, «Скрыть выполненные»); пока ни один не задан, она неактивна — панель не сдвигается, +когда фильтр появляется или снимается. Фильтры действуют вместе +(И). Сохранённое значение исполнителя или эпика, которого нет в данных, SHALL оставаться выбранным и видимым в списке. + +**Запоминание:** свёрнутые секции, выбранный эпик, исполнитель, «Скрыть выполненные», видимость панели эпиков и скрытые +колонки SHALL храниться в `localStorage` под ключом `sdlc-hub-agile` — JSON по метке проекта и странице (`backlog`, +`sprint`, `kanban`); у разных страниц и проектов свои значения. Поиск и выбор спринтов не запоминаются. Нечитаемое или +закрытое хранилище — значения по умолчанию, страница работает. + +**«Бэклог»** (`/app/jira/backlog`), сверху вниз: секции активных спринтов, будущих спринтов, «Бэклог», затем +свёрнутый список «Прошлые спринты (N)». Заголовок секции спринта: имя, состояние, даты «ДД.ММ–ДД.ММ», цель (если есть), +«N задач», «S SP (выполнено D)» и «без оценки: U», если `U > 0` — итоги спринта, фильтры их не меняют; если фильтры +скрыли часть задач секции — ещё «показано K». Задачи секции — по рангу. Секция без задач показывает «Нет задач»; все +задачи скрыты фильтрами — «Нет задач по фильтрам». Любую секцию (спринта и «Бэклог») SHALL можно свернуть и развернуть +щелчком по заголовку (кнопка с `aria-expanded`); по умолчанию развёрнуты все. Прошлый спринт в списке — строка «имя · +закрыт ДД.ММ · выполнено X из N · S SP»; раскрытие показывает его задачи с пометкой «не выполнено» у невыполненных. + +Эпики (задачи вида «эпик») MUST NOT показываться строками в секциях «Бэклога». Панель «Эпики» — слева от секций: +пункт «Все задачи», затем эпики — каждая задача-эпик из данных страницы по рангу, затем эпики, на которые ссылается +`epic_key` задач, но строки которых в данных нет, по ключу; в конце — «Без эпика». Пункт эпика: тема (или ключ, если +темы нет), ключ, число задач эпика в секциях активных и будущих спринтов и «Бэклога». Выбор пункта (кнопка с +`aria-pressed`) фильтрует все секции, включая прошлые спринты; повторный выбор выбранного эпика возвращает «Все +задачи». При «Скрыть выполненные» выполненные эпики в панели не показываются (кроме выбранного). На узком экране +(контейнер страницы уже 900 px) панель SHALL становиться выбором «Эпик» в панели фильтров. + +«Скрыть выполненные» на «Бэклоге» скрывает выполненные задачи в секциях активных и будущих спринтов и «Бэклога»; +прошлые спринты показываются полностью. + +**«Спринт»** (`/app/jira/sprint`): выбранные спринты доски — по умолчанию все активные. Выбор спринтов — список с +отметками: активные, затем будущие (по дате начала, без даты — в конце); выбор хранится в адресе +`?sprint=[,…]` (переход без новой записи истории); идентификаторы, которых нет среди активных и будущих, +отбрасываются, если не осталось ни одного — все активные. Над доской — заголовок каждого выбранного спринта (имя, +даты, «осталось N дн.» или «срок прошёл» — только у активного, цель, суммы оценок), под ними колонки доски Jira с числом +задач; задачи доски — задачи выбранных спринтов, карточки в колонке — по рангу; колонка «Без колонки» — для статусов вне +настроек доски, только если в ней есть задачи. Нет активного спринта и ничего не выбрано — «Нет активного спринта» и +ссылка на «Бэклог»; если есть будущие спринты, выбор спринтов при этом доступен. + +**«Kanban»** (`/app/jira/kanban`): колонки доски из `board_issues`, карточки по рангу, у последней колонки подпись +«за 14 дней»; если `kanban_backlog = true` — над колонками свёрнутый список «Бэклог (N)» по рангу; поиск и исполнитель +действуют и на него. + +**Колонки доски** («Спринт» и «Kanban»): заголовок колонки — `h3`. Меню «Колонки» — отметки по колонкам доски (и «Без +колонки», если она есть); снятая отметка скрывает колонку, последнюю видимую скрыть нельзя (отметка недоступна); если +скрыты колонки — на кнопке их число. Доска SHALL занимать высоту окна под заголовком и панелью фильтров (не меньше +360 px; на окне уже 640 px — не меньше 240 px), все колонки — во всю высоту доски; карточки, не помещающиеся по высоте, прокручиваются внутри своей колонки. +Колонки, не помещающиеся по ширине, SHALL прокручиваться по горизонтали внутри доски, не страницы, и полоса горизонтальной +прокрутки доски SHALL находиться в пределах окна при любом числе карточек. Заголовок вкладки — «Бэклог», «Спринт», +«Kanban» по общему правилу оболочки. + +#### Scenario: Меню scrum-проекта +- **WHEN** текущий проект `AWPCA`, `board_type = scrum` +- **THEN** в группе «Jira» пункты «Связи», «Бэклог», «Спринт», «Задачи»; пункта «Kanban» нет + +#### Scenario: Проект без доски +- **WHEN** текущий проект `STOCKS_DRRK` без доски, открыт `/app/jira/backlog?project=STOCKS_DRRK` +- **THEN** пунктов «Бэклог», «Спринт», «Kanban» в меню нет; страница — «У проекта нет scrum-доски» со ссылкой на «Связи» + +#### Scenario: Бэклог только смотрит +- **WHEN** открыт «Бэклог» `AWPCA` +- **THEN** на странице нет перетаскиваемых элементов; кнопки и поля — только свёртка секций, панель фильтров, пункты + панели эпиков и открытие карточки задачи; ни одно из них не ставит команд + +#### Scenario: Прошлый спринт +- **WHEN** спринт `AWPCA_Sprint_2_VGO` закрыт, в нём 73 задачи, 61 в последней колонке, оценки у 10 задач на 25 SP +- **THEN** в «Прошлые спринты» строка «AWPCA_Sprint_2_VGO · закрыт ДД.ММ · выполнено 61 из 73 · 25 SP»; раскрытие — + 73 задачи, 12 с пометкой «не выполнено» + +#### Scenario: Агент без досок +- **WHEN** `AWPCA` scrum, агент v14 (данных доски нет), открыт «Спринт» +- **THEN** «Нужен агент v15 и обновление из Jira» и кнопка «Обновить» + +#### Scenario: Свёрнутый активный спринт +- **WHEN** на «Бэклоге» свёрнута секция активного спринта, страница перезагружена +- **THEN** секция свёрнута (видны заголовок и итоги, задач нет), остальные развёрнуты + +#### Scenario: Фильтр по эпику +- **WHEN** в бэклоге 3 задачи эпика `AURORA-10` и 5 других, в активном спринте 2 задачи `AURORA-10`; в панели «Эпики» + выбран `AURORA-10` +- **THEN** в секции спринта 2 задачи и «показано 2», в «Бэклоге» 3; строки самого `AURORA-10` в секциях нет; у пункта + `AURORA-10` в панели — 5 + +#### Scenario: Скрыть выполненные +- **WHEN** в активном спринте 10 задач, 4 выполнены; включено «Скрыть выполненные» +- **THEN** в секции 6 задач, заголовок — «10 задач» и «показано 6»; «Прошлые спринты» показывают все свои задачи + +#### Scenario: Исполнитель на доске спринта +- **WHEN** на «Спринте» выбран исполнитель `ivanov` +- **THEN** на доске только его карточки; числа в заголовках колонок — по видимым карточкам; на «Бэклоге» исполнитель не + выбран (свой фильтр у каждой страницы) + +#### Scenario: Будущий спринт на доске +- **WHEN** у доски активный спринт 101 и будущий 102; в выборе спринтов отмечен и 102 +- **THEN** адрес — `?sprint=101,102`; над доской два заголовка, «осталось N дн.» только у 101; на доске задачи обоих + +#### Scenario: Сброс фильтров на месте +- **WHEN** открыт «Спринт» без фильтров, затем выбран исполнитель +- **THEN** «Сбросить фильтры» видна и неактивна до выбора, после — активна на том же месте; флажка «Скрыть выполненные» + на «Спринте» нет + +#### Scenario: Скрытая колонка +- **WHEN** на «Спринте» в меню «Колонки» снята отметка «Выполнено», страница перезагружена +- **THEN** колонки «Выполнено» нет, на кнопке «Колонки» — 1; остальные колонки на месте + +#### Scenario: Широкая доска +- **WHEN** окно 2560 × 1300 px, у доски 12 колонок, в одной из них 40 карточек +- **THEN** все колонки одной высоты и помещаются в окно по высоте; полоса горизонтальной прокрутки доски видна в окне; + прокрутка доски вправо до конца показывает «Без колонки» целиком; страница горизонтально не прокручивается + +## ADDED Requirements + +### Requirement: Правки вики в ленте +Модуль wiki SHALL давать ленте источник «Вики»: правки страниц проекта за период (`GET /wiki/changes` с `project`, +`since`, `until`). Правки сгруппированы по странице; у группы — название страницы ссылкой на неё в разделе «Вики» +(`/app/wiki?project=<метка>&page=<код>`) и путь в дереве; под ней правки по времени, новые сверху, каждая — время +(`ЧЧ:ММ`, с датой `ДД.ММ`, если правка не в текущих сутках), автор (имя) и «+A / −R». `history_at = null` — на месте +правок вики сообщение «история вики ещё не загружена — нужен eva-agent v2». + +Нажатие на правку SHALL открывать окно поверх ленты по адресу `/app/wiki/pages/{code}/versions/{version}?project=<метка>` +(фоновая страница остаётся под ним, закрытие — кнопкой, Escape или «назад» — возвращает ленту с тем же периодом и +источниками). Адрес окна, открытый напрямую (перезагрузка, ссылка), показывает окно поверх страницы «Вики» с этой +страницей (`/app/wiki?project=<метка>&page=<код>`); закрытие оставляет её. Окно показывает: название и путь страницы, «правка N из M», +автора, время, «+A / −R» и дифф правки (`GET /wiki/pages/{code}/diff` с `version`): строки, добавленные и удалённые, — +цветами состояния успеха и ошибки из токенов темы с подписью для экранных читалок, строки `@@` — второстепенным цветом, +моноширинным шрифтом, без горизонтальной прокрутки страницы (длинная строка переносится или прокручивается внутри +окна). Дифф длиннее одной части — кнопка «Показать дальше» догружает следующую (`offset = next_offset`). Кнопки +«Предыдущая правка» и «Следующая правка» переключают версию той же страницы (по `GET /wiki/pages/{code}/versions`), +заменяя адрес окна без новой записи истории, — «назад» по-прежнему закрывает окно; у первой и последней +соответствующая кнопка недоступна. Ссылка «Открыть страницу» ведёт на страницу в разделе «Вики». +Окно только показывает: в Eva и в хабе ничего не меняет. + +Контракт веб-модуля SHALL получить необязательное поле источника ленты: ядро передаёт источнику проект, границы +периода и признак «выбран», а источник отдаёт свои группы со временем последнего события, состояние (загрузка, +ошибка, `truncated`, пустая история) и, при необходимости, свои настройки над лентой (виды событий Jira). Ядро MUST NOT +знать видов событий Jira и полей правок вики (требование «Граница модулей в вебе»). + +#### Scenario: Правка из ленты +- **WHEN** в ленте AWPCA за сегодня группа DOC-101252 «17.2.2.1 ЭФ «Ознакомление перед подписанием»» с правкой «12:40 + Дмитрий Соснов +40 / −12»; нажата правка +- **THEN** поверх ленты окно: «правка 4 из 4», Соснов, 12:40, «+40 / −12», дифф с подсвеченными строками; запрос + `GET /wiki/pages/DOC-101252/diff?project=AWPCA&version=DOCH-…` + +#### Scenario: Предыдущая правка +- **WHEN** в окне правки 4 из 4 нажата «Предыдущая правка» +- **THEN** показана правка 3 из 4 той же страницы; «Следующая правка» доступна; «назад» браузера закрывает окно и + возвращает ленту + +#### Scenario: Окно по ссылке +- **WHEN** открыт `/app/wiki/pages/DOC-101252/versions/DOCH-3159076?project=AWPCA` в новой вкладке +- **THEN** окно правки поверх страницы «Вики» с DOC-101252; закрытие окна — адрес `/app/wiki?project=AWPCA&page=DOC-101252` + +#### Scenario: Длинный дифф +- **WHEN** у правки `next_offset = 20000` +- **THEN** под диффом кнопка «Показать дальше»; нажатие догружает часть с `offset=20000` ниже уже показанной + +#### Scenario: История не загружена +- **WHEN** у проекта `history_at = null` +- **THEN** на месте правок вики — «история вики ещё не загружена — нужен eva-agent v2» + +#### Scenario: Окно на узком экране +- **WHEN** ширина окна 390 px, открыто окно правки с длинными строками Markdown +- **THEN** горизонтальной прокрутки страницы нет; строки диффа переносятся или прокручиваются внутри окна diff --git a/openspec/changes/wiki-history/specs/wiki/spec.md b/openspec/changes/wiki-history/specs/wiki/spec.md new file mode 100644 index 0000000..9c4de80 --- /dev/null +++ b/openspec/changes/wiki-history/specs/wiki/spec.md @@ -0,0 +1,102 @@ +## ADDED Requirements + +### Requirement: История правок страниц +Хаб SHALL хранить опубликованные версии страниц дерева проекта: код версии Eva (`DOCH-…`), код страницы, время правки +(UTC), автора (`login`, `name`), Markdown версии и число строк, добавленных и удалённых против предыдущей версии той же +страницы (порядок версий — по времени, затем по коду; у первой — против пустого текста). Время правки — время создания +версии в Eva (`cmf_created_at`). Версии страниц, которых нет в дереве проекта, хаб MUST NOT хранить; неопубликованные +версии (черновики) агент MUST NOT присылать. + +При выдаче `wiki_sync` хаб SHALL дописывать в параметры команды `known_versions` — объект `{<код страницы>: [<код +версии>…]}` по версиям, уже хранящимся у страниц текущей копии проекта (без копии — `{}`); в `command.params` в базе +поле MUST NOT записываться. Агент `wiki` SHALL после страниц прислать опубликованные версии страниц дерева, которых нет +в `known_versions` их страницы, пачками `POST /agent/wiki/versions {command_id, versions}`: версия — `{page_code, code, +created_at, author: {login, name}, html, attachments, links}` (`created_at` — ISO-8601 со смещением; `login` и `name` +— строки или `null`, если Eva автора не отдала; `attachments` и `links` — как у страниц, по `html` версии), в пачке не +больше 50 версий, — и отчитаться `done` с `result.versions` — +числом присланных версий. Пачка по команде не в `claimed` или не `wiki_sync` — 409; неверная форма версии — 422 +`bad_versions` с кодом версии и полем. Хаб SHALL переводить HTML версии в Markdown при приёме вне транзакции записи +(ссылки на вложения — текстом `📎 <имя>` без ссылки, ссылки на страницы — `page:<код>`) и складывать в стадию команды. + +По отчёту `done` с `result.versions` хаб SHALL в той же операции, что и копию (см. «Синхронизация вики»): добавить +версии из стадии к страницам, чьи коды есть в `codes`, отбросив остальные; удалить версии страниц, которых нет в +`codes`; посчитать «+/−» у новых версий и у версии, следующей за вставленной раньше неё; записать время применения +истории `history_at`. Присланных версий меньше `result.versions` — команда `failed` «нет версий: пришло N из M», копия и +история прежние. Отчёт `done` без `result.versions` (агент без истории) SHALL обновлять копию как прежде и не менять +историю и `history_at`. Отчёт `failed` и отброшенный отчёт по сменённому корню историю не меняют. Сброс корня SHALL +удалять историю проекта вместе с копией. Стадия версий очищается так же, как стадия страниц. + +#### Scenario: Первая загрузка истории +- **WHEN** у `AWPCA` дерево DOC-1, DOC-2; в хабе версий нет; выдан `wiki_sync` +- **THEN** в параметрах выдачи `known_versions = {}`; агент прислал 3 версии DOC-1 и 2 версии DOC-2, отчёт `done + {versions: 5}`; в хабе 5 версий, у первой версии каждой страницы «+N / −0», `history_at` заполнен + +#### Scenario: Догрузка новых версий +- **WHEN** у DOC-1 в хабе версии DOCH-10, DOCH-11; в Eva опубликована DOCH-12 +- **THEN** при выдаче `known_versions = {"DOC-1": ["DOCH-10", "DOCH-11"], …}`; агент прислал только DOCH-12; у неё + «+/−» против DOCH-11 + +#### Scenario: Агент без истории +- **WHEN** агент v1 отчитался `done {root_title, codes, eva_calls}` без `versions` +- **THEN** копия обновлена, версии и `history_at` прежние + +#### Scenario: Страница ушла из дерева +- **WHEN** у DOC-3 в хабе 4 версии; новая синхронизация вернула `codes` без DOC-3 +- **THEN** версий DOC-3 в хабе нет + +#### Scenario: Версия чужой страницы +- **WHEN** в стадии версия с `page_code = DOC-99`, которой нет в `codes` +- **THEN** версия не записана, команда `done` + +#### Scenario: Потерянная пачка версий +- **WHEN** отчёт `done {versions: 5}`, в стадии 3 версии +- **THEN** команда `failed` «нет версий: пришло 3 из 5», копия и история прежние + +### Requirement: Маршруты истории вики +Хаб SHALL предоставлять под клиентским токеном (проект — `project=<метка>`, неизвестный — 404, без корня — 409 +`no_root`), читая только базу хаба: +- `GET /wiki/changes?project=&since=&until=&author=&limit=` — правки страниц текущей копии проекта со временем в + `[since, until]`. `since`, `until` — ISO-8601 со смещением или `Z` (без смещения — 422); без `until` — текущее время; + без `since` — `until` минус 24 часа; `author` — подстрока имени или логина без учёта регистра; `limit` — по умолчанию + 500, 1–2000. Ответ — `{since, until, synced_at, history_at, truncated, changes: [{at, page_code, title, path, + version, author: {login, name}, added, removed}]}` по `at` по убыванию, затем по `version` по убыванию; правок больше + `limit` — первые `limit` и `truncated = true`. Неверное время, `since` позже `until`, `limit` вне 1..2000 — 422 + `bad_request` с именем параметра. `history_at = null` — история проекта ещё не загружалась. +- `GET /wiki/pages/{code}/versions?project=` — `{code, title, versions: [{version, at, author: {login, name}, added, + removed}]}`, новые первыми; страницы нет в копии — 404. +- `GET /wiki/pages/{code}/diff?project=&version=&offset=` — правка `version` (без параметра — последняя) против + предыдущей версии страницы: `{code, title, path, version, prev_version, at, author: {login, name}, added, removed, + index, total, offset, next_offset, diff}`, где `diff` — unified diff Markdown (контекст 3 строки; у первой версии — + против пустого, `prev_version = null`) с символа `offset` до 20 000 символов, `next_offset` — начало следующей части + или `null`, `index` — номер версии от старой (с 1), `total` — число версий. Страницы нет в копии — 404; версии нет у + страницы — 404 `no_version`; у страницы нет версий — 404 `no_versions`. + +`GET /wiki/pages/{code}` SHALL дополнительно отдавать `modified_at`, `modified_by: {login, name}` (последняя версия; +нет версий — `null`) и `versions` — число версий. `GET /wiki/status` SHALL дополнительно отдавать `history_at`. + +#### Scenario: Что сделали за сегодня +- **WHEN** у `AWPCA` 27.10 в 17:59 и 18:02 (MSK) Соснов опубликовал версии DOC-101252, 26.10 Молчанов — версию DOC-2; + `GET /wiki/changes?project=AWPCA&since=2026-10-27T00:00:00+03:00` +- **THEN** две правки DOC-101252, новая первой, у каждой автор `{login: "sosnov-dvi@rshbeco.ru", name: "Дмитрий + Соснов"}`, `added`/`removed`, путь страницы в дереве; правки DOC-2 нет + +#### Scenario: Фильтр по автору +- **WHEN** `GET /wiki/changes?project=AWPCA&since=…&author=соснов` +- **THEN** только правки Соснова + +#### Scenario: Дифф последней правки +- **WHEN** у DOC-101252 4 версии; `GET /wiki/pages/DOC-101252/diff?project=AWPCA` +- **THEN** `index = 4`, `total = 4`, `prev_version` — третья версия, `diff` начинается с `--- ` и `+++ ` + +#### Scenario: Длинный дифф +- **WHEN** дифф правки 45 000 символов, запрос без `offset` +- **THEN** первые 20 000 символов, `next_offset = 20000`; запрос с `offset=40000` — остаток, `next_offset = null` + +#### Scenario: История ещё не загружена +- **WHEN** у `AWPCA` копия есть, агент v1, `GET /wiki/changes?project=AWPCA` +- **THEN** 200, `history_at = null`, `changes = []` + +#### Scenario: Последняя правка в странице +- **WHEN** в хабе 4 версии DOC-101252, последняя — Соснов, `created_at = 2026-10-27T23:48:02+03:00`; + `GET /wiki/pages/DOC-101252?project=AWPCA` +- **THEN** `modified_at = "2026-10-27T20:48:02+00:00"`, `modified_by.name = "Дмитрий Соснов"`, `versions = 4` diff --git a/src/sdlc_hub/core/mcp.py b/src/sdlc_hub/core/mcp.py index 56bb161..4a6e7a1 100644 --- a/src/sdlc_hub/core/mcp.py +++ b/src/sdlc_hub/core/mcp.py @@ -9,6 +9,7 @@ import os import sys import tempfile from collections.abc import Callable +from datetime import datetime, timedelta from pathlib import Path from . import paths, registry @@ -99,6 +100,26 @@ def value(v): return "—" if v in (None, "", []) else str(v) +def local_if_naive(value: str) -> str: + """Время без смещения из чата — местное время машины MCP (хаб такое отвергает, M-2); неразбираемое — как есть.""" + if not value: + return value + try: + d = datetime.fromisoformat(value) + except ValueError: + return value + return d.astimezone().isoformat() if d.tzinfo is None else value + + +def since_of(hours: float | None, since: str, now: datetime) -> str: + """Начало периода: hours → now − hours; без обоих — начало суток now; иначе since как есть.""" + if hours is not None: + return (now - timedelta(hours=hours)).isoformat() + if not since: + return now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat() + return since + + def fmt_agent(a) -> str: state = "онлайн" if a.get("online") else "офлайн" cur = f"выполняет #{a['current_command_id']}" if a.get("current_command_id") else "очередь свободна" diff --git a/src/sdlc_hub/modules/jira/mcp.py b/src/sdlc_hub/modules/jira/mcp.py index 857e09d..0c80ad0 100644 --- a/src/sdlc_hub/modules/jira/mcp.py +++ b/src/sdlc_hub/modules/jira/mcp.py @@ -7,7 +7,7 @@ import json import stat import time import unicodedata -from datetime import datetime, timedelta +from datetime import datetime from pathlib import Path from mcp.server.fastmcp import FastMCP @@ -380,17 +380,6 @@ async def attachment(key: str, attachment: str, wait_seconds: int = 120) -> str: return (f"{prefix}{path}\n{att['filename']} ({fmt_size(att.get('size'))}), {core.value(att.get('mime_type'))}, задача {key}") -def _local_if_naive(value: str) -> str: - """Время без смещения из чата — местное время машины MCP (хаб такое отвергает, M-2); неразбираемое — как есть.""" - if not value: - return value - try: - d = datetime.fromisoformat(value) - except ValueError: - return value - return d.astimezone().isoformat() if d.tzinfo is None else value - - @mcp.tool() @core.guard def changes(hours: float | None = None, since: str = "", until: str = "", kinds: str = "", key: str = "") -> str: @@ -405,11 +394,8 @@ def changes(hours: float | None = None, since: str = "", until: str = "", kinds: if refusal is not None: return refusal now = datetime.now().astimezone() - if hours is not None: - since = (now - timedelta(hours=hours)).isoformat() - elif not since: - since = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat() - data = core.client().get("/changes", since=_local_if_naive(since), until=_local_if_naive(until), kinds=kinds, key=key, + since = core.since_of(hours, since, now) + data = core.client().get("/changes", since=core.local_if_naive(since), until=core.local_if_naive(until), kinds=kinds, key=key, project=None if key else core.PROJECT) return fmt_changes(data, now) diff --git a/src/sdlc_hub/modules/wiki/api.py b/src/sdlc_hub/modules/wiki/api.py index 476852c..8a870d1 100644 --- a/src/sdlc_hub/modules/wiki/api.py +++ b/src/sdlc_hub/modules/wiki/api.py @@ -1,10 +1,11 @@ """HTTP модуля wiki: маршруты клиента (состояние, дерево, страница, поиск, синхронизация, вложения) и агента.""" import json +from datetime import timedelta from urllib.parse import unquote from ...core import db, files, queue -from ...core.http import ApiError, Raw, int_group, not_found, project_param -from . import attachments, commands, paths, search, stage +from ...core.http import ApiError, Raw, int_group, is_digits, not_found, project_param +from . import attachments, commands, history, paths, search, stage MIME = "application/octet-stream" # типа в базе вики нет; скачивание — всегда вложением, без угадывания @@ -13,6 +14,10 @@ def h_agent_pages(hub, conn, m, q, body): return 200, stage.accept(conn, body.get("command_id"), body.get("pages")) +def h_agent_versions(hub, conn, m, q, body): + return 200, stage.accept_versions(conn, body.get("command_id"), body.get("versions")) + + def _project(conn, q) -> tuple[str, dict]: """(метка, строка wiki_project): без project — 422, неизвестный — 404, без корня — 409 no_root.""" label = project_param(conn, q) @@ -38,7 +43,8 @@ def _batch(conn, label): def h_status(hub, conn, m, q, body): label, r = _project(conn, q) return 200, {"root": r["root_code"], "root_title": r["root_title"], "synced_at": r["synced_at"], - "pages": r["pages_count"], "error": r["sync_error"], "batch": _batch(conn, label)} + "pages": r["pages_count"], "error": r["sync_error"], "batch": _batch(conn, label), + "history_at": history.history_at(conn, label)} def _walk_order(conn, label) -> list[str]: @@ -77,7 +83,63 @@ def h_page(hub, conn, m, q, body): "SELECT id, name, state, size, error, url FROM wiki_attachment WHERE label = ? AND page_code = ? ORDER BY id", (label, code))] return 200, {"code": code, "title": r["title"], "path": r["path"], "url": r["url"], "synced_at": r["synced_at"], - "markdown": text, "attachments": atts} + "markdown": text, "attachments": atts, **history.last_change(conn, label, code)} + + +def _time_param(q, name, default): + """Время запроса: ISO-8601 со смещением или Z → UTC; без смещения — 422 (пояс не угадываем).""" + raw = q.get(name) + if raw in (None, ""): + return default + value = history.utc(raw) + if value is None: + raise ApiError(422, "bad_request", f"{name}: ожидается время ISO-8601 со смещением или Z") + return value + + +def h_changes(hub, conn, m, q, body): + label, r = _project(conn, q) + until = _time_param(q, "until", db.now()) + since = _time_param(q, "since", (db.parse(until) - timedelta(hours=24)).isoformat()) + if since > until: + raise ApiError(422, "bad_request", "since: позже until") + try: + limit = int(q.get("limit", 500)) + except (TypeError, ValueError): + limit = 0 + if not 1 <= limit <= 2000: + raise ApiError(422, "bad_request", "limit: целое от 1 до 2000") + out = history.feed(conn, label, since, until, q.get("author") or None, limit) + return 200, {"since": since, "until": until, "synced_at": r["synced_at"], "history_at": history.history_at(conn, label), + "truncated": out["truncated"], "changes": out["changes"]} + + +def _wiki_page(conn, label, code): + return not_found(conn.execute("SELECT * FROM wiki_page WHERE label = ? AND code = ?", (label, code)).fetchone(), + f"страница {code}") + + +def h_versions(hub, conn, m, q, body): + label, _ = _project(conn, q) + code = unquote(m.group(1)) + page_row = _wiki_page(conn, label, code) + return 200, {"code": code, "title": page_row["title"], "versions": history.page_versions(conn, label, code)} + + +def h_diff(hub, conn, m, q, body): + label, _ = _project(conn, q) + code = unquote(m.group(1)) + page_row = _wiki_page(conn, label, code) + raw = q.get("offset", "0") + if not is_digits(raw): + raise ApiError(422, "bad_request", "offset: целое ≥ 0") + try: + out = history.diff(conn, label, code, q.get("version") or None, int(raw)) + except history.NoVersions: + raise ApiError(404, "no_versions", f"у страницы {code} нет версий") from None + except history.NoVersion: + raise ApiError(404, "no_version", f"версии {q.get('version')} у страницы {code} нет") from None + return 200, {"code": code, "title": page_row["title"], "path": page_row["path"], **out} def h_search(hub, conn, m, q, body): @@ -143,9 +205,13 @@ def h_attachment_content(hub, conn, m, q, body): ROUTES = ( ("POST", r"^/agent/wiki/pages$", h_agent_pages, "agent"), + ("POST", r"^/agent/wiki/versions$", h_agent_versions, "agent"), ("GET", r"^/wiki/status$", h_status, "client"), ("GET", r"^/wiki/tree$", h_tree, "client"), ("GET", r"^/wiki/pages/([^/]+)$", h_page, "client"), + ("GET", r"^/wiki/changes$", h_changes, "client"), + ("GET", r"^/wiki/pages/([^/]+)/versions$", h_versions, "client"), + ("GET", r"^/wiki/pages/([^/]+)/diff$", h_diff, "client"), ("GET", r"^/wiki/search$", h_search, "client"), ("POST", r"^/wiki/sync$", h_sync, "client"), ("GET", r"^/wiki/attachments/(\d+)$", h_attachment_get, "client"), diff --git a/src/sdlc_hub/modules/wiki/commands.py b/src/sdlc_hub/modules/wiki/commands.py index f01509a..6f12efa 100644 --- a/src/sdlc_hub/modules/wiki/commands.py +++ b/src/sdlc_hub/modules/wiki/commands.py @@ -4,7 +4,7 @@ import re from ...core import queue from ...core.errors import CommandError from ...core.modules import CommandHooks -from . import attachments, sync +from . import attachments, history, sync TYPES = {"wiki_sync", "wiki_fetch"} SPEC = {"wiki_sync": {"required": ["label", "root"], "optional": []}, @@ -113,10 +113,17 @@ def after_result(conn, cmd, status) -> None: sync.after_result(conn, cmd, status) +def deliver_params(conn, cmd, files_dir) -> dict: + """wiki_sync получает known_versions — коды уже известных хабу версий; в базе params остаются без них.""" + if cmd["type"] != "wiki_sync": + return cmd["params"] + return {**cmd["params"], "known_versions": history.known_versions(conn, cmd["params"]["label"])} + + # wiki_sync — sync.py (хуки before/abort/after только у него), wiki_fetch — attachments.py HOOKS = CommandHooks( types=frozenset(TYPES), read_types=frozenset(TYPES), validate=validate, check_batch=check_batch, - tail=lambda conn, cmds, project: None, pause=lambda conn, ctype: 0, deliver_params=lambda conn, cmd, files_dir: cmd["params"], + tail=lambda conn, cmds, project: None, pause=lambda conn, ctype: 0, deliver_params=deliver_params, temp_definer=None, temp_keys_used=lambda cmd: [], substitute=lambda ctype, params, mapping: params, drop_unresolved=lambda cmd, unresolved: unresolved, retry_params=lambda cmd: cmd["params"], check_confirmed=lambda conn, cmd: (False, None), on_claim=sync.on_claim, on_failed=on_failed, diff --git a/src/sdlc_hub/modules/wiki/convert.py b/src/sdlc_hub/modules/wiki/convert.py index 8bf57ed..62536c6 100644 --- a/src/sdlc_hub/modules/wiki/convert.py +++ b/src/sdlc_hub/modules/wiki/convert.py @@ -146,8 +146,15 @@ def _strip_editor_ui(soup) -> None: el.decompose() -def to_markdown(html: str, attachments: list[dict] | None, links: list[dict] | None) -> tuple[str, list[dict]]: - """HTML страницы → (Markdown, вложения страницы [{url, name}]).""" +_FILE_LINK = re.compile(r"\[📎 ((?:\\.|[^\]\\])*)\]\(attachment:@\d+\)") + + +def to_markdown(html: str, attachments: list[dict] | None, links: list[dict] | None, *, + file_links: bool = True) -> tuple[str, list[dict]]: + """HTML страницы → (Markdown, вложения страницы [{url, name}]). + + `file_links=False` (версии вики, #140): ссылка вложения становится текстом `📎 <имя>` без адреса, список файлов пуст; + ссылки на страницы остаются `page:<код>`.""" soup = BeautifulSoup(html, "html.parser") _strip_editor_ui(soup) _strip_private(soup) @@ -155,6 +162,8 @@ def to_markdown(html: str, attachments: list[dict] | None, links: list[dict] | N md = conv.convert_soup(soup).strip() + "\n" md = md.replace("attachment:@", "attachment:‌@") # авторское «attachment:@» больше не совпадает с шаблоном md = _MARK.sub(lambda m: "](attachment:@%s)" % m.group(1), md) # настоящие ссылки вложений + if not file_links: + return _FILE_LINK.sub(lambda m: "📎 " + m.group(1), md), [] return md, conv.files diff --git a/src/sdlc_hub/modules/wiki/history.py b/src/sdlc_hub/modules/wiki/history.py new file mode 100644 index 0000000..c4654ca --- /dev/null +++ b/src/sdlc_hub/modules/wiki/history.py @@ -0,0 +1,119 @@ +"""История правок вики (wiki-history, #140): время версий в UTC, выдача известных версий агенту, подсчёт «+/−».""" +import difflib +from datetime import datetime, timezone + + +def utc(value) -> str | None: + """ISO-8601 со смещением или `Z` (дробные секунды допустимы) → UTC в формате `db.now()`; иначе `None`: + без смещения, не строка или неразбираемое значение.""" + if not isinstance(value, str): + return None + try: + dt = datetime.fromisoformat(value) + except ValueError: + return None + if dt.tzinfo is None: + return None + return dt.astimezone(timezone.utc).replace(microsecond=0).isoformat() + + +def known_versions(conn, label: str) -> dict[str, list[str]]: + """Коды версий страниц текущей копии проекта по страницам, в порядке (created_at, code); без копии — {}.""" + out: dict[str, list[str]] = {} + for r in conn.execute( + "SELECT v.page_code, v.code FROM wiki_version v JOIN wiki_page p ON p.label = v.label AND p.code = v.page_code " + "WHERE v.label = ? ORDER BY v.page_code, v.created_at, v.code", (label,)): + out.setdefault(r["page_code"], []).append(r["code"]) + return out + + +def line_delta(old: str, new: str) -> tuple[int, int]: + """(добавлено, удалено) строк между двумя текстами; две служебные строки заголовка диффа не считаются.""" + lines = list(difflib.unified_diff(old.splitlines(), new.splitlines(), lineterm=""))[2:] + return (sum(1 for x in lines if x.startswith("+")), sum(1 for x in lines if x.startswith("-"))) + + +def history_at(conn, label: str) -> str | None: + """Время последней загрузки истории проекта или None, если её ещё не было.""" + row = conn.execute("SELECT history_at FROM wiki_history WHERE label = ?", (label,)).fetchone() + return row["history_at"] if row else None + + +# --- чтение истории (spec wiki «Лента правок», «Версии и дифф», wiki-history #140) --------------------------------- + +CHUNK = 20000 # символов в части диффа (как у `page`) + + +class NoVersions(Exception): + """У страницы нет ни одной версии в копии истории.""" + + +class NoVersion(Exception): + """Версии с таким кодом у страницы нет.""" + + +def _author(row) -> dict: + return {"login": row["author_login"], "name": row["author_name"]} + + +def feed(conn, label: str, since: str, until: str, author: str | None, limit: int) -> dict: + """Лента правок страниц текущей копии в [since, until]: по `at` ↓, затем `version` ↓. Фильтр автора — подстрока + без учёта регистра в имени или логине (casefold в Python: SQLite LOWER/LIKE знают только ASCII); срез — после фильтра. + Возвращает {truncated, changes}: truncated — правок больше limit.""" + rows = conn.execute( + "SELECT v.code, v.created_at, v.author_login, v.author_name, v.added, v.removed, v.page_code, p.title, p.path " + "FROM wiki_version v JOIN wiki_page p ON p.label = v.label AND p.code = v.page_code " + "WHERE v.label = ? AND v.created_at >= ? AND v.created_at <= ? " + "ORDER BY v.created_at DESC, v.code DESC", (label, since, until)).fetchall() + needle = author.casefold() if author else None + out = [] + for r in rows: + if needle is not None and not any(needle in (x or "").casefold() for x in (r["author_name"], r["author_login"])): + continue + out.append({"at": r["created_at"], "page_code": r["page_code"], "title": r["title"], "path": r["path"], + "version": r["code"], "author": _author(r), "added": r["added"], "removed": r["removed"]}) + return {"truncated": len(out) > limit, "changes": out[:limit]} + + +def page_versions(conn, label: str, code: str) -> list[dict]: + """Версии страницы, новые первыми.""" + rows = conn.execute("SELECT code, created_at, author_login, author_name, added, removed FROM wiki_version " + "WHERE label = ? AND page_code = ? ORDER BY created_at DESC, code DESC", (label, code)).fetchall() + return [{"version": r["code"], "at": r["created_at"], "author": _author(r), "added": r["added"], "removed": r["removed"]} + for r in rows] + + +def diff(conn, label: str, code: str, version: str | None, offset: int) -> dict: + """Дифф версии относительно предыдущей (первой — относительно пустого текста); version None — последняя. + Текст диффа режется по CHUNK символов, с offset; next_offset — где продолжить или None.""" + rows = conn.execute("SELECT code, created_at, author_login, author_name, markdown, added, removed FROM wiki_version " + "WHERE label = ? AND page_code = ? ORDER BY created_at, code", (label, code)).fetchall() + if not rows: + raise NoVersions() + if version is None: + idx = len(rows) - 1 + else: + idx = next((i for i, r in enumerate(rows) if r["code"] == version), None) + if idx is None: + raise NoVersion() + cur = rows[idx] + prev = rows[idx - 1] if idx > 0 else None + lines = difflib.unified_diff((prev["markdown"] if prev else "").splitlines(), cur["markdown"].splitlines(), + fromfile=prev["code"] if prev else "", tofile=cur["code"], n=3, lineterm="") + text = "".join(f"{x}\n" for x in lines) + end = offset + CHUNK + return {"version": cur["code"], "at": cur["created_at"], "author": _author(cur), "index": idx + 1, + "total": len(rows), "prev_version": prev["code"] if prev else None, "added": cur["added"], + "removed": cur["removed"], "offset": offset, "diff": text[offset:end], + "next_offset": end if end < len(text) else None} + + +def last_change(conn, label: str, code: str) -> dict: + """Последняя правка страницы: время, автор (None — если автора нет) и число версий.""" + n = conn.execute("SELECT COUNT(*) AS n FROM wiki_version WHERE label = ? AND page_code = ?", (label, code)).fetchone()["n"] + if n == 0: + return {"modified_at": None, "modified_by": None, "versions": 0} + r = conn.execute("SELECT created_at, author_login, author_name FROM wiki_version WHERE label = ? AND page_code = ? " + "ORDER BY created_at DESC, code DESC LIMIT 1", (label, code)).fetchone() + by = _author(r) if r["author_login"] is not None or r["author_name"] is not None else None + return {"modified_at": r["created_at"], "modified_by": by, "versions": n} diff --git a/src/sdlc_hub/modules/wiki/mcp.py b/src/sdlc_hub/modules/wiki/mcp.py index 9aab6f2..e6b38dd 100644 --- a/src/sdlc_hub/modules/wiki/mcp.py +++ b/src/sdlc_hub/modules/wiki/mcp.py @@ -4,6 +4,7 @@ import asyncio import re import time +from datetime import datetime from urllib.parse import quote from mcp.server.fastmcp import FastMCP @@ -58,6 +59,23 @@ def _walk(pages: list, code: str, depth: int) -> list: return [p for p in out if p["depth"] - base <= depth] +def _stamp(at) -> str: + """Время из базы (UTC) → «ДД.ММ ЧЧ:ММ» в местном поясе машины MCP; пусто — «—».""" + return "—" if not at else datetime.fromisoformat(at).astimezone().strftime("%d.%m %H:%M") + + +def _who(a) -> str: + """Автор правки: имя, иначе логин, иначе «—».""" + a = a or {} + return _line(a.get("name") or a.get("login") or None) + + +def _modified(d) -> str: + if not d.get("modified_at"): + return "изменено: история не загружена" + return f"изменено: {_stamp(d['modified_at'])} {_who(d.get('modified_by'))} (версий: {d.get('versions') or 0})" + + @mcp.tool() @core.guard @core.scoped(True) @@ -116,7 +134,7 @@ def page(code: str = "", title: str = "", offset: int = 0) -> str: text, offset = d.get("markdown") or "", max(int(offset), 0) atts = d.get("attachments") or [] head = [f"{_line(d['code'])} — {_line(d.get('title'))}", f"путь: {_line(d.get('path'))}", - f"Eva: {_line(d.get('url'))}", f"синхронизировано: {_line(d.get('synced_at'))}"] + f"Eva: {_line(d.get('url'))}", f"синхронизировано: {_line(d.get('synced_at'))}", _modified(d)] head += ([f"вложения ({len(atts)}):"] + [f" #{a['id']} {_line(a.get('name'))} ({_line(a.get('state'))})" for a in atts] if atts else ["вложений нет"]) if offset >= len(text) and (offset > 0 or not text): @@ -147,6 +165,71 @@ def _store(attachment_id: int, name, data: bytes): return core.store_copy(f"wiki-{attachment_id}", core.safe_name(base, str(attachment_id)), data) +def fmt_wiki_changes(data: dict, now: datetime) -> str: + """Правки по страницам: страницы — по времени последней правки (лента уже новые сверху), под страницей — её правки.""" + lines = [f"правки вики с {_stamp(data['since'])} по {_stamp(data['until'])}", + f"синхронизировано: {_stamp(data.get('synced_at'))}"] + if data.get("history_at") is None: + return "\n".join(lines + ["история ещё не загружена: нужен eva-agent v2 и sync"]) + events = data.get("changes") or [] + if not events: + return "\n".join(lines + ["за период правок нет"]) + groups: dict[str, list] = {} + for c in events: + groups.setdefault(c["page_code"], []).append(c) + for code, items in groups.items(): + lines.append(f"{code} {_line(items[0].get('title'))} — {_line(items[0].get('path'))}") + for c in items: + local = datetime.fromisoformat(c["at"]).astimezone() + when = local.strftime("%H:%M") if local.date() == now.date() else local.strftime("%d.%m %H:%M") + lines.append(f" {when} {_who(c.get('author'))}: +{c['added']} / −{c['removed']} ({_line(c['version'])})") + if data.get("truncated"): + lines.append(f"показаны последние {len(events)} правок, сузьте период") + return "\n".join(lines) + + +@mcp.tool() +@core.guard +@core.scoped(True) +def changes(hours: float | None = None, since: str = "", until: str = "", author: str = "") -> str: + """Правки страниц вики проекта за период: по умолчанию — с начала сегодняшних суток; hours — за последние N часов; + since/until — ISO-8601 (без смещения — местное время); author — подстрока имени или логина без учёта регистра. + Команд не ставит: читает копию хаба.""" + if hours is not None and since: + return "либо hours, либо since" + if hours is not None and hours <= 0: + return "hours — число больше 0" + if (refusal := _gate()) is not None: + return refusal + now = datetime.now().astimezone() + data = core.client().get("/wiki/changes", project=core.PROJECT, since=core.local_if_naive(core.since_of(hours, since, now)), + until=core.local_if_naive(until), author=author) + return fmt_wiki_changes(data, now) + + +@mcp.tool() +@core.guard +@core.scoped(True) +def diff(code: str, version: str = "", offset: int = 0) -> str: + """Дифф правки страницы вики: шапка (код, название, путь, версия «N из M», автор, время, «+A / −R», предыдущая + версия) и unified diff с offset, до 20 000 символов за вызов; если дифф длиннее, последняя строка — «продолжение: + offset=». Без version — последняя правка. Команд не ставит.""" + if (refusal := _gate()) is not None: + return refusal + params = {"project": core.PROJECT, "offset": max(int(offset), 0)} + if version: + params["version"] = version.strip() + d = core.client().get(f"/wiki/pages/{quote(code.strip(), safe='')}/diff", **params) + prev = d.get("prev_version") + head = [f"{_line(d['code'])} — {_line(d.get('title'))}", f"путь: {_line(d.get('path'))}", + f"версия {d['index']} из {d['total']}: {_line(d['version'])}", f"автор: {_who(d.get('author'))}", + f"время: {_stamp(d.get('at'))}", f"+{d['added']} / −{d['removed']}", + f"предыдущая: {_line(prev) if prev else 'нет (первая версия)'}"] + out = "\n".join(head + ["", d.get("diff") or ""]) + nxt = d.get("next_offset") + return out + f"\nпродолжение: offset={nxt}" if nxt is not None else out + + @mcp.tool() @core.guard @core.scoped(True) @@ -180,6 +263,7 @@ def _summary(client) -> str: lines.append(f"ошибка синхронизации: {_line(s['error'])}") b = s.get("batch") lines.append(f"пакет: #{b['id']} {b['status']}" if b else "пакета в работе нет") + lines.append(f"история: {s['history_at']}" if s.get("history_at") else "история не загружена") return "\n".join(lines) @@ -187,20 +271,21 @@ def _summary(client) -> str: @core.guard @core.scoped(True) async def sync(wait: bool = False) -> str: - """Синхронизировать вики проекта с Eva: ставит пакет (или сообщает о текущем) и возвращает его id. wait=true — - дождаться итога (до 300 с).""" + """Синхронизировать вики проекта с Eva: ставит пакет и отвечает «поставлено: пакет N» или «уже идёт: пакет N». + wait=true — ждёт итога до 300 с: «ок: синхронизировано ДД.ММ ЧЧ:ММ», «ошибка: <текст>» или «не дождался за 300 с: + пакет N». Данных копии в ответе нет — их читают page, changes, status.""" refusal = await asyncio.to_thread(_gate, False) if refusal is not None: return refusal client = core.client() try: batch_id = (await asyncio.to_thread(client.post, "/wiki/sync", {"project": core.PROJECT, "author": core.AUTHOR}))["batch_id"] - head = f"синхронизация поставлена: пакет #{batch_id}" + head = f"поставлено: пакет {batch_id}" except HubError as e: if e.error != "sync_in_progress" or not isinstance(e.detail, dict): raise batch_id = e.detail["batch_id"] - head = f"синхронизация уже идёт: пакет #{batch_id}" + head = f"уже идёт: пакет {batch_id}" if not wait: return head deadline = time.monotonic() + SYNC_WAIT @@ -209,11 +294,15 @@ async def sync(wait: bool = False) -> str: if b["status"] in ("done", "cancelled") or time.monotonic() >= deadline: break await asyncio.sleep(POLL_SECONDS) - lines = [f"пакет #{batch_id}: {b['status']}"] - lines += [f" #{c['id']} {c['type']} {c['status']}" + (f": {_line(c['error'])}" if c.get("error") else "") for c in b.get("commands") or []] if b["status"] not in ("done", "cancelled"): - return "\n".join(lines + ["(таймаут ожидания, пакет ещё выполняется)"]) - return "\n".join(lines + [await asyncio.to_thread(_summary, client)]) + return f"не дождался за {SYNC_WAIT:g} с: пакет {batch_id}" + if b["status"] == "cancelled": + return f"ошибка: пакет {batch_id} отменён" + failed = next((c for c in b.get("commands") or [] if c.get("status") == "failed"), None) + if failed is not None: + return f"ошибка: {_line(failed.get('error'))}" + st = await asyncio.to_thread(client.get, "/wiki/status", project=core.PROJECT) + return f"ок: синхронизировано {_stamp(st.get('synced_at'))}" @mcp.tool() diff --git a/src/sdlc_hub/modules/wiki/projects.py b/src/sdlc_hub/modules/wiki/projects.py index 7379643..13e732b 100644 --- a/src/sdlc_hub/modules/wiki/projects.py +++ b/src/sdlc_hub/modules/wiki/projects.py @@ -53,6 +53,8 @@ def _drop_copy(conn, label: str) -> None: conn.execute("DELETE FROM wiki_page_fts WHERE label = ?", (label,)) conn.execute("DELETE FROM wiki_page WHERE label = ?", (label,)) conn.execute("DELETE FROM wiki_attachment WHERE label = ?", (label,)) + conn.execute("DELETE FROM wiki_version WHERE label = ?", (label,)) + conn.execute("DELETE FROM wiki_history WHERE label = ?", (label,)) conn.execute("DELETE FROM wiki_project WHERE label = ?", (label,)) try: base = paths.project_dir(conn, label) diff --git a/src/sdlc_hub/modules/wiki/schema.py b/src/sdlc_hub/modules/wiki/schema.py index 0deea3c..d47f495 100644 --- a/src/sdlc_hub/modules/wiki/schema.py +++ b/src/sdlc_hub/modules/wiki/schema.py @@ -43,4 +43,24 @@ CREATE TABLE IF NOT EXISTS wiki_sync_stage ( payload TEXT NOT NULL, PRIMARY KEY (command_id, code) ); +CREATE TABLE IF NOT EXISTS wiki_version ( + label TEXT NOT NULL, + code TEXT NOT NULL, + page_code TEXT NOT NULL, + created_at TEXT NOT NULL, + author_login TEXT, + author_name TEXT, + markdown TEXT NOT NULL, + added INTEGER NOT NULL DEFAULT 0, + removed INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (label, code)); +CREATE INDEX IF NOT EXISTS wiki_version_page ON wiki_version(label, page_code, created_at); +CREATE INDEX IF NOT EXISTS wiki_version_time ON wiki_version(label, created_at); +CREATE TABLE IF NOT EXISTS wiki_version_stage ( + command_id INTEGER NOT NULL, + code TEXT NOT NULL, + page_code TEXT NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY (command_id, code)); +CREATE TABLE IF NOT EXISTS wiki_history (label TEXT PRIMARY KEY REFERENCES project(label), history_at TEXT NOT NULL); """ diff --git a/src/sdlc_hub/modules/wiki/stage.py b/src/sdlc_hub/modules/wiki/stage.py index b62a10e..39cdb11 100644 --- a/src/sdlc_hub/modules/wiki/stage.py +++ b/src/sdlc_hub/modules/wiki/stage.py @@ -3,10 +3,12 @@ import json from ...core import db, queue from ...core.http import ApiError -from . import convert +from . import convert, history MAX_PAGES = 50 MAX_STAGE = 20000 # страниц одной команды в стадии (между пачками) +MAX_VERSIONS = 50 # версий в одной пачке (#140) +MAX_VERSION_STAGE = 20000 # версий одной команды в стадии (между пачками) LIMITS = {"code": 200, "title": 1000, "url": 4000, "parent_code": 200, "eva_id": 200} @@ -35,6 +37,20 @@ def _clean(v): return v +def _refs(attachments, links, fail) -> tuple[list, list]: + """Вложения и ссылки страницы или версии → поля для стадии; `fail(поле)` строит ошибку формы.""" + attachments, links = attachments or [], links or [] + if not isinstance(attachments, list) or not all( + isinstance(a, dict) and isinstance(a.get("ref"), str) and isinstance(a.get("url"), str) + and isinstance(a.get("name") or "", str) for a in attachments): + raise fail("attachments") + if not isinstance(links, list) or not all( + isinstance(l, dict) and isinstance(l.get("ref"), str) and isinstance(l.get("code"), str) for l in links): + raise fail("links") + return ([{"ref": a["ref"], "url": a["url"], "name": a.get("name") or ""} for a in attachments], + [{"ref": l["ref"], "code": l["code"]} for l in links]) + + def _check(i, page) -> dict: """Форма одной страницы → поля для стадии (без html); ApiError 422 `<код>: <поле>`.""" if not isinstance(page, dict): @@ -53,18 +69,10 @@ def _check(i, page) -> dict: eva_id = page.get("eva_id") if eva_id is not None and (not isinstance(eva_id, str) or len(eva_id) > LIMITS["eva_id"]): raise _bad(code, "eva_id") - attachments, links = page.get("attachments") or [], page.get("links") or [] - if not isinstance(attachments, list) or not all( - isinstance(a, dict) and isinstance(a.get("ref"), str) and isinstance(a.get("url"), str) - and isinstance(a.get("name") or "", str) for a in attachments): - raise _bad(code, "attachments") - if not isinstance(links, list) or not all( - isinstance(l, dict) and isinstance(l.get("ref"), str) and isinstance(l.get("code"), str) for l in links): - raise _bad(code, "links") + attachments, links = _refs(page.get("attachments"), page.get("links"), lambda f: _bad(code, f)) return {"code": code, "eva_id": eva_id, "title": title, "parent_code": _text(page, "parent_code", code, False, True), "orderno": orderno, "url": _text(page, "url", code, False, True), "html": html, - "attachments": [{"ref": a["ref"], "url": a["url"], "name": a.get("name") or ""} for a in attachments], - "links": [{"ref": l["ref"], "code": l["code"]} for l in links]} + "attachments": attachments, "links": links} def accept(conn, command_id, pages) -> dict: @@ -97,3 +105,69 @@ def accept(conn, command_id, pages) -> dict: raise ApiError(422, "bad_pages", f"pages: в команде больше {MAX_STAGE} страниц") conn.executemany("INSERT OR REPLACE INTO wiki_sync_stage(command_id, code, payload) VALUES (?, ?, ?)", rows) return {"accepted": len(rows)} + + +def _vbad(prefix, field): + return ApiError(422, "bad_versions", f"{prefix}: {field}") + + +def _version(i, v) -> dict: + """Форма одной версии → поля для стадии (без markdown); ApiError 422 `bad_versions`: `версия : …` до кода, + `<код>: <поле>` после (#140).""" + if not isinstance(v, dict): + raise ApiError(422, "bad_versions", f"версия {i}: не объект") + v = _clean(v) + code = v.get("code") + if not isinstance(code, str) or not code.strip() or len(code) > LIMITS["code"]: + raise ApiError(422, "bad_versions", f"версия {i}: code") + page_code = v.get("page_code") + if not isinstance(page_code, str) or not page_code.strip() or len(page_code) > LIMITS["code"]: + raise _vbad(code, "page_code") + created_at = history.utc(v.get("created_at")) + if created_at is None: + raise _vbad(code, "created_at") + author = v.get("author") + if not isinstance(author, dict): + raise _vbad(code, "author") + login, name = author.get("login"), author.get("name") + if not all(x is None or isinstance(x, str) for x in (login, name)): + raise _vbad(code, "author") + html = v.get("html") + if not isinstance(html, str): + raise _vbad(code, "html") + attachments, links = _refs(v.get("attachments"), v.get("links"), lambda f: _vbad(code, f)) + return {"code": code, "page_code": page_code, "created_at": created_at, "author_login": login, + "author_name": name, "html": html, "attachments": attachments, "links": links} + + +def accept_versions(conn, command_id, versions) -> dict: + """Пачка версий команды wiki_sync → стадия версий (#140). Перевод HTML идёт до записи, строки — короткой транзакцией.""" + if isinstance(command_id, bool) or not isinstance(command_id, int): + raise ApiError(422, "bad_request", "command_id — целое") + if not isinstance(versions, list) or not versions or len(versions) > MAX_VERSIONS: + raise ApiError(422, "bad_versions", f"versions — список от 1 до {MAX_VERSIONS} версий") + cmd = queue.get_command(conn, command_id) + if cmd is None: + raise ApiError(404, "not_found", f"command {command_id}") + if cmd["type"] != "wiki_sync" or cmd["status"] != "claimed": + raise ApiError(409, "not_claimed", f"команда {command_id} не выдана агенту как wiki_sync") + checked = [_version(i, v) for i, v in enumerate(versions, 1)] + rows = [] + for v in checked: + try: + md, _ = convert.to_markdown(v["html"], v["attachments"], v["links"], file_links=False) + except Exception as e: # noqa: BLE001 недоверенный HTML: сбой разбора — ошибка версии, не сервера + raise _vbad(v["code"], f"html ({type(e).__name__})") from None + payload = {k: v[k] for k in ("page_code", "code", "created_at", "author_login", "author_name")} + payload["markdown"] = md + rows.append((command_id, v["code"], v["page_code"], json.dumps(payload, ensure_ascii=False))) + with db.transaction(conn): + cur = conn.execute("SELECT status FROM command WHERE id = ?", (command_id,)).fetchone() + if cur is None or cur["status"] != "claimed": # состояние могло смениться, пока шёл перевод + raise ApiError(409, "not_claimed", f"команда {command_id} уже не в работе") + have = conn.execute("SELECT COUNT(*) AS n FROM wiki_version_stage WHERE command_id = ?", (command_id,)).fetchone()["n"] + if have + len(rows) > MAX_VERSION_STAGE: + raise ApiError(422, "bad_versions", f"versions: в команде больше {MAX_VERSION_STAGE} версий") + conn.executemany("INSERT OR REPLACE INTO wiki_version_stage(command_id, code, page_code, payload) " + "VALUES (?, ?, ?, ?)", rows) + return {"accepted": len(rows)} diff --git a/src/sdlc_hub/modules/wiki/sync.py b/src/sdlc_hub/modules/wiki/sync.py index 22b1372..db880a6 100644 --- a/src/sdlc_hub/modules/wiki/sync.py +++ b/src/sdlc_hub/modules/wiki/sync.py @@ -14,7 +14,7 @@ from pathlib import Path from ...core import db from ...core.paths import secure_dir -from . import convert, paths, search +from . import convert, history, paths, search log = logging.getLogger(__name__) @@ -42,6 +42,9 @@ class Prepared: old_dir: Path | None = None # прежняя копия после подмены: удаляется в after_result committed: bool = False root_title: str | None = None + with_history: bool = False # агент прислал versions: историю обновить + versions: list = field(default_factory=list) # новые строки wiki_version (с added/removed) + recount: list = field(default_factory=list) # (added, removed, code) известных версий _PREPARED: dict[int, Prepared] = {} @@ -180,6 +183,36 @@ def _allocate_ids(conn, label, pages) -> tuple[dict, list, list]: return ids, new_ids, attach +def _plan_versions(conn, label: str, codes, staged) -> tuple[list[dict], list[tuple[int, int, str]]]: + """Новые версии из стадии и пересчёт «+/−» (R-6: вне транзакции). Берутся версии страниц из codes с кодом, которого + у проекта ещё нет; по затронутой странице версии идут по (created_at, code), предыдущий текст первой — пустой. + Известная версия, чей предшественник новый, пересчитывается.""" + marks = json.dumps(list(codes)) + known = {r["code"] for r in conn.execute( + "SELECT code FROM wiki_version WHERE label = ? AND page_code IN (SELECT value FROM json_each(?))", (label, marks))} + fresh = [s for s in staged if s["page_code"] in set(codes) and s["code"] not in known] + by_page: dict[str, list[dict]] = {} + for s in fresh: + by_page.setdefault(s["page_code"], []).append(dict(s, new=True)) + new_rows, recount = [], [] + for page_code, items in by_page.items(): + for r in conn.execute("SELECT code, created_at, markdown, added, removed FROM wiki_version " + "WHERE label = ? AND page_code = ?", (label, page_code)): + items.append(dict(r, new=False)) + items.sort(key=lambda x: (x["created_at"], x["code"])) + prev, prev_new = "", False + for it in items: + if it["new"] or prev_new: + added, removed = history.line_delta(prev, it["markdown"]) + if it["new"]: + new_rows.append({**{k: it[k] for k in ("code", "page_code", "created_at", "author_login", "author_name", + "markdown")}, "added": added, "removed": removed}) + elif (added, removed) != (it["added"], it["removed"]): + recount.append((added, removed, it["code"])) + prev, prev_new = it["markdown"], it["new"] + return new_rows, recount + + def _abandon(conn, p: Prepared) -> None: """Подготовка не пригодилась: каталог .new и добавленные ею вложения убираются.""" if p.new_dir is not None: @@ -219,6 +252,16 @@ def prepare(conn, cmd: dict, result: dict | None) -> Prepared: if c not in stage: raise SyncFailed(f"нет страницы {c}") pages = [stage[c] for c in codes] + if "versions" in (result or {}): + m = result["versions"] + if isinstance(m, bool) or not isinstance(m, int) or m < 0: + raise SyncFailed("versions — неотрицательное целое") + staged = [json.loads(r["payload"]) for r in conn.execute( + "SELECT payload FROM wiki_version_stage WHERE command_id = ?", (cmd["id"],))] + if len(staged) < m: + raise SyncFailed(f"нет версий: пришло {len(staged)} из {m}") + p.versions, p.recount = _plan_versions(conn, label, codes, staged) + p.with_history = True cur, _, old = _siblings(conn, label, cmd["id"]) secure_dir(p.new_dir.parent) shutil.rmtree(p.new_dir, ignore_errors=True) @@ -320,6 +363,12 @@ def _swap(cur: Path, new: Path, old: Path) -> bool: return had +def _clear_stage(conn, cmd_id: int) -> None: + """Стадия страниц и версий команды.""" + conn.execute("DELETE FROM wiki_sync_stage WHERE command_id = ?", (cmd_id,)) + conn.execute("DELETE FROM wiki_version_stage WHERE command_id = ?", (cmd_id,)) + + def _fail(conn, cmd, error) -> None: """Команда уже записана как done, но копия не применилась: failed + sync_error.""" conn.execute("UPDATE command SET status = 'failed', error = ? WHERE id = ?", (error[:1500], cmd["id"])) @@ -345,7 +394,7 @@ def on_result(conn, cmd, status, result, jira_calls, rate_limited, now, files_di if not p.discarded: out = dict(result or {}, discarded="корень сменился") conn.execute("UPDATE command SET result = ? WHERE id = ?", (json.dumps(out, ensure_ascii=False), cmd["id"])) - conn.execute("DELETE FROM wiki_sync_stage WHERE command_id = ?", (cmd["id"],)) + _clear_stage(conn, cmd["id"]) conn.execute("UPDATE wiki_project SET failed_at = ? WHERE label = ?", (now, p.label)) return try: @@ -372,7 +421,18 @@ def _commit(conn, p: Prepared, now: str) -> None: conn.execute("DELETE FROM wiki_attachment WHERE id = ?", (r["id"],)) conn.execute("UPDATE wiki_project SET synced_at = ?, pages_count = ?, root_title = ?, sync_error = NULL, failed_at = NULL " "WHERE label = ?", (now, len(p.rows), p.root_title, p.label)) - conn.execute("DELETE FROM wiki_sync_stage WHERE command_id = ?", (p.cmd_id,)) + if p.with_history: + conn.execute("DELETE FROM wiki_version WHERE label = ? AND page_code NOT IN (SELECT value FROM json_each(?))", + (p.label, json.dumps([r["code"] for r in p.rows]))) + conn.executemany( + "INSERT OR IGNORE INTO wiki_version(label, code, page_code, created_at, author_login, author_name, markdown, " + "added, removed) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + [(p.label, v["code"], v["page_code"], v["created_at"], v["author_login"], v["author_name"], v["markdown"], + v["added"], v["removed"]) for v in p.versions]) + conn.executemany("UPDATE wiki_version SET added = ?, removed = ? WHERE label = ? AND code = ?", + [(a, r, p.label, code) for a, r, code in p.recount]) + conn.execute("INSERT OR REPLACE INTO wiki_history(label, history_at) VALUES (?, ?)", (p.label, now)) + _clear_stage(conn, p.cmd_id) cur, new, old = _siblings(conn, p.label, p.cmd_id) if _swap(cur, new, old): # последним: сбой откатывает строки (savepoint) p.old_dir = old @@ -382,7 +442,7 @@ def on_failed_or_reported_failed(conn, cmd: dict, error: str | None) -> None: """Отчёт failed или предел выдач: sync_error и failed_at проекта (если корень команды ещё текущий), стадия очищена.""" if cmd["type"] != "wiki_sync": return - conn.execute("DELETE FROM wiki_sync_stage WHERE command_id = ?", (cmd["id"],)) + _clear_stage(conn, cmd["id"]) conn.execute("UPDATE wiki_project SET sync_error = ?, failed_at = ? WHERE label = ? AND root_code = ?", ((error or "")[:1500], db.now(), cmd["params"]["label"], cmd["params"]["root"])) @@ -396,7 +456,7 @@ def on_failed(conn, cmd, now) -> None: def on_claim(conn, cmd, now, redelivery) -> None: """Повторная выдача: пачки прошлой выдачи не нужны, агент пришлёт заново.""" if cmd["type"] == "wiki_sync" and redelivery: - conn.execute("DELETE FROM wiki_sync_stage WHERE command_id = ?", (cmd["id"],)) + _clear_stage(conn, cmd["id"]) def after_result(conn, cmd, status) -> None: @@ -443,8 +503,9 @@ def finish_pending_swaps(root: Path) -> None: def housekeeping(conn) -> None: """Стадия команд, которые уже не в работе (отмена, терминальный статус), очищается; отменённая синхронизация сдвигает failed_at проекта, чтобы автосинхронизация не ставила пакеты каждый тик.""" - conn.execute("DELETE FROM wiki_sync_stage WHERE command_id NOT IN " - "(SELECT id FROM command WHERE status IN ('queued', 'claimed'))") + live = "(SELECT id FROM command WHERE status IN ('queued', 'claimed'))" + conn.execute(f"DELETE FROM wiki_sync_stage WHERE command_id NOT IN {live}") + conn.execute(f"DELETE FROM wiki_version_stage WHERE command_id NOT IN {live}") for r in conn.execute("SELECT label, synced_at, failed_at FROM wiki_project").fetchall(): last = max((t for t in (r["synced_at"], r["failed_at"]) if t), default="") c = conn.execute("SELECT MAX(finished_at) AS t FROM command WHERE type = 'wiki_sync' AND status = 'cancelled' " diff --git a/tests/fixtures/baseline/mcp_wiki_tools.expected.json b/tests/fixtures/baseline/mcp_wiki_tools.expected.json index 8c3c1de..209e165 100644 --- a/tests/fixtures/baseline/mcp_wiki_tools.expected.json +++ b/tests/fixtures/baseline/mcp_wiki_tools.expected.json @@ -66,6 +66,70 @@ }, "name": "search" }, + { + "description": "Правки страниц вики проекта за период: по умолчанию — с начала сегодняшних суток; hours — за последние N часов;\nsince/until — ISO-8601 (без смещения — местное время); author — подстрока имени или логина без учёта регистра.\nКоманд не ставит: читает копию хаба.", + "inputSchema": { + "properties": { + "author": { + "default": "", + "title": "Author", + "type": "string" + }, + "hours": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Hours" + }, + "since": { + "default": "", + "title": "Since", + "type": "string" + }, + "until": { + "default": "", + "title": "Until", + "type": "string" + } + }, + "title": "changesArguments", + "type": "object" + }, + "name": "changes" + }, + { + "description": "Дифф правки страницы вики: шапка (код, название, путь, версия «N из M», автор, время, «+A / −R», предыдущая\nверсия) и unified diff с offset, до 20 000 символов за вызов; если дифф длиннее, последняя строка — «продолжение:\noffset=». Без version — последняя правка. Команд не ставит.", + "inputSchema": { + "properties": { + "code": { + "title": "Code", + "type": "string" + }, + "offset": { + "default": 0, + "title": "Offset", + "type": "integer" + }, + "version": { + "default": "", + "title": "Version", + "type": "string" + } + }, + "required": [ + "code" + ], + "title": "diffArguments", + "type": "object" + }, + "name": "diff" + }, { "description": "Скачать вложение страницы вики и вернуть путь к локальной копии (читайте файл по этому пути). id — номер из\nответа page. Хаб просит агента скачать файл; ожидание до 60 с, не дождался — вызовите инструмент ещё раз: вторая\nкоманда не поставится.", "inputSchema": { @@ -84,7 +148,7 @@ "name": "attachment" }, { - "description": "Синхронизировать вики проекта с Eva: ставит пакет (или сообщает о текущем) и возвращает его id. wait=true —\nдождаться итога (до 300 с).", + "description": "Синхронизировать вики проекта с Eva: ставит пакет и отвечает «поставлено: пакет N» или «уже идёт: пакет N».\nwait=true — ждёт итога до 300 с: «ок: синхронизировано ДД.ММ ЧЧ:ММ», «ошибка: <текст>» или «не дождался за 300 с:\nпакет N». Данных копии в ответе нет — их читают page, changes, status.", "inputSchema": { "properties": { "wait": { diff --git a/tests/fixtures/baseline/routes.expected.json b/tests/fixtures/baseline/routes.expected.json index e2e48ac..71f3a26 100644 --- a/tests/fixtures/baseline/routes.expected.json +++ b/tests/fixtures/baseline/routes.expected.json @@ -77,6 +77,12 @@ "agent", "h_agent_pages" ], + [ + "POST", + "^/agent/wiki/versions$", + "agent", + "h_agent_versions" + ], [ "GET", "^/agents$", @@ -245,12 +251,30 @@ "client", "h_attachment_fetch" ], + [ + "GET", + "^/wiki/changes$", + "client", + "h_changes" + ], [ "GET", "^/wiki/pages/([^/]+)$", "client", "h_page" ], + [ + "GET", + "^/wiki/pages/([^/]+)/diff$", + "client", + "h_diff" + ], + [ + "GET", + "^/wiki/pages/([^/]+)/versions$", + "client", + "h_versions" + ], [ "GET", "^/wiki/search$", diff --git a/tests/fixtures/baseline/schema.expected.json b/tests/fixtures/baseline/schema.expected.json index ca2c5fe..b829f29 100644 --- a/tests/fixtures/baseline/schema.expected.json +++ b/tests/fixtures/baseline/schema.expected.json @@ -35,6 +35,16 @@ "ix_jira_sprint_board", "CREATE INDEX ix_jira_sprint_board ON jira_sprint(board_id)" ], + [ + "index", + "wiki_version_page", + "CREATE INDEX wiki_version_page ON wiki_version(label, page_code, created_at)" + ], + [ + "index", + "wiki_version_time", + "CREATE INDEX wiki_version_time ON wiki_version(label, created_at)" + ], [ "table", "agent_status", @@ -125,6 +135,11 @@ "wiki_attachment", "CREATE TABLE wiki_attachment (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n label TEXT NOT NULL,\n page_code TEXT NOT NULL,\n url TEXT NOT NULL,\n name TEXT,\n state TEXT NOT NULL DEFAULT 'none',\n file TEXT,\n size INTEGER,\n error TEXT,\n fetched_at TEXT,\n UNIQUE (label, page_code, url)\n)" ], + [ + "table", + "wiki_history", + "CREATE TABLE wiki_history (label TEXT PRIMARY KEY REFERENCES project(label), history_at TEXT NOT NULL)" + ], [ "table", "wiki_page", @@ -139,6 +154,16 @@ "table", "wiki_sync_stage", "CREATE TABLE wiki_sync_stage (\n command_id INTEGER NOT NULL,\n code TEXT NOT NULL,\n payload TEXT NOT NULL,\n PRIMARY KEY (command_id, code)\n)" + ], + [ + "table", + "wiki_version", + "CREATE TABLE wiki_version (\n label TEXT NOT NULL,\n code TEXT NOT NULL,\n page_code TEXT NOT NULL,\n created_at TEXT NOT NULL,\n author_login TEXT,\n author_name TEXT,\n markdown TEXT NOT NULL,\n added INTEGER NOT NULL DEFAULT 0,\n removed INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (label, code))" + ], + [ + "table", + "wiki_version_stage", + "CREATE TABLE wiki_version_stage (\n command_id INTEGER NOT NULL,\n code TEXT NOT NULL,\n page_code TEXT NOT NULL,\n payload TEXT NOT NULL,\n PRIMARY KEY (command_id, code))" ] ], "schema_version": 9 diff --git a/tests/test_wiki_convert.py b/tests/test_wiki_convert.py index cbc05de..175fa88 100644 --- a/tests/test_wiki_convert.py +++ b/tests/test_wiki_convert.py @@ -347,3 +347,13 @@ def test_empty_external_link_dropped(): """Финальное ревью M-1: пустая внешняя ссылка без вложения внутри не выводится адресом.""" md, _ = convert.to_markdown('

abc

', [], []) assert md == "abc\n" + + +def test_version_markdown_attachment_as_text(): + md, files = convert.to_markdown(_html("page_file.html"), [THUMB, FILE], [], file_links=False) + assert "📎 model.xlsx" in md and "attachment:" not in md and files == [] + + +def test_version_markdown_page_links_kept(): + md, _ = convert.to_markdown('

т

', [], [LINK], file_links=False) + assert md == "[т](page:DOC-1)\n" diff --git a/tests/test_wiki_history.py b/tests/test_wiki_history.py new file mode 100644 index 0000000..670b7f8 --- /dev/null +++ b/tests/test_wiki_history.py @@ -0,0 +1,338 @@ +"""История правок вики (wiki-history, #140): приём версий `POST /agent/wiki/versions`, время `history.utc`.""" +import json + +import pytest + +from sdlc_hub.core.client import HubError +from sdlc_hub.modules.wiki import history + +from test_wiki_sync import CODES, TREE, api, first_sync, page # noqa: F401 — api — фикстура + +SOSNOV = {"login": "sosnov-dvi@rshbeco.ru", "name": "Дмитрий Соснов"} +MOLCH = {"login": "molchanov-iv@rshbeco.ru", "name": "Илья Молчанов"} + + +def version(page_code, code, created_at, html="

текст

", author=SOSNOV, attachments=None, links=None): + return {"page_code": page_code, "code": code, "created_at": created_at, "author": author, "html": html, + "attachments": attachments or [], "links": links or []} + + +def stage_rows(api, cmd_id): + return api.rows("SELECT code, page_code, payload FROM wiki_version_stage WHERE command_id = ? ORDER BY code", cmd_id) + + +def test_versions_staged(api): # перевод и форма payload + cmd = api.claim() + out = api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [ + version("DOC-3", "DOCH-1", "2026-10-27T23:48:02+03:00", "

статус новый

")]}) + assert out == {"accepted": 1} + (row,) = stage_rows(api, cmd["id"]) + p = json.loads(row["payload"]) + assert p == {"page_code": "DOC-3", "code": "DOCH-1", "created_at": "2026-10-27T20:48:02+00:00", + "author_login": "sosnov-dvi@rshbeco.ru", "author_name": "Дмитрий Соснов", "markdown": "статус **новый**\n"} + + +def test_versions_resend_same_batch(api): # Review Focus 2: повтор пачки — одна строка на код + cmd = api.claim() + batch = [version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z"), version("DOC-3", "DOCH-2", "2026-10-27T11:00:00Z")] + for _ in range(2): + api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": batch}) + assert [r["code"] for r in stage_rows(api, cmd["id"])] == ["DOCH-1", "DOCH-2"] + + +@pytest.mark.parametrize("raw,stored", [("2026-10-27T23:48:02.123456+03:00", "2026-10-27T20:48:02+00:00"), + ("2026-10-27T20:48:02Z", "2026-10-27T20:48:02+00:00"), + ("2026-10-27T23:48:02+0300", "2026-10-27T20:48:02+00:00")]) +def test_versions_created_at_forms(api, raw, stored): # Review Focus 4 → created_at в payload == stored + cmd = api.claim() + api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [version("DOC-3", "DOCH-1", raw)]}) + (row,) = stage_rows(api, cmd["id"]) + assert json.loads(row["payload"])["created_at"] == stored + + +def test_versions_created_at_without_offset_422(api): + cmd = api.claim() + with pytest.raises(HubError) as e: + api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [ + version("DOC-3", "DOCH-1", "2026-10-27T23:48:02")]}) + assert e.value.status == 422 and e.value.error == "bad_versions" and e.value.detail == "DOCH-1: created_at" + + +def test_versions_author_nulls(api): + cmd = api.claim() + api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [ + version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z", author={"login": None, "name": None})]}) + (row,) = stage_rows(api, cmd["id"]) + p = json.loads(row["payload"]) + assert p["author_login"] is None and p["author_name"] is None + + +@pytest.mark.parametrize("field,value", [("page_code", ""), ("page_code", 5), ("html", None), ("author", "Соснов"), + ("author", {"login": 1, "name": "x"}), ("attachments", [{"ref": 1}]), + ("links", "x")]) +def test_versions_bad_field(api, field, value): # 422 bad_versions, detail == f"DOCH-1: {field}" + cmd = api.claim() + bad = version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z") | {field: value} + with pytest.raises(HubError) as e: + api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [bad]}) + assert e.value.status == 422 and e.value.error == "bad_versions" and e.value.detail == f"DOCH-1: {field}" + assert api.rows("SELECT COUNT(*) AS n FROM wiki_version_stage")[0]["n"] == 0 + + +def test_versions_bad_batch(api): # [] и 51 версия → 422 bad_versions; command_id "1" → 422 bad_request; + # версия-не объект → detail "версия 1: не объект"; без code → "версия 1: code" + cmd = api.claim() + with pytest.raises(HubError) as e: + api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": []}) + assert e.value.status == 422 and e.value.error == "bad_versions" + many = [version("DOC-3", f"DOCH-{i}", "2026-10-27T10:00:00Z") for i in range(51)] + with pytest.raises(HubError) as e: + api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": many}) + assert e.value.status == 422 and e.value.error == "bad_versions" + with pytest.raises(HubError) as e: + api.wiki.post("/agent/wiki/versions", {"command_id": "1", "versions": [version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z")]}) + assert e.value.status == 422 and e.value.error == "bad_request" + with pytest.raises(HubError) as e: + api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": ["x"]}) + assert e.value.status == 422 and e.value.detail == "версия 1: не объект" + no_code = {k: v for k, v in version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z").items() if k != "code"} + with pytest.raises(HubError) as e: + api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [no_code]}) + assert e.value.status == 422 and e.value.detail == "версия 1: code" + + +def test_versions_not_claimed(api): # команда queued → 409 not_claimed; несуществующая → 404 + (row,) = api.rows("SELECT id FROM command WHERE type = 'wiki_sync'") + with pytest.raises(HubError) as e: + api.wiki.post("/agent/wiki/versions", {"command_id": row["id"], "versions": [version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z")]}) + assert e.value.status == 409 and e.value.error == "not_claimed" + with pytest.raises(HubError) as e: + api.wiki.post("/agent/wiki/versions", {"command_id": 999999, "versions": [version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z")]}) + assert e.value.status == 404 + + +def test_versions_wrong_agent(api): # токеном агента jira (api.jira) → 401 + cmd = api.claim() + with pytest.raises(HubError) as e: + api.jira.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z")]}) + assert e.value.status == 401 + + +@pytest.mark.parametrize("raw,expected", [ + ("2026-10-27T23:48:02.123456+03:00", "2026-10-27T20:48:02+00:00"), + ("2026-10-27T20:48:02Z", "2026-10-27T20:48:02+00:00"), + ("2026-10-27T23:48:02+0300", "2026-10-27T20:48:02+00:00"), + ("2026-10-27T23:48:02", None), # без смещения + ("не дата", None), + (None, None), + (1234, None), +]) +def test_history_utc(raw, expected): + assert history.utc(raw) == expected + + +# --- Task 2: known_versions и запись версий в синхронизации --------------------------------------------------- + +import sqlite3 # noqa: E402 + +from sdlc_hub.modules.wiki import convert, stage, sync # noqa: E402 + + +def run_history(api, pages, versions, codes=None, reported=None): + """Взять wiki_sync, прислать страницы и версии (пачками по 50), отчитаться done с versions=reported (None — len).""" + cmd = api.claim() + api.wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": pages}) + for i in range(0, len(versions), 50): + api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": versions[i:i + 50]}) + result = {"root_title": "АРМ КА", "codes": codes or [p["code"] for p in pages], "eva_calls": 3, + "versions": len(versions) if reported is None else reported} + return cmd, api.report(cmd, "done", result) + + +def versions_of(api): + return api.rows("SELECT code, page_code, added, removed, created_at, markdown FROM wiki_version " + "ORDER BY page_code, created_at, code") + + +def first_load(api): + vs = ([version("DOC-1", f"DOCH-1{i}", f"2026-10-2{i}T10:00:00Z", "

а

" * (i + 1)) for i in range(3)] + + [version("DOC-3", f"DOCH-3{i}", f"2026-10-2{i}T10:00:00Z") for i in range(2)]) + return run_history(api, TREE, vs, CODES) + + +def stage_count(api): + return api.rows("SELECT COUNT(*) AS n FROM wiki_version_stage")[0]["n"] + + +def test_first_history_load(api): + cmd, done = first_load(api) + assert cmd["params"]["known_versions"] == {} and done["status"] == "done" + rows = versions_of(api) + assert len(rows) == 5 + assert [(r["added"], r["removed"]) for r in rows if r["code"] in ("DOCH-10", "DOCH-30")] == [(1, 0), (1, 0)] + assert api.rows("SELECT history_at FROM wiki_history")[0]["history_at"] + assert "known_versions" not in api.rows("SELECT params FROM command WHERE id = ?", cmd["id"])[0]["params"] + + +def test_known_versions_on_delivery_and_recovery(api): + first_load(api) + api.enqueue() + cmd = api.claim() + expected = {"DOC-1": ["DOCH-10", "DOCH-11", "DOCH-12"], "DOC-3": ["DOCH-30", "DOCH-31"]} + assert cmd["params"]["known_versions"] == expected + assert api.wiki.get(f"/agent/commands/{cmd['id']}")["params"]["known_versions"] == expected + api.wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": TREE}) + api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [ + version("DOC-1", "DOCH-13", "2026-10-25T10:00:00Z", "

б

")]}) + api.report(cmd, "done", {"root_title": "АРМ КА", "codes": CODES, "eva_calls": 1, "versions": 1}) + rows = {r["code"]: r for r in versions_of(api)} + assert (rows["DOCH-13"]["added"], rows["DOCH-13"]["removed"]) == history.line_delta(rows["DOCH-12"]["markdown"], rows["DOCH-13"]["markdown"]) + + +def test_agent_without_history(api): + first_load(api) + before = api.rows("SELECT history_at FROM wiki_history") + old_sync = api.project()["wiki_synced_at"] + api.enqueue() + cmd = api.claim() + api.wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": TREE}) + assert api.report(cmd, "done", {"root_title": "АРМ КА", "codes": CODES, "eva_calls": 1})["status"] == "done" + assert api.project()["wiki_synced_at"] >= old_sync + assert len(versions_of(api)) == 5 and api.rows("SELECT history_at FROM wiki_history") == before + + +def test_page_left_tree(api): + first_load(api) + api.enqueue() + cmd, done = run_history(api, [TREE[0], TREE[2]], [], ["DOC-1"]) + assert done["status"] == "done" + assert {r["page_code"] for r in versions_of(api)} == {"DOC-1"} + + +def test_foreign_page_version_dropped(api): + _, done = run_history(api, TREE, [version("DOC-99", "DOCH-9", "2026-10-25T10:00:00Z")], CODES) + assert done["status"] == "done" and versions_of(api) == [] + + +def test_lost_versions_batch(api): + first_load(api) + api.enqueue() + old_sync = api.project()["wiki_synced_at"] + _, done = run_history(api, TREE, [version("DOC-1", f"DOCH-N{i}", "2026-10-26T10:00:00Z") for i in range(3)], CODES, reported=5) + assert done["status"] == "failed" and done["error"] == "нет версий: пришло 3 из 5" + assert api.project()["wiki_synced_at"] == old_sync and len(versions_of(api)) == 5 + assert stage_count(api) == 0 and api.rows("SELECT COUNT(*) AS n FROM wiki_sync_stage")[0]["n"] == 0 + + +def test_resend_counts_unique(api): + cmd = api.claim() + api.wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": TREE}) + batch = [version("DOC-1", "DOCH-1", "2026-10-25T10:00:00Z"), version("DOC-1", "DOCH-2", "2026-10-26T10:00:00Z")] + for _ in range(2): + api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": batch}) + done = api.report(cmd, "done", {"root_title": "АРМ КА", "codes": CODES, "eva_calls": 1, "versions": 2}) + assert done["status"] == "done" and len(versions_of(api)) == 2 + + +@pytest.mark.parametrize("bad", ["5", True, -1, 1.5]) +def test_versions_field_bad(api, bad): + _, done = run_history(api, TREE, [], CODES, reported=bad) + assert done["status"] == "failed" and done["error"] == "versions — неотрицательное целое" + + +def test_late_version_recounts_successor(api): + run_history(api, TREE, [version("DOC-1", "V1", "2026-10-25T10:00:00Z", "

а

"), + version("DOC-1", "V3", "2026-10-25T12:00:00Z", "

а

в

")], CODES) + api.enqueue() + run_history(api, TREE, [version("DOC-1", "V2", "2026-10-25T11:00:00Z", "

а

б

")], CODES) + r = {x["code"]: x for x in versions_of(api)} + assert (r["V2"]["added"], r["V2"]["removed"]) == history.line_delta(r["V1"]["markdown"], r["V2"]["markdown"]) + assert (r["V3"]["added"], r["V3"]["removed"]) == history.line_delta(r["V2"]["markdown"], r["V3"]["markdown"]) + assert (r["V3"]["added"], r["V3"]["removed"]) != history.line_delta(r["V1"]["markdown"], r["V3"]["markdown"]) + + +def test_known_version_resent_ignored(api): + first_load(api) + before = versions_of(api) + api.enqueue() + run_history(api, TREE, [version("DOC-1", "DOCH-11", "2026-10-21T10:00:00Z", "

совсем другое

")], CODES) + assert versions_of(api) == before + + +def test_reset_root_drops_history(api): + first_load(api) + api.client.patch("/projects/AWPCA", {"wiki_root": None}) + assert versions_of(api) == [] and api.rows("SELECT * FROM wiki_history") == [] + + +def test_redelivery_clears_version_stage(api): + cmd = api.claim() + api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [version("DOC-1", "DOCH-1", "2026-10-25T10:00:00Z")]}) + assert stage_count(api) == 1 + again = api.claim() + assert again["id"] == cmd["id"] and stage_count(api) == 0 + + +def test_discarded_report_keeps_history(api): + first_load(api) + api.enqueue() + cmd = api.claim() + api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [version("DOC-1", "DOCH-X", "2026-10-26T10:00:00Z")]}) + api.wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": TREE}) + c = api.hub.conn() + try: + c.execute("UPDATE wiki_project SET root_code = 'DOC-7'") + c.commit() + finally: + c.close() + done = api.report(cmd, "done", {"root_title": "АРМ КА", "codes": CODES, "eva_calls": 1, "versions": 1}) + assert done["result"]["discarded"] and len(versions_of(api)) == 5 and stage_count(api) == 0 + + +def test_failed_report_clears_version_stage(api): + first_load(api) + api.enqueue() + cmd = api.claim() + api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [version("DOC-1", "DOCH-X", "2026-10-26T10:00:00Z")]}) + api.report(cmd, "failed", None, "Eva: HTTP 503") + assert stage_count(api) == 0 and len(versions_of(api)) == 5 + + +def test_housekeeping_clears_version_stage(api): + cmd = api.claim() + api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [version("DOC-1", "DOCH-1", "2026-10-25T10:00:00Z")]}) + c = api.hub.conn() + try: + c.execute("UPDATE command SET status = 'cancelled' WHERE id = ?", (cmd["id"],)) + sync.housekeeping(c) + c.commit() + finally: + c.close() + assert stage_count(api) == 0 + + +def test_history_heavy_work_off_write_lock(api, monkeypatch): + seen = [] + + def probe(tag): + other = sqlite3.connect(api.db_path, timeout=0) + try: + other.execute("BEGIN IMMEDIATE") + other.execute("ROLLBACK") + seen.append((tag, "free")) + except sqlite3.OperationalError: + seen.append((tag, "locked")) + finally: + other.close() + + real_md, real_delta = convert.to_markdown, sync.history.line_delta + monkeypatch.setattr(stage.convert, "to_markdown", lambda *a, **k: (probe("markdown"), real_md(*a, **k))[1]) + monkeypatch.setattr(sync.history, "line_delta", lambda *a: (probe("delta"), real_delta(*a))[1]) + first_load(api) + assert ("delta", "free") in seen and ("markdown", "free") in seen and not [s for s in seen if s[1] == "locked"] + + +def test_line_delta(): + assert history.line_delta("", "a\nb\n") == (2, 0) + assert history.line_delta("a\nb\nc\n", "a\nB\nc\nd\n") == (2, 1) + assert history.line_delta("a\n", "a\n") == (0, 0) diff --git a/tests/test_wiki_history_api.py b/tests/test_wiki_history_api.py new file mode 100644 index 0000000..f2dae7a --- /dev/null +++ b/tests/test_wiki_history_api.py @@ -0,0 +1,176 @@ +"""История правок вики, чтение HTTP API (wiki-history, #140): лента `GET /wiki/changes`, версии, дифф, поля страницы и +состояния. Агента имитирует `run_history` из test_wiki_history; фикстуры — через api из test_wiki_sync.""" +import difflib +from datetime import timedelta + +import pytest + +from sdlc_hub.core import db +from sdlc_hub.core.client import HubError +from test_wiki_history import MOLCH, SOSNOV, run_history, version # noqa: F401 +from test_wiki_sync import CODES, TREE, api, first_sync, page # noqa: F401 — api — фикстура + +DAY_SINCE, DAY_UNTIL = "2026-10-27T00:00:00+03:00", "2026-10-28T00:00:00+03:00" + + +def window(**extra): + return {"project": "AWPCA", "since": DAY_SINCE, "until": DAY_UNTIL} | extra + + +def hub_error(fn, *a, **kw): + with pytest.raises(HubError) as e: + fn(*a, **kw) + return e.value + + +@pytest.fixture +def history(api): + """Версии: DOC-3 (Соснов) 17:59 и 18:02 +03:00, DOC-2 (Молчанов) 26.10 12:00 +03:00; DOC-1 без версий.""" + _, done = run_history(api, TREE, [ + version("DOC-3", "DOCH-30", "2026-10-27T17:59:00+03:00", "

статус

"), + version("DOC-3", "DOCH-31", "2026-10-27T18:02:00+03:00", "

статус

новый

"), + version("DOC-2", "DOCH-20", "2026-10-26T12:00:00+03:00", "

роли

", author=MOLCH), + ], CODES) + assert done["status"] == "done" + return api + + +@pytest.fixture +def four(api): + """Четыре версии DOC-3 Соснова за 27.10 (последняя — 23:48:02 +03:00).""" + stamps = ["2026-10-27T17:59:00+03:00", "2026-10-27T18:02:00+03:00", "2026-10-27T20:30:00+03:00", + "2026-10-27T23:48:02+03:00"] + vs = [version("DOC-3", f"DOCH-4{i}", ts, f"

строка {i}

") for i, ts in enumerate(stamps, 1)] + _, done = run_history(api, TREE, vs, CODES) + assert done["status"] == "done" + return api + + +# --- лента ----------------------------------------------------------------------------------------------------------- + +def test_changes_today(history): # сценарий «Что сделали за сегодня» + d = history.client.get("/wiki/changes", **window()) + assert [c["at"] for c in d["changes"]] == ["2026-10-27T15:02:00+00:00", "2026-10-27T14:59:00+00:00"] + c = d["changes"][0] + assert c["page_code"] == "DOC-3" and c["title"] == "Статусы" and c["path"] == "АРМ КА/01_Статусы" + assert c["author"] == SOSNOV and c["version"] == "DOCH-31" + assert isinstance(c["added"], int) and isinstance(c["removed"], int) + assert d["since"] == "2026-10-26T21:00:00+00:00" and d["until"] == "2026-10-27T21:00:00+00:00" + assert d["truncated"] is False and d["synced_at"] and d["history_at"] + + +@pytest.mark.parametrize("author", ["соснов", "СОСНОВ", "Sosnov-DVI"]) +def test_changes_author_cyrillic_case(history, author): # Review Focus 1: только две правки DOC-3 + d = history.client.get("/wiki/changes", **window(author=author)) + assert len(d["changes"]) == 2 and {c["page_code"] for c in d["changes"]} == {"DOC-3"} + + +def test_changes_default_window(history): # без since/until: окно ровно 24 часа + d = history.client.get("/wiki/changes", project="AWPCA") + assert db.parse(d["until"]) - db.parse(d["since"]) == timedelta(hours=24) + + +def test_changes_limit_truncated(history): + d = history.client.get("/wiki/changes", **window(limit="1")) + assert len(d["changes"]) == 1 and d["truncated"] is True + + +@pytest.mark.parametrize("q,param", [ + ({"since": "2026-10-27T00:00:00"}, "since"), ({"until": "вчера"}, "until"), + ({"limit": "0"}, "limit"), ({"limit": "2001"}, "limit"), ({"limit": "x"}, "limit"), + ({"since": "2026-10-28T00:00:00Z", "until": "2026-10-27T00:00:00Z"}, "since"), +]) +def test_changes_bad_params(history, q, param): + e = hub_error(history.client.get, "/wiki/changes", **({"project": "AWPCA"} | q)) + assert e.status == 422 and e.error == "bad_request" and e.detail.startswith(param) + + +def test_changes_history_not_loaded(api): # синхронизация без версий: история не загружена + first_sync(api) + d = api.client.get("/wiki/changes", **window()) + assert d["history_at"] is None and d["changes"] == [] and d["synced_at"] + assert api.client.get("/wiki/status", project="AWPCA")["history_at"] is None + + +def test_changes_no_root_and_unknown(api): + api.client.post("/projects", {"label": "BEZ", "name": "Без корня"}) + assert hub_error(api.client.get, "/wiki/changes", project="BEZ").status == 409 + assert hub_error(api.client.get, "/wiki/changes", project="NOPE").status == 404 + + +# --- версии страницы ------------------------------------------------------------------------------------------------- + +def test_versions_list(history): # новые первыми + d = history.client.get("/wiki/pages/DOC-3/versions", project="AWPCA") + assert d["code"] == "DOC-3" and d["title"] == "Статусы" + assert len(d["versions"]) == 2 and d["versions"][0]["at"] == "2026-10-27T15:02:00+00:00" + assert d["versions"][0]["version"] == "DOCH-31" and d["versions"][0]["author"] == SOSNOV + assert hub_error(history.client.get, "/wiki/pages/DOC-99/versions", project="AWPCA").status == 404 + + +# --- дифф ------------------------------------------------------------------------------------------------------------ + +def test_diff_last(four): # сценарий «Дифф последней правки» + d = four.client.get("/wiki/pages/DOC-3/diff", project="AWPCA") + v3, v4 = "DOCH-43", "DOCH-44" + assert d["index"] == 4 and d["total"] == 4 and d["prev_version"] == v3 and d["version"] == v4 + assert d["diff"].startswith(f"--- {v3}\n+++ {v4}\n") and d["next_offset"] is None + assert d["code"] == "DOC-3" and d["title"] == "Статусы" and d["path"] == "АРМ КА/01_Статусы" + assert d["at"] == "2026-10-27T20:48:02+00:00" and d["author"] == SOSNOV and d["offset"] == 0 + + +def test_diff_first_version(history): + d = history.client.get("/wiki/pages/DOC-3/diff", project="AWPCA", version="DOCH-30") + assert d["index"] == 1 and d["prev_version"] is None and d["removed"] == 0 + assert d["diff"].startswith("--- \n+++ ") + + +def test_diff_chunks(api): # сценарий «Длинный дифф»: склейка частей == полный дифф + html = "".join(f"

строка {i:04d} текста, которой много

" for i in range(1300)) + _, done = run_history(api, TREE, [version("DOC-3", "DOCH-90", "2026-10-27T10:00:00Z", html)], CODES) + assert done["status"] == "done" + md = api.rows("SELECT markdown FROM wiki_version WHERE code = 'DOCH-90'")[0]["markdown"] + full = "".join(f"{x}\n" for x in difflib.unified_diff([], md.splitlines(), fromfile="", tofile="DOCH-90", + n=3, lineterm="")) + assert 40000 < len(full) <= 60000 + + def part(offset): + return api.client.get("/wiki/pages/DOC-3/diff", project="AWPCA", offset=str(offset)) + + p0 = part(0) + assert len(p0["diff"]) == 20000 and p0["next_offset"] == 20000 and p0["diff"] == full[:20000] + assert p0["offset"] == 0 + p40 = part(40000) + assert p40["diff"] == full[40000:] and p40["next_offset"] is None and p40["offset"] == 40000 + assert p0["diff"] + part(20000)["diff"] + p40["diff"] == full + for bad in ("-1", "x"): + e = hub_error(api.client.get, "/wiki/pages/DOC-3/diff", project="AWPCA", offset=bad) + assert e.status == 422 and e.error == "bad_request" and e.detail == "offset: целое ≥ 0" + + +def test_diff_errors(history): + e = hub_error(history.client.get, "/wiki/pages/DOC-99/diff", project="AWPCA") + assert e.status == 404 and e.error == "not_found" + e = hub_error(history.client.get, "/wiki/pages/DOC-3/diff", project="AWPCA", version="DOCH-404") + assert e.status == 404 and e.error == "no_version" + e = hub_error(history.client.get, "/wiki/pages/DOC-1/diff", project="AWPCA") + assert e.status == 404 and e.error == "no_versions" + + +# --- поля страницы и состояния --------------------------------------------------------------------------------------- + +def test_page_last_change(four): # сценарий «Последняя правка в странице» + p = four.client.get("/wiki/pages/DOC-3", project="AWPCA") + assert p["modified_at"] == "2026-10-27T20:48:02+00:00" + assert p["modified_by"] == SOSNOV and p["versions"] == 4 + + +def test_page_last_change_without_versions(history): + p = history.client.get("/wiki/pages/DOC-1", project="AWPCA") + assert p["modified_at"] is None and p["modified_by"] is None and p["versions"] == 0 + + +def test_status_history_at(history): + st = history.client.get("/wiki/status", project="AWPCA") + stored = history.rows("SELECT history_at FROM wiki_history")[0]["history_at"] + assert stored and st["history_at"] == stored diff --git a/tests/test_wiki_mcp.py b/tests/test_wiki_mcp.py index a7c39ab..8beac38 100644 --- a/tests/test_wiki_mcp.py +++ b/tests/test_wiki_mcp.py @@ -3,6 +3,8 @@ import asyncio import pytest +from datetime import datetime, timedelta, timezone + from sdlc_hub.core import mcp as core_mcp from sdlc_hub.core import registry from sdlc_hub.core.client import HubError @@ -73,6 +75,8 @@ def test_unbound_tools_refuse(monkeypatch): assert wmcp.tree() == core_mcp.NOT_BOUND assert wmcp.search("x") == core_mcp.NOT_BOUND assert wmcp.status() == core_mcp.NOT_BOUND + assert wmcp.changes() == core_mcp.NOT_BOUND + assert wmcp.diff("DOC-1") == core_mcp.NOT_BOUND assert wmcp.root_set("DOC-1") == core_mcp.NOT_BOUND assert asyncio.run(wmcp.sync()) == core_mcp.NOT_BOUND assert fake.calls == [] @@ -84,6 +88,7 @@ def test_no_root_message(hub): assert wmcp.tree() == msg assert wmcp.page(code="DOC-1") == msg assert wmcp.search("x") == msg + assert wmcp.changes() == msg and wmcp.diff("DOC-1") == msg assert asyncio.run(wmcp.sync()) == msg assert asyncio.run(wmcp.attachment(1)) == msg assert not any(p.startswith("/wiki/") for p in paths_of(fake)) @@ -216,29 +221,6 @@ def test_status_no_root_still_shows_agent(hub): assert not any(p.startswith("/wiki/") for p in paths_of(fake)) -def test_sync_nowait_and_in_progress(hub): - fake = hub({("POST", "/wiki/sync"): [{"batch_id": 5}, HubError(409, "sync_in_progress", {"batch_id": 5})]}) - assert "#5" in asyncio.run(wmcp.sync()) - assert fake.calls[-1] == ("POST", "/wiki/sync", {"project": "AWPCA", "author": "claude"}) - out = asyncio.run(wmcp.sync()) - assert "уже идёт" in out and "#5" in out - - -def test_sync_wait(hub): - done = {"id": 5, "status": "done", "commands": [{"id": 1, "type": "wiki_sync", "status": "done", "error": None}]} - hub({("POST", "/wiki/sync"): {"batch_id": 5}, - "/batches/5": [{"id": 5, "status": "running", "commands": []}, done], - "/wiki/status": {"root": "DOC-1", "root_title": "К", "synced_at": "s", "pages": 3, "error": None, "batch": None}}) - out = asyncio.run(wmcp.sync(wait=True)) - assert "#5" in out and "done" in out and "страниц: 3" in out - - -def test_sync_wait_timeout(hub, monkeypatch): - monkeypatch.setattr(wmcp, "SYNC_WAIT", 0.05) - hub({("POST", "/wiki/sync"): {"batch_id": 5}, "/batches/5": {"id": 5, "status": "running", "commands": []}}) - assert "ещё выполняется" in asyncio.run(wmcp.sync(wait=True)) - - ATT_NAME = "../../etc/pass:wd.xlsx" @@ -293,7 +275,8 @@ def test_run_wiki_module(): def test_wiki_tool_names_order(): names = [t.name for t in asyncio.run(wmcp.mcp.list_tools())] - assert names == ["tree", "page", "search", "attachment", "sync", "status", "root_set", "projects", "project_create"] + assert names == ["tree", "page", "search", "changes", "diff", "attachment", "sync", "status", "root_set", "projects", + "project_create"] def test_projects_table_and_create(hub): @@ -338,11 +321,150 @@ def test_search_tree_candidates_newlines_collapsed(hub): assert cands[0].startswith("под название «T» подходит") and "DOC-2 T U" in cands and "DOC-3 T V" in cands -def test_sync_wait_error_and_root_title_one_line(hub): - done = {"id": 5, "status": "done", "commands": [{"id": 1, "type": "wiki_sync", "status": "failed", "error": "Eva: 503\nпуть: x"}]} +SOSNOV = {"login": "sosnov", "name": "Дмитрий Соснов"} +MOLCH = {"login": "molchanov", "name": "Илья Молчанов"} +TITLES = {"DOC-3": ("Статусы", "АРМ КА/01_Статусы"), "DOC-2": ("Статистика", "АРМ КА/02_Статистика")} + + +def today_at(h, m): + """ISO UTC момента сегодня в местное h:m (как db.now()).""" + return datetime.now().astimezone().replace(hour=h, minute=m, second=0, microsecond=0).astimezone(timezone.utc).isoformat(timespec="seconds") + + +def loc(iso): + return datetime.fromisoformat(iso).astimezone() + + +def chg(code, version, at, added, removed, author=SOSNOV): + title, path = TITLES[code] + return {"at": at, "page_code": code, "title": title, "path": path, "version": version, "author": author, + "added": added, "removed": removed} + + +def period(**kw): + return {"since": today_at(0, 0), "until": today_at(14, 5), "synced_at": today_at(14, 0), + "history_at": today_at(14, 0), "truncated": False, "changes": [], **kw} + + +def test_since_of(): + now = datetime(2026, 10, 28, 14, 5).astimezone() + assert core_mcp.since_of(2, "", now) == (now - timedelta(hours=2)).isoformat() + assert core_mcp.since_of(None, "", now) == now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat() + assert core_mcp.since_of(None, "2026-10-01T00:00", now) == "2026-10-01T00:00" + + +def test_changes_today_grouped(hub): # сценарий «Что сделали аналитики сегодня» + t1, t2 = today_at(11, 2), today_at(12, 40) + fake = hub({"/wiki/changes": period(changes=[chg("DOC-3", "DOCH-2", t2, 40, 12), chg("DOC-3", "DOCH-1", t1, 5, 0), + chg("DOC-2", "DOCH-9", "2026-10-26T09:00:00+00:00", 1, 1, MOLCH)])}) + lines = wmcp.changes().splitlines() + assert lines[0] == f"правки вики с {loc(today_at(0, 0)):%d.%m %H:%M} по {loc(today_at(14, 5)):%d.%m %H:%M}" + assert lines[1] == f"синхронизировано: {loc(today_at(14, 0)):%d.%m %H:%M}" + assert lines[2:5] == ["DOC-3 Статусы — АРМ КА/01_Статусы", " 12:40 Дмитрий Соснов: +40 / −12 (DOCH-2)", + " 11:02 Дмитрий Соснов: +5 / −0 (DOCH-1)"] + assert lines[5] == "DOC-2 Статистика — АРМ КА/02_Статистика" + assert lines[6] == f" {loc('2026-10-26T09:00:00+00:00'):%d.%m %H:%M} Илья Молчанов: +1 / −1 (DOCH-9)" + params = fake.calls[-1][2] + assert fake.calls[-1][1] == "/wiki/changes" and params["project"] == "AWPCA" + assert params["since"] == datetime.now().astimezone().replace(hour=0, minute=0, second=0, microsecond=0).isoformat() + assert not any(m == "POST" for m, _, _ in fake.calls) # команд не поставлено + + +def test_changes_period_args(hub): + fake = hub({"/wiki/changes": period()}) + assert wmcp.changes(hours=2, since="2026-10-28T09:00") == "либо hours, либо since" + assert wmcp.changes(hours=0) == "hours — число больше 0" + assert not any(p == "/wiki/changes" for p in paths_of(fake)) + wmcp.changes(since="2026-10-28T09:00", author="соснов") + params = fake.calls[-1][2] + assert params["since"] == loc("2026-10-28T09:00").isoformat() and params["author"] == "соснов" + + +def test_changes_messages(hub): + hub({"/wiki/changes": period()}) + assert wmcp.changes().splitlines()[-1] == "за период правок нет" + hub({"/wiki/changes": period(history_at=None)}) + assert wmcp.changes().splitlines()[-1] == "история ещё не загружена: нужен eva-agent v2 и sync" + rows = [chg("DOC-3", "DOCH-2", today_at(12, 40), 40, 12), chg("DOC-3", "DOCH-1", today_at(11, 2), 5, 0)] + hub({"/wiki/changes": period(truncated=True, changes=rows)}) + assert wmcp.changes().splitlines()[-1] == "показаны последние 2 правок, сузьте период" + hub({"/wiki/changes": HubError(422, "bad_request", "since: позже until")}) + assert wmcp.changes() == "ошибка хаба 422 bad_request: since: позже until" + + +def test_changes_title_newlines(hub): + row = {**chg("DOC-3", "DOCH-1", today_at(11, 2), 5, 0), "title": "Статусы\nпуть: x"} + hub({"/wiki/changes": period(changes=[row])}) + assert wmcp.changes().splitlines()[2] == "DOC-3 Статусы путь: x — АРМ КА/01_Статусы" + + +DIFF = {"version": "DOCH-4", "at": today_at(12, 40), "author": SOSNOV, "index": 4, "total": 4, "prev_version": "DOCH-3", + "added": 40, "removed": 12, "offset": 0, "diff": "@@ -1 +1 @@\n-старое\n+новое\n", "next_offset": None, + "code": "DOC-3", "title": "Статусы", "path": "АРМ КА/01_Статусы"} + + +def test_diff_header_and_chunk(hub): # сценарий «Дифф правки» + fake = hub({"/wiki/pages/DOC-3/diff": DIFF}) + out = wmcp.diff("DOC-3") + assert out.splitlines()[:8] == ["DOC-3 — Статусы", "путь: АРМ КА/01_Статусы", "версия 4 из 4: DOCH-4", + "автор: Дмитрий Соснов", f"время: {loc(today_at(12, 40)):%d.%m %H:%M}", "+40 / −12", + "предыдущая: DOCH-3", ""] + assert "-старое\n+новое\n" in out + assert fake.calls[-1] == ("GET", "/wiki/pages/DOC-3/diff", {"project": "AWPCA", "offset": 0}) + hub({"/wiki/pages/DOC-3/diff": {**DIFF, "next_offset": 20000, "diff": "x" * 20000}}) + assert wmcp.diff("DOC-3").splitlines()[-1] == "продолжение: offset=20000" + hub({"/wiki/pages/DOC-3/diff": {**DIFF, "index": 1, "total": 4, "prev_version": None}}) + assert "предыдущая: нет (первая версия)" in wmcp.diff("DOC-3").splitlines() + fake = hub({"/wiki/pages/DOC-3/diff": DIFF}) + wmcp.diff(code="DOC-3", version="DOCH-3", offset=20000) + assert fake.calls[-1] == ("GET", "/wiki/pages/DOC-3/diff", {"project": "AWPCA", "version": "DOCH-3", "offset": 20000}) + + +def test_page_modified_line(hub): + base = {"code": "DOC-3", "title": "Статусы", "path": "p", "url": "u", "synced_at": "2026-10-24T10:00:00+00:00", + "markdown": "тело", "attachments": [], "modified_at": "2026-10-27T20:48:02+00:00", "modified_by": SOSNOV, + "versions": 4} + hub({"/wiki/pages/DOC-3": base}) + lines = wmcp.page(code="DOC-3").splitlines() + assert lines[3] == "синхронизировано: 2026-10-24T10:00:00+00:00" + assert lines[4] == f"изменено: {loc('2026-10-27T20:48:02+00:00'):%d.%m %H:%M} Дмитрий Соснов (версий: 4)" + hub({"/wiki/pages/DOC-3": {**base, "modified_at": None, "modified_by": None, "versions": 0}}) + assert "изменено: история не загружена" in wmcp.page(code="DOC-3").splitlines() + + +def test_sync_short(hub): + fake = hub({("POST", "/wiki/sync"): [{"batch_id": 5}, HubError(409, "sync_in_progress", {"batch_id": 5})]}) + assert asyncio.run(wmcp.sync()) == "поставлено: пакет 5" + assert fake.calls[-1] == ("POST", "/wiki/sync", {"project": "AWPCA", "author": "claude"}) + assert asyncio.run(wmcp.sync()) == "уже идёт: пакет 5" + + +def test_sync_wait_ok(hub): + done = {"id": 5, "status": "done", "commands": [{"id": 1, "type": "wiki_sync", "status": "done", "error": None}]} hub({("POST", "/wiki/sync"): {"batch_id": 5}, "/batches/5": done, - "/wiki/status": {"root": "DOC-1", "root_title": "К\nстраниц: 999", "synced_at": "s", "pages": 3, - "error": "Eva: 503\nпуть: x", "batch": None}}) - lines = asyncio.run(wmcp.sync(wait=True)).splitlines() - assert " #1 wiki_sync failed: Eva: 503 путь: x" in lines and "ошибка синхронизации: Eva: 503 путь: x" in lines - assert not any(line.startswith("путь:") or line.startswith("страниц:") for line in lines) + "/wiki/status": {"root": "DOC-1", "root_title": "К", "synced_at": today_at(14, 0), "pages": 3, "error": None, + "batch": None, "history_at": None}}) + assert asyncio.run(wmcp.sync(wait=True)) == f"ок: синхронизировано {loc(today_at(14, 0)):%d.%m %H:%M}" + + +def test_sync_wait_error(hub): + failed = {"id": 5, "status": "done", "commands": [{"id": 1, "type": "wiki_sync", "status": "failed", + "error": "Eva: 503\nпуть: x"}]} + hub({("POST", "/wiki/sync"): {"batch_id": 5}, "/batches/5": failed}) + assert asyncio.run(wmcp.sync(wait=True)) == "ошибка: Eva: 503 путь: x" + hub({("POST", "/wiki/sync"): {"batch_id": 5}, "/batches/5": {"id": 5, "status": "cancelled", "commands": []}}) + assert asyncio.run(wmcp.sync(wait=True)) == "ошибка: пакет 5 отменён" + + +def test_sync_wait_timeout(hub, monkeypatch): + monkeypatch.setattr(wmcp, "SYNC_WAIT", 0.05) + hub({("POST", "/wiki/sync"): {"batch_id": 5}, "/batches/5": {"id": 5, "status": "running", "commands": []}}) + assert asyncio.run(wmcp.sync(wait=True)) == f"не дождался за {wmcp.SYNC_WAIT:g} с: пакет 5" + + +def test_status_history(hub): + base = {"root": "DOC-1", "root_title": "Корень", "synced_at": "s", "pages": 3, "error": None, "batch": None} + hub({"/wiki/status": {**base, "history_at": "2026-10-28T09:00:00+00:00"}, "/agent": agent_ok()}) + assert "история: 2026-10-28T09:00:00+00:00" in wmcp.status() + hub({"/wiki/status": {**base, "history_at": None}, "/agent": agent_ok()}) + assert "история не загружена" in wmcp.status() diff --git a/web/e2e/feed.spec.ts b/web/e2e/feed.spec.ts index c57eec4..ab57064 100644 --- a/web/e2e/feed.spec.ts +++ b/web/e2e/feed.spec.ts @@ -18,7 +18,7 @@ async function seedHistory() { test("лента: событие за сегодня, карточка окном поверх ленты, история в карточке", async ({ page }) => { await seedHistory(); - await login(page, "/app/jira/feed?project=AWPCA"); + await login(page, "/app/feed?project=AWPCA"); const group = page.getByTestId("feed-group").filter({ has: page.getByRole("link", { name: "AURORA-9002", exact: true }) }); await expect(group.getByText("статус «В работе» → «Готово»").first()).toBeVisible(); const other = page.getByRole("checkbox", { name: "Прочие поля" }); @@ -39,7 +39,7 @@ test("лента: событие за сегодня, карточка окно test("лента на 390 px: нет горизонтальной прокрутки при длинных значениях", async ({ page }) => { await seedHistory(); await page.setViewportSize({ width: 390, height: 844 }); - await login(page, "/app/jira/feed?project=AWPCA&kinds=status,other"); + await login(page, "/app/feed?project=AWPCA&kinds=status,other"); await expect(page.getByTestId("feed-group").first()).toBeVisible(); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); expect(overflow).toBeLessThanOrEqual(0); diff --git a/web/e2e/wiki.spec.ts b/web/e2e/wiki.spec.ts index 604cdc3..790c611 100644 --- a/web/e2e/wiki.spec.ts +++ b/web/e2e/wiki.spec.ts @@ -33,3 +33,31 @@ test("вики: вложение скачивается через запрос await req; // агента в тестовом хабе нет: скачивание остаётся «Загружается…», достаточно, что запрос ушёл await expect(page.getByRole("article").getByText("Загружается…")).toBeVisible(); }); + +// две правки DOC-9003 засеяны seed-hub.py (сегодня, автор Дмитрий Соснов): новая «+1 / −1», старая «+5 / −0» +test("правка из ленты: окно с диффом, «Предыдущая правка», назад — лента", async ({ page }) => { + await login(page, "/app/feed?project=AWPCA&period=7d&sources=wiki"); + const feed = page.getByTestId("page-feed"); + await feed.getByRole("link", { name: /\+1 \/ −1/ }).click(); + const d = page.getByRole("dialog"); + await expect(d.getByRole("heading", { name: "Регламент согласования" })).toBeVisible(); + await expect(d.getByText("правка 2 из 2")).toBeVisible(); + await expect(d.locator(".wiki-version-line--add").first()).toBeVisible(); + await expect(d.locator(".wiki-version-line--del").first()).toBeVisible(); + await expect(feed).toBeVisible(); // лента под окном + await d.getByRole("button", { name: "Предыдущая правка" }).click(); + await expect(d.getByText("правка 1 из 2")).toBeVisible(); + await expect(d.getByRole("button", { name: "Предыдущая правка" })).toBeDisabled(); + await page.goBack(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page).toHaveURL(/\/app\/feed\?project=AWPCA&period=7d&sources=wiki/); +}); + +test("окно правки на 390 px: горизонтальной прокрутки страницы нет", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }); + await login(page, "/app/feed?project=AWPCA&period=7d&sources=wiki"); + await page.getByTestId("page-feed").getByRole("link", { name: /\+5 \/ −0/ }).click(); + await expect(page.getByRole("dialog").locator(".wiki-version-line--add").first()).toBeVisible(); + const fits = await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth); + expect(fits).toBe(true); +}); diff --git a/web/scripts/seed-hub.py b/web/scripts/seed-hub.py index 37d529e..604a13a 100644 --- a/web/scripts/seed-hub.py +++ b/web/scripts/seed-hub.py @@ -3,7 +3,7 @@ Дополнения только для тестового хаба (описание, комментарии, команды) — из seed-extras.json рядом со скриптом.""" import json import sys -from datetime import date, timedelta +from datetime import date, datetime, timedelta, timezone from pathlib import Path from sdlc_hub.core import db, paths, settings @@ -109,6 +109,14 @@ for r in wiki_rows: "VALUES ('AWPCA', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (r["code"], r["eva_id"], r["title"], r["parent_code"], r["orderno"], r["depth"], r["path"], r["url"], r["size"], r["synced_at"])) wiki_search.replace(conn, "AWPCA", [(r["code"], r["title"], r["markdown"]) for r in wiki_rows]) +# история правок DOC-9003 (две версии сегодня) и отметка загрузки истории — для ленты и окна правки +_md = WIKI_PAGES[2]["markdown"] +_v1 = _md.replace("Порядок согласования описан в файле.", "Черновик порядка согласования.") +for _code, _ago, _md_v, _added, _removed in (("DOCH-9001", 50, _v1, 5, 0), ("DOCH-9002", 20, _md, 1, 1)): + _at = (datetime.now(timezone.utc) - timedelta(minutes=_ago)).replace(microsecond=0).isoformat() + conn.execute("INSERT OR REPLACE INTO wiki_version(label, code, page_code, created_at, author_login, author_name, markdown, added, removed) " + "VALUES ('AWPCA', ?, 'DOC-9003', ?, 'd.sosnov', 'Дмитрий Соснов', ?, ?, ?)", (_code, _at, _md_v, _added, _removed)) +conn.execute("INSERT OR REPLACE INTO wiki_history(label, history_at) VALUES ('AWPCA', ?)", (db.now(),)) (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") diff --git a/web/src/boundary.test.ts b/web/src/boundary.test.ts index 4d94b8a..7c395d3 100644 --- a/web/src/boundary.test.ts +++ b/web/src/boundary.test.ts @@ -24,7 +24,7 @@ function resolve(from: string, spec: string): string { } // имена полей и типов модулей, которых в ядре быть не должно (типизирует сам модуль) -const MODULE_TERMS = /\b(fact_status|closed_statuses|kind_labels|link_types|BoardIssue|IssueRow|jira_synced_at|wiki_synced_at|wiki_sync_error|wiki_root|board_id|board_type|issues_count)\b/; +const MODULE_TERMS = /\b(fact_status|closed_statuses|kind_labels|link_types|BoardIssue|IssueRow|jira_synced_at|wiki_synced_at|wiki_sync_error|wiki_root|board_id|board_type|issues_count|ChangeKind|kinds|history_at|page_code|prev_version)\b/; // классы, которые «определяет» CSS-текст: только селекторы; значения свойств, атрибуты и :not(...) не считаются const classesOf = (css: string): Set => { @@ -106,6 +106,7 @@ describe("детекторы границы", () => { expect(MODULE_TERMS.test("p.wiki_root")).toBe(true); expect(MODULE_TERMS.test("board_type")).toBe(true); expect(MODULE_TERMS.test("fact_status")).toBe(true); + expect(MODULE_TERMS.test("kinds: string")).toBe(true); expect(MODULE_TERMS.test("метка задач в Jira")).toBe(false); }); }); diff --git a/web/src/modules/index.ts b/web/src/modules/index.ts index 71c941b..8ce3e58 100644 --- a/web/src/modules/index.ts +++ b/web/src/modules/index.ts @@ -1,6 +1,6 @@ import { jiraModule } from "./jira/index.tsx"; import { wikiModule } from "./wiki/index.tsx"; -import type { HubModule, Overlay } from "./types.ts"; +import type { FeedResult, FeedSourceInput, HubModule, Overlay } from "./types.ts"; // список модулей — константа: хуки модулей вызываются в постоянном порядке на каждой отрисовке export const MODULES: readonly HubModule[] = [jiraModule, wikiModule]; @@ -11,3 +11,12 @@ const OVERLAY_HOOKS = MODULES.flatMap((m) => m.useOverlay ?? []); export function useOverlays(): Overlay[] { return OVERLAY_HOOKS.map((use) => use()); } + +// модули с источником ленты — в порядке MODULES +export const FEED_MODULES: readonly HubModule[] = MODULES.filter((m) => m.feed); +const FEED_HOOKS = FEED_MODULES.map((m) => m.feed!.useFeed); + +// результаты источников ленты; inputs — по одному на модуль из FEED_MODULES, число и порядок не меняются +export function useFeeds(inputs: FeedSourceInput[]): FeedResult[] { + return FEED_HOOKS.map((use, i) => use(inputs[i])); +} diff --git a/web/src/modules/jira/Summary.tsx b/web/src/modules/jira/Summary.tsx index 467e255..c334fc8 100644 --- a/web/src/modules/jira/Summary.tsx +++ b/web/src/modules/jira/Summary.tsx @@ -26,7 +26,7 @@ export function Summary({ project }: { project: Project }) { ); diff --git a/web/src/modules/jira/agile/BacklogPage.test.tsx b/web/src/modules/jira/agile/BacklogPage.test.tsx index 96c7718..0ee640c 100644 --- a/web/src/modules/jira/agile/BacklogPage.test.tsx +++ b/web/src/modules/jira/agile/BacklogPage.test.tsx @@ -66,18 +66,18 @@ const links = () => within(jiraGroup()).getAllByRole("link").map((l) => l.textCo describe("«Бэклог»", () => { beforeEach(() => { setToken("tok"); localStorage.clear(); }); - it("меню scrum-проекта: «Связи», «Бэклог», «Спринт», «Задачи», «Лента»", async () => { + it("меню scrum-проекта: «Связи», «Бэклог», «Спринт», «Задачи»", async () => { hub(); open("/app/jira/links?project=AWPCA"); await within(jiraGroup()).findByRole("link", { name: "Бэклог" }); - expect(links()).toEqual(["Связи", "Бэклог", "Спринт", "Задачи", "Лента"]); + expect(links()).toEqual(["Связи", "Бэклог", "Спринт", "Задачи"]); }); it("проект без доски: пунктов нет, «У проекта нет scrum-доски» со ссылкой «Связи», запроса /agile нет", async () => { const calls = hub(); open("/app/jira/backlog?project=STOCKS"); expect(await screen.findByText("У проекта нет scrum-доски")).toBeInTheDocument(); - expect(links()).toEqual(["Связи", "Задачи", "Лента"]); + expect(links()).toEqual(["Связи", "Задачи"]); const inPage = within(screen.getByRole("main")); expect(inPage.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links?project=STOCKS"); expect(calls).toHaveLength(0); @@ -87,7 +87,7 @@ describe("«Бэклог»", () => { hub({ projects: "hang" }); open("/app/jira/links?project=AWPCA"); await screen.findByRole("group", { name: "Jira" }); - expect(links()).toEqual(["Связи", "Задачи", "Лента"]); + expect(links()).toEqual(["Связи", "Задачи"]); }); it("секции: активный, будущий, «Бэклог», «Прошлые спринты (1)»; заголовок спринта с суммами", async () => { diff --git a/web/src/modules/jira/agile/KanbanPage.test.tsx b/web/src/modules/jira/agile/KanbanPage.test.tsx index f60c958..9360fc9 100644 --- a/web/src/modules/jira/agile/KanbanPage.test.tsx +++ b/web/src/modules/jira/agile/KanbanPage.test.tsx @@ -54,12 +54,12 @@ const colNames = (p: HTMLElement) => Array.from(p.querySelectorAll(".agile-col__ describe("«Kanban»", () => { beforeEach(() => { setToken("tok"); localStorage.clear(); }); - it("меню kanban-проекта: «Связи», «Kanban», «Задачи», «Лента»", async () => { + it("меню kanban-проекта: «Связи», «Kanban», «Задачи»", async () => { hub(agile(false)); open("/app/jira/links?project=KAN"); const g = () => screen.getByRole("group", { name: "Jira" }); await within(g()).findByRole("link", { name: "Kanban" }); - expect(within(g()).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Kanban", "Задачи", "Лента"]); + expect(within(g()).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Kanban", "Задачи"]); }); it("колонки из board_issues, у последней — «за 14 дней», «Без колонки» при статусе вне доски", async () => { diff --git a/web/src/modules/jira/agile/SprintPage.test.tsx b/web/src/modules/jira/agile/SprintPage.test.tsx index aaf2e62..0107ded 100644 --- a/web/src/modules/jira/agile/SprintPage.test.tsx +++ b/web/src/modules/jira/agile/SprintPage.test.tsx @@ -61,11 +61,11 @@ const colNames = (p: HTMLElement) => Array.from(p.querySelectorAll(".agile-col__ describe("«Спринт»", () => { beforeEach(() => { setToken("tok"); localStorage.clear(); }); - it("меню scrum-проекта: «Связи», «Бэклог», «Спринт», «Задачи», «Лента»; «Kanban» нет", async () => { + it("меню scrum-проекта: «Связи», «Бэклог», «Спринт», «Задачи»; «Kanban» нет", async () => { hub(agile()); open("/app/jira/links?project=AWPCA"); await within(jiraGroup()).findByRole("link", { name: "Спринт" }); - expect(within(jiraGroup()).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Бэклог", "Спринт", "Задачи", "Лента"]); + expect(within(jiraGroup()).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Бэклог", "Спринт", "Задачи"]); }); it("колонки доски с числом задач и «Без колонки»", async () => { diff --git a/web/src/modules/jira/feed/EventList.tsx b/web/src/modules/jira/feed/EventList.tsx index 708f3a5..cb04667 100644 --- a/web/src/modules/jira/feed/EventList.tsx +++ b/web/src/modules/jira/feed/EventList.tsx @@ -1,5 +1,6 @@ import type { ChangeEvent } from "../api/types.ts"; -import { eventText, eventTime } from "./events.ts"; +import { eventTime } from "../../../ui/format.ts"; +import { eventText } from "./events.ts"; import "./feed.css"; // строки событий журнала: время, автор, что изменилось (лента и история в карточке; spec web-app «Лента изменений») diff --git a/web/src/modules/jira/feed/FeedGroup.tsx b/web/src/modules/jira/feed/FeedGroup.tsx new file mode 100644 index 0000000..6fc7a38 --- /dev/null +++ b/web/src/modules/jira/feed/FeedGroup.tsx @@ -0,0 +1,19 @@ +import { Badge, Panel } from "../../../ui/index.ts"; +import { IssueLink } from "../issues/IssueLink.tsx"; +import { EventList } from "./EventList.tsx"; +import type { IssueGroup } from "./events.ts"; +import "./feed.css"; + +// группа ленты — одна задача: ключ ссылкой на карточку, тема, статус и события по времени +export function FeedGroup({ group, now }: { group: IssueGroup; now: Date }) { + return ( + +
+ + {group.summary ?? "нет в зеркале"} + {group.status && {group.status}} +
+ +
+ ); +} diff --git a/web/src/modules/jira/feed/FeedPage.test.tsx b/web/src/modules/jira/feed/FeedPage.test.tsx deleted file mode 100644 index 1d9b293..0000000 --- a/web/src/modules/jira/feed/FeedPage.test.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { createMemoryRouter } from "react-router"; -import { focusManager } from "@tanstack/react-query"; -import { act } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { App } from "../../../App.tsx"; -import type { ChangeEvent } from "../api/types.ts"; -import { setToken } from "../../../auth/token.ts"; -import settings from "../board/__fixtures__/settings.json"; -import { routes } from "../../../routes.tsx"; -import { json, mockFetch } from "../../../test/fetchMock.ts"; -import { issue } from "../../../test/issueFixtures.ts"; - -const SNAP = JSON.parse(readFileSync(join(import.meta.dirname, "..", "board", "__fixtures__", "hub-basic.board.json"), "utf8")); -const A = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9 }; -const B = { label: "STOCKS", name: "Запасы", board_id: null, board_type: null, created_at: "", issues_count: 1 }; -const minutesAgo = (m: number) => new Date(Date.now() - m * 60_000).toISOString(); -const ev = (over: Partial): ChangeEvent => ({ - at: minutesAgo(30), key: "AURORA-9002", summary: "API свода", status: "Готово", kind: "status", field: "status", - author: "Петров Пётр", from: "В работе", to: "Готово", from_value: null, to_value: null, ...over, -}); - -type Changes = { since?: string; until?: string; truncated?: boolean; changes: ChangeEvent[] } | Response; -function hub(changes: () => Changes = () => ({ changes: [] })) { - const calls: URL[] = []; - mockFetch((method, url) => { - const k = `${method} ${url.pathname}`; - if (k === "GET /changes") { - calls.push(url); - const r = changes(); - return r instanceof Response ? r : json(200, { since: "", until: "", truncated: false, ...r }); - } - if (k === "GET /projects") return json(200, { projects: [A, B] }); - if (k === "GET /settings") return json(200, settings); - if (k === "GET /board") return json(200, SNAP); - if (k === "GET /issues") return json(200, { issues: [] }); - if (k === "GET /batches") return json(200, { batches: [] }); - if (k === "GET /agents") return json(200, { agents: [] }); - if (k.startsWith("GET /issues/")) return json(200, issue(decodeURIComponent(url.pathname.split("/").pop()!))); - return json(404, { error: "not_found" }); - }); - return calls; -} -const open = (path: string) => { - const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" }); - render(); - return router; -}; -const page = () => screen.findByTestId("page-feed"); - -describe("лента изменений", () => { - beforeEach(() => { setToken("tok"); localStorage.clear(); }); - afterEach(() => { vi.useRealTimers(); focusManager.setFocused(undefined); }); - - it("шапка: вторичная кнопка «Обновить из Jira», primary нет", async () => { - hub(); - open("/app/jira/feed?project=AWPCA"); - await page(); - expect(screen.getByRole("button", { name: "Обновить из Jira" })).toHaveClass("ui-btn--secondary"); - expect(document.querySelector(".ui-btn--primary")).toBeNull(); - }); - - it("C-1/C-2: возврат на вкладку перечитывает ленту с границей «до» на момент запроса", async () => { - vi.useFakeTimers({ toFake: ["Date"] }); - vi.setSystemTime(new Date(2026, 9, 22, 10, 0)); - const calls = hub(); - open("/app/jira/feed?project=AWPCA"); - await page(); - await waitFor(() => expect(calls.length).toBe(1)); - expect(new Date(calls[0].searchParams.get("until")!).getTime()).toBe(new Date(2026, 9, 22, 10, 0).getTime()); - vi.setSystemTime(new Date(2026, 9, 22, 11, 30)); - act(() => { focusManager.setFocused(false); focusManager.setFocused(true); }); - await waitFor(() => expect(calls.length).toBe(2)); - expect(new Date(calls[1].searchParams.get("until")!).getTime()).toBe(new Date(2026, 9, 22, 11, 30).getTime()); - expect(new Date(calls[1].searchParams.get("since")!).getTime()).toBe(new Date(2026, 9, 22, 0, 0).getTime()); - }); - - it("по умолчанию — «Сегодня», виды без «Прочих полей», события сгруппированы по задаче", async () => { - const calls = hub(() => ({ changes: [ - ev({}), ev({ key: "AURORA-9001", summary: "Схема", status: "В работе", kind: "comment", field: "comment", from: null, to: "Готово к проверке" }), - ev({ at: minutesAgo(40), kind: "description", field: "description" }), - ] })); - open("/app/jira/feed?project=AWPCA"); - await page(); - const groups = await screen.findAllByTestId("feed-group"); - expect(groups.map((g) => within(g).getByRole("link").textContent)).toEqual(["AURORA-9002", "AURORA-9001"]); - expect(within(groups[0]).getByText("API свода")).toBeInTheDocument(); - expect(within(groups[0]).getByText(/статус «В работе» → «Готово»/)).toBeInTheDocument(); - expect(within(groups[0]).getByText("описание изменено")).toBeInTheDocument(); - expect(within(groups[1]).getByText("комментарий: Готово к проверке")).toBeInTheDocument(); - const q = calls[0].searchParams; - expect(q.get("project")).toBe("AWPCA"); - expect(q.get("kinds")).toBe("status,description,dates,comment"); - const midnight = new Date(); midnight.setHours(0, 0, 0, 0); - expect(new Date(q.get("since")!).getTime()).toBe(midnight.getTime()); - expect(q.get("limit")).toBe("2000"); - expect(screen.getByRole("radio", { name: "Сегодня" })).toHaveAttribute("aria-checked", "true"); - expect(screen.getByText(/3 события/)).toBeInTheDocument(); - }); - - it("«Что закрылось за два часа»: период и виды в адресе и в запросе", async () => { - const calls = hub(); - const router = open("/app/jira/feed?project=AWPCA"); - await page(); - await userEvent.click(screen.getByRole("radio", { name: "2 часа" })); - for (const name of ["Описание", "Сроки", "Комментарии"]) await userEvent.click(screen.getByRole("checkbox", { name })); - await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA&period=2h&kinds=status")); - expect(router.state.location.pathname).toBe("/app/jira/feed"); - await waitFor(() => expect(calls.at(-1)!.searchParams.get("kinds")).toBe("status")); - const since = new Date(calls.at(-1)!.searchParams.get("since")!).getTime(); - expect(Math.abs(Date.now() - 2 * 3600_000 - since)).toBeLessThan(60_000); - }); - - it("карточка из ленты — окно поверх, закрытие возвращает ленту с тем же периодом", async () => { - hub(() => ({ changes: [ev({})] })); - const router = open("/app/jira/feed?project=AWPCA&period=7d"); - await page(); - await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" })); - const d = await screen.findByRole("dialog"); - expect(await within(d).findByRole("heading", { name: "Тема AURORA-9002" })).toBeInTheDocument(); - expect(screen.getByTestId("page-feed")).toBeInTheDocument(); - await userEvent.click(within(d).getAllByRole("button", { name: "Закрыть" })[0]); - await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); - expect(router.state.location.pathname + router.state.location.search).toBe("/app/jira/feed?project=AWPCA&period=7d"); - }); - - it("M-1: /app/jira/feed без project — проект дописывается в адрес", async () => { - hub(); - const router = open("/app/jira/feed"); - await page(); - await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA")); - }); - - it("M-3/M-6/M-7: подсказка truncated над группами, автор без имени — «—», группы не регионы", async () => { - hub(() => ({ truncated: true, changes: [ev({ author: null })] })); - open("/app/jira/feed?project=AWPCA"); - const notice = await screen.findByText("показаны последние 1 событий — выберите период короче"); - const group = screen.getByTestId("feed-group"); - expect(notice.compareDocumentPosition(group) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - expect(within(group).getByText("—", { selector: ".feed-event__author" })).toBeInTheDocument(); - expect(within(screen.getByTestId("page-feed")).queryAllByRole("region")).toHaveLength(0); - }); - - it("пустой период, truncated, ошибка с «Повторить»", async () => { - let mode: "empty" | "truncated" | "error" = "empty"; - hub(() => mode === "error" ? json(500, { error: "boom", detail: "сбой" }) - : mode === "truncated" ? { truncated: true, changes: [ev({})] } : { changes: [] }); - open("/app/jira/feed?project=AWPCA"); - expect(await screen.findByText("за период изменений нет")).toBeInTheDocument(); - mode = "truncated"; - await userEvent.click(screen.getByRole("radio", { name: "Вчера" })); - expect(await screen.findByText("показаны последние 1 событий — выберите период короче")).toBeInTheDocument(); - mode = "error"; - await userEvent.click(screen.getByRole("radio", { name: "7 дней" })); - const retry = await screen.findByRole("button", { name: "Повторить" }); - mode = "empty"; - await userEvent.click(retry); - expect(await screen.findByText("за период изменений нет")).toBeInTheDocument(); - }); - - it("все флажки сняты — подсказка, запроса нет", async () => { - const calls = hub(); - open("/app/jira/feed?project=AWPCA&kinds=status"); - await page(); - await waitFor(() => expect(calls.length).toBe(1)); - await userEvent.click(screen.getByRole("checkbox", { name: "Статусы" })); - expect(await screen.findByText("выберите хотя бы один вид")).toBeInTheDocument(); - expect(calls.length).toBe(1); - }); - - it("мусор в адресе: период по умолчанию, неизвестные виды отброшены", async () => { - const calls = hub(); - open("/app/jira/feed?project=AWPCA&period=foo&kinds=status,foo"); - await page(); - await waitFor(() => expect(calls.length).toBeGreaterThan(0)); - expect(screen.getByRole("radio", { name: "Сегодня" })).toHaveAttribute("aria-checked", "true"); - expect(calls[0].searchParams.get("kinds")).toBe("status"); - expect(screen.getByRole("checkbox", { name: "Статусы" })).toBeChecked(); - expect(screen.getByRole("checkbox", { name: "Сроки" })).not.toBeChecked(); - }); - - it("смена проекта: запрос по новому проекту, период и виды сохраняются", async () => { - const calls = hub(); - const router = open("/app/jira/feed?project=AWPCA&period=2h&kinds=status"); - await page(); - await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS"); - await waitFor(() => expect(calls.at(-1)!.searchParams.get("project")).toBe("STOCKS")); - expect(calls.at(-1)!.searchParams.get("kinds")).toBe("status"); - expect(router.state.location.pathname).toBe("/app/jira/feed"); - expect(router.state.location.search).toBe("?project=STOCKS&period=2h&kinds=status"); - }); -}); diff --git a/web/src/modules/jira/feed/FeedPage.tsx b/web/src/modules/jira/feed/FeedPage.tsx deleted file mode 100644 index 51b97f3..0000000 --- a/web/src/modules/jira/feed/FeedPage.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useMemo } from "react"; -import { useSearchParams } from "react-router"; -import { useChanges } from "../api/queries.ts"; -import type { ChangeKind } from "../api/types.ts"; -import { IssueLink } from "../issues/IssueLink.tsx"; -import { Badge, Button, Checkbox, Notice, PageHeader, Panel, Segmented } from "../../../ui/index.ts"; -import { RefreshButton } from "../RefreshButton.tsx"; -import { EventList } from "./EventList.tsx"; -import { groupByIssue, KINDS, kindsParam, parseKinds, parsePeriod, PERIODS, periodRange, type Period } from "./events.ts"; -import "./feed.css"; - -const pad = (n: number) => String(n).padStart(2, "0"); -const stamp = (d: Date) => isNaN(d.getTime()) ? "—" : `${pad(d.getDate())}.${pad(d.getMonth() + 1)} ${pad(d.getHours())}:${pad(d.getMinutes())}`; - -function plural(n: number): string { - const m10 = n % 10, m100 = n % 100; - if (m10 === 1 && m100 !== 11) return "событие"; - if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return "события"; - return "событий"; -} - -// лента изменений проекта за период (spec web-app «Лента изменений», design web-feed 1–7) -export function FeedPage({ project }: { project: string }) { - const [params, setParams] = useSearchParams(); - const period = parsePeriod(params.get("period")); - const kinds = useMemo(() => parseKinds(params.get("kinds")), [params]); - // границы считает запрос в момент выполнения (C-1): ключ кэша — проект, период и виды - const changes = useChanges({ project, period, kinds: kinds.join(",") }, () => periodRange(period, new Date()), kinds.length > 0); - const now = new Date(changes.dataUpdatedAt || Date.now()); // «текущие сутки» для времени событий — на момент ответа - - const setParam = (name: string, value: string | null) => setParams((p) => { - const n = new URLSearchParams(p); - if (value) n.set(name, value); else n.delete(name); - return n; - }); - const choosePeriod = (p: Period) => setParam("period", p === "today" ? null : p); - const toggleKind = (k: ChangeKind, on: boolean) => { - const next = on ? [...kinds, k] : kinds.filter((x) => x !== k); - setParam("kinds", next.length ? kindsParam(next) : "none"); // пустой набор — явное «none», иначе адрес вернул бы умолчание - }; - - const data = changes.data; - const groups = useMemo(() => groupByIssue(data?.changes ?? []), [data]); - const count = data?.changes.length ?? 0; - // окно — из ответа хаба: подзаголовок совпадает с тем, что показано - const subtitle = kinds.length && data ? `${count} ${plural(count)} · ${stamp(new Date(data.since))} — ${stamp(new Date(data.until))}` : undefined; - - return ( -
- } /> -
- -
- {KINDS.map((k) => ( - toggleKind(k.value, e.currentTarget.checked)} /> - ))} -
-
- {!kinds.length && выберите хотя бы один вид} - {kinds.length > 0 && changes.isError && ( - void changes.refetch()}>Повторить}>{(changes.error as Error).message} - )} - {kinds.length > 0 && changes.isPending &&

Загрузка…

} - {data && !count && за период изменений нет} - {data?.truncated && {`показаны последние ${count} событий — выберите период короче`}} - {groups.map((g) => ( - -
- - {g.summary ?? "нет в зеркале"} - {g.status && {g.status}} -
- -
- ))} -
- ); -} diff --git a/web/src/modules/jira/feed/events.test.ts b/web/src/modules/jira/feed/events.test.ts index bfd61e6..fa6025d 100644 --- a/web/src/modules/jira/feed/events.test.ts +++ b/web/src/modules/jira/feed/events.test.ts @@ -1,28 +1,12 @@ import { describe, expect, it } from "vitest"; import type { ChangeEvent } from "../api/types.ts"; -import { DEFAULT_KINDS, eventText, eventTime, groupByIssue, kindsParam, parseKinds, parsePeriod, periodRange } from "./events.ts"; +import { DEFAULT_KINDS, eventText, groupByIssue, kindsParam, parseKinds } from "./events.ts"; -const NOW = new Date(2026, 9, 22, 14, 5); const ev = (over: Partial = {}): ChangeEvent => ({ at: "2026-10-22T08:15:00Z", key: "X", summary: "Тема", status: "Готово", kind: "status", field: "status", author: "Петров", from: "В работе", to: "Готово", from_value: null, to_value: null, ...over, }); -describe("период", () => { - it("границы по местному времени", () => { - expect(periodRange("today", NOW)).toEqual({ since: new Date(2026, 9, 22, 0, 0), until: NOW }); - expect(periodRange("yesterday", NOW)).toEqual({ since: new Date(2026, 9, 21, 0, 0), until: new Date(2026, 9, 22, 0, 0) }); - expect(periodRange("2h", NOW)).toEqual({ since: new Date(2026, 9, 22, 12, 5), until: NOW }); - expect(periodRange("7d", NOW)).toEqual({ since: new Date(2026, 9, 15, 14, 5), until: NOW }); - }); - - it("мусор в адресе — «Сегодня»", () => { - expect(parsePeriod("foo")).toBe("today"); - expect(parsePeriod(null)).toBe("today"); - expect(parsePeriod("7d")).toBe("7d"); - }); -}); - describe("виды", () => { it("неизвестные отброшены, пусто — по умолчанию", () => { expect(parseKinds("status,foo")).toEqual(["status"]); @@ -77,16 +61,6 @@ describe("текст события", () => { }); }); -describe("время события", () => { - it("сегодня — ЧЧ:ММ, иначе с датой, без времени — прочерк", () => { - expect(eventTime(new Date(2026, 9, 22, 11, 15).toISOString(), NOW)).toBe("11:15"); - expect(eventTime(new Date(2026, 9, 21, 9, 5).toISOString(), NOW)).toBe("21.10 09:05"); - expect(eventTime(new Date(2026, 9, 22, 11, 15).toISOString(), NOW, true)).toBe("22.10 11:15"); - expect(eventTime(null, NOW)).toBe("—"); - expect(eventTime("мусор", NOW)).toBe("—"); - }); -}); - describe("группировка", () => { it("группы в порядке первого появления, у группы тема и статус", () => { const g = groupByIssue([ev({ key: "Y", summary: "Вторая", status: null }), ev(), ev({ key: "Y", summary: "Вторая", status: null })]); diff --git a/web/src/modules/jira/feed/events.ts b/web/src/modules/jira/feed/events.ts index 72587dd..a02abfd 100644 --- a/web/src/modules/jira/feed/events.ts +++ b/web/src/modules/jira/feed/events.ts @@ -1,23 +1,6 @@ import type { ChangeEvent, ChangeKind } from "../api/types.ts"; import { wikiPlain } from "../issues/wiki/plain.ts"; -// период ленты: границы по местному времени браузера; «Сегодня» — по умолчанию и в адрес не пишется (spec web-app «Лента изменений») -export type Period = "2h" | "today" | "yesterday" | "7d"; -export const PERIODS: { value: Period; label: string }[] = [ - { value: "2h", label: "2 часа" }, { value: "today", label: "Сегодня" }, { value: "yesterday", label: "Вчера" }, { value: "7d", label: "7 дней" }, -]; -export const parsePeriod = (v: string | null): Period => (PERIODS.some((p) => p.value === v) ? (v as Period) : "today"); - -export function periodRange(p: Period, now: Date): { since: Date; until: Date } { - const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - switch (p) { - case "2h": return { since: new Date(now.getTime() - 2 * 3600_000), until: now }; - case "yesterday": return { since: new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1), until: midnight }; - case "7d": return { since: new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7, now.getHours(), now.getMinutes(), now.getSeconds(), now.getMilliseconds()), until: now }; - default: return { since: midnight, until: now }; - } -} - export const KINDS: { value: ChangeKind; label: string }[] = [ { value: "status", label: "Статусы" }, { value: "description", label: "Описание" }, { value: "dates", label: "Сроки" }, { value: "comment", label: "Комментарии" }, { value: "other", label: "Прочие поля" }, @@ -38,17 +21,6 @@ export function kindsParam(kinds: ChangeKind[]): string | null { return ordered.join(",") === DEFAULT_KINDS.join(",") ? null : ordered.join(","); } -const pad = (n: number) => String(n).padStart(2, "0"); - -// «ЧЧ:ММ» для событий текущих суток, иначе «ДД.ММ ЧЧ:ММ»; без времени — «—» -export function eventTime(at: string | null, now: Date, alwaysDate = false): string { - const d = at ? new Date(at) : null; - if (!d || isNaN(d.getTime())) return "—"; - const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`; - const sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate(); - return sameDay && !alwaysDate ? hm : `${pad(d.getDate())}.${pad(d.getMonth() + 1)} ${hm}`; -} - const FIELD_LABELS: Record = { assignee: "исполнитель", Sprint: "спринт", labels: "метки", priority: "приоритет", summary: "тема", epic_link: "эпик", Link: "связь", Attachment: "вложение", resolution: "резолюция", diff --git a/web/src/modules/jira/feed/feed.css b/web/src/modules/jira/feed/feed.css index 9f948e4..3dd2176 100644 --- a/web/src/modules/jira/feed/feed.css +++ b/web/src/modules/jira/feed/feed.css @@ -1,9 +1,5 @@ -/* лента изменений (spec web-app «Лента изменений»): только токены дизайн-системы */ -.feed{display:flex;flex-direction:column;gap:var(--space-4);padding-top:var(--space-6)} -.feed .ui-page-header{margin-bottom:0} -.feed__controls{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-3) var(--space-5)} +/* источник Jira в ленте (spec web-app «Лента изменений»): только токены дизайн-системы */ .feed__kinds{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2) var(--space-4)} -.feed__hint{margin:0;color:var(--color-text-secondary)} .feed-group{display:flex;flex-direction:column;gap:var(--space-3);min-width:0} .feed-group__head{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2)} .feed-group__key{font-family:var(--font-mono);font-size:13px;color:var(--color-action-primary);text-decoration:none} diff --git a/web/src/modules/jira/feed/source.tsx b/web/src/modules/jira/feed/source.tsx new file mode 100644 index 0000000..183a0d0 --- /dev/null +++ b/web/src/modules/jira/feed/source.tsx @@ -0,0 +1,59 @@ +import { useMemo } from "react"; +import { useSearchParams } from "react-router"; +import { Checkbox } from "../../../ui/index.ts"; +import type { FeedResult, FeedSource, FeedSourceInput } from "../../types.ts"; +import { useChanges } from "../api/queries.ts"; +import type { ChangeKind } from "../api/types.ts"; +import { groupByIssue, KINDS, kindsParam, parseKinds } from "./events.ts"; +import { FeedGroup } from "./FeedGroup.tsx"; +import "./feed.css"; + +// виды событий — настройка источника, хранится в адресе (`kinds=`); пустой набор — явное «none», иначе адрес вернул бы умолчание +function Kinds() { + const [params, setParams] = useSearchParams(); + const kinds = useMemo(() => parseKinds(params.get("kinds")), [params]); + const toggle = (k: ChangeKind, on: boolean) => { + const next = on ? [...kinds, k] : kinds.filter((x) => x !== k); + const value = next.length ? kindsParam(next) : "none"; + setParams((p) => { + const n = new URLSearchParams(p); + if (value) n.set("kinds", value); else n.delete("kinds"); + return n; + }); + }; + return ( +
+ {KINDS.map((k) => ( + toggle(k.value, e.currentTarget.checked)} /> + ))} +
+ ); +} + +const EMPTY: FeedResult = { groups: [], count: 0, window: null, pending: false, error: null, truncated: false, notice: null }; + +function useJiraFeed(input: FeedSourceInput): FeedResult { + const [params] = useSearchParams(); + const kinds = useMemo(() => parseKinds(params.get("kinds")), [params]); + // границы считает запрос в момент выполнения (C-1): ключ кэша — проект, период и виды + const q = useChanges({ project: input.project.label, period: input.period, kinds: kinds.join(",") }, input.bounds, + input.enabled && kinds.length > 0); + const data = input.enabled && kinds.length > 0 ? q.data : undefined; + const updatedAt = q.dataUpdatedAt; + const groups = useMemo(() => { + const now = new Date(updatedAt || Date.now()); // «текущие сутки» для времени событий — на момент ответа + return groupByIssue(data?.changes ?? []).map((g) => ({ + key: g.key, at: g.events[0]?.at ?? "", node: , + })); + }, [data, updatedAt]); + if (!input.enabled) return EMPTY; + if (!kinds.length) return { ...EMPTY, notice: "выберите хотя бы один вид" }; + return { + groups, count: data?.changes.length ?? 0, window: data ? { since: data.since, until: data.until } : null, + pending: q.isPending, truncated: !!data?.truncated, + error: q.isError ? { message: (q.error as Error).message, retry: () => void q.refetch() } : null, notice: null, + }; +} + +export const jiraFeed: FeedSource = { available: () => true, useFeed: useJiraFeed, Controls: Kinds }; diff --git a/web/src/modules/jira/index.tsx b/web/src/modules/jira/index.tsx index 8d8191c..8ca8e21 100644 --- a/web/src/modules/jira/index.tsx +++ b/web/src/modules/jira/index.tsx @@ -1,12 +1,12 @@ import { Navigate, useLocation, useParams } from "react-router"; import { ProjectGate } from "../../projects/ProjectGate.tsx"; -import { IconBacklog, IconBoard, IconHistory, IconKanban, IconList, IconSprint } from "../../ui/index.ts"; +import { IconBacklog, IconBoard, IconKanban, IconList, IconSprint } from "../../ui/index.ts"; import type { HubModule } from "../types.ts"; import { BacklogPage } from "./agile/BacklogPage.tsx"; import { KanbanPage } from "./agile/KanbanPage.tsx"; import { SprintPage } from "./agile/SprintPage.tsx"; import { BoardPage } from "./board/BoardPage.tsx"; -import { FeedPage } from "./feed/FeedPage.tsx"; +import { jiraFeed } from "./feed/source.tsx"; import { useIssueOverlay } from "./issues/overlay.tsx"; import { IssuesPage } from "./issues/IssuesPage.tsx"; import { boardType } from "./projectFields.ts"; @@ -30,13 +30,12 @@ export const jiraModule: HubModule = { { path: "/jira/sprint", element: {(p) => } }, { path: "/jira/kanban", element: {(p) => } }, { path: "/jira/issues", element: {(p) => } }, - { path: "/jira/feed", element: {(p) => } }, // прежние адреса (закладки): перенаправление с сохранением строки запроса, без новой записи истории { path: "/jira/board", element: }, { path: "/board", element: }, { path: "/issues", element: }, { path: "/issues/:key", element: }, - { path: "/feed", element: }, + { path: "/jira/feed", element: }, // лента — страница ядра ], nav: [ { to: "/jira/links", label: "Связи", icon: }, @@ -44,10 +43,10 @@ export const jiraModule: HubModule = { { to: "/jira/sprint", label: "Спринт", icon: , when: (p) => boardType(p) === "scrum" }, { to: "/jira/kanban", label: "Kanban", icon: , when: (p) => boardType(p) === "kanban" }, { to: "/jira/issues", label: "Задачи", icon: , padded: true }, - { to: "/jira/feed", label: "Лента", icon: , padded: true, keepSearch: true }, ], status: jiraStatus, Summary, useOverlay: useIssueOverlay, + feed: jiraFeed, projectFields: [{ name: "board_id", label: "Номер доски Jira", hint: "scrum или kanban определится сам", kind: "int" }], }; diff --git a/web/src/modules/modules.test.tsx b/web/src/modules/modules.test.tsx index 3328300..ffb9cac 100644 --- a/web/src/modules/modules.test.tsx +++ b/web/src/modules/modules.test.tsx @@ -6,12 +6,12 @@ import { MODULES } from "./index.ts"; describe("сборка оболочки из модулей", () => { it("страницы: «Обзор», адреса jira, перенаправления, вики, NotFound последним", () => { - expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/jira/links", "/jira/backlog", "/jira/sprint", "/jira/kanban", "/jira/issues", "/jira/feed", "/jira/board", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]); + expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/feed", "/jira/links", "/jira/backlog", "/jira/sprint", "/jira/kanban", "/jira/issues", "/jira/board", "/board", "/issues", "/issues/:key", "/jira/feed", "/wiki", "*"]); }); it("пункты панели: подписи прежние, адреса jira с префиксом, плюс вики", () => { expect(MODULES.flatMap((m) => m.nav).map((n) => [n.label, n.to])).toEqual([ - ["Связи", "/jira/links"], ["Бэклог", "/jira/backlog"], ["Спринт", "/jira/sprint"], ["Kanban", "/jira/kanban"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"], + ["Связи", "/jira/links"], ["Бэклог", "/jira/backlog"], ["Спринт", "/jira/sprint"], ["Kanban", "/jira/kanban"], ["Задачи", "/jira/issues"], ["Вики", "/wiki"], ]); }); @@ -42,7 +42,7 @@ describe("NotFound при повторе адреса", () => { const { NotFound: NF } = await import("../shell/NotFound.tsx"); const err = vi.spyOn(console, "error").mockImplementation(() => {}); render(); - expect(screen.getAllByRole("link")).toHaveLength(3); // «Обзор» + два раздела + expect(screen.getAllByRole("link")).toHaveLength(4); // «Обзор», «Лента» + два раздела expect(err.mock.calls.flat().join(" ")).not.toMatch(/same key/); err.mockRestore(); vi.doUnmock("../modules/index.ts"); diff --git a/web/src/modules/types.ts b/web/src/modules/types.ts index 75cd887..18a96c7 100644 --- a/web/src/modules/types.ts +++ b/web/src/modules/types.ts @@ -29,6 +29,31 @@ export type ModuleStatus = { invalidate(qc: QueryClient, project: string | null): void; // что перечитать после пакета модуля }; +// источник общей ленты (страница ядра «Лента», design wiki-history): ядро ведёт период, выбор источников и порядок групп, +// модуль — запросы, группы и свои настройки над лентой +export type FeedBounds = { since: Date; until: Date }; +export type FeedSourceInput = { + project: Project; + period: string; // ключ кэша: "2h" | "today" | "yesterday" | "7d" + bounds: () => FeedBounds; // границы на момент запроса (перечитывание берёт «до сейчас») + enabled: boolean; // источник выбран и доступен; false — запросов нет +}; +export type FeedGroup = { key: string; at: string; node: ReactNode }; // at — ISO события группы, по которому группы упорядочены +export type FeedResult = { + groups: FeedGroup[]; + count: number; // событий в ответе — для подзаголовка + window: { since: string; until: string } | null; // границы из ответа хаба; null — данных нет + pending: boolean; + error: { message: string; retry(): void } | null; + truncated: boolean; + notice: string | null; // своё сообщение на месте событий источника (тогда groups пусты) +}; +export type FeedSource = { + available(p: Project): boolean; + useFeed(input: FeedSourceInput): FeedResult; // хук: ядро вызывает его у всех модулей с feed в постоянном порядке + Controls?: ComponentType; // свои настройки над лентой; видны, пока источник выбран +}; + // описание веб-модуля (design hub-modules 10): оболочка берёт из него страницы, пункты панели, состояние данных, окна export type HubModule = { name: string; @@ -39,5 +64,6 @@ export type HubModule = { status?: ModuleStatus; Summary?: ComponentType<{ project: Project }>; // карточка модуля на «Обзоре» useOverlay?: () => Overlay; // вызывается оболочкой в постоянном порядке модулей + feed?: FeedSource; // источник ленты; id в адресе (`sources=`) — name, подпись флажка — title projectFields?: ProjectFieldDef[]; // поля проекта модуля: окно создания собирает их у всех модулей }; diff --git a/web/src/modules/wiki/Summary.test.tsx b/web/src/modules/wiki/Summary.test.tsx index cb7e620..5936642 100644 --- a/web/src/modules/wiki/Summary.test.tsx +++ b/web/src/modules/wiki/Summary.test.tsx @@ -17,4 +17,9 @@ describe("сводка вики", () => { expect(screen.getByText("ещё не синхронизирована")).toBeInTheDocument(); expect(screen.queryByText(/0 страниц/)).toBeNull(); }); + + it("у проекта с корнем — ссылка «Правки» на ленту с источником вики", () => { + renderWithProviders(); + expect(screen.getByRole("link", { name: "Правки" })).toHaveAttribute("href", "/feed?project=AWPCA&sources=wiki"); + }); }); diff --git a/web/src/modules/wiki/Summary.tsx b/web/src/modules/wiki/Summary.tsx index 70ed80e..43e205c 100644 --- a/web/src/modules/wiki/Summary.tsx +++ b/web/src/modules/wiki/Summary.tsx @@ -26,6 +26,7 @@ export function Summary({ project }: { project: Project }) {

{pages === null ? "ещё не синхронизирована" : `${pages} страниц`}

Вики + Правки ); } diff --git a/web/src/modules/wiki/api/queries.ts b/web/src/modules/wiki/api/queries.ts index fd6cf2c..79e10aa 100644 --- a/web/src/modules/wiki/api/queries.ts +++ b/web/src/modules/wiki/api/queries.ts @@ -1,10 +1,10 @@ -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef } from "react"; import { api } from "../../../api/client.ts"; export interface WikiBatch { id: number; status: string } export interface WikiStatus { - root: string; root_title: string | null; synced_at: string | null; pages: number; error: string | null; batch: WikiBatch | null; + root: string; root_title: string | null; synced_at: string | null; history_at?: string | null; pages: number; error: string | null; batch: WikiBatch | null; } export interface WikiNode { code: string; title: string | null; depth: number; parent_code: string | null; size: number | null } export interface WikiAttachment { id: number; name: string | null; state: string; size: number | null; error?: string | null; image?: boolean } @@ -14,6 +14,21 @@ export interface WikiPageData { export interface WikiHit { code: string; title: string | null; path: string; snippet: string } export interface WikiAttachmentState { id: number; name: string | null; state: string; error: string | null; size: number | null } +export interface WikiChange { + at: string; page_code: string; title: string | null; path: string | null; version: string; + author: { login: string | null; name: string | null }; added: number; removed: number; +} +export interface WikiChanges { + since: string; until: string; synced_at: string | null; history_at: string | null; truncated: boolean; changes: WikiChange[]; +} + +export interface WikiAuthor { login: string | null; name: string | null } +export interface WikiVersion { version: string; at: string; author: WikiAuthor; added: number; removed: number } +export interface WikiDiff { + code: string; title: string | null; path: string | null; version: string; prev_version: string | null; at: string; author: WikiAuthor; + added: number; removed: number; index: number; total: number; offset: number; next_offset: number | null; diff: string; +} + export const POLL_MS = 3000; // опрос хода синхронизации, пока пакет не завершён export const SEARCH_LIMIT = 20; @@ -24,6 +39,9 @@ export const wikiQk = { tree: (p: string) => ["wiki", p, "tree"] as const, page: (p: string, code: string) => ["wiki", p, "page", code] as const, search: (p: string, q: string) => ["wiki", p, "search", q] as const, + changes: (p: string, period: string) => ["wiki", p, "changes", period] as const, + versions: (p: string, code: string) => ["wiki", p, "versions", code] as const, + diff: (p: string, code: string, version: string) => ["wiki", p, "diff", code, version] as const, }; const terminal = (b: WikiBatch | null) => b === null || b.status === "done" || b.status === "cancelled"; @@ -62,3 +80,29 @@ export const useWikiSearch = (project: string, q: string) => queryKey: wikiQk.search(project, q), enabled: q !== "", queryFn: () => api.get<{ results: WikiHit[] }>("/wiki/search", { project, q, limit: SEARCH_LIMIT }).then((r) => r.results), }); + +// правки за период для ленты; границы считает запрос в момент выполнения (перечитывание берёт «до сейчас») +export const WIKI_CHANGES_LIMIT = 2000; +export const useWikiChanges = (project: string, period: string, bounds: () => { since: Date; until: Date }, enabled: boolean) => + useQuery({ + queryKey: wikiQk.changes(project, period), enabled, refetchOnWindowFocus: true, + queryFn: () => { + const { since, until } = bounds(); + return api.get("/wiki/changes", { project, since: since.toISOString(), until: until.toISOString(), limit: WIKI_CHANGES_LIMIT }); + }, + }); + +// версии страницы, новые первыми — для «Предыдущая/Следующая правка» и «правка N из M» +export const useWikiVersions = (project: string, code: string) => + useQuery({ + queryKey: wikiQk.versions(project, code), + queryFn: () => api.get<{ code: string; title: string | null; versions: WikiVersion[] }>(`/wiki/pages/${encodeURIComponent(code)}/versions`, { project }).then((r) => r.versions), + }); + +// дифф правки частями: pageParam — offset (0, затем next_offset прежней части) +export const useWikiDiff = (project: string, code: string, version: string) => + useInfiniteQuery({ + queryKey: wikiQk.diff(project, code, version), initialPageParam: 0, + queryFn: ({ pageParam }) => api.get(`/wiki/pages/${encodeURIComponent(code)}/diff`, { project, version, offset: pageParam }), + getNextPageParam: (last) => last.next_offset, + }); diff --git a/web/src/modules/wiki/feed/WikiFeedGroup.tsx b/web/src/modules/wiki/feed/WikiFeedGroup.tsx new file mode 100644 index 0000000..2948f74 --- /dev/null +++ b/web/src/modules/wiki/feed/WikiFeedGroup.tsx @@ -0,0 +1,31 @@ +import { Link, useLocation } from "react-router"; +import { Panel } from "../../../ui/index.ts"; +import { eventTime } from "../../../ui/format.ts"; +import { versionHref, wikiPageHref } from "../paths.ts"; +import { authorName, deltaText, type PageGroup } from "./group.ts"; +import "./feed.css"; + +// группа ленты — одна страница вики: название ссылкой на страницу, путь и правки; правка открывает окно версии поверх ленты +export function WikiFeedGroup({ project, group, now }: { project: string; group: PageGroup; now: Date }) { + const loc = useLocation(); + const state = { background: { pathname: loc.pathname, search: loc.search }, depth: 1 }; + return ( + +
+ {group.title || group.code} + {group.path && {group.path}} +
+
    + {group.changes.map((c) => ( +
  • + + {eventTime(c.at, now)} + {authorName(c.author)} + {deltaText(c)} + +
  • + ))} +
+
+ ); +} diff --git a/web/src/modules/wiki/feed/feed.css b/web/src/modules/wiki/feed/feed.css new file mode 100644 index 0000000..dc4ec53 --- /dev/null +++ b/web/src/modules/wiki/feed/feed.css @@ -0,0 +1,12 @@ +/* источник «Вики» в ленте (spec web-app «Правки вики в ленте»): только токены дизайн-системы */ +.wiki-feed-group{display:flex;flex-direction:column;gap:var(--space-3);min-width:0} +.wiki-feed-group__head{display:flex;flex-wrap:wrap;align-items:baseline;gap:var(--space-1) var(--space-3)} +.wiki-feed-group__title{font-weight:600;min-width:0;overflow-wrap:anywhere;color:var(--color-action-primary);text-decoration:none} +.wiki-feed-group__title:hover{text-decoration:underline} +.wiki-feed-group__path{min-width:0;overflow-wrap:anywhere;font-size:13px;color:var(--color-text-secondary)} +.wiki-feed-edits{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--space-2)} +.wiki-feed-edit{display:flex;flex-wrap:wrap;gap:var(--space-1) var(--space-3);font-size:var(--fs-body);line-height:var(--lh-body);min-width:0;color:var(--color-text-primary);text-decoration:none} +.wiki-feed-edit:hover .wiki-feed-edit__author{text-decoration:underline} +.wiki-feed-edit__time{flex:0 0 auto;font-family:var(--font-mono);font-size:13px;color:var(--color-text-secondary);white-space:nowrap} +.wiki-feed-edit__author{font-weight:600;min-width:0;overflow-wrap:anywhere} +.wiki-feed-edit__delta{font-family:var(--font-mono);font-size:13px;color:var(--color-text-secondary);white-space:nowrap} diff --git a/web/src/modules/wiki/feed/group.test.ts b/web/src/modules/wiki/feed/group.test.ts new file mode 100644 index 0000000..e79b8d4 --- /dev/null +++ b/web/src/modules/wiki/feed/group.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import type { WikiChange } from "../api/queries.ts"; +import { authorName, deltaText, groupByPage } from "./group.ts"; + +const c = (page_code: string, version: string): WikiChange => ({ + at: "2026-10-28T09:00:00+00:00", page_code, title: page_code, path: null, version, + author: { login: null, name: null }, added: 1, removed: 0, +}); + +describe("группировка правок вики", () => { + it("по странице, по первому появлению; правки — в порядке ответа", () => { + const g = groupByPage([c("DOC-3", "V3"), c("DOC-2", "V2"), c("DOC-3", "V1")]); + expect(g.map((x) => x.code)).toEqual(["DOC-3", "DOC-2"]); + expect(g[0].changes.map((x) => x.version)).toEqual(["V3", "V1"]); + }); + it("deltaText: плюс, косая черта, минус U+2212", () => { + expect(deltaText({ added: 40, removed: 12 })).toBe("+40 / −12"); + }); + it("authorName: имя, иначе логин, иначе «—»", () => { + expect(authorName({ login: "x", name: null })).toBe("x"); + expect(authorName({ login: "x", name: "Икс" })).toBe("Икс"); + expect(authorName({ login: null, name: null })).toBe("—"); + }); +}); diff --git a/web/src/modules/wiki/feed/group.ts b/web/src/modules/wiki/feed/group.ts new file mode 100644 index 0000000..1675935 --- /dev/null +++ b/web/src/modules/wiki/feed/group.ts @@ -0,0 +1,18 @@ +import type { WikiChange } from "../api/queries.ts"; + +export type PageGroup = { code: string; title: string | null; path: string | null; changes: WikiChange[] }; + +// правки одной страницы вместе; группы — по первому появлению (ответ хаба идёт от новых к старым), правки — в порядке ответа +export function groupByPage(changes: WikiChange[]): PageGroup[] { + const by = new Map(); + for (const c of changes) { + let g = by.get(c.page_code); + if (!g) { g = { code: c.page_code, title: c.title, path: c.path, changes: [] }; by.set(c.page_code, g); } + g.changes.push(c); + } + return [...by.values()]; +} + +export const deltaText = (c: { added: number; removed: number }): string => `+${c.added} / −${c.removed}`; + +export const authorName = (a: { login: string | null; name: string | null }): string => a.name || a.login || "—"; diff --git a/web/src/modules/wiki/feed/source.tsx b/web/src/modules/wiki/feed/source.tsx new file mode 100644 index 0000000..6f0299b --- /dev/null +++ b/web/src/modules/wiki/feed/source.tsx @@ -0,0 +1,29 @@ +import { useMemo } from "react"; +import type { FeedResult, FeedSource, FeedSourceInput } from "../../types.ts"; +import { useWikiChanges } from "../api/queries.ts"; +import { groupByPage } from "./group.ts"; +import { WikiFeedGroup } from "./WikiFeedGroup.tsx"; + +const EMPTY: FeedResult = { groups: [], count: 0, window: null, pending: false, error: null, truncated: false, notice: null }; + +function useWikiFeed(input: FeedSourceInput): FeedResult { + const project = input.project.label; + const q = useWikiChanges(project, input.period, input.bounds, input.enabled); + const data = input.enabled ? q.data : undefined; + const updatedAt = q.dataUpdatedAt; + const groups = useMemo(() => { + const now = new Date(updatedAt || Date.now()); // «текущие сутки» для времени правок — на момент ответа + return groupByPage(data?.changes ?? []).map((g) => ({ + key: g.code, at: g.changes[0]?.at ?? "", node: , + })); + }, [data, updatedAt, project]); + if (!input.enabled) return EMPTY; + return { + groups, count: data?.changes.length ?? 0, window: data ? { since: data.since, until: data.until } : null, + pending: q.isPending, truncated: !!data?.truncated, + error: q.isError ? { message: (q.error as Error).message, retry: () => void q.refetch() } : null, + notice: data && data.history_at === null ? "история вики ещё не загружена — нужен eva-agent v2" : null, + }; +} + +export const wikiFeed: FeedSource = { available: (p) => !!p.wiki_root, useFeed: useWikiFeed }; diff --git a/web/src/modules/wiki/index.tsx b/web/src/modules/wiki/index.tsx index 36d9a82..14e31f1 100644 --- a/web/src/modules/wiki/index.tsx +++ b/web/src/modules/wiki/index.tsx @@ -1,8 +1,10 @@ import { ProjectGate } from "../../projects/ProjectGate.tsx"; import { IconBook } from "../../ui/index.ts"; import type { HubModule } from "../types.ts"; +import { wikiFeed } from "./feed/source.tsx"; import { wikiStatus } from "./status.ts"; import { Summary } from "./Summary.tsx"; +import { useVersionOverlay } from "./versions/overlay.tsx"; import { WikiPage } from "./WikiPage.tsx"; export const wikiModule: HubModule = { @@ -12,5 +14,7 @@ export const wikiModule: HubModule = { nav: [{ to: "/wiki", label: "Вики", icon: , padded: true }], status: wikiStatus, Summary, + feed: wikiFeed, + useOverlay: useVersionOverlay, projectFields: [{ name: "wiki_root", label: "Корень вики", hint: "DOC-… или ссылка на страницу Eva", kind: "text" }], }; diff --git a/web/src/modules/wiki/paths.ts b/web/src/modules/wiki/paths.ts new file mode 100644 index 0000000..5c73956 --- /dev/null +++ b/web/src/modules/wiki/paths.ts @@ -0,0 +1,10 @@ +// адреса вики: страница с выбранным кодом и окно версии (окно — поверх текущей страницы) +export const VERSION_PATH = "/wiki/pages/:code/versions/:version"; + +export function wikiPageHref(project: string, code: string): string { + return `/wiki?${new URLSearchParams({ project, page: code })}`; +} + +export function versionHref(project: string, code: string, version: string): string { + return `/wiki/pages/${encodeURIComponent(code)}/versions/${encodeURIComponent(version)}?${new URLSearchParams({ project })}`; +} diff --git a/web/src/modules/wiki/versions/VersionModal.test.tsx b/web/src/modules/wiki/versions/VersionModal.test.tsx new file mode 100644 index 0000000..fa6a1db --- /dev/null +++ b/web/src/modules/wiki/versions/VersionModal.test.tsx @@ -0,0 +1,196 @@ +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 settings from "../../jira/board/__fixtures__/settings.json"; +import { routes } from "../../../routes.tsx"; +import { json, mockFetch } from "../../../test/fetchMock.ts"; +import type { WikiChange } from "../api/queries.ts"; + +const A = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9, wiki_root: "DOC-1" }; +const SOSNOV = { login: "d.sosnov", name: "Дмитрий Соснов" }; +const at = (hhmm: string) => { const d = new Date(); d.setHours(+hhmm.slice(0, 2), +hhmm.slice(3), 0, 0); return d.toISOString(); }; +const TIMES = ["10:00", "11:00", "12:00", "12:40"]; +const VERSIONS = [4, 3, 2, 1].map((n) => ({ version: `DOCH-${n}`, at: at(TIMES[n - 1]), author: SOSNOV, added: n === 4 ? 40 : n, removed: n === 4 ? 12 : 0 })); +const change = (n: number): WikiChange => ({ + at: at(TIMES[n - 1]), page_code: "DOC-101252", title: "Методика расчёта", path: "АРМ / Методика", version: `DOCH-${n}`, + author: SOSNOV, added: n === 4 ? 40 : n, removed: n === 4 ? 12 : 0, +}); +type Part = { diff: string; next_offset: number | null }; +const diffBody = (version: string, offset: number, part: Part) => { + const n = Number(version.slice(5)); + return { + code: "DOC-101252", title: "Методика расчёта", path: "АРМ / Методика", version, prev_version: n > 1 ? `DOCH-${n - 1}` : null, + at: at(TIMES[n - 1]), author: SOSNOV, added: n === 4 ? 40 : n, removed: n === 4 ? 12 : 0, index: n, total: 4, offset, ...part, + }; +}; +const DEFAULT_DIFF = (v: string): Part => ({ diff: `--- DOCH-prev\n+++ ${v}\n@@ -1 +1 @@\n-старая строка\n+новая строка\n общая строка\n`, next_offset: null }); + +function hub(parts: (version: string, offset: number) => Part = (v) => DEFAULT_DIFF(v)) { + const calls: { method: string; url: URL }[] = []; + mockFetch((method, url) => { + calls.push({ method, url }); + const k = `${method} ${url.pathname}`; + if (k === "GET /projects") return json(200, { projects: [A] }); + if (k === "GET /settings") return json(200, settings); + if (k === "GET /batches") return json(200, { batches: [] }); + if (k === "GET /agents") return json(200, { agents: [] }); + if (k === "GET /changes") return json(200, { since: "", until: "", truncated: false, changes: [] }); + if (k === "GET /wiki/changes") { + return json(200, { since: "", until: "", synced_at: null, history_at: "2026-10-28T09:00:00+00:00", truncated: false, changes: [change(4), change(3)] }); + } + if (k === "GET /wiki/status") return json(200, { root: "DOC-1", root_title: "Корень", synced_at: null, pages: 1, error: null, batch: null }); + if (k === "GET /wiki/tree") return json(200, { pages: [{ code: "DOC-101252", title: "Методика расчёта", depth: 0, parent_code: null, size: 1 }] }); + if (k === "GET /wiki/pages/DOC-101252") { + return json(200, { code: "DOC-101252", title: "Методика расчёта", path: "АРМ / Методика", url: null, synced_at: null, markdown: "Текст страницы", attachments: [] }); + } + if (k === "GET /wiki/pages/DOC-101252/versions") return json(200, { code: "DOC-101252", title: "Методика расчёта", versions: VERSIONS }); + if (k === "GET /wiki/pages/DOC-101252/diff") { + const version = url.searchParams.get("version") ?? "DOCH-4"; + const offset = Number(url.searchParams.get("offset") ?? 0); + return json(200, diffBody(version, offset, parts(version, offset))); + } + return json(404, { error: "not_found" }); + }); + return calls; +} +const open = (entries: string[], index = entries.length - 1) => { + const router = createMemoryRouter(routes, { initialEntries: entries, initialIndex: index, basename: "/app" }); + render(); + return router; +}; +const FEED = "/app/feed?project=AWPCA&period=7d&sources=wiki"; +const dialog = () => screen.getByRole("dialog"); +async function openFromFeed(entries = [FEED]) { + const router = open(entries); + await userEvent.click((await screen.findAllByRole("link", { name: /\+40 \/ −12/ }))[0]); + await screen.findByRole("dialog"); + return router; +} + +describe("окно правки вики", () => { + beforeEach(() => setToken("tok")); + + it("«Правка из ленты»: шапка, строки диффа и лента под окном", async () => { + const calls = hub(); + await openFromFeed(); + const d = dialog(); + expect(await within(d).findByRole("heading", { name: "Методика расчёта" })).toBeInTheDocument(); + expect(within(d).getByText("правка 4 из 4")).toBeInTheDocument(); + expect(within(d).getByText("Дмитрий Соснов")).toBeInTheDocument(); + expect(within(d).getByText("12:40")).toBeInTheDocument(); + expect(within(d).getByText("+40 / −12")).toBeInTheDocument(); + const add = await within(d).findByText("+новая строка"); + expect(add).toHaveClass("wiki-version-line--add"); + expect(within(add).getByText("добавлено:")).toHaveClass("visually-hidden"); + const del = within(d).getByText("-старая строка"); + expect(del).toHaveClass("wiki-version-line--del"); + expect(within(del).getByText("удалено:")).toHaveClass("visually-hidden"); + expect(screen.getByTestId("page-feed")).toBeInTheDocument(); + const dc = calls.find((c) => c.url.pathname === "/wiki/pages/DOC-101252/diff")!; + expect(dc.url.searchParams.get("version")).toBe("DOCH-4"); + }); + + it("«Предыдущая правка»: 3 из 4, у первой недоступна; назад закрывает окно, лента прежняя", async () => { + hub(); + const router = await openFromFeed(); + const d = dialog(); + await within(d).findByText("правка 4 из 4"); + expect(within(d).getByRole("button", { name: "Следующая правка" })).toBeDisabled(); + await userEvent.click(within(d).getByRole("button", { name: "Предыдущая правка" })); + expect(await within(d).findByText("правка 3 из 4")).toBeInTheDocument(); + expect(router.state.location.pathname).toBe("/app/wiki/pages/DOC-101252/versions/DOCH-3"); + expect(within(d).getByRole("button", { name: "Следующая правка" })).toBeEnabled(); + await userEvent.click(within(d).getByRole("button", { name: "Предыдущая правка" })); + await userEvent.click(within(d).getByRole("button", { name: "Предыдущая правка" })); + await within(d).findByText("правка 1 из 4"); + expect(within(d).getByRole("button", { name: "Предыдущая правка" })).toBeDisabled(); + act(() => { void router.navigate(-1); }); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(router.state.location.pathname + router.state.location.search).toBe(FEED); + }); + + it("Escape и «Закрыть» закрывают окно — адрес ленты прежний", async () => { + hub(); + const router = await openFromFeed(); + await within(dialog()).findByText("правка 4 из 4"); + await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(router.state.location.pathname + router.state.location.search).toBe(FEED); + await userEvent.click((await screen.findAllByRole("link", { name: /\+40 \/ −12/ }))[0]); + await screen.findByRole("dialog"); + fireEvent(dialog(), new Event("cancel", { cancelable: true })); // Esc в jsdom: событие cancel окна + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(router.state.location.pathname + router.state.location.search).toBe(FEED); + }); + + it("«Окно по ссылке»: поверх «Вики» с этой страницей, закрытие — на страницу", async () => { + hub(); + const router = open(["/app/wiki/pages/DOC-101252/versions/DOCH-3?project=AWPCA"]); + const d = await screen.findByRole("dialog"); + expect(await within(d).findByText("правка 3 из 4")).toBeInTheDocument(); + expect(await screen.findByRole("heading", { level: 1, name: "Вики" })).toBeInTheDocument(); + await userEvent.click(within(d).getByRole("button", { name: "Закрыть" })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(router.state.location.pathname + router.state.location.search).toBe("/app/wiki?project=AWPCA&page=DOC-101252"); + }); + + it("«Длинный дифф»: «Показать дальше» грузит со следующего offset; без next_offset кнопки нет", async () => { + const calls = hub((_v, offset) => offset === 0 + ? { diff: "--- a\n+++ b\n+первая часть\n", next_offset: 20000 } + : { diff: "+вторая часть\n", next_offset: null }); + await openFromFeed(); + const d = dialog(); + await within(d).findByText("+первая часть"); + expect(within(d).queryByText("+вторая часть")).toBeNull(); + await userEvent.click(within(d).getByRole("button", { name: "Показать дальше" })); + const second = await within(d).findByText("+вторая часть"); + expect(calls.some((c) => c.url.searchParams.get("offset") === "20000")).toBe(true); + expect(within(d).getByText("+первая часть").compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(within(d).queryByRole("button", { name: "Показать дальше" })).toBeNull(); + }); + + it("I-2: строки диффа — дети одного контейнера строк, «Показать дальше» вне него", async () => { + hub(() => ({ diff: "--- a\n+++ b\n+первая\n+вторая\n", next_offset: 20000 })); + await openFromFeed(); + const d = dialog(); + const l1 = await within(d).findByText("+первая"); + const l2 = within(d).getByText("+вторая"); + const box = l1.parentElement!; + expect(box).toHaveClass("wiki-version__lines"); + expect(l2.parentElement).toBe(box); + expect(box.contains(within(d).getByRole("button", { name: "Показать дальше" }))).toBe(false); + }); + + it("«Показать дальше»: часть рвёт строку — строка целая и одна", async () => { + hub((_v, offset) => offset === 0 + ? { diff: "--- a\n+++ b\n+строка дл", next_offset: 20000 } + : { diff: "инная\n+другая\n", next_offset: null }); + await openFromFeed(); + const d = dialog(); + await within(d).findByText("+строка дл"); + await userEvent.click(within(d).getByRole("button", { name: "Показать дальше" })); + expect(await within(d).findByText("+строка длинная")).toHaveClass("wiki-version-line--add"); + expect(d.querySelectorAll(".wiki-version-line--add")).toHaveLength(2); + expect(within(d).queryByText("+строка дл")).toBeNull(); + }); + + it("«Открыть страницу» ведёт на страницу вики", async () => { + hub(); + await openFromFeed(); + const link = await within(dialog()).findByRole("link", { name: "Открыть страницу" }); + expect(link).toHaveAttribute("href", "/app/wiki?project=AWPCA&page=DOC-101252"); + }); + + it("окно ничего не пишет: только GET", async () => { + const calls = hub(); + await openFromFeed(); + await within(dialog()).findByText("+новая строка"); + await userEvent.click(within(dialog()).getByRole("button", { name: "Предыдущая правка" })); + await within(dialog()).findByText("правка 3 из 4"); + await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); + expect(calls.filter((c) => c.method !== "GET")).toEqual([]); + }); +}); diff --git a/web/src/modules/wiki/versions/VersionModal.tsx b/web/src/modules/wiki/versions/VersionModal.tsx new file mode 100644 index 0000000..9613a7c --- /dev/null +++ b/web/src/modules/wiki/versions/VersionModal.tsx @@ -0,0 +1,100 @@ +import { useEffect, useMemo, useRef } from "react"; +import { Link } from "react-router"; +import { ErrorBoundary } from "../../../shell/ErrorBoundary.tsx"; +import { Button, Dialog, IconButton, IconClose, Notice } from "../../../ui/index.ts"; +import { eventTime } from "../../../ui/format.ts"; +import { useWikiDiff, useWikiVersions } from "../api/queries.ts"; +import { authorName, deltaText } from "../feed/group.ts"; +import { wikiPageHref } from "../paths.ts"; +import { diffLines } from "./diffLines.ts"; +import "./versions.css"; + +type Props = { project: string; code: string; version: string; onClose: () => void; onNavigate: (version: string) => void }; + +// окно правки (spec web-app «Правки вики в ленте»): Esc, «Закрыть» и щелчок по фону — onClose; окно исчезает вместе с адресом +export function VersionModal(props: Props) { + const downOnBackdrop = useRef(false); + const { onClose } = props; + useEffect(() => { + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; // страница под окном не прокручивается + return () => { document.body.style.overflow = prev; }; + }, []); + return ( + { if (e.cancelable) { e.preventDefault(); onClose(); } }} + onNativeClose={(e) => { const d = e.currentTarget; 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(); + }}> + + + ); +} + +function VersionBody({ project, code, version, onClose, onNavigate }: Props) { + const diff = useWikiDiff(project, code, version); + const versions = useWikiVersions(project, code); + const first = diff.data?.pages[0]; + // части склеиваются до разбора на строки: часть может оборваться посреди строки + const text = diff.data?.pages.map((p) => p.diff).join("") ?? ""; + const lines = useMemo(() => diffLines(text), [text]); + const list = versions.data; + const pos = list ? list.findIndex((v) => v.version === version) : -1; + const older = list && pos >= 0 ? list[pos + 1] : undefined; + const newer = list && pos > 0 ? list[pos - 1] : undefined; + const index = first?.index ?? (list && pos >= 0 ? list.length - pos : null); + const total = first?.total ?? list?.length ?? null; + const title = first?.title || code; + const path = first?.path; + const now = new Date(); + + return ( +
+
+
+

{title}

+ } onClick={onClose} /> +
+ {path &&

{path}

} +

+ {index !== null && total !== null && {`правка ${index} из ${total}`}} + {first && <> + {authorName(first.author)} + {eventTime(first.at, now)} + {deltaText(first)} + } +

+
+ + + Открыть страницу +
+
+ {diff.isPending &&

загрузка правки…

} + {diff.isError && !diff.data && ( + void diff.refetch()}>Повторить}>{(diff.error as Error).message} + )} + {first && ( +
+ {lines.length === 0 &&

В правке нет изменений текста.

} +
+ {lines.map((l, i) => ( +
+ {l.kind === "add" && добавлено: } + {l.kind === "del" && удалено: } + {l.text} +
+ ))} +
+ {diff.hasNextPage && ( + + )} +
+ )} +
+ ); +} diff --git a/web/src/modules/wiki/versions/diffLines.test.ts b/web/src/modules/wiki/versions/diffLines.test.ts new file mode 100644 index 0000000..9766f8e --- /dev/null +++ b/web/src/modules/wiki/versions/diffLines.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { diffLines } from "./diffLines.ts"; + +describe("diffLines", () => { + it("разбирает unified diff по видам строк", () => { + const lines = diffLines("--- A\n+++ B\n@@ -1 +1 @@\n-x\n+y\n z\n"); + expect(lines.map((l) => l.kind)).toEqual(["head", "head", "hunk", "del", "add", "ctx"]); + expect(lines[3]).toEqual({ kind: "del", text: "-x" }); + }); + + it("после первого @@ строка «--- …» — удаление, а не заголовок", () => { + expect(diffLines("--- A\n+++ B\n@@ -1 +1 @@\n--- x\n").map((l) => l.kind)).toEqual(["head", "head", "hunk", "del"]); + }); + + it("хвостовой пустой строки нет, пустой текст — пустой список", () => { + expect(diffLines("+a\n")).toHaveLength(1); + expect(diffLines("")).toEqual([]); + }); +}); diff --git a/web/src/modules/wiki/versions/diffLines.ts b/web/src/modules/wiki/versions/diffLines.ts new file mode 100644 index 0000000..047bff3 --- /dev/null +++ b/web/src/modules/wiki/versions/diffLines.ts @@ -0,0 +1,16 @@ +export type DiffLine = { kind: "head" | "hunk" | "add" | "del" | "ctx"; text: string }; + +// unified diff — в строки с видом; хвостовой пустой строки нет. `---`/`+++` — заголовок только до первого `@@` (дальше это удалённая/добавленная строка) +export function diffLines(text: string): DiffLine[] { + if (text === "") return []; + const rows = text.split("\n"); + if (rows[rows.length - 1] === "") rows.pop(); + let inHunk = false; + return rows.map((t): DiffLine => { + if (!inHunk && (t.startsWith("---") || t.startsWith("+++"))) return { kind: "head", text: t }; + if (t.startsWith("@@")) { inHunk = true; return { kind: "hunk", text: t }; } + if (t.startsWith("+")) return { kind: "add", text: t }; + if (t.startsWith("-")) return { kind: "del", text: t }; + return { kind: "ctx", text: t }; + }); +} diff --git a/web/src/modules/wiki/versions/overlay.tsx b/web/src/modules/wiki/versions/overlay.tsx new file mode 100644 index 0000000..226774e --- /dev/null +++ b/web/src/modules/wiki/versions/overlay.tsx @@ -0,0 +1,50 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { matchPath, useLocation, useNavigate } from "react-router"; +import type { Overlay } from "../../types.ts"; +import { VERSION_PATH, versionHref } from "../paths.ts"; +import { pageBackground, versionState } from "./versionState.ts"; +import { VersionModal } from "./VersionModal.tsx"; + +// окно правки /wiki/pages/{code}/versions/{version} поверх страницы под ним (spec web-app «Правки вики в ленте») +export function useVersionOverlay(): Overlay { + const location = useLocation(); + const navigate = useNavigate(); + const params = matchPath(VERSION_PATH, location.pathname)?.params; + const code = params?.code ?? null; + const version = params?.version ?? null; + const open = code !== null && version !== null; + const st = open ? versionState(location.state) : null; + const direct = code ? pageBackground(code, location.search) : null; + const bg = open ? (st?.background ?? direct) : null; + const bgPath = bg?.pathname ?? null; + const bgSearch = bg?.search ?? ""; + const pageLocation = useMemo(() => bgPath === null ? null : { pathname: bgPath, search: bgSearch, hash: "", state: null, key: "version-background" }, + [bgPath, bgSearch]); + + // прямой адрес: запись истории получает state окна поверх «Вики» с этой страницей + useEffect(() => { + if (open && !st && direct) { + void navigate(location.pathname + location.search, { replace: true, state: { background: direct, depth: 0, direct: true } }); + } + }, [open, st, direct, location.pathname, location.search, navigate]); + + // повторное закрытие на той же записи истории игнорируется + const closingKey = useRef(null); + useEffect(() => { closingKey.current = null; }, [location.key]); + const close = useCallback(() => { + if (closingKey.current === location.key || !direct) return; + closingKey.current = location.key; + if (!st || st.depth === 0) { void navigate(st?.background ?? direct, { replace: true }); return; } + void navigate(-st.depth); + }, [st, direct, navigate, location.key]); + + const project = new URLSearchParams(location.search).get("project") ?? ""; + // соседняя правка заменяет запись: то же state, «назад» закрывает окно, а не листает правки + const go = useCallback((v: string) => { + if (!code) return; + void navigate(versionHref(project, code, v), { replace: true, state: st ?? undefined }); + }, [navigate, project, code, st]); + + const element = open ? : null; + return { pageLocation, element }; +} diff --git a/web/src/modules/wiki/versions/versionState.test.ts b/web/src/modules/wiki/versions/versionState.test.ts new file mode 100644 index 0000000..2f3443b --- /dev/null +++ b/web/src/modules/wiki/versions/versionState.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { pageBackground, versionState } from "./versionState.ts"; + +describe("versionState", () => { + it("мусорный state — null", () => { + expect(versionState(null)).toBeNull(); + expect(versionState("x")).toBeNull(); + expect(versionState({ background: 1, depth: 1 })).toBeNull(); + expect(versionState({ background: { pathname: "/feed", search: "" }, depth: "1" })).toBeNull(); + }); + + it("разбирает фон, глубину и direct; глубина нормализуется", () => { + expect(versionState({ background: { pathname: "/feed", search: "?a=1" }, depth: 1 })) + .toEqual({ background: { pathname: "/feed", search: "?a=1" }, depth: 1, direct: false }); + expect(versionState({ background: { pathname: "/wiki", search: "" }, depth: -3, direct: true })?.depth).toBe(0); + }); + + it("фон по прямому адресу — «Вики» с этой страницей", () => { + expect(pageBackground("DOC-1", "?project=AWPCA")).toEqual({ pathname: "/wiki", search: "?project=AWPCA&page=DOC-1" }); + expect(pageBackground("DOC-1", "")).toEqual({ pathname: "/wiki", search: "?page=DOC-1" }); + }); +}); diff --git a/web/src/modules/wiki/versions/versionState.ts b/web/src/modules/wiki/versions/versionState.ts new file mode 100644 index 0000000..b82cb3d --- /dev/null +++ b/web/src/modules/wiki/versions/versionState.ts @@ -0,0 +1,22 @@ +// состояние окна правки в записи истории: страница под окном, сколько записей заняли окна поверх неё, прямой ли адрес +// (своя копия логики окна карточки: модуль не импортирует другой модуль) +export interface Background { pathname: string; search: string } +export interface VersionState { background: Background; depth: number; direct: boolean } + +export function versionState(s: unknown): VersionState | 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; + return { background: { pathname, search }, depth, direct: c.direct === true }; +} + +// фон по прямому адресу: «Вики» с этой страницей, проект — из адреса окна +export function pageBackground(code: string, search: string): Background { + const params = new URLSearchParams(); + const project = new URLSearchParams(search).get("project"); + if (project) params.set("project", project); + params.set("page", code); + return { pathname: "/wiki", search: "?" + params.toString() }; +} diff --git a/web/src/modules/wiki/versions/versions.css b/web/src/modules/wiki/versions/versions.css new file mode 100644 index 0000000..406a1b1 --- /dev/null +++ b/web/src/modules/wiki/versions/versions.css @@ -0,0 +1,21 @@ +/* окно правки вики (spec web-app «Правки вики в ленте»): только токены дизайн-системы */ +.ui-dialog.wiki-version-modal{padding:0} +.wiki-version{display:flex;flex-direction:column;gap:var(--space-4);padding:0 var(--space-6) var(--space-6);min-width:0} +.wiki-version__head{display:flex;flex-direction:column;gap:var(--space-2);position:sticky;top:0;z-index:1;background:var(--color-bg-surface);padding:var(--space-4) 0 var(--space-3);border-bottom:1px solid var(--color-border-default)} +.wiki-version__row{display:flex;align-items:flex-start;gap:var(--space-3);justify-content:space-between} +.wiki-version__title{margin:0;min-width:0;overflow-wrap:anywhere;font-size:var(--fs-section)} +.wiki-version__path{margin:0;font-size:13px;color:var(--color-text-secondary);overflow-wrap:anywhere} +.wiki-version__meta{margin:0;display:flex;flex-wrap:wrap;gap:var(--space-1) var(--space-3);color:var(--color-text-secondary)} +.wiki-version__author{font-weight:600;color:var(--color-text-primary);overflow-wrap:anywhere} +.wiki-version__time,.wiki-version__delta{font-family:var(--font-mono);font-size:13px;white-space:nowrap} +.wiki-version__nav{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2) var(--space-3)} +.wiki-version__page{color:var(--color-action-primary)} +.wiki-version__hint{margin:0;color:var(--color-text-secondary)} +.wiki-version__diff{display:flex;flex-direction:column;gap:var(--space-3);align-items:flex-start;min-width:0} +.wiki-version__lines{display:flex;flex-direction:column;align-self:stretch;min-width:0} +.wiki-version-line{font-family:var(--font-mono);font-size:13px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere;min-width:0;max-width:100%;align-self:stretch} +.wiki-version-line--add{color:var(--color-status-success);background:var(--color-status-success-bg)} +.wiki-version-line--del{color:var(--color-status-error);background:var(--color-status-error-bg)} +.wiki-version-line--head,.wiki-version-line--hunk{color:var(--color-text-secondary)} +.wiki-version-line--ctx{color:var(--color-text-primary)} +@media (max-width:640px){.wiki-version{padding:0 var(--space-4) var(--space-4)}} diff --git a/web/src/shell/OverviewPage.test.tsx b/web/src/shell/OverviewPage.test.tsx index e3f8900..537caf6 100644 --- a/web/src/shell/OverviewPage.test.tsx +++ b/web/src/shell/OverviewPage.test.tsx @@ -75,7 +75,7 @@ describe("страница «Обзор»", () => { expect(await jira.findByText((_, el) => el?.classList.contains("jira-summary__counts") === true && el.textContent === "4 задач · 3 открытых · 1 просрочено")).toBeInTheDocument(); expect(jira.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links?project=AWPCA"); expect(jira.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/jira/issues?project=AWPCA"); - expect(jira.getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/jira/feed?project=AWPCA"); + expect(jira.getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/feed?project=AWPCA&sources=jira"); }); it("overview card without root: объяснение и «Привязать корень вики»", async () => { diff --git a/web/src/shell/Sidebar.test.tsx b/web/src/shell/Sidebar.test.tsx index a74b505..3b9b35e 100644 --- a/web/src/shell/Sidebar.test.tsx +++ b/web/src/shell/Sidebar.test.tsx @@ -54,10 +54,10 @@ describe("боковая панель", () => { it("раздел «Лента» ведёт на /feed с проектом и отмечается как текущий", async () => { hub({ "GET /changes": () => json(200, { since: "", until: "", truncated: false, changes: [] }) }); - open("/app/jira/feed?project=AWPCA"); + open("/app/feed?project=AWPCA"); expect(await screen.findByTestId("page-feed")).toBeInTheDocument(); const nav = within(bar()).getByRole("navigation", { name: "Разделы" }); - await waitFor(() => expect(within(nav).getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/jira/feed?project=AWPCA")); + await waitFor(() => expect(within(nav).getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/feed?project=AWPCA")); expect(within(nav).getByRole("link", { name: "Лента" })).toHaveAttribute("aria-current", "page"); expect(within(nav).getByRole("link", { name: "Задачи" })).not.toHaveAttribute("aria-current"); }); @@ -72,7 +72,7 @@ describe("боковая панель", () => { const main = within(screen.getByRole("main")); expect(main.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links"); expect(main.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/jira/issues"); - expect(main.getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/jira/feed"); // M-2 + expect(main.getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/feed"); // M-2 expect(main.getByRole("link", { name: "Обзор" })).toHaveAttribute("href", expect.stringMatching(/^\/app\/?$/)); expect(screen.getByRole("alert")).toHaveTextContent("Страница не найдена"); }); @@ -133,9 +133,9 @@ describe("боковая панель", () => { open("/app/jira/links?project=AWPCA"); const nav = within(bar()).getByRole("navigation", { name: "Разделы" }); const links = (await within(nav).findAllByRole("link")).map((l) => l.textContent); - expect(links).toEqual(["Обзор", "Связи", "Задачи", "Лента", "Вики"]); + expect(links).toEqual(["Обзор", "Лента", "Связи", "Задачи", "Вики"]); const jira = within(nav).getByRole("group", { name: "Jira" }); - expect(within(jira).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Задачи", "Лента"]); + expect(within(jira).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Задачи"]); expect(within(nav).getByRole("group", { name: "Вики" })).toBeInTheDocument(); await waitFor(() => expect(within(nav).getByRole("link", { name: "Обзор" })).toHaveAttribute("href", expect.stringMatching(/^\/app\/?\?project=AWPCA$/))); }); diff --git a/web/src/shell/Sidebar.tsx b/web/src/shell/Sidebar.tsx index f8a013d..652015c 100644 --- a/web/src/shell/Sidebar.tsx +++ b/web/src/shell/Sidebar.tsx @@ -6,7 +6,7 @@ import type { Ref } from "react"; import { clearToken } from "../auth/token.ts"; import { useActivitySummary } from "./activity/activity.ts"; import { ModuleEventStrip, ModuleStatusBlock } from "./ModuleStatusBlock.tsx"; -import { GROUPS, OVERVIEW, visibleNav } from "./nav.tsx"; +import { FEED, GROUPS, OVERVIEW, visibleNav } from "./nav.tsx"; import { useProjectContext } from "../projects/ProjectContext.ts"; import { withProject } from "../projects/current.ts"; import { ProjectPicker } from "../projects/ProjectPicker.tsx"; @@ -122,6 +122,7 @@ function Panel({ section, project, drawer }: Props & { drawer?: { close(): void;